From 328a77fd02252f45795cba4deb2fa0e931891f0b Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Thu, 9 Jul 2026 19:22:17 +0200 Subject: [PATCH 001/290] fix(fsdp): import os in safe_get_rank fallback (#4959) Signed-off-by: Minh Vu --- megatron/core/distributed/fsdp/src/megatron_fsdp/utils.py | 1 + .../mfsdp_v1/test_mcore_tensor_parallelism_detect.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/utils.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/utils.py index f771c17c17d..8fd56795065 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/utils.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/utils.py @@ -16,6 +16,7 @@ import inspect import logging import operator +import os from contextlib import nullcontext from functools import reduce from importlib.metadata import version diff --git a/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py b/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py index c69ca817872..7cdda0d163b 100644 --- a/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py +++ b/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py @@ -7,6 +7,7 @@ from megatron.core.distributed.fsdp.src.megatron_fsdp.utils import ( get_mcore_tensor_parallel_partition_dim, is_mcore_tensor_parallel_duplicated, + safe_get_rank, using_tensor_parallel, ) @@ -79,6 +80,13 @@ def test_using_tensor_parallel_false_when_mesh_size_one(): assert using_tensor_parallel(dist_index) is False +def test_safe_get_rank_should_fall_back_to_rank_env_if_distributed_is_not_initialized(monkeypatch): + monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False) + monkeypatch.setenv("RANK", "7") + + assert safe_get_rank() == 7 + + class DummyConfig: # Just enough attributes for __init__ to run if needed in future tests. gradient_accumulation_fusion = False From ce8865c6c1e3f339e502b9c258a38f26894ab3c4 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 9 Jul 2026 10:33:53 -0700 Subject: [PATCH 002/290] Add forward all-gather overlap (#5513) Signed-off-by: Jingyue Wu Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 86 ++++++- .../experimental/parameter_group.py | 17 +- .../distributed/mfsdp_v2/test_context.py | 2 +- .../distributed/mfsdp_v2/test_fully_shard.py | 234 +++++++++++++++++- 4 files changed, 314 insertions(+), 25 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 3f1d24b1517..3c97fda3242 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -14,6 +14,8 @@ """Module mixin for the minimal Megatron-FSDP path.""" +import dataclasses +from collections import deque from collections.abc import Callable from typing import Literal, cast @@ -26,23 +28,54 @@ from .placement import MeshAxis, Placements +@dataclasses.dataclass(frozen=True) +class DelayedRelease: + """A module whose unsharded storage can be released after its consumer event.""" + + consumer_event: torch.cuda.Event | None + module: "FsdpModule" + + class FsdpContext: - """Runtime state shared by one experimental FSDP subtree.""" + """Runtime state, stream, and release scheduler shared by one FSDP subtree.""" + allgather_stream: torch.cuda.Stream + delayed_releases: deque[DelayedRelease] # HFSDP/HSDP need explicit last-microbatch state. First-microbatch state is # unnecessary because it can be detected when ``model_weight``, after syncing # from ``main_weight``, has placements different from ``Placements.optimizer``. is_last_microbatch: bool root_module: "FsdpModule" - def __init__(self, root_module: "FsdpModule") -> None: + def __init__(self, device: torch.device, root_module: "FsdpModule") -> None: """Create rank-local runtime state for a root FSDP subtree. Args: + device: Device on which this context schedules communication. root_module: Outermost module that owns this context. """ self.root_module = root_module self.is_last_microbatch = True + self.delayed_releases = deque() + with torch.cuda.device(device): + self.allgather_stream = torch.cuda.Stream() + + def enqueue_release(self, module: "FsdpModule") -> None: + """Queue a module's unsharded storage for delayed release.""" + consumer_event = torch.cuda.current_stream(self.allgather_stream.device).record_event() + self.delayed_releases.append(DelayedRelease(consumer_event=consumer_event, module=module)) + + def drain_delayed_releases(self, target_length: int) -> None: + """Release queued module storages FIFO until the queue reaches ``target_length``.""" + if target_length < 0: + raise ValueError(f"target_length must be non-negative, got {target_length}.") + + while len(self.delayed_releases) > target_length: + delayed_release = self.delayed_releases.popleft() + with torch.cuda.stream(self.allgather_stream): + if delayed_release.consumer_event is not None: + self.allgather_stream.wait_event(delayed_release.consumer_event) + delayed_release.module.release_unsharded_storage() class FsdpModule: @@ -114,7 +147,7 @@ def _lazy_init_context(self) -> None: if self._context is not None: return - context = FsdpContext(root_module=self) + context = FsdpContext(device=self._parameter_groups[0].main_weight.device, root_module=self) for submodule_name, submodule in cast(nn.Module, self).named_modules(): if not isinstance(submodule, FsdpModule): continue @@ -172,33 +205,64 @@ def pre_forward(self) -> None: self._lazy_init_context() torch.cuda.nvtx.range_push(self._nvtx_label("forward")) self._ready_grad_parameters.clear() - for group in self._parameter_groups: - group.sync_model_weight_from_main_weight() - group.unshard_parameters() + if self.is_root(): + allgather_stream = self.context.allgather_stream + allgather_stream.wait_stream(torch.cuda.current_stream(allgather_stream.device)) + self._unshard_parameter_groups(sync_model_weight=True) + + def _unshard_parameter_groups(self, *, sync_model_weight: bool) -> None: + """Materialize full parameters for this FSDP unit.""" + self.context.drain_delayed_releases(target_length=1) + + allgather_stream = self.context.allgather_stream + current_stream = torch.cuda.current_stream(allgather_stream.device) + + with torch.cuda.stream(allgather_stream): + for group in self._parameter_groups: + if sync_model_weight: + # TODO: After NVIDIA/Megatron-LM#5411 lands, move this sync to the + # optimizer post-step hook instead of running it every microbatch. + group.sync_model_weight_from_main_weight() + group.unshard_parameters() + current_stream.wait_stream(allgather_stream) def post_forward(self) -> None: """Return parameters to their sharded resting state after forward compute.""" + self._reshard_parameter_groups() + self.context.enqueue_release(self) + if self.is_root(): + self.context.drain_delayed_releases(target_length=0) + torch.cuda.nvtx.range_pop() + + def _reshard_parameter_groups(self) -> None: for group in self._parameter_groups: group.reshard_parameters() - torch.cuda.nvtx.range_pop() def pre_backward(self) -> None: """Prepare full parameters for backward compute.""" torch.cuda.nvtx.range_push(self._nvtx_label("backward")) - for group in self._parameter_groups: - group.unshard_parameters() + self._unshard_parameter_groups(sync_model_weight=False) def post_backward(self) -> None: """Reduce gradients and return parameters to their sharded resting state.""" for group in self._parameter_groups: if group.requires_grad: group.reduce_gradients() - group.reshard_parameters() + self._reshard_parameter_groups() + self.context.enqueue_release(self) + if self.is_root(): + self.context.drain_delayed_releases(target_length=0) self._ready_grad_parameters.clear() torch.cuda.nvtx.range_pop() + def release_unsharded_storage(self) -> None: + """Release unsharded storage owned by this FSDP unit.""" + for group in self._parameter_groups: + group.release_unsharded_storage() + + @property def parameter_groups(self) -> tuple[FsdpParameterGroup, ...]: - """Return parameter groups owned by this FSDP unit.""" + """Parameter groups owned by this FSDP unit.""" return self._parameter_groups def _nvtx_label(self, phase: Literal["forward", "backward"]) -> str: diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 28145cf2e07..eeec848416b 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -232,12 +232,17 @@ def unshard_parameters(self) -> None: def reshard_parameters(self) -> None: """Install sharded DTensor parameters on the owning modules.""" self._switch_to_sharded_parameters() - # At post-backward time, replacing unsharded parameter .data with size-0 - # empty tensors would also be safe: autograd has consumed the saved - # forward views. That alternative is not much cleaner than releasing - # this storage, and splitting post-forward and post-backward reshard - # behavior would make the caller code less clean, so keep the shared - # storage-release path. + + def release_unsharded_storage(self) -> None: + """Release this group's full-parameter storage.""" + # This method is shared by the post-forward and post-backward release + # paths. Post-forward must release storage because autograd may have + # saved forward views into the unsharded parameters. Post-backward could + # replace unsharded parameter .data with size-0 empty tensors, instead + # of releasing storage, because autograd has consumed those saved views. + # That alternative is not much cleaner, and splitting post-forward and + # post-backward reshard behavior would make the caller code less clean, + # so keep the shared storage-release path. self._unsharded_model_weight.release_storage() def reduce_gradients(self) -> None: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_context.py b/tests/unit_tests/distributed/mfsdp_v2/test_context.py index 7235a094240..9ffd6bd8cdb 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_context.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_context.py @@ -83,7 +83,7 @@ def test_two_child_subtrees_then_parent_collapse_to_one_context(distributed_setu def test_sibling_roots_without_parent_keep_separate_contexts(distributed_setup): - """Independent FSDP roots should not share runtime state.""" + """Independent FSDP roots should not share runtime scheduling state.""" device = distributed_setup.device mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 869903fd7fa..229cd0bff4b 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -9,6 +9,7 @@ from torch import nn from torch.distributed.device_mesh import init_device_mesh from torch.distributed.tensor import DTensor +from torch.profiler import ProfilerActivity, profile from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( Flat, @@ -48,6 +49,22 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return self.inner(x) + self.bias +class MultiChildModel(nn.Module): + """Model with direct parameters and multiple child FSDP units.""" + + def __init__(self, dim: int, num_children: int) -> None: + super().__init__() + self.bias = nn.Parameter(torch.ones(dim)) + self.layers = nn.ModuleList([nn.Linear(dim, dim, bias=False) for _ in range(num_children)]) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Run through every child layer with a root-owned bias.""" + x = x + self.bias + for layer in self.layers: + x = torch.relu(layer(x)) + return x + + class SaveNonLeafWeightView(torch.autograd.Function): """Autograd function that saves a non-leaf parameter view for backward.""" @@ -87,6 +104,13 @@ def _mb(num_bytes: int) -> str: return f"{num_bytes / 1024**2:.2f} MB" +def _events_overlap(first, second) -> bool: + return ( + first.time_range.start < second.time_range.end + and second.time_range.start < first.time_range.end + ) + + @pytest.mark.parametrize("num_microbatches", [1, 3]) def test_fully_shard_losses_match_baseline(distributed_setup, num_microbatches): """Minimal per-module FSDP training should match single-rank SGD.""" @@ -157,15 +181,211 @@ def test_nested_fully_shard_excludes_child_owned_parameters(distributed_setup): fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) fully_shard(model, mesh=mesh, placements=_flat_placements()) - inner_names = [ - name for group in model.inner.parameter_groups() for name in group.parameter_names - ] - outer_names = [name for group in model.parameter_groups() for name in group.parameter_names] + inner_names = [name for group in model.inner.parameter_groups for name in group.parameter_names] + outer_names = [name for group in model.parameter_groups for name in group.parameter_names] assert inner_names == ["weight"] assert outer_names == ["bias"] +def test_forward_peak_memory_bounds_in_flight_child_all_gathers(distributed_setup): + """Forward peak memory should stay below three live child all-gathers.""" + rank = distributed_setup.rank + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + + mesh = init_device_mesh(device.type, (world_size,)) + dim = 4096 + dtype = torch.bfloat16 + model = MultiChildModel(dim=dim, num_children=4).to(dtype=dtype, device=device) + placements = _flat_placements() + policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + + x = torch.randn(2, dim, device=device, dtype=dtype) + with torch.no_grad(): + model(x) + torch.cuda.synchronize(device) + torch.cuda.empty_cache() + + resting_allocated = torch.cuda.memory_allocated(device) + torch.cuda.reset_peak_memory_stats(device) + with torch.no_grad(): + model(x) + torch.cuda.synchronize(device) + peak_delta = torch.cuda.max_memory_allocated(device) - resting_allocated + + child_weight_nbytes = dim * dim * torch.empty((), dtype=dtype).element_size() + bound_nbytes = 3 * child_weight_nbytes + + # A parent forward should keep one previous child unsharded until its compute + # stream consumer is safe, plus the current child being unsharded. The bound + # is looser than two child weights to avoid coupling this test to CUDA + # allocator granularity and small temporary buffers, while still catching + # delayed releases piling up across the four child layers. + assert peak_delta < bound_nbytes, ( + "FSDP forward peak memory exceeded the in-flight all-gather bound: " + f"rank={rank}, peak_delta={_mb(peak_delta)}, " + f"three_child_weights={_mb(bound_nbytes)}" + ) + + +def test_root_forward_returns_to_resting_memory(distributed_setup): + """Root forward should release child all-gather storage before returning.""" + rank = distributed_setup.rank + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + + mesh = init_device_mesh(device.type, (world_size,)) + dim = 4096 + dtype = torch.bfloat16 + model = MultiChildModel(dim=dim, num_children=2).to(dtype=dtype, device=device) + placements = _flat_placements() + policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + + x = torch.randn(2, dim, device=device, dtype=dtype) + torch.cuda.synchronize(device) + torch.cuda.empty_cache() + resting_allocated = torch.cuda.memory_allocated(device) + + with torch.no_grad(): + output = model(x) + del output + torch.cuda.synchronize(device) + allocated_after_forward = torch.cuda.memory_allocated(device) + extra_allocated = allocated_after_forward - resting_allocated + child_weight_nbytes = dim * dim * torch.empty((), dtype=dtype).element_size() + + assert extra_allocated < child_weight_nbytes, ( + "Root forward did not return to resting memory after draining child releases: " + f"rank={rank}, extra_allocated={_mb(extra_allocated)}, " + f"one_child_weight={_mb(child_weight_nbytes)}" + ) + + +def test_root_backward_returns_to_resting_memory(distributed_setup): + """Root backward should release child all-gather storage before returning.""" + rank = distributed_setup.rank + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + + mesh = init_device_mesh(device.type, (world_size,)) + dim = 4096 + dtype = torch.bfloat16 + model = MultiChildModel(dim=dim, num_children=2).to(dtype=dtype, device=device) + placements = _flat_placements() + policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + + x = torch.randn(2, dim, device=device, dtype=dtype, requires_grad=True) + output = model(x) + loss = output.float().square().mean() + torch.cuda.synchronize(device) + torch.cuda.empty_cache() + allocated_before_backward = torch.cuda.memory_allocated(device) + + loss.backward() + del loss, output + torch.cuda.synchronize(device) + allocated_after_backward = torch.cuda.memory_allocated(device) + extra_allocated = allocated_after_backward - allocated_before_backward + child_weight_nbytes = dim * dim * torch.empty((), dtype=dtype).element_size() + + assert extra_allocated < child_weight_nbytes, ( + "Root backward did not return to resting memory after draining child releases: " + f"rank={rank}, extra_allocated={_mb(extra_allocated)}, " + f"one_child_weight={_mb(child_weight_nbytes)}" + ) + + +def test_overlaps_all_gather_and_compute(distributed_setup): + """A shared root context should let child all-gathers overlap GEMM compute.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + + mesh = init_device_mesh(device.type, (world_size,)) + dim = 4096 + num_children = 4 + dtype = torch.bfloat16 + model = MultiChildModel(dim=dim, num_children=num_children).to(dtype=dtype) + placements = _flat_placements() + policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + + x = torch.randn(4096, dim, device=device, dtype=dtype, requires_grad=True) + + def train_one_iteration() -> None: + model.zero_grad(set_to_none=True) + model(x).sum().backward() + + train_one_iteration() + torch.cuda.synchronize(device) + + with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: + train_one_iteration() + # Synchronize inside the profiler context so in-flight device kernels + # complete and get recorded before the profiler stops on __exit__. + # Synchronizing after the context would finalize the trace first and + # drop the CUDA events. + torch.cuda.synchronize(device) + + cuda_events = [event for event in prof.events() if event.device_type.name == "CUDA"] + all_gather_events = [ + event + for event in cuda_events + if "nccl" in event.name.lower() and "allgather" in event.name.lower() + ] + # GEMM device-kernel names vary across CUDA/cuBLAS versions and GPU archs + # (e.g. "*gemm*", "cutlass*", "cublas*", and cuBLASLt's Hopper "nvjet_sm90_*"). + gemm_events = [ + event + for event in cuda_events + if any(token in event.name.lower() for token in ("gemm", "cutlass", "cublas", "nvjet")) + ] + assert all_gather_events, [event.name for event in cuda_events] + assert gemm_events, [event.name for event in cuda_events] + + all_gather_streams = {event.device_resource_id for event in all_gather_events} + gemm_streams = {event.device_resource_id for event in gemm_events} + assert len(all_gather_streams) == 1 + assert all_gather_streams.isdisjoint(gemm_streams) + + overlap_count = sum( + any(_events_overlap(all_gather_event, gemm_event) for gemm_event in gemm_events) + for all_gather_event in all_gather_events + ) + # This profiles a full forward/backward iteration, so backward all-gathers are + # included in all_gather_events. The expected overlap count is from the forward + # child pipeline: each child after the first can all-gather while the previous + # child computes, giving num_children - 1 overlaps. Backward does not overlap + # in this all-gather-only path because gradient reduction is not delayed: + # each module synchronously reduces gradients in post_backward before autograd + # reaches the next module's pre_backward all-gather. The next PR addresses + # this by delaying gradient reduction. + expected_overlap_count = num_children - 1 + assert overlap_count >= expected_overlap_count, ( + f"Expected at least {expected_overlap_count} all-gather events to overlap compute, " + f"got {overlap_count}/{len(all_gather_events)}." + ) + + def test_frozen_parameter_group_does_not_allocate_main_grad(distributed_setup): """A non-trainable parameter group should not allocate persistent main gradients.""" world_size = distributed_setup.world_size @@ -179,7 +399,7 @@ def test_frozen_parameter_group_does_not_allocate_main_grad(distributed_setup): fully_shard(model, mesh=mesh, placements=_flat_placements()) - (group,) = model.parameter_groups() + (group,) = model.parameter_groups assert not group.requires_grad assert group.main_grad is None @@ -279,7 +499,7 @@ def test_cpu_initialized_parameters_shard_to_mesh_device(distributed_setup): fully_shard(model, mesh=mesh, placements=_flat_placements()) - (group,) = model.parameter_groups() + (group,) = model.parameter_groups full_weight = group.model_weight.allgather(0).get_local_tensor(0) assert full_weight.device.type == device.type torch.testing.assert_close(full_weight, expected_weight) @@ -296,7 +516,7 @@ def test_non_leaf_parameter_view_survives_storage_resize(distributed_setup): model = NonLeafViewModel().to(device) fully_shard(model, mesh=mesh, placements=_flat_placements()) - group = model.parameter_groups()[0] + group = model.parameter_groups[0] x = torch.randn(8, device=device, requires_grad=True) loss = model(x).sum() From e86c262ccd0c021384376c4b911e9f0a3d4877f5 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Thu, 9 Jul 2026 18:35:49 -0700 Subject: [PATCH 003/290] Fix seq_load_balancing loss with inter-document masking and MBS > 1 (#5696) Signed-off-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 --- .../core/extensions/transformer_engine.py | 2 + megatron/core/packed_seq_params.py | 1 + .../core/transformer/transformer_layer.py | 76 +++++++++++++++++-- pretrain_gpt.py | 1 + pretrain_hybrid.py | 1 + .../transformer/moe/test_aux_loss.py | 63 +++++++++++++++ 6 files changed, 139 insertions(+), 5 deletions(-) diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index b7de1013695..ac7d5c1da9b 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -1749,8 +1749,10 @@ def __init__( self.kept_packed_seq_params.discard("cu_seqlens_kv_padded") # total_tokens and seq_idx are only for Mamba and should not be forwarded to TE attention. + # tokens_per_sample is only for MoE sequence-level aux loss reshaping. self.kept_packed_seq_params.discard("total_tokens") self.kept_packed_seq_params.discard("seq_idx") + self.kept_packed_seq_params.discard("tokens_per_sample") if config.qk_clip or config.log_max_attention_logit: # qk-clip is only supported in TE 2.9.0 and later diff --git a/megatron/core/packed_seq_params.py b/megatron/core/packed_seq_params.py index 322f12a4122..bd598bb557a 100644 --- a/megatron/core/packed_seq_params.py +++ b/megatron/core/packed_seq_params.py @@ -24,6 +24,7 @@ class PackedSeqParams: cp_group: dist.ProcessGroup = None total_tokens: int = None seq_idx: Tensor = None + tokens_per_sample: int = None def __post_init__(self): """Pre-compute seq_idx for Mamba mixer CUDA graph compatibility. diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index 904912c18d8..f6ea382077e 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -742,6 +742,7 @@ def forward(self, *args, **kwargs): hidden_states, kwargs.get("inference_context", None), padding_mask=kwargs.get("padding_mask", None), + packed_seq_params=kwargs.get("packed_seq_params", None), ) return output, context @@ -759,11 +760,51 @@ def _forward_pre_mlp_layernorm(self, hidden_states: Tensor): return pre_mlp_layernorm_output + def _maybe_unflatten_for_moe(self, hidden_states, padding_mask, packed_seq_params): + """Un-flatten packed sequences to restore the batch dimension for MoE. + + When inter-document masking flattens MBS > 1 into [mbs*S, 1, H], the MoE + router sees bsz=1 and computes seq_aux_loss over the entire flattened + sequence instead of per sample. Un-flattening to [S, mbs, H] before the + MoE layer restores the correct per-sample structure. + + Returns: + (hidden_states, padding_mask, mbs) where mbs is None if no + un-flattening was applied. + """ + if ( + not self.is_moe_layer + or packed_seq_params is None + or getattr(packed_seq_params, 'tokens_per_sample', None) is None + ): + return hidden_states, padding_mask, None + + tokens_per_sample = packed_seq_params.tokens_per_sample + mbs = hidden_states.shape[0] // tokens_per_sample + if mbs <= 1: + return hidden_states, padding_mask, None + + # The flattened tensor has all tokens from sample 0, then all tokens + # from sample 1, etc. A plain reshape would keep that ordering, but we + # need dim 0 to be the token position and dim 1 to be the sample index, + # so view + transpose is required. + hidden_states = hidden_states.view(mbs, tokens_per_sample, -1).transpose(0, 1).contiguous() + if padding_mask is not None: + padding_mask = padding_mask.view(mbs, tokens_per_sample) + return hidden_states, padding_mask, mbs + + def _maybe_reflatten_from_moe(self, output, packed_seq_params, mbs): + """Re-flatten MoE output back to [mbs*S, 1, H] for the residual add.""" + if mbs is None: + return output + return output.transpose(0, 1).reshape(mbs * packed_seq_params.tokens_per_sample, 1, -1) + def _forward_mlp( self, hidden_states: Tensor, inference_context: BaseInferenceContext | None = None, padding_mask: Tensor | None = None, + packed_seq_params=None, ) -> Tensor | list[Tensor | None]: """ Perform a forward pass through the feed-forward layer. @@ -776,6 +817,8 @@ def _forward_mlp( Shape [bsz, seq_length]. True = padding (exclude), False = valid (include). Only used for MoE layers to exclude padding tokens from aux loss computations. The MoELayer will internally transform this to [seq_length, bsz] format. + packed_seq_params: Packed sequence parameters, used to detect flattened + batches that need reshaping for MoE sequence load balancing. Returns: output (Tensor): Transformed hidden states of shape [s, b, h]. """ @@ -798,6 +841,10 @@ def _forward_mlp( if self.config.fp32_residual_connection: residual = residual.float() + pre_mlp_layernorm_output, padding_mask, moe_unflatten_mbs = self._maybe_unflatten_for_moe( + pre_mlp_layernorm_output, padding_mask, packed_seq_params + ) + nvtx_range_push(suffix="mlp") # Potentially chunk the MLP computation during prefill to minimize the peak activation size should_chunk_mlp_for_prefill = ( @@ -868,6 +915,13 @@ def _forward_mlp( pre_mlp_layernorm_output, padding_mask=padding_mask ) + if moe_unflatten_mbs is not None: + mlp_output, mlp_bias = mlp_output_with_bias + mlp_output = self._maybe_reflatten_from_moe( + mlp_output, packed_seq_params, moe_unflatten_mbs + ) + mlp_output_with_bias = (mlp_output, mlp_bias) + nvtx_range_pop(suffix="mlp") if ( @@ -1645,7 +1699,9 @@ def _forward_mlp_postprocess(self, residual, output, shared_expert_output, mlp_b return out - def _forward_mlp(self, hidden_states, inference_context=None, padding_mask=None): + def _forward_mlp( + self, hidden_states, inference_context=None, padding_mask=None, packed_seq_params=None + ): """ Orchestrates the MLP forward pass, handling partial CUDA graph execution logic. @@ -1680,11 +1736,15 @@ def _forward_mlp_partial_cudagraphs( ) if self.use_partial_cudagraphs: + hidden_states, padding_mask, moe_unflatten_mbs = self._maybe_unflatten_for_moe( + hidden_states, padding_mask, packed_seq_params + ) + if self.moe_layer_recompute: if self.config.fp8 or self.config.fp4: from megatron.core.extensions.transformer_engine import te_checkpoint - return te_checkpoint( + result = te_checkpoint( _forward_mlp_partial_cudagraphs, False, tensor_parallel.random.get_cuda_rng_tracker, @@ -1693,7 +1753,7 @@ def _forward_mlp_partial_cudagraphs( padding_mask=padding_mask, ) else: - return tensor_parallel.checkpoint( + result = tensor_parallel.checkpoint( functools.partial( _forward_mlp_partial_cudagraphs, padding_mask=padding_mask ), @@ -1701,6 +1761,12 @@ def _forward_mlp_partial_cudagraphs( hidden_states, ) else: - return _forward_mlp_partial_cudagraphs(hidden_states, padding_mask=padding_mask) + result = _forward_mlp_partial_cudagraphs(hidden_states, padding_mask=padding_mask) + + result = self._maybe_reflatten_from_moe(result, packed_seq_params, moe_unflatten_mbs) + + return result else: - return super()._forward_mlp(hidden_states, padding_mask=padding_mask) + return super()._forward_mlp( + hidden_states, padding_mask=padding_mask, packed_seq_params=packed_seq_params + ) diff --git a/pretrain_gpt.py b/pretrain_gpt.py index 0a87db5cdb1..06bae9965f1 100644 --- a/pretrain_gpt.py +++ b/pretrain_gpt.py @@ -325,6 +325,7 @@ def forward_step(data_iterator, model: GPTModel, return_schedule_plan: bool = Fa max_seqlen_kv=int(max_seqlen.item()), local_cp_size=int(local_cp_size.item()) if local_cp_size is not None else None, cp_group=hybrid_cp_group, + tokens_per_sample=args.seq_length, ) timers('batch-generator').stop() diff --git a/pretrain_hybrid.py b/pretrain_hybrid.py index 6cd65b4e47c..46f5e74d824 100644 --- a/pretrain_hybrid.py +++ b/pretrain_hybrid.py @@ -321,6 +321,7 @@ def forward_step(data_iterator, model: HybridModel): local_cp_size=int(local_cp_size.item()) if local_cp_size is not None else None, cp_group=hybrid_cp_group, total_tokens=int(cu_seqlens_for_params[-1].item()), + tokens_per_sample=args.seq_length, ) timers('batch-generator').stop() diff --git a/tests/unit_tests/transformer/moe/test_aux_loss.py b/tests/unit_tests/transformer/moe/test_aux_loss.py index 118203ee1a6..de4fa906821 100644 --- a/tests/unit_tests/transformer/moe/test_aux_loss.py +++ b/tests/unit_tests/transformer/moe/test_aux_loss.py @@ -634,6 +634,69 @@ def test_force_balanced_aux_loss(self, tp_size, ep_size, cp_size): assert aux_loss.item() == 1, f"{aux_loss_type}: {aux_loss.item()}" clear_aux_losses_tracker() + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + def test_seq_aux_loss_flattened_packed_sequences(self): + """Test that TransformerLayer reshapes flattened packed sequences for MoE. + + When inter-document masking flattens MBS > 1 into [mbs*S, 1, H], + TransformerLayer._maybe_reshape_for_moe should restore [S, mbs, H] so + the router computes seq_aux_loss per sample. This test runs a forward + pass through a real TransformerLayer with an MoE MLP and verifies that passing + packed_seq_params with the flattened input produces the same + seq_load_balancing_loss as the un-flattened input. + """ + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_submodules + from megatron.core.packed_seq_params import PackedSeqParams + from megatron.core.transformer.transformer_layer import TransformerLayer + + seq_len = 128 + batch_size = 4 + hidden_size = 12 + + transformer_config = TransformerConfig( + num_layers=1, + hidden_size=hidden_size, + num_attention_heads=4, + num_moe_experts=32, + use_cpu_initialization=True, + moe_router_load_balancing_type="seq_aux_loss", + moe_router_topk=2, + moe_aux_loss_coeff=1.0, + moe_ffn_hidden_size=64, + add_bias_linear=False, + bf16=True, + params_dtype=torch.bfloat16, + hidden_dropout=0.0, + ) + submodules = get_gpt_layer_local_submodules(num_experts=32, moe_grouped_gemm=False) + layer = TransformerLayer(transformer_config, submodules).cuda().bfloat16() + assert layer.is_moe_layer + + hidden_states = torch.randn( + (seq_len, batch_size, hidden_size), device=torch.device("cuda"), dtype=torch.bfloat16 + ) + + def _get_seq_aux_loss(hidden_states, packed_seq_params=None): + clear_aux_losses_tracker() + layer._forward_mlp(hidden_states, packed_seq_params=packed_seq_params) + return get_moe_layer_wise_logging_tracker()["seq_load_balancing_loss"]["values"][0] + + # Baseline: forward with the original [seq_len, mbs, H] shape. + loss_baseline = _get_seq_aux_loss(hidden_states) + + # Flatten to [mbs*seq_len, 1, H] the same way the dataloader does. + flattened = hidden_states.transpose(0, 1).reshape(batch_size * seq_len, 1, -1) + + # With packed_seq_params, _maybe_reshape_for_moe restores [S, mbs, H] + # before the router, recovering the correct per-sample loss. + packed_seq_params = PackedSeqParams(tokens_per_sample=seq_len) + loss_with_implicit_reshape = _get_seq_aux_loss( + flattened, packed_seq_params=packed_seq_params + ) + + torch.testing.assert_close(loss_with_implicit_reshape, loss_baseline) + class TestPaddingMaskAuxLoss: """Test padding mask support in various aux loss types.""" From 779c5b748dbcf00ad9e36d539c576b404ab4abe9 Mon Sep 17 00:00:00 2001 From: Guihong Li Date: Thu, 9 Jul 2026 19:43:09 -0700 Subject: [PATCH 004/290] Avoid X11 master port default (#5299) Signed-off-by: guihong-nv --- examples/academic_paper_scripts/msdp/eval_knwl_generation.sh | 2 +- examples/academic_paper_scripts/msdp/eval_resp_generation.sh | 2 +- examples/academic_paper_scripts/msdp/prompt_knwl_gen.sh | 2 +- examples/academic_paper_scripts/msdp/prompt_resp_gen.sh | 2 +- examples/bert/train_bert_340m_distributed.sh | 2 +- examples/gpt3/train_gpt3_175b_distributed.sh | 2 +- examples/gptoss/02_train.sh | 4 ++-- examples/gptoss/README.md | 2 +- examples/llama/train_llama3_8b_h100_fp8.sh | 2 +- examples/mamba/run_text_gen_server_8b.sh | 2 +- examples/mamba/run_text_gen_server_8b_gpt3.sh | 2 +- examples/megatron_fsdp/README.md | 2 +- examples/megatron_fsdp/train_llama3_8b_fsdp_h100_fp8.sh | 2 +- examples/mixtral/README.md | 2 +- examples/mixtral/train_mixtral_8x7b_distributed.sh | 2 +- examples/t5/train_t5_220m_distributed.sh | 2 +- megatron/core/transformer/moe/README.md | 2 +- tests/functional_tests/shell_test_utils/_run_training.sh | 2 +- tests/performance_tests/shell_test_utils/run_perf_test.sh | 2 +- tests/unit_tests/run_ci_test.sh | 2 +- tests/unit_tests/test_utilities.py | 2 +- 21 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/academic_paper_scripts/msdp/eval_knwl_generation.sh b/examples/academic_paper_scripts/msdp/eval_knwl_generation.sh index 8fc2fff1fb7..4735850797d 100644 --- a/examples/academic_paper_scripts/msdp/eval_knwl_generation.sh +++ b/examples/academic_paper_scripts/msdp/eval_knwl_generation.sh @@ -9,7 +9,7 @@ DISTRIBUTED_ARGS="--nproc_per_node $WORLD_SIZE \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" MODEL_GEN_PATH= \ (e.g., /testseen_knowledge_generations.txt) diff --git a/examples/academic_paper_scripts/msdp/eval_resp_generation.sh b/examples/academic_paper_scripts/msdp/eval_resp_generation.sh index 3ce87e07795..084d10de2fc 100644 --- a/examples/academic_paper_scripts/msdp/eval_resp_generation.sh +++ b/examples/academic_paper_scripts/msdp/eval_resp_generation.sh @@ -9,7 +9,7 @@ DISTRIBUTED_ARGS="--nproc_per_node $WORLD_SIZE \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" MODEL_GEN_PATH= \ (e.g., /testseen_response_generations.txt) diff --git a/examples/academic_paper_scripts/msdp/prompt_knwl_gen.sh b/examples/academic_paper_scripts/msdp/prompt_knwl_gen.sh index 12e0cc5b380..5e4081c1134 100644 --- a/examples/academic_paper_scripts/msdp/prompt_knwl_gen.sh +++ b/examples/academic_paper_scripts/msdp/prompt_knwl_gen.sh @@ -10,7 +10,7 @@ DISTRIBUTED_ARGS="--nproc_per_node $WORLD_SIZE \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" CHECKPOINT_PATH= (e.g., /357m) VOCAB_PATH= (e.g., /gpt2-vocab.json) diff --git a/examples/academic_paper_scripts/msdp/prompt_resp_gen.sh b/examples/academic_paper_scripts/msdp/prompt_resp_gen.sh index b836d7feacf..1ec44405af9 100644 --- a/examples/academic_paper_scripts/msdp/prompt_resp_gen.sh +++ b/examples/academic_paper_scripts/msdp/prompt_resp_gen.sh @@ -11,7 +11,7 @@ DISTRIBUTED_ARGS="--nproc_per_node $WORLD_SIZE \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" CHECKPOINT_PATH= (e.g., /357m) VOCAB_PATH= (e.g., /gpt2-vocab.json) diff --git a/examples/bert/train_bert_340m_distributed.sh b/examples/bert/train_bert_340m_distributed.sh index f0d9c87c8bf..81c88952f2e 100644 --- a/examples/bert/train_bert_340m_distributed.sh +++ b/examples/bert/train_bert_340m_distributed.sh @@ -7,7 +7,7 @@ export CUDA_DEVICE_MAX_CONNECTIONS=1 GPUS_PER_NODE=8 # Change for multinode config MASTER_ADDR=localhost -MASTER_PORT=6000 +MASTER_PORT=29500 NUM_NODES=1 NODE_RANK=0 WORLD_SIZE=$(($GPUS_PER_NODE*$NUM_NODES)) diff --git a/examples/gpt3/train_gpt3_175b_distributed.sh b/examples/gpt3/train_gpt3_175b_distributed.sh index be00d76120d..0b4977b4288 100755 --- a/examples/gpt3/train_gpt3_175b_distributed.sh +++ b/examples/gpt3/train_gpt3_175b_distributed.sh @@ -7,7 +7,7 @@ export CUDA_DEVICE_MAX_CONNECTIONS=1 GPUS_PER_NODE=8 # Change for multinode config MASTER_ADDR=localhost -MASTER_PORT=6000 +MASTER_PORT=29500 NUM_NODES=1 NODE_RANK=0 WORLD_SIZE=$(($GPUS_PER_NODE*$NUM_NODES)) diff --git a/examples/gptoss/02_train.sh b/examples/gptoss/02_train.sh index d129adc2b84..2659b17cc19 100755 --- a/examples/gptoss/02_train.sh +++ b/examples/gptoss/02_train.sh @@ -71,7 +71,7 @@ echo "TensorBoard logs path exists: $TENSORBOARD_LOGS_PATH" GPUS_PER_NODE=8 NUM_NODES=1 MASTER_ADDR="localhost" -MASTER_PORT=6000 +MASTER_PORT=29500 NODE_RANK=0 # Load distributed config from file if provided @@ -89,7 +89,7 @@ fi GPUS_PER_NODE=${GPUS_PER_NODE:-8} NUM_NODES=${NUM_NODES:-1} MASTER_ADDR=${MASTER_ADDR:-localhost} -MASTER_PORT=${MASTER_PORT:-6000} +MASTER_PORT=${MASTER_PORT:-29500} NODE_RANK=${NODE_RANK:-0} WORLD_SIZE=$(($GPUS_PER_NODE*$NUM_NODES)) diff --git a/examples/gptoss/README.md b/examples/gptoss/README.md index eeb92ad9953..78347221b7c 100644 --- a/examples/gptoss/README.md +++ b/examples/gptoss/README.md @@ -93,7 +93,7 @@ cat > ./distributed_config.env << 'EOF' GPUS_PER_NODE=8 NUM_NODES=1 MASTER_ADDR=localhost -MASTER_PORT=6000 +MASTER_PORT=29500 NODE_RANK=0 EOF ``` diff --git a/examples/llama/train_llama3_8b_h100_fp8.sh b/examples/llama/train_llama3_8b_h100_fp8.sh index 28227546bc7..266417d7a1c 100644 --- a/examples/llama/train_llama3_8b_h100_fp8.sh +++ b/examples/llama/train_llama3_8b_h100_fp8.sh @@ -22,7 +22,7 @@ mkdir -p "$(dirname "$TENSORBOARD_LOGS_PATH")" GPUS_PER_NODE=8 NUM_NODES=1 MASTER_ADDR=${MASTER_ADDR:-localhost} -MASTER_PORT=${MASTER_PORT:-6000} +MASTER_PORT=${MASTER_PORT:-29500} NODE_RANK=${NODE_RANK:-0} WORLD_SIZE=$(($GPUS_PER_NODE*$NUM_NODES)) diff --git a/examples/mamba/run_text_gen_server_8b.sh b/examples/mamba/run_text_gen_server_8b.sh index f183dea4ad1..77921aae04b 100755 --- a/examples/mamba/run_text_gen_server_8b.sh +++ b/examples/mamba/run_text_gen_server_8b.sh @@ -12,7 +12,7 @@ DISTRIBUTED_ARGS="--nproc_per_node 1 \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" export NCCL_IB_SL=1 export CUDA_DEVICE_MAX_CONNECTIONS=1 diff --git a/examples/mamba/run_text_gen_server_8b_gpt3.sh b/examples/mamba/run_text_gen_server_8b_gpt3.sh index 5413b245ed3..af2fdecde14 100644 --- a/examples/mamba/run_text_gen_server_8b_gpt3.sh +++ b/examples/mamba/run_text_gen_server_8b_gpt3.sh @@ -10,7 +10,7 @@ DISTRIBUTED_ARGS="--nproc_per_node 1 \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" export NCCL_IB_SL=1 export CUDA_DEVICE_MAX_CONNECTIONS=1 diff --git a/examples/megatron_fsdp/README.md b/examples/megatron_fsdp/README.md index cc37911c12d..4e0a4fa2ab6 100644 --- a/examples/megatron_fsdp/README.md +++ b/examples/megatron_fsdp/README.md @@ -49,7 +49,7 @@ USE_UV=0 bash examples/megatron_fsdp/train_llama3_8b_fsdp_h100_fp8.sh | `SHARDING_STRATEGY` | `optim_grads_params` | FSDP sharding strategy (ZeRO-3). Options: `no_shard`, `optim`, `optim_grads`, `optim_grads_params`. | | `OUTER_SHARDING_STRATEGY` | `no_shard` | DP-Outer sharding strategy for HSDP/HFSDP. Options: `no_shard`, `optim`. | | `MASTER_ADDR` | `localhost` | Master node address for distributed training. | -| `MASTER_PORT` | `6000` | Master node port. | +| `MASTER_PORT` | `29500` | Master node port. | | `NODE_RANK` | `0` | Rank of the current node. | #### Configuration Summary diff --git a/examples/megatron_fsdp/train_llama3_8b_fsdp_h100_fp8.sh b/examples/megatron_fsdp/train_llama3_8b_fsdp_h100_fp8.sh index 46e26a3ab85..5014366897e 100755 --- a/examples/megatron_fsdp/train_llama3_8b_fsdp_h100_fp8.sh +++ b/examples/megatron_fsdp/train_llama3_8b_fsdp_h100_fp8.sh @@ -15,7 +15,7 @@ mkdir -p "$(dirname "$TENSORBOARD_LOGS_PATH")" GPUS_PER_NODE=8 NUM_NODES=1 MASTER_ADDR=${MASTER_ADDR:-localhost} -MASTER_PORT=${MASTER_PORT:-6000} +MASTER_PORT=${MASTER_PORT:-29500} NODE_RANK=${NODE_RANK:-0} WORLD_SIZE=$(($GPUS_PER_NODE*$NUM_NODES)) diff --git a/examples/mixtral/README.md b/examples/mixtral/README.md index e85eccd6efd..2cf59609a33 100644 --- a/examples/mixtral/README.md +++ b/examples/mixtral/README.md @@ -55,7 +55,7 @@ DISTRIBUTED_ARGS="--nproc_per_node 2 \ --nnodes 1 \ --node_rank 0 \ --master_addr localhost \ - --master_port 6000" + --master_port 29500" CHECKPOINT= TOKENIZER_MODEL= diff --git a/examples/mixtral/train_mixtral_8x7b_distributed.sh b/examples/mixtral/train_mixtral_8x7b_distributed.sh index ed44d60f5c0..7c086934dbc 100644 --- a/examples/mixtral/train_mixtral_8x7b_distributed.sh +++ b/examples/mixtral/train_mixtral_8x7b_distributed.sh @@ -7,7 +7,7 @@ export CUDA_DEVICE_MAX_CONNECTIONS=1 GPUS_PER_NODE=8 # Change for multinode config MASTER_ADDR=${MASTER_ADDR:-"localhost"} -MASTER_PORT=${MASTER_PORT:-"6000"} +MASTER_PORT=${MASTER_PORT:-"29500"} NNODES=${SLURM_NNODES:-"1"} NODE_RANK=${RANK:-"0"} WORLD_SIZE=$(($GPUS_PER_NODE*$NNODES)) diff --git a/examples/t5/train_t5_220m_distributed.sh b/examples/t5/train_t5_220m_distributed.sh index 62e6f9db4bd..8636e66b662 100755 --- a/examples/t5/train_t5_220m_distributed.sh +++ b/examples/t5/train_t5_220m_distributed.sh @@ -7,7 +7,7 @@ export CUDA_DEVICE_MAX_CONNECTIONS=1 GPUS_PER_NODE=8 # Change for multinode config MASTER_ADDR=localhost -MASTER_PORT=6000 +MASTER_PORT=29500 NUM_NODES=1 NODE_RANK=0 WORLD_SIZE=$(($GPUS_PER_NODE*$NUM_NODES)) diff --git a/megatron/core/transformer/moe/README.md b/megatron/core/transformer/moe/README.md index 57a657d0a61..f3c35fe4f6e 100644 --- a/megatron/core/transformer/moe/README.md +++ b/megatron/core/transformer/moe/README.md @@ -587,7 +587,7 @@ export CUDA_DEVICE_MAX_CONNECTIONS=1 GPUS_PER_NODE=8 MASTER_ADDR=${MASTER_ADDR:-"localhost"} -MASTER_PORT=${MASTER_PORT:-"6000"} +MASTER_PORT=${MASTER_PORT:-"29500"} NNODES=${NNODES:-"4"} NODE_RANK=${RANK:-"0"} WORLD_SIZE=$(($GPUS_PER_NODE*$NNODES)) diff --git a/tests/functional_tests/shell_test_utils/_run_training.sh b/tests/functional_tests/shell_test_utils/_run_training.sh index 7441f210c22..0ef6a9abcad 100644 --- a/tests/functional_tests/shell_test_utils/_run_training.sh +++ b/tests/functional_tests/shell_test_utils/_run_training.sh @@ -180,7 +180,7 @@ set -x ######## Distributed training settings. ######## echo "------ARGUMENTS for SLURM ---" MASTER_ADDR=${MASTER_ADDR:-localhost} -MASTER_PORT=${MASTER_PORT:-6000} +MASTER_PORT=${MASTER_PORT:-29500} NUM_NODES=${NUM_NODES:-${SLURM_NNODES:-1}} GPUS_PER_NODE=${GPUS_PER_NODE:-8} NODE_RANK=${SLURM_NODEID:-${NODE_RANK:-0}} diff --git a/tests/performance_tests/shell_test_utils/run_perf_test.sh b/tests/performance_tests/shell_test_utils/run_perf_test.sh index 60d95314b0f..b8effb030b5 100755 --- a/tests/performance_tests/shell_test_utils/run_perf_test.sh +++ b/tests/performance_tests/shell_test_utils/run_perf_test.sh @@ -171,7 +171,7 @@ fi # ── Launch the inference server in the background ───────────────────────────── MASTER_ADDR=${MASTER_ADDR:-localhost} -MASTER_PORT=${MASTER_PORT:-6000} +MASTER_PORT=${MASTER_PORT:-29500} SERVER_PORT=${SERVER_PORT:-5000} SERVER_LOG="$SERVER_LOG_DIR/server.log" diff --git a/tests/unit_tests/run_ci_test.sh b/tests/unit_tests/run_ci_test.sh index eaca4fe2441..a51f7a21449 100755 --- a/tests/unit_tests/run_ci_test.sh +++ b/tests/unit_tests/run_ci_test.sh @@ -130,7 +130,7 @@ done < <(python tests/unit_tests/find_test_cases.py "$BUCKET" "$PLATFORM") echo "------ARGUMENTS for SLURM ---" MASTER_ADDR=${MASTER_ADDR:-localhost} -MASTER_PORT=${MASTER_PORT:-6000} +MASTER_PORT=${MASTER_PORT:-29500} NUM_NODES=${NUM_NODES:-${SLURM_NNODES:-1}} GPUS_PER_NODE=${GPUS_PER_NODE:-8} NODE_RANK=${SLURM_NODEID:-${SLURM_NODEID:-0}} diff --git a/tests/unit_tests/test_utilities.py b/tests/unit_tests/test_utilities.py index 9529a419938..0ff22fefb5f 100644 --- a/tests/unit_tests/test_utilities.py +++ b/tests/unit_tests/test_utilities.py @@ -62,7 +62,7 @@ def initialize_distributed(): torch.cuda.set_device(Utils.rank % torch.cuda.device_count()) init_method = 'tcp://' master_ip = os.getenv('MASTER_ADDR', 'localhost') - master_port = os.getenv('MASTER_PORT', '6000') + master_port = os.getenv('MASTER_PORT', '29500') init_method += master_ip + ':' + master_port rendezvous_iterator = rendezvous( init_method, Utils.rank, Utils.world_size, timeout=timedelta(minutes=1) From 1aa880d0ea1dfddef567785ebc9a384f2a600d18 Mon Sep 17 00:00:00 2001 From: Asadbek Xodjayev <100586658+asadbekXodjayev@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:20:56 +0500 Subject: [PATCH 005/290] Fix infinite recursion in abstract tokenizer special-id property aliases (#5445) Signed-off-by: asadbekXodjayev Co-authored-by: asadbekXodjayev Co-authored-by: Dmytro Pykhtar <37850217+dimapihtar@users.noreply.github.com> --- .../text/libraries/abstract_tokenizer.py | 24 ++++++++-------- tests/unit_tests/tokenizers/test_tokenizer.py | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/megatron/core/tokenizers/text/libraries/abstract_tokenizer.py b/megatron/core/tokenizers/text/libraries/abstract_tokenizer.py index 360db03e5f2..8f5b7d3b4f5 100644 --- a/megatron/core/tokenizers/text/libraries/abstract_tokenizer.py +++ b/megatron/core/tokenizers/text/libraries/abstract_tokenizer.py @@ -95,22 +95,22 @@ def add_special_tokens(self): @property def cls_id(self) -> int: """Property alias to match MegatronTokenizer; returns cls_id if available.""" - if hasattr(self, 'cls_id'): - return self.cls_id + if hasattr(self, 'cls'): + return self.cls raise AttributeError(f"{type(self).__name__} has no attribute 'cls' or 'cls_id'") @property def sep_id(self) -> int: """Property alias to match MegatronTokenizer; returns sep_id if available.""" - if hasattr(self, 'sep_id'): - return self.sep_id + if hasattr(self, 'sep'): + return self.sep raise AttributeError(f"{type(self).__name__} has no attribute 'sep' or 'sep_id'") @property def pad_id(self) -> int: """Property alias to match MegatronTokenizer; returns pad_id if available.""" - if hasattr(self, 'pad_id'): - return self.pad_id + if hasattr(self, 'pad'): + return self.pad raise AttributeError(f"{type(self).__name__} has no attribute 'pad' or 'pad_id'") @property @@ -128,20 +128,20 @@ def eod(self) -> int: @property def bos_id(self) -> int: """Property alias to match MegatronTokenizer; returns bos_id if available.""" - if hasattr(self, 'bos_id'): - return self.bos_id + if hasattr(self, 'bos'): + return self.bos raise AttributeError(f"{type(self).__name__} has no attribute 'bos' or 'bos_id'") @property def eos_id(self) -> int: """Property alias to match MegatronTokenizer; returns eos_id if available.""" - if hasattr(self, 'eos_id'): - return self.eos_id + if hasattr(self, 'eos'): + return self.eos raise AttributeError(f"{type(self).__name__} has no attribute 'eos' or 'eos_id'") @property def mask_id(self) -> int: """Property alias to match MegatronTokenizer; returns mask_id if available.""" - if hasattr(self, 'mask_id'): - return self.mask_id + if hasattr(self, 'mask'): + return self.mask raise AttributeError(f"{type(self).__name__} has no attribute 'mask' or 'mask_id'") diff --git a/tests/unit_tests/tokenizers/test_tokenizer.py b/tests/unit_tests/tokenizers/test_tokenizer.py index 9c42f5b90be..48432bda8fb 100755 --- a/tests/unit_tests/tokenizers/test_tokenizer.py +++ b/tests/unit_tests/tokenizers/test_tokenizer.py @@ -8,6 +8,7 @@ from packaging import version from megatron.core.tokenizers import MegatronTokenizer +from megatron.core.tokenizers.text.libraries.bytelevel_tokenizer import ByteLevelTokenizer from megatron.core.tokenizers.utils.build_tokenizer import build_tokenizer try: @@ -694,3 +695,30 @@ def test_1d_ndarray(self): # --- 2D raw ndarray (1, seq_len) — the bug fixed in this PR --- def test_2d_ndarray_batch1(self): self._check(np.array([_IDS])) # shape (1, 5) + + +class TestAbstractTokenizerSpecialIdAliases: + """Regression tests for the special-id property aliases on + ``MegatronTokenizerTextAbstract`` (cls_id / sep_id / pad_id / bos_id / eos_id / mask_id). + + Each alias previously checked ``hasattr(self, '_id')`` and returned + ``self._id`` — i.e. it re-entered itself — so accessing an alias that a + subclass did not override raised ``RecursionError`` instead of returning the + backing short-name attribute (``self.cls`` ...) or a clean ``AttributeError``. + ``ByteLevelTokenizer`` overrides pad_id/bos_id/eos_id but not cls_id/sep_id/mask_id, + so those reach the base implementation and exercise the shared fix. + """ + + def test_unoverridden_alias_raises_attributeerror_not_recursion(self): + tok = ByteLevelTokenizer(vocab_size=512) + # No backing short-name attribute -> a clean AttributeError, not RecursionError. + for name in ("cls_id", "sep_id", "mask_id"): + with pytest.raises(AttributeError): + getattr(tok, name) + + def test_alias_returns_backing_short_name_attribute(self): + tok = ByteLevelTokenizer(vocab_size=512) + tok.cls, tok.sep, tok.mask = 5, 6, 7 + assert tok.cls_id == 5 + assert tok.sep_id == 6 + assert tok.mask_id == 7 From 2c579b45b96b6eb0fdc45de0a819697f0e09afb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Buschk=C3=A4mper?= Date: Fri, 10 Jul 2026 18:28:41 +0200 Subject: [PATCH 006/290] Set Bert TE spec q/k_layernorm to None (#5687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Björn Buschkämper Co-authored-by: Philip Petrakian --- megatron/core/models/bert/bert_layer_specs.py | 7 ++-- tests/unit_tests/models/test_bert_model.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/megatron/core/models/bert/bert_layer_specs.py b/megatron/core/models/bert/bert_layer_specs.py index dc0099fa66e..0da3d1b4326 100644 --- a/megatron/core/models/bert/bert_layer_specs.py +++ b/megatron/core/models/bert/bert_layer_specs.py @@ -62,8 +62,11 @@ def get_bert_layer_with_transformer_engine_submodules() -> TransformerLayerSubmo linear_qkv=not_none(TELayerNormColumnParallelLinear), core_attention=not_none(TEDotProductAttention), linear_proj=not_none(TERowParallelLinear), - q_layernorm=IdentityOp, - k_layernorm=IdentityOp, + # Leave q_layernorm/k_layernorm unset (None) rather than IdentityOp so that + # TransformerConfig.qk_layernorm can select the default TENorm through the + # shared SelfAttention fallback (`submodules.q_layernorm or TENorm`). + q_layernorm=None, + k_layernorm=None, ), ), self_attn_bda=get_bias_dropout_add, diff --git a/tests/unit_tests/models/test_bert_model.py b/tests/unit_tests/models/test_bert_model.py index db7b8255776..fb3385b8723 100644 --- a/tests/unit_tests/models/test_bert_model.py +++ b/tests/unit_tests/models/test_bert_model.py @@ -92,6 +92,42 @@ def test_post_process_forward(self): assert logits[0].shape[1] == sequence_length assert logits[0].shape[2] == self.bert_model.vocab_size + @pytest.mark.internal + def test_qk_layernorm_submodules_are_none(self): + # The TE BERT spec leaves q_layernorm/k_layernorm unset (None) instead of hardcoding + # IdentityOp, so that TransformerConfig.qk_layernorm can select the default TENorm + # through the shared SelfAttention fallback (`submodules.q_layernorm or TENorm`). + spec = get_bert_layer_with_transformer_engine_spec() + assert spec.submodules.self_attention.submodules.q_layernorm is None + assert spec.submodules.self_attention.submodules.k_layernorm is None + + @pytest.mark.internal + def test_qk_layernorm_from_config_fallback(self): + # With config.qk_layernorm=True and the spec's q_layernorm/k_layernorm left unset, + # SelfAttention should fall back to instantiating a real TE LayerNorm for Q and K. + te_pytorch = pytest.importorskip("transformer_engine.pytorch") + + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + use_cpu_initialization=True, + perform_initialization=True, + qk_layernorm=True, + pipeline_dtype=torch.bfloat16, + attention_backend=AttnBackend.unfused, + ) + bert_model = BertModel( + config=transformer_config, + num_tokentypes=0, + transformer_layer_spec=get_bert_layer_with_transformer_engine_spec(), + vocab_size=100, + max_sequence_length=4, + ) + attention = bert_model.encoder.layers[0].self_attention + assert isinstance(attention.q_layernorm, te_pytorch.LayerNorm) + assert isinstance(attention.k_layernorm, te_pytorch.LayerNorm) + class TestBertModelAttentionDimensions: From 5389d147b54c5933c6022743454a0574bd3029ac Mon Sep 17 00:00:00 2001 From: Zhengmao Ye Date: Sat, 11 Jul 2026 02:39:12 +0800 Subject: [PATCH 007/290] Set is_first_microbatch when quant_recipe is configured (#5642) Signed-off-by: yezhengmao --- megatron/core/transformer/module.py | 2 + tests/unit_tests/transformer/test_module.py | 59 +++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/megatron/core/transformer/module.py b/megatron/core/transformer/module.py index bae3c70cf9c..6a55bfbc348 100644 --- a/megatron/core/transformer/module.py +++ b/megatron/core/transformer/module.py @@ -106,11 +106,13 @@ def set_is_first_microbatch(self): """Sets the is_first_microbatch flag if it exists and config.fp8==True. When this flag is set, TE modules will update their fp8 parameter cache. If kitchen is being used, kitchen controls quantization level. + A quant_recipe (e.g. from --te-precision-config-file) also enables the flag. """ if ( self.config.fp8 is not None or self.config.fp4 is not None or getattr(self.config, 'use_kitchen', False) + or getattr(self.config, 'quant_recipe', None) is not None ): if not hasattr(self, "modules_with_is_first_microbatch"): self.modules_with_is_first_microbatch = [] diff --git a/tests/unit_tests/transformer/test_module.py b/tests/unit_tests/transformer/test_module.py index 73b0235f474..92f15b2f46d 100644 --- a/tests/unit_tests/transformer/test_module.py +++ b/tests/unit_tests/transformer/test_module.py @@ -57,6 +57,65 @@ def test_megatron_module(self): # failed_module.bf16 = True +class _FirstMicrobatchModule(torch.nn.Module): + """Stand-in for a TE module that exposes the is_first_microbatch flag.""" + + def __init__(self): + super().__init__() + self.is_first_microbatch = False + + +class DummyQuantModule(MegatronModule): + def __init__(self, config: TransformerConfig): + super().__init__(config) + self.child = _FirstMicrobatchModule() + + def forward(self, x): + return x + + +class TestSetIsFirstMicrobatch: + + def setup_method(self, method): + Utils.initialize_model_parallel(1, 1) + model_parallel_cuda_manual_seed(123) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def _build_module(self, **overrides): + config = TransformerConfig( + num_layers=2, hidden_size=12, num_attention_heads=4, use_cpu_initialization=True + ) + for key, value in overrides.items(): + setattr(config, key, value) + return DummyQuantModule(config=config) + + def test_quant_recipe_sets_flag(self): + # quant_recipe alone must enable the flag, even with fp8/fp4/kitchen off. + module = self._build_module(quant_recipe=object()) + assert module.config.fp8 is None + assert module.config.fp4 is None + assert getattr(module.config, 'use_kitchen', False) is False + assert module.config.quant_recipe is not None + assert module.child.is_first_microbatch is False + + module.set_is_first_microbatch() + assert module.child.is_first_microbatch is True + + def test_no_quant_leaves_flag_untouched(self): + # With no quantization mode configured the flag must not be touched. + module = self._build_module() + assert module.config.fp8 is None + assert module.config.fp4 is None + assert getattr(module.config, 'use_kitchen', False) is False + assert module.config.quant_recipe is None + assert module.child.is_first_microbatch is False + + module.set_is_first_microbatch() + assert module.child.is_first_microbatch is False + + class TestFloat16Module: def setup_method(self, method): From 75a2132023fe1987c37c1f7baa1bce7e4193cf3c Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Fri, 10 Jul 2026 15:27:31 -0400 Subject: [PATCH 008/290] Assign BERT CODEOWNERS to GPT team (#5746) Signed-off-by: Philip Petrakian --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3bb4ca5bae3..64ecd80d3d6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,7 @@ megatron/core/ @NVIDIA/core-adlr @NVIDIA/core-nemo +megatron/core/models/bert/ @NVIDIA/core-adlr @NVIDIA/core-nemo @NVIDIA/gpt + megatron/core/models/common/ @NVIDIA/core-adlr @NVIDIA/core-nemo @NVIDIA/gpt megatron/core/models/gpt/ @NVIDIA/core-adlr @NVIDIA/core-nemo @NVIDIA/gpt From 48a887fecd01674346f724dcf44f417f455989f0 Mon Sep 17 00:00:00 2001 From: Jon Barker Date: Fri, 10 Jul 2026 14:27:06 -0600 Subject: [PATCH 009/290] Remove use of exec_module (#5744) Signed-off-by: Jon Barker --- .../rl/environment_configs/countdown.yaml | 2 +- examples/rl/environment_configs/dapo.yaml | 4 +- examples/rl/environment_configs/default.yaml | 4 +- examples/rl/environment_configs/gsm8k.yaml | 2 +- .../rl/environment_configs/gsm8k_nanov3.yaml | 2 +- examples/rl/environment_configs/math.yaml | 6 +-- .../openmathinstructv2.yaml | 2 +- megatron/rl/__init__.py | 27 +---------- megatron/rl/agent/registry.py | 41 +++++++++++++++++ megatron/rl/agent/weighted_multi_task.py | 7 ++- .../rl/server/agent/fastapi_env_server.py | 5 +- .../gpt_grpo_basic_function/env_config.yaml | 2 +- .../env_config.yaml | 2 +- .../env_config.yaml | 2 +- .../env_config.yaml | 2 +- .../env_config.yaml | 2 +- .../env_config.yaml | 2 +- .../env_config.yaml | 2 +- tests/unit_tests/test_agent_registry.py | 46 +++++++++++++++++++ 19 files changed, 112 insertions(+), 50 deletions(-) create mode 100644 megatron/rl/agent/registry.py create mode 100644 tests/unit_tests/test_agent_registry.py diff --git a/examples/rl/environment_configs/countdown.yaml b/examples/rl/environment_configs/countdown.yaml index 083ed030af2..d0b274c8d2f 100644 --- a/examples/rl/environment_configs/countdown.yaml +++ b/examples/rl/environment_configs/countdown.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: hf_dataset_name: "Jiayi-Pan/Countdown-Tasks-3to4" split: "train" diff --git a/examples/rl/environment_configs/dapo.yaml b/examples/rl/environment_configs/dapo.yaml index c501f4a19b8..11b1eb069c6 100644 --- a/examples/rl/environment_configs/dapo.yaml +++ b/examples/rl/environment_configs/dapo.yaml @@ -1,8 +1,8 @@ -- agent_type: examples.rl.environments.math.dapo_agent.DAPOAgent +- agent_type: DAPOAgent agent_args: format_reward: 0.0 weight: 1.0 -- agent_type: examples.rl.environments.math.aime_agent.AIMEAgent +- agent_type: AIMEAgent agent_args: format_reward: 0.0 weight: 0.0 diff --git a/examples/rl/environment_configs/default.yaml b/examples/rl/environment_configs/default.yaml index 2bfcd15cec4..3bca843f813 100644 --- a/examples/rl/environment_configs/default.yaml +++ b/examples/rl/environment_configs/default.yaml @@ -1,8 +1,8 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: hf_dataset_name: "Jiayi-Pan/Countdown-Tasks-3to4" split: "train" weight: 1.0 -- agent_type: examples.rl.environments.math.openmath_agent.OpenMathInstructAgent +- agent_type: OpenMathInstructAgent agent_args: {} weight: 1.0 diff --git a/examples/rl/environment_configs/gsm8k.yaml b/examples/rl/environment_configs/gsm8k.yaml index dc0f34dd4ca..8f3136c8a7a 100644 --- a/examples/rl/environment_configs/gsm8k.yaml +++ b/examples/rl/environment_configs/gsm8k.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.math.gsm8k_agent.GSM8KAgent +- agent_type: GSM8KAgent agent_args: answer_format: "boxed" format_reward: 0.5 diff --git a/examples/rl/environment_configs/gsm8k_nanov3.yaml b/examples/rl/environment_configs/gsm8k_nanov3.yaml index b759423ee5b..0ac2931a593 100644 --- a/examples/rl/environment_configs/gsm8k_nanov3.yaml +++ b/examples/rl/environment_configs/gsm8k_nanov3.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.math.gsm8k_agent.GSM8KAgent +- agent_type: GSM8KAgent agent_args: answer_format: "boxed" format_reward: 0.5 diff --git a/examples/rl/environment_configs/math.yaml b/examples/rl/environment_configs/math.yaml index ab55cd142c1..d5b65723a55 100644 --- a/examples/rl/environment_configs/math.yaml +++ b/examples/rl/environment_configs/math.yaml @@ -1,10 +1,10 @@ -- agent_type: examples.rl.environments.math.openmath_agent.OpenMathInstructAgent +- agent_type: OpenMathInstructAgent agent_args: {} weight: 1.0 -- agent_type: examples.rl.environments.math.bigmath_agent.BigMathAgent +- agent_type: BigMathAgent agent_args: {} weight: 1.0 -- agent_type: examples.rl.environments.math.aime_agent.AIMEAgent +- agent_type: AIMEAgent agent_args: {} weight: 0.0 evaluation_only: true diff --git a/examples/rl/environment_configs/openmathinstructv2.yaml b/examples/rl/environment_configs/openmathinstructv2.yaml index 7685d224575..f6dd46b4886 100644 --- a/examples/rl/environment_configs/openmathinstructv2.yaml +++ b/examples/rl/environment_configs/openmathinstructv2.yaml @@ -1,3 +1,3 @@ -- agent_type: examples.rl.environments.math.openmath_agent.OpenMathInstructAgent +- agent_type: OpenMathInstructAgent agent_args: {} weight: 1.0 diff --git a/megatron/rl/__init__.py b/megatron/rl/__init__.py index 08ae226bfe4..539e8de323e 100644 --- a/megatron/rl/__init__.py +++ b/megatron/rl/__init__.py @@ -2,39 +2,14 @@ import asyncio import functools -import importlib -import os -import sys import time import traceback -from typing import Callable, Coroutine, Type +from typing import Callable, Coroutine from pydantic import BaseModel, ConfigDict, Field from typing_extensions import Self, Type -def import_class(class_path: str) -> Type: - """Import a class from a string path. - - Args: - class_path: String path to the class (e.g. 'examples.rl.environments.countdown.countdown_agent.CountdownAgent' or '../environments.countdown.py:CountdownAgent') - - Returns: - The class object - """ - if '.py:' in class_path: - # filepath.py:Classname branch. - module_path, class_name = class_path.split(':') - abs_path = os.path.abspath(module_path) - spec = importlib.util.spec_from_file_location('acemath_agent', abs_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - else: - module_path, class_name = class_path.rsplit('.', 1) - module = importlib.import_module(module_path, package=__package__) - return getattr(module, class_name) - - class TypeLookupable(BaseModel, extra='allow'): """Supports 'unwrapping' of base class into subclasses.""" diff --git a/megatron/rl/agent/registry.py b/megatron/rl/agent/registry.py new file mode 100644 index 00000000000..e2f520915bc --- /dev/null +++ b/megatron/rl/agent/registry.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from importlib import import_module +from typing import Type + +# Allowlist mapping stable config names to their fully-qualified import targets +# in "module.path:ClassName" form. Targets are imported lazily in +# get_agent_class() so that importing this module does not pull in optional +# agent dependencies (e.g. math_verify, nemogym2mrl). +AGENT_REGISTRY: dict[str, str] = { + "RemoteAgent": "megatron.rl.agent.remote_agent:RemoteAgent", + "CountdownAgent": "examples.rl.environments.countdown.countdown_agent:CountdownAgent", + "OpenMathInstructAgent": "examples.rl.environments.math.openmath_agent:OpenMathInstructAgent", + "BigMathAgent": "examples.rl.environments.math.bigmath_agent:BigMathAgent", + "DAPOAgent": "examples.rl.environments.math.dapo_agent:DAPOAgent", + "GSM8KAgent": "examples.rl.environments.math.gsm8k_agent:GSM8KAgent", + "AIMEAgent": "examples.rl.environments.math.aime_agent:AIMEAgent", + "NemoGymAgent": "nemogym2mrl.nemo_gym_agent:NemoGymAgent", + "AceMathAgent": "environments.acemath_agent:AceMathAgent", +} + + +def get_agent_class(agent_name: str) -> Type: + """Resolve a config agent_type string to a registered agent class. + + Only explicitly registered agent names are allowed, and each maps to a fixed + import target defined in this module. This prevents arbitrary code execution + from untrusted environment configuration files. The target module is imported + lazily so importing this module does not require optional agent dependencies. + """ + try: + import_path = AGENT_REGISTRY[agent_name] + except KeyError as exc: + known = ", ".join(sorted(AGENT_REGISTRY)) + raise ValueError( + f"Unknown agent_type {agent_name!r}. " + f"Registered agent types: {known or '(none)'}" + ) from exc + module_path, class_name = import_path.split(":") + module = import_module(module_path) + return getattr(module, class_name) diff --git a/megatron/rl/agent/weighted_multi_task.py b/megatron/rl/agent/weighted_multi_task.py index 72efbb9be32..2c52784be1c 100644 --- a/megatron/rl/agent/weighted_multi_task.py +++ b/megatron/rl/agent/weighted_multi_task.py @@ -6,7 +6,7 @@ import numpy as np -from .. import import_class +from .registry import get_agent_class from .api import ( AgentBaseModel, ContrastiveRollout, @@ -77,7 +77,7 @@ def from_config( Args: config: List of dicts with keys: - - agent_type: String path to agent class + - agent_type: Registered agent name (see megatron.rl.agent.registry) - agent_args: Dict of arguments to pass to agent constructor - weight: Float weight for this agent @@ -91,8 +91,7 @@ def from_config( agent_args = entry.get('agent_args', {}) agent_args['parallel_generation_tasks'] = parallel_generation_tasks - # Import and instantiate the agent class - agent_type = import_class(entry['agent_type']) + agent_type = get_agent_class(entry['agent_type']) agent_configs.append( AgentConfig( agent_type=agent_type, diff --git a/megatron/rl/server/agent/fastapi_env_server.py b/megatron/rl/server/agent/fastapi_env_server.py index 89da3c74146..1bcd3a21fa1 100644 --- a/megatron/rl/server/agent/fastapi_env_server.py +++ b/megatron/rl/server/agent/fastapi_env_server.py @@ -14,7 +14,8 @@ LOGGING_CONFIG['root'] = {"handlers": ["default"], "level": "INFO"} -from ... import import_class, inference +from ... import inference +from ...agent.registry import get_agent_class from ...agent.api import ( Agent, ContrastiveRollout, @@ -200,6 +201,6 @@ async def run_server(): args = parser.parse_args() with open(args.env_config, 'r') as f: config = yaml.safe_load(f)[0] - agent_cls = import_class(config['agent_type']) + agent_cls = get_agent_class(config['agent_type']) cls_args = config['agent_args'] run(agent_cls, cls_args, port=args.port) diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/env_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/env_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/env_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/env_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/env_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/env_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/env_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/env_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/env_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/env_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/env_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/env_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/env_config.yaml b/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/env_config.yaml index 329246987bf..9789c07f426 100644 --- a/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/env_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/env_config.yaml @@ -1,4 +1,4 @@ -- agent_type: examples.rl.environments.countdown.countdown_agent.CountdownAgent +- agent_type: CountdownAgent agent_args: dataset_file: "/mnt/artifacts/rl_environments/Jiayi-Pan___countdown-tasks-3to4" split: "train" diff --git a/tests/unit_tests/test_agent_registry.py b/tests/unit_tests/test_agent_registry.py new file mode 100644 index 00000000000..d3007a8b7f6 --- /dev/null +++ b/tests/unit_tests/test_agent_registry.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import pytest + +from megatron.rl.agent.registry import AGENT_REGISTRY, get_agent_class + +BUILTIN_AGENTS = { + "RemoteAgent", + "CountdownAgent", + "OpenMathInstructAgent", + "BigMathAgent", + "DAPOAgent", + "GSM8KAgent", + "AIMEAgent", +} + + +def test_agent_registry_includes_builtin_agents(): + assert BUILTIN_AGENTS <= AGENT_REGISTRY.keys() + + +def test_agent_registry_targets_are_wellformed(): + # Every entry must be a "module.path:ClassName" import target so that + # get_agent_class can resolve it without config-supplied import paths. + for name, target in AGENT_REGISTRY.items(): + module_path, _, class_name = target.partition(":") + assert module_path and class_name, f"malformed target for {name!r}: {target!r}" + + +def test_get_agent_class_lazily_imports_target(monkeypatch): + # Uses a stdlib target to exercise the lazy import path without pulling in + # optional agent dependencies. + from collections import OrderedDict + + monkeypatch.setitem(AGENT_REGISTRY, "DummyAgent", "collections:OrderedDict") + assert get_agent_class("DummyAgent") is OrderedDict + + +def test_get_agent_class_rejects_unknown_agent(): + with pytest.raises(ValueError, match="Unknown agent_type"): + get_agent_class("examples.evil.MaliciousAgent") + + +def test_get_agent_class_rejects_module_paths(): + with pytest.raises(ValueError, match="Unknown agent_type"): + get_agent_class("examples.rl.environments.countdown.countdown_agent.CountdownAgent") From a28ca480bb001bb0c2740b721dbc7b1c736dbf55 Mon Sep 17 00:00:00 2001 From: Hristo Filaretov Date: Tue, 14 Jul 2026 01:40:40 +0200 Subject: [PATCH 010/290] Short-circuit condition to avoid copying from GPU memory in `ChainedOptimizer` (#5623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hristo Filaretov Co-authored-by: Michał Marcinkiewicz <43240942+mmarcinkiewicz@users.noreply.github.com> --- megatron/core/optimizer/optimizer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/megatron/core/optimizer/optimizer.py b/megatron/core/optimizer/optimizer.py index 4a74328d0d9..b40b2cb2dd5 100644 --- a/megatron/core/optimizer/optimizer.py +++ b/megatron/core/optimizer/optimizer.py @@ -1728,7 +1728,12 @@ def step(self): use_decoupled_grad=use_decoupled_grad, ) - if grad_norm > optimizer.config.grad_norm_skip_threshold and main_params: + grad_norm_skip_threshold = optimizer.config.grad_norm_skip_threshold + if ( + main_params + and math.isfinite(grad_norm_skip_threshold) + and grad_norm > grad_norm_skip_threshold + ): log_single_rank( logger, logging.INFO, "skipping grad norm because it's too large %s", grad_norm ) From 3005e9c1ad7aba5f16114ebd3c645906ae178274 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Mon, 13 Jul 2026 23:00:45 -0700 Subject: [PATCH 011/290] Increase Megatron-FSDP overlap test dim to 8192 for reliable overlap (#5770) Signed-off-by: Jingyue Wu Co-authored-by: Claude Opus 4.8 (1M context) --- tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 229cd0bff4b..ff892ec74ee 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -319,7 +319,7 @@ def test_overlaps_all_gather_and_compute(distributed_setup): pytest.skip("This test requires at least 2 ranks.") mesh = init_device_mesh(device.type, (world_size,)) - dim = 4096 + dim = 8192 num_children = 4 dtype = torch.bfloat16 model = MultiChildModel(dim=dim, num_children=num_children).to(dtype=dtype) From eadbaa6189fbd272843b4e2bd75ae20984f891d4 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Mon, 13 Jul 2026 23:53:27 -0700 Subject: [PATCH 012/290] Inference: Add profile endpoints to chat completions. (#5611) Signed-off-by: Siddharth Singh --- .../data_parallel_inference_coordinator.py | 10 +++++ .../core/inference/engines/dynamic_engine.py | 6 +++ megatron/core/inference/headers.py | 2 + megatron/core/inference/inference_client.py | 13 ++++++ .../endpoints/__init__.py | 3 +- .../endpoints/profile.py | 41 +++++++++++++++++++ 6 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/profile.py diff --git a/megatron/core/inference/data_parallel_inference_coordinator.py b/megatron/core/inference/data_parallel_inference_coordinator.py index 50f586cc598..6c090bc62df 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator.py +++ b/megatron/core/inference/data_parallel_inference_coordinator.py @@ -533,6 +533,16 @@ def start(self): if header == Headers.STOP: self.state = self.CoordinatorState.RUNNING + elif header in (Headers.START_CUDA_PROFILER, Headers.STOP_CUDA_PROFILER): + # Profiler control: broadcast to every connected DP engine. Not a + # state transition, so no CoordinatorState checks — just forward. + if sender_identity not in known_clients: + logging.warning("Coordinator: ignoring profiler signal from unknown client.") + continue + broadcast_payload = msgpack.packb(deserialized_payload, use_bin_type=True) + for data_parallel_rank_id in list(self.identities_of_data_parallel_ranks): + self._send_to_engine(data_parallel_rank_id, broadcast_payload) + elif header == Headers.ENGINE_REPLY: # This is the output of a single engine step on some data parallel rank. assert sender_identity in self.identities_of_data_parallel_ranks diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 6b40e016b4b..01a130656a4 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -2364,6 +2364,12 @@ def schedule_requests(self) -> int: nvtx_range_pop("add_request") elif header == Headers.SET_GENERATION_EPOCH: new_generation_epoch = data[1] + elif header == Headers.START_CUDA_PROFILER: + # Side-effect, not a state transition: apply immediately on every + # rank so an outer nsys --capture-range=cudaProfilerApi starts here. + torch.cuda.cudart().cudaProfilerStart() + elif header == Headers.STOP_CUDA_PROFILER: + torch.cuda.cudart().cudaProfilerStop() else: # Control signal: queue for second pass. self._pending_signals.append(message) diff --git a/megatron/core/inference/headers.py b/megatron/core/inference/headers.py index 8ad1913e6b1..107a200818c 100644 --- a/megatron/core/inference/headers.py +++ b/megatron/core/inference/headers.py @@ -21,6 +21,8 @@ class Headers(Enum): DISCONNECT = auto() SHUTDOWN = auto() TP_BROADCAST = auto() + START_CUDA_PROFILER = auto() + STOP_CUDA_PROFILER = auto() class UnknownHeaderError(Exception): diff --git a/megatron/core/inference/inference_client.py b/megatron/core/inference/inference_client.py index f5a78d1b16c..c3017737146 100644 --- a/megatron/core/inference/inference_client.py +++ b/megatron/core/inference/inference_client.py @@ -201,6 +201,19 @@ def unpause_engines(self) -> None: """Sends UNPAUSE to all engines. No synchronization needed.""" self._send_signal_to_engines(Headers.UNPAUSE) + def start_cuda_profiler(self) -> None: + """Sends START_CUDA_PROFILER to all engines via coordinator. + + Each engine calls ``torch.cuda.profiler.start()`` (cudaProfilerStart) on + its next loop iteration, so an outer ``nsys profile --capture-range= + cudaProfilerApi`` begins recording. No synchronization needed. + """ + self._send_signal_to_engines(Headers.START_CUDA_PROFILER) + + def stop_cuda_profiler(self) -> None: + """Sends STOP_CUDA_PROFILER to all engines (cudaProfilerStop).""" + self._send_signal_to_engines(Headers.STOP_CUDA_PROFILER) + def set_generation_epoch(self, generation_epoch: int): """Sends a signal to stamp all in-flight requests with the given generation epoch. diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/__init__.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/__init__.py index f2b0661dace..251039fa60a 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/__init__.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/__init__.py @@ -5,7 +5,8 @@ from .chat_completions import bp as ChatCompletions from .completions import bp as Completions from .health import bp as Health + from .profile import bp as Profile - __all__ = [Completions, ChatCompletions, Health] + __all__ = [Completions, ChatCompletions, Health, Profile] except ImportError: __all__ = [] diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/profile.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/profile.py new file mode 100644 index 00000000000..71478aace70 --- /dev/null +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/profile.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. + +"""CUDA profiler control endpoints. + +POST /start_profile and /stop_profile relay a control signal through the +InferenceClient -> data-parallel coordinator -> every connected EP/DP engine, +which calls cudaProfilerStart()/cudaProfilerStop(). Pair with an outer +`nsys profile --capture-range=cudaProfilerApi` to bracket a capture window. +""" + +import logging + +logger = logging.getLogger(__name__) + +try: + from quart import Blueprint, current_app, jsonify + + bp = Blueprint('profile_api', __name__) + + @bp.route('/start_profile', methods=['POST']) + @bp.route('/v1/start_profile', methods=['POST']) + async def start_profile(): + """Broadcast cudaProfilerStart to all engines.""" + client = current_app.config.get('client') + if client is None: + return jsonify({"status": "error", "details": "client not initialized"}), 503 + client.start_cuda_profiler() + return jsonify({"status": "ok", "action": "start_profile"}), 200 + + @bp.route('/stop_profile', methods=['POST']) + @bp.route('/v1/stop_profile', methods=['POST']) + async def stop_profile(): + """Broadcast cudaProfilerStop to all engines.""" + client = current_app.config.get('client') + if client is None: + return jsonify({"status": "error", "details": "client not initialized"}), 503 + client.stop_cuda_profiler() + return jsonify({"status": "ok", "action": "stop_profile"}), 200 + +except ImportError as e: + logger.warning(f"Could not import quart: {e}") From 3a253ac5c2d31ad81c9939aca6443e8ac441fbe4 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Tue, 14 Jul 2026 01:42:35 -0700 Subject: [PATCH 013/290] Inference: Do not route pad/dummy tokens to any expert (#4922) Signed-off-by: Siddharth Singh --- .../inference/contexts/dynamic_context.py | 63 +++++++++-- megatron/core/inference/contexts/gpu_view.py | 10 ++ .../text_generation_controller.py | 9 ++ .../moe/inference_routing_mask_kernel.py | 101 ++++++++++++++++++ .../moe/token_dispatcher_inference.py | 49 +++++++++ .../test_moe_dispatching_and_routing.py | 82 ++++++++++++++ 6 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 megatron/core/transformer/moe/inference_routing_mask_kernel.py diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 19ff501af91..8e8db5d603d 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -631,6 +631,12 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC and model_config.inference_moe_token_dispatcher_type == 'nccl' ) + # are we using the inference_optimized nvls ep dispatcher for MoEs? + self._nvls_dispatcher = ( + get_pg_size(self.expert_model_parallel_group) > 1 + and model_config.inference_moe_token_dispatcher_type == 'nvls' + ) + # are we using the training a2a dispatcher for MoEs? # Note that this is not optimal for speed. self._training_ep_dispatcher = ( @@ -675,12 +681,17 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC # Allocate per-step dispatcher buffers upfront so update_metadata never # triggers an allocation inside a captured CUDA graph. - # Both dispatchers need _valid_tokens_tensor initialized even at EP=1: - # mcore_fused_moe's Triton kernel reads it as a pointer regardless of EP size. - if model_config.inference_moe_token_dispatcher_type == 'nccl': + # + # The shared _valid_tokens_tensor scalar is read as a pointer by both fused + # MoE backends (mcore_fused_moe and vllm_fused_moe) regardless of EP size, so + # allocate it unconditionally (covers EP=1, where no dispatcher comm buffers + # exist). The EP>1 dispatchers below reallocate it as part of their own buffer + # setup, which is harmless. + InferenceAllGatherDispatcherBase.allocate_valid_tokens_tensor() + if self._nccl_ep_dispatcher: NCCLAllGatherDispatcher.allocate_buffers() - elif get_pg_size(self.expert_model_parallel_group) > 1: - # Use moe_latent_size if set, else hidden_size. + elif self._nvls_dispatcher: + # Use moe_latent_size if set (latent MoE: SuperV3, UltraV3), else hidden_size. moe_hidden_size = model_config.moe_latent_size or model_config.hidden_size NVLSAllGatherVDispatcher.allocate_buffers( per_rank_worst_case_token_count=self.round_up_tokens(self.max_tokens) // tp_size, @@ -688,10 +699,6 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC hidden_size=moe_hidden_size, ep_group=self.expert_model_parallel_group, ) - else: - # EP=1 with nvls: skip symmetric memory init (requires NVLink between - # multiple GPUs) and just initialize the shared valid_tokens scalar. - InferenceAllGatherDispatcherBase.allocate_valid_tokens_tensor() # Deal with chunked prefill self.enable_chunked_prefill = inference_config.enable_chunked_prefill @@ -710,8 +717,17 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC # Allocate GPU state. self.is_tensor_state_allocated = False + self._bookkeeping_no_real_work = False self.initialize_all_tensors() + # Bind the GPU real-token-count tensor onto the NVLS dispatcher class + # so it can mask out CUDA-graph padding tokens during routing. The + # tensor lives inside gpu_view._buf (fixed address) and is refreshed + # each step by transfer_bookkeeping_to_gpu(). NVLS-only — the NCCL + # dispatcher requires equal token counts across ranks already. + if self._nvls_dispatcher: + NVLSAllGatherVDispatcher.set_real_token_count_tensor(self.gpu_view.real_token_count) + # Print info. active_blocks = self.kv_block_allocator.active_count total_blocks = self.kv_block_allocator.total_count @@ -1017,6 +1033,10 @@ def initialize_all_tensors(self) -> None: _tok_int32_bytes = self.max_tokens * 4 # Request-level fields are all 4 bytes wide (5 int32 + 2 float32 = 7 fields). _req_4byte_bytes = self.max_requests * 4 + # Scalar: real (unpadded) token count for the current step. Refreshed + # in transfer_bookkeeping_to_gpu(); read on GPU via + # `gpu_view.real_token_count` (MoE routing masks padding tokens). + _real_token_count_bytes = 4 # MHA section: 5 fields (int32) shared between GraphedMHAMetadata and # NonGraphedMHAMetadata. max_bs == max_requests. _mha_query_lengths_bytes = self.max_requests * 4 @@ -1028,6 +1048,7 @@ def initialize_all_tensors(self) -> None: 3 * _tok_int64_bytes + 3 * _tok_int32_bytes + 7 * _req_4byte_bytes + + _real_token_count_bytes + _mha_query_lengths_bytes + _mha_cu_query_seq_lengths_bytes + _mha_kv_seq_lengths_bytes @@ -1154,6 +1175,14 @@ def initialize_all_tensors(self) -> None: ].view(torch.int32) _off += _req_4byte_bytes + # Scalar staging slot for the real (unpadded) token count. Refreshed + # from `self.batch_dimensions.token_count` in transfer_bookkeeping_to_gpu() + # and read on GPU via `gpu_view.real_token_count`. + self._staging_real_token_count = self._cpu_bookkeeping_buf[ + _off : _off + _real_token_count_bytes + ].view(torch.int32) + _off += _real_token_count_bytes + # Static tensor addresses to make `last_token_logits` graphable with speculative decoding. max_logit_idxs = self.max_requests * (self.num_speculative_tokens + 1) self.active_logit_idxs = torch.zeros( @@ -2377,6 +2406,13 @@ def initialize_attention_state( # No-op when the queue is already empty (regular non-warmup steps). self._execute_pending_mamba_ops() + # Record whether this step produces real output — false on CUDA-graph + # capture (warmup) or dummy EP steps. Used by transfer_bookkeeping_to_gpu + # to publish real_token_count=0 so MoE routing masks all padding tokens. + self._bookkeeping_no_real_work = ( + construct_graph_dimensions is not None or is_expert_parallel_dummy_cuda_graph_step + ) + # Run the H2D transfer here so callers that bypass the controller # (e.g. unit tests that call `model.forward()` directly after # `initialize_attention_state()`) see populated GPU bookkeeping. The @@ -2453,6 +2489,15 @@ def transfer_bookkeeping_to_gpu(self) -> None: self._staging_request_query_lengths[n_active:padded_active] = 0 self._staging_request_kv_length_offsets[n_active:padded_active] = 0 + # Real (unpadded) token count for this step. CUDA-graph replay pads + # the token dim to a captured size; MoE routing reads this on GPU and + # rewrites padding rows' routing entries to -1 so they don't go to + # any expert. Set to 0 on CUDA-graph capture / dummy EP steps so + # every row gets masked out. + self._staging_real_token_count[0] = ( + 0 if self._bookkeeping_no_real_work else self.batch_dimensions.token_count + ) + # Coalesced H2D: one cudaMemcpyAsync for the entire bookkeeping buffer. # Copying the whole (max_tokens + max_requests)-sized buffer including # unused slots is cheap (~71 KB total, ~3-5 us on PCIe Gen4) and saves diff --git a/megatron/core/inference/contexts/gpu_view.py b/megatron/core/inference/contexts/gpu_view.py index 651d95055da..2066375d19e 100644 --- a/megatron/core/inference/contexts/gpu_view.py +++ b/megatron/core/inference/contexts/gpu_view.py @@ -43,6 +43,9 @@ def __init__( # query_lengths, kv_length_offsets) + 1 int32 (top_k) + 2 float32 # (temperature, top_p) + 1 int32 (active_request_last_token_idxs) = 7 fields. req_4byte_bytes = max_requests * 4 + # Scalar: real (unpadded) token count for the current step. Used by + # MoE routing to mask out CUDA-graph padding tokens. + real_token_count_bytes = 4 # MHA section: 5 fields shared by both graphed and non-graphed MHAMetadata # (only one is active per step, so sharing storage is fine). @@ -74,6 +77,7 @@ def __init__( 3 * tok_int64_bytes + 3 * tok_int32_bytes + 7 * req_4byte_bytes + + real_token_count_bytes + mha_query_lengths_bytes + mha_cu_query_seq_lengths_bytes + mha_kv_seq_lengths_bytes @@ -163,6 +167,12 @@ def __init__( ) off += req_4byte_bytes + # Real (unpadded) token count for the current step. Scalar int32 view. + # MoE routing reads this to skip routing CUDA-graph padding tokens to + # experts. Refreshed each step by transfer_bookkeeping_to_gpu(). + self.real_token_count = self._buf[off : off + real_token_count_bytes].view(torch.int32) + off += real_token_count_bytes + # MHA flash-attention metadata (shared between GraphedMHAMetadata and # NonGraphedMHAMetadata — only one is active per step). self.mha_query_lengths = self._buf[off : off + mha_query_lengths_bytes].view(torch.int32) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index f7a2a67f732..a9c5a6a92f7 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -42,6 +42,7 @@ from megatron.core.transformer.moe.moe_layer import BaseMoELayer from megatron.core.transformer.moe.router_replay import RouterReplay, RouterReplayAction from megatron.core.transformer.moe.router_trace import get_moe_router_tracer +from megatron.core.transformer.moe.token_dispatcher_inference import NVLSAllGatherVDispatcher from megatron.core.transformer.utils import set_model_to_sequence_parallel from megatron.core.utils import ( accepts_parameter, @@ -962,6 +963,14 @@ def _compute_serial_mtp_and_sample(self): position_ids_buf[0, active_request_count:] = 0 nvtx_range_pop("mtp-spec-decoding/serial-mtp-init") + + # MTP MoE forwards are request-count shaped: the routing map holds + # active_request_count real rows followed by padding up to padded_count. + # The NVLS routing mask defaults to the main step's token count, so point + # it at the MTP row count instead, else padding rows route to experts. + if context._nvls_dispatcher: + NVLSAllGatherVDispatcher.modify_real_token_count_for_mtp(active_request_count) + for depth in range(self.num_mtp_depths): nvtx_range_push(f"mtp-spec-decoding/depth-{depth}") diff --git a/megatron/core/transformer/moe/inference_routing_mask_kernel.py b/megatron/core/transformer/moe/inference_routing_mask_kernel.py new file mode 100644 index 00000000000..e38869a1f6d --- /dev/null +++ b/megatron/core/transformer/moe/inference_routing_mask_kernel.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Triton kernel for masking CUDA-graph padding rows of a local routing map. + +Under CUDA-graph capture the local token count is padded up to a captured +graph size; those padding rows have garbage routing indices and, if left +alone, would dispatch padding tokens to real experts. This kernel zeroes +that out by writing ``-1`` into every topk slot of rows in +``[real_token_count, local_tokens)``. + +The kernel reads ``real_token_count`` from a fixed-address ``int32[1]`` GPU +tensor, so it is safe to call from inside a captured graph: only the value +behind the pointer changes between replays. +""" + +from torch import Tensor + +try: + import triton + import triton.language as tl + + HAVE_TRITON = True +except ImportError: + from unittest.mock import MagicMock + + from megatron.core.utils import null_decorator + + triton = MagicMock() + triton.jit = null_decorator + triton.autotune = null_decorator + tl = MagicMock() + HAVE_TRITON = False + + +@triton.jit +def _mask_routing_padding_kernel( + routing_map_ptr, # int64* [total_rows, topk] + real_token_count_ptr, # int32* [1] + total_rows: tl.int32, + tp_rank: tl.int32, # SP/TP rank — local row r maps to global row r + tp_rank*total_rows + TOPK: tl.constexpr, # actual topk + BLOCK_M: tl.constexpr, # rows per program + BLOCK_TOPK: tl.constexpr, # next_power_of_2(TOPK), column block +): + """Fill `routing_map[real_token_count:, :]` with -1, BLOCK_M rows per program.""" + pid = tl.program_id(0) + rows = pid * BLOCK_M + tl.arange(0, BLOCK_M) + + real_count = tl.load(real_token_count_ptr).to(tl.int32) + + # real_count is in the global (pre-SP-shard) frame; rows is local to this SP rank. + global_rows = rows + tp_rank * total_rows + row_mask = (global_rows >= real_count) & (rows < total_rows) + + cols = tl.arange(0, BLOCK_TOPK) + col_mask = cols < TOPK + + offs = rows[:, None].to(tl.int64) * TOPK + cols[None, :].to(tl.int64) + mask = row_mask[:, None] & col_mask[None, :] + + neg_one = tl.full((BLOCK_M, BLOCK_TOPK), -1, dtype=tl.int64) + tl.store(routing_map_ptr + offs, neg_one, mask=mask) + + +def mask_routing_padding( + routing_map: Tensor, real_token_count_tensor: Tensor, tp_rank: int = 0 +) -> None: + """In-place fill -1 into ``routing_map[real_token_count:, :]``. + + Args: + routing_map: ``[N, topk]`` int64 local routing map. ``N`` is the + (possibly CUDA-graph-padded) local token count. + real_token_count_tensor: ``[1]`` int32 GPU tensor holding the real + (unpadded) token count for this step, in the global (pre-SP-shard) + frame. Read inside the kernel so the mask boundary moves correctly + across CUDA-graph replays. + tp_rank: This rank's index in the SP/TP group. Local row ``r`` is + row ``r + tp_rank * N`` in the global frame; the kernel uses this + offset to compare against ``real_token_count_tensor``. + """ + assert routing_map.is_cuda, "routing_map must be on CUDA" + assert routing_map.dim() == 2, f"expected 2D routing_map, got {routing_map.shape}" + assert routing_map.dtype.is_floating_point is False, "routing_map must be integer" + + total_rows, topk = routing_map.shape + if total_rows == 0: + return + + BLOCK_M = 8 if total_rows < 64 else 128 + BLOCK_TOPK = triton.next_power_of_2(topk) + grid = (triton.cdiv(total_rows, BLOCK_M),) + + _mask_routing_padding_kernel[grid]( + routing_map, + real_token_count_tensor, + total_rows=total_rows, + tp_rank=tp_rank, + TOPK=topk, + BLOCK_M=BLOCK_M, + BLOCK_TOPK=BLOCK_TOPK, + ) diff --git a/megatron/core/transformer/moe/token_dispatcher_inference.py b/megatron/core/transformer/moe/token_dispatcher_inference.py index 081497f734c..b08d88f2641 100644 --- a/megatron/core/transformer/moe/token_dispatcher_inference.py +++ b/megatron/core/transformer/moe/token_dispatcher_inference.py @@ -40,6 +40,7 @@ gather_from_sequence_parallel_region, reduce_scatter_to_sequence_parallel_region, ) +from megatron.core.transformer.moe.inference_routing_mask_kernel import mask_routing_padding from megatron.core.transformer.moe.shared_experts import SharedExpertMLP from megatron.core.transformer.moe.token_dispatcher import MoEAllGatherTokenDispatcher from megatron.core.transformer.transformer_config import TransformerConfig @@ -313,6 +314,12 @@ class NVLSAllGatherVDispatcher(InferenceAllGatherDispatcherBase): _step_metadata: Optional[torch.Tensor] = None # [3] int32 _per_rank_worst_case_token_count: int = 2048 # round_up_tokens(max_tokens) // tp_size + # [1] int32 view onto context.gpu_view.real_token_count. Fixed GPU address; + # written each step by the context's transfer_bookkeeping_to_gpu(). Holds the + # real (unpadded) local token count so the dispatcher can mask routing for + # CUDA-graph padding tokens. Wired once by the context after gpu_view init. + _real_token_count_tensor: Optional[torch.Tensor] = None + # ── Class-level symmetric buffer handles (allocated once at model init) ─────── # Dtypes: hidden=bf16, routing=int64, probs=fp32, rsv=fp32. _symm_agv_hidden: Optional[dict] = None # {"tensor": ..., "handle": ...} @@ -326,6 +333,31 @@ def _get_rsv_tensor(cls) -> Optional[torch.Tensor]: unpermute output directly into it, avoiding a copy before RSV.""" return cls._symm_rsv["tensor"] if cls._symm_rsv is not None else None + @classmethod + def set_real_token_count_tensor(cls, tensor: torch.Tensor) -> None: + """Bind the context's GPU real-token-count tensor on the dispatcher class. + + Called once by DynamicInferenceContext after gpu_view is initialised. + The tensor is a fixed-address int32[1] view whose value is refreshed + each step by transfer_bookkeeping_to_gpu(). + """ + cls._real_token_count_tensor = tensor + + @classmethod + def modify_real_token_count_for_mtp(cls, mtp_token_count: int) -> None: + """Override the routing-mask token count for an MTP forward. + + Each step the context publishes batch_dimensions.token_count into the + bound tensor. MTP forwards are request-count shaped, so the controller + calls this before an MTP forward to point the mask at the MTP row count + instead. + """ + assert cls._real_token_count_tensor is not None, ( + "real-token-count tensor not wired; DynamicInferenceContext must " + "call set_real_token_count_tensor first" + ) + cls._real_token_count_tensor.fill_(mtp_token_count) + @classmethod def _rank_token_offset(cls) -> torch.Tensor: return cls._step_metadata[1:2] @@ -343,6 +375,7 @@ def _delete_buffers(cls): cls._symm_agv_probs = None cls._symm_rsv = None cls._symm_metadata = None + cls._real_token_count_tensor = None @classmethod def allocate_buffers( @@ -466,6 +499,10 @@ def __init__( runs_metadata_sync=runs_metadata_sync, ) self.topk = config.moe_router_topk + # Rank inside pg_collection.tp — the *standard* TP group that SP shards + # the routing map along. Base class self.tp_rank is the expt_tp rank, + # which is not what we want for the SP padding offset. + self.sp_rank = get_pg_rank(pg_collection.tp) # Set in dispatch_preprocess; consumed by token_dispatch and token_combine. self._local_tokens: int = 0 # When shared_expert_overlap is enabled, the shared expert forward is launched @@ -524,6 +561,18 @@ def token_dispatch(self, hidden_states, probs): if self._runs_metadata_sync: self.update_metadata(hidden_states.shape[0]) + # Mask out CUDA-graph padding rows of the local routing map so the AGV + # propagates -1 into agv_r for those slots; padding tokens then route + # to no expert. _real_token_count_tensor is wired by the context and + # holds the *global* unpadded token count, so we pass self.sp_rank to + # shift local rows into the global frame for the comparison. When unset + # (standalone dispatcher use without a context) all rows are real, so + # skip the mask. + if self.__class__._real_token_count_tensor is not None: + mask_routing_padding( + self.routing_map, self.__class__._real_token_count_tensor, self.sp_rank + ) + agv_h = self.__class__._symm_agv_hidden agv_r = self.__class__._symm_agv_routing agv_p = self.__class__._symm_agv_probs diff --git a/tests/unit_tests/inference/test_moe_dispatching_and_routing.py b/tests/unit_tests/inference/test_moe_dispatching_and_routing.py index 49b5df613f7..5b21ab4c364 100644 --- a/tests/unit_tests/inference/test_moe_dispatching_and_routing.py +++ b/tests/unit_tests/inference/test_moe_dispatching_and_routing.py @@ -439,3 +439,85 @@ def test_cuda_graph_dispatch_combine(self, max_rank_tokens, seed): expected_combined = (global_hidden[start:end].float() * ep_size).bfloat16() torch.testing.assert_close(graph_combined, expected_combined, atol=0, rtol=0) + + +# ────────────────────────────────────────────────────────────────────── +# mask_routing_padding kernel +# ────────────────────────────────────────────────────────────────────── + +from megatron.core.transformer.moe.inference_routing_mask_kernel import ( # noqa: E402 + HAVE_TRITON, + mask_routing_padding, +) + +requires_triton_cuda = pytest.mark.skipif( + not HAVE_TRITON or not torch.cuda.is_available(), + reason="mask_routing_padding requires triton and CUDA", +) + + +@pytest.mark.internal +@requires_triton_cuda +class TestMaskRoutingPadding: + """Unit tests for the CUDA-graph padding-row routing mask. + + ``mask_routing_padding`` fills ``routing_map[real_token_count:, :]`` with -1 so + the NVLS dispatcher routes padding rows to no expert. ``real_token_count`` is in + the global (pre-SP-shard) frame; a non-zero ``tp_rank`` shifts local rows into + that frame before the comparison. Runs standalone — no context or NVLS hardware. + """ + + TOPK = 6 + + def _routing_map(self, n_rows, fill=3): + # All entries non-negative so masked (-1) slots are unambiguous. + return torch.full((n_rows, self.TOPK), fill, dtype=torch.int64, device="cuda") + + def _real_token_count(self, count): + return torch.tensor([count], dtype=torch.int32, device="cuda") + + @pytest.mark.parametrize("n_rows, real_count", [(16, 10), (128, 1), (7, 7), (64, 0)]) + def test_masks_rows_past_real_count(self, n_rows, real_count): + """Rows >= real_count become -1; rows < real_count are untouched (tp_rank=0).""" + routing_map = self._routing_map(n_rows) + original = routing_map.clone() + + mask_routing_padding(routing_map, self._real_token_count(real_count), tp_rank=0) + + torch.testing.assert_close(routing_map[:real_count], original[:real_count]) + assert torch.all(routing_map[real_count:] == -1) + + def test_real_count_equal_rows_is_noop(self): + """real_count == n_rows masks nothing (the unpadded decode case).""" + routing_map = self._routing_map(32) + original = routing_map.clone() + + mask_routing_padding(routing_map, self._real_token_count(32), tp_rank=0) + + torch.testing.assert_close(routing_map, original) + + def test_sp_rank_offset(self): + """Local rows are shifted by tp_rank * n_rows into the global frame. + + With 8 local rows on SP rank 1, local row r is global row r + 8. A global + real_count of 11 keeps global rows [8, 11) real (local [0, 3)) and masks + global rows [11, 16) (local [3, 8)). + """ + routing_map = self._routing_map(8) + original = routing_map.clone() + + mask_routing_padding(routing_map, self._real_token_count(11), tp_rank=1) + + torch.testing.assert_close(routing_map[:3], original[:3]) + assert torch.all(routing_map[3:] == -1) + + def test_sp_rank_fully_masked(self): + """An SP rank entirely beyond real_count is fully masked. + + 8 local rows on rank 1 cover global rows [8, 16); real_count=8 masks all. + """ + routing_map = self._routing_map(8) + + mask_routing_padding(routing_map, self._real_token_count(8), tp_rank=1) + + assert torch.all(routing_map == -1) From bcf4c8fb51798af2e91e800ac2b24cb9c26905e8 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Tue, 14 Jul 2026 02:43:59 -0700 Subject: [PATCH 014/290] Inference: Add load aware routing to prefix caching. (#5607) Signed-off-by: Siddharth Singh --- megatron/core/inference/config.py | 6 +- .../data_parallel_inference_coordinator.py | 66 ++++++++++++++----- megatron/training/arguments.py | 16 +++-- megatron/training/config/inference_config.py | 13 ++-- .../dynamic_inference_functional_tests.md | 6 +- .../golden_values_dev_dgx_h100.json | 0 .../model_config.yaml | 2 +- ...pt-dynamic-inference-with-coordinator.yaml | 2 +- .../inference/coordinator_test_utils.py | 1 - ...est_data_parallel_inference_coordinator.py | 35 +++++----- ...test_dynamic_prefix_caching_coordinator.py | 33 +++++----- 11 files changed, 109 insertions(+), 71 deletions(-) rename tests/functional_tests/test_cases/gpt/{gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq => gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq}/golden_values_dev_dgx_h100.json (100%) rename tests/functional_tests/test_cases/gpt/{gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq => gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq}/model_config.yaml (99%) diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index ac2c0f8f8c7..274a475670b 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -100,8 +100,8 @@ class PrefixCachingCoordinatorPolicy(str, Enum): FIRST_PREFIX_BLOCK = "first_prefix_block" """Route to the rank that has the first block hash cached. O(ranks) check.""" - ROUND_ROBIN = "round_robin" - """Route requests to ranks in round-robin order, ignoring prefix affinity.""" + LOAD_BALANCED = "load_balanced" + """Route to the rank with the fewest in-flight requests. Ignores prefix affinity.""" class KVCacheManagementMode(str, Enum): @@ -300,7 +300,7 @@ class InferenceConfig: """ prefix_caching_coordinator_policy: PrefixCachingCoordinatorPolicy = ( - PrefixCachingCoordinatorPolicy.FIRST_PREFIX_BLOCK + PrefixCachingCoordinatorPolicy.LOAD_BALANCED ) """Routing policy for the DP inference coordinator. See `PrefixCachingCoordinatorPolicy` for options. diff --git a/megatron/core/inference/data_parallel_inference_coordinator.py b/megatron/core/inference/data_parallel_inference_coordinator.py index 6c090bc62df..a35c20a8feb 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator.py +++ b/megatron/core/inference/data_parallel_inference_coordinator.py @@ -55,8 +55,8 @@ class DataParallelInferenceCoordinator: `InferenceClient`, and performs a simple handshake. 3. **Request Forwarding**: It receives inference requests from clients, assigns a unique server-side request ID, tokenizes the prompt, and forwards the request - to one of the available data parallel rank using a round-robin scheduling - strategy. + to one of the available data parallel ranks using load-balanced (and, + when prefix caching is enabled, prefix-affinity-aware) routing. 4. **Response Routing**: It receives completed results from the data parallel ranks and routes them back to the original client that made the request. @@ -67,7 +67,7 @@ class DataParallelInferenceCoordinator: router_socket (zmq.Socket): The central ZMQ ROUTER socket for all communication. data_parallel_size (int): The number of data parallel workers to expect. identities_of_data_parallel_ranks (deque): A deque holding the ZMQ - identities of connected TP-coordinators, used for round-robin scheduling. + identities of connected data parallel instances, used for request routing. request_id_to_client_id (dict): Maps server-side request IDs to the ZMQ identity of the client that initiated the request. request_id_to_client_request_id (dict): Maps server-side request IDs to the @@ -109,7 +109,7 @@ def __init__( Args: pipe_connection (Connection): A connecting pipe to the parent process. - data_parallel_size (int): The number of TP-coordinator workers that are + data_parallel_size (int): The number of data parallel instances that are expected to connect. tokenizer: The tokenizer to use for prompt tokenization and detokenization. inference_coordinator_port (Optional[int]): The TCP port number to bind the server to. @@ -182,7 +182,6 @@ def __init__( self.identities_of_data_parallel_ranks = deque( sorted(self.identities_of_data_parallel_ranks) ) - self._round_robin_idx = 0 self.request_id_to_client_id = {} self.request_id_to_client_request_id = {} @@ -221,19 +220,19 @@ def __init__( self._hash_table: dict[int, dict[int, int]] = {} self._hash_assignment_counter = 0 - def get_next_data_parallel_rank(self): + def get_least_loaded_data_parallel_rank(self): """ - Selects the next data parallel rank using round-robin scheduling. + Selects the data parallel rank with the fewest in-flight requests. + + Ties are broken by lowest rank index for deterministic behavior. Returns: - bytes: The ZMQ identity of the next data parallel rank to receive a request. + bytes: The ZMQ identity of the least-loaded data parallel rank. """ - identities = self.identities_of_data_parallel_ranks - if not identities: + if not self._identities_list: raise RuntimeError("No engines connected") - idx = self._round_robin_idx % len(identities) - self._round_robin_idx = idx + 1 - return identities[idx] + best_idx = int(np.argmin(self._pending_counts)) + return self._identities_list[best_idx] def _register_rank_identity(self, identity): """Register a new rank identity in the scoring data structures. @@ -256,8 +255,37 @@ def _register_rank_identity(self, identity): ) def _remove_engine(self, identity): - """Remove a disconnected engine from the routing pool.""" + """Remove a disconnected engine from all routing bookkeeping. + Called both during shutdown and when an engine becomes unreachable mid-operation + (e.g. zmq.EHOSTUNREACH in _send_to_engine). The O(n) index-shifting and hash-table + rebuild are acceptable because the number of connected engines is small; optimize + only if dynamic registration/deregistration at high engine counts becomes a use case. + """ self.identities_of_data_parallel_ranks.remove(identity) + idx = self.identity_to_rank_index.pop(identity, None) + if idx is None: + return + self._identities_list.pop(idx) + self._pending_counts = np.delete(self._pending_counts, idx) + # Shift indices for engines that came after the removed slot. + for ident in self.identity_to_rank_index: + if self.identity_to_rank_index[ident] > idx: + self.identity_to_rank_index[ident] -= 1 + # Drop hash-table entries for the removed rank; shift indices above it. + new_hash_table = {} + for h, rank_ts in self._hash_table.items(): + # h is hash index + # rank_ts is a dict mapping rank_idx → timestamp + new_row = {} + for r, ts in rank_ts.items(): + if r == idx: + # skip this rank as it is removed + continue + new_r = r - 1 if r > idx else r + new_row[new_r] = ts + if new_row: + new_hash_table[h] = new_row + self._hash_table = new_hash_table logging.warning( "Coordinator: removed engine %s (now %d engines)", identity, @@ -312,11 +340,13 @@ def get_best_data_parallel_rank(self, request_hashes): Returns: bytes: The ZMQ identity of the selected data parallel rank. """ - if self.prefix_caching_coordinator_policy == PrefixCachingCoordinatorPolicy.ROUND_ROBIN: - return self.get_next_data_parallel_rank() + if self.prefix_caching_coordinator_policy == PrefixCachingCoordinatorPolicy.LOAD_BALANCED: + return self.get_least_loaded_data_parallel_rank() + # Without prefix caching (or when the request has no hashes to match on) + # fall back to load-balanced routing. if not self.enable_prefix_caching or not request_hashes: - return self.get_next_data_parallel_rank() + return self.get_least_loaded_data_parallel_rank() match, recency = self._match_vector(request_hashes) @@ -639,7 +669,7 @@ def entrypoint( ready_event (Event): A threading or multiprocessing event object that is set() once the coordinator is ready to accept connections. inference_coordinator_port (int): The port to bind to. - data_parallel_size (int): The number of expected TP-coordinators. + data_parallel_size (int): The number of expected data parallel instances. deterministic_mode (bool): Whether to enable deterministic scheduling. block_size_tokens (Optional[int]): Token block size for prefix caching hashing. enable_prefix_caching (bool): Whether prefix caching is enabled. diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index ae8496845c8..4f3c0af7b30 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1943,14 +1943,18 @@ def _add_inference_args(parser): 'free pool when ref_count hits 0. "lru" keeps blocks ' 'cached and evicts via LRU only when space is needed.') group.add_argument('--inference-dynamic-batching-prefix-caching-coordinator-policy', - type=str, default='first_prefix_block', - choices=['longest_prefix', 'first_prefix_block', 'round_robin'], + type=str, default='load_balanced', + choices=['longest_prefix', 'first_prefix_block', 'load_balanced'], dest='inference_dynamic_batching_prefix_caching_coordinator_policy', help='Coordinator routing policy for prefix caching. ' - '"first_prefix_block" (default) routes based on the first ' - 'block hash only. "longest_prefix" routes to the rank with ' - 'the longest matching prefix. "round_robin" ignores prefix ' - 'affinity and cycles through ranks.') + '"load_balanced" (default) routes to the rank with the fewest ' + 'in-flight requests, ignoring prefix affinity. ' + '"first_prefix_block" routes based on the first block hash only. ' + '"longest_prefix" routes to the rank with the longest matching ' + 'prefix. "first_prefix_block" and "longest_prefix" both combine ' + 'prefix affinity with load balancing and fall back to ' + 'load-balanced routing when prefix caching is disabled or no ' + 'prefix match exists.') group.add_argument('--inference-dynamic-batching-prefix-caching-routing-alpha', type=float, default=0.5, dest='inference_dynamic_batching_prefix_caching_routing_alpha', diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index edad1f4d21d..0e3b960cbe3 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -176,11 +176,14 @@ class InferenceSetupConfig: space is needed.""" inference_dynamic_batching_prefix_caching_coordinator_policy: Literal[ - "longest_prefix", "first_prefix_block", "round_robin" - ] = "first_prefix_block" - """Coordinator routing policy for prefix caching. "first_prefix_block" (default) routes based on - the first block hash only. "longest_prefix" routes to the rank with the longest matching prefix. - "round_robin" ignores prefix affinity and cycles through ranks.""" + "longest_prefix", "first_prefix_block", "load_balanced" + ] = "load_balanced" + """Coordinator routing policy for prefix caching. "load_balanced" (default) routes to the rank + with the fewest in-flight requests, ignoring prefix affinity. "first_prefix_block" routes based + on the first block hash only. "longest_prefix" routes to the rank with the longest matching + prefix. "first_prefix_block" and "longest_prefix" both combine prefix affinity with load + balancing and fall back to load-balanced routing when prefix caching is disabled or no prefix + match exists.""" inference_dynamic_batching_prefix_caching_routing_alpha: float = 0.5 """Weight for prefix-aware routing score: score = alpha * match + (1 - alpha) * normalized_load. diff --git a/tests/functional_tests/dynamic_inference_functional_tests.md b/tests/functional_tests/dynamic_inference_functional_tests.md index 2cc84670484..b1453700cb0 100644 --- a/tests/functional_tests/dynamic_inference_functional_tests.md +++ b/tests/functional_tests/dynamic_inference_functional_tests.md @@ -53,7 +53,7 @@ CLI flags below are verified to exist in `megatron/training/arguments.py` and/or |---|---|---|---| | Enable prefix caching | `--inference-dynamic-batching-prefix-caching` | off | Reuse KV blocks for shared prompt prefixes | | Eviction policy | `--inference-dynamic-batching-prefix-caching-eviction-policy {ref_zero, lru}` | `ref_zero` | Block reclamation strategy | -| Coordinator routing | `--inference-dynamic-batching-prefix-caching-coordinator-policy {longest_prefix, first_prefix_block, round_robin}` | `first_prefix_block` | Multi-rank request routing | +| Coordinator routing | `--inference-dynamic-batching-prefix-caching-coordinator-policy {longest_prefix, first_prefix_block, load_balanced}` | `load_balanced` | Multi-rank request routing | | Routing alpha | `--inference-dynamic-batching-prefix-caching-routing-alpha` | 0.5 | 0=load-balance, 1=prefix-affinity | | Mamba state cache | `--inference-dynamic-batching-prefix-caching-mamba-gb` | — | GPU memory for Mamba hybrid block states | @@ -306,7 +306,7 @@ User selected the **recommended cut** of 6 tests + drift fix + cw-dfw golden gen **Decision (2026-05-12):** User selected Tier 2 (18 tests). Substitutions vs. original Tier 2 pitch: - CP-parallelism tests **dropped** — dynamic inference doesn't support CP (issue #6). -- Added 2 ZMQ-coordinator tests (longest_prefix + round_robin policies). +- Added 2 ZMQ-coordinator tests (longest_prefix + load_balanced policies). | # | Test | model_config | recipe | run | golden | pytest verified | |---|---|---|---|---|---|---| @@ -327,7 +327,7 @@ User selected the **recommended cut** of 6 tests + drift fix + cw-dfw golden gen | 15 | `gpt_dynamic_inference_tp4_pp1_ep4_16B_prefix_caching` (MoE) | ✅ | ✅ | ✅ | ✅ committed | ✅ **PASSED** | | 16 | `gpt_dynamic_inference_tp4_pp1_ep4_16B_chunked_prefill` (MoE) | ✅ | ✅ | ✅ | ✅ committed | ⏸️ (not sampled) | | 17 | `gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_longest_prefix_zmq` | ✅ | ✅ | ✅ | ✅ committed | ✅ **PASSED** | -| 18 | `gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq` | ✅ | ✅ | ✅ | ✅ committed | ⏸️ (not sampled) | +| 18 | `gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq` | ✅ | ✅ | ✅ | ✅ committed | ⏸️ (not sampled) | **Verification methodology**: ran 4 representative tests (one per category: parallelism, 3-way combo, MoE, DP+ZMQ) without `RECORD_CHECKPOINTS=true` so the pytest comparison actually executes. All 4 reported `test_inference_pipeline PASSED` against their committed goldens. diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/golden_values_dev_dgx_h100.json similarity index 100% rename from tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq/golden_values_dev_dgx_h100.json rename to tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/golden_values_dev_dgx_h100.json diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml similarity index 99% rename from tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq/model_config.yaml rename to tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml index 9ba47a56e6e..c915728da33 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml @@ -44,7 +44,7 @@ MODEL_ARGS: --num-tokens-to-generate: 30 --inference-dynamic-batching-buffer-size-gb: 20 --inference-dynamic-batching-prefix-caching: true - --inference-dynamic-batching-prefix-caching-coordinator-policy: round_robin + --inference-dynamic-batching-prefix-caching-coordinator-policy: load_balanced --dist-ckpt-strictness: log_unexpected --inference-ckpt-non-strict: true # To handle the extra_state errors --output-path: ${INFERENCE_OUTPUT_PATH} diff --git a/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml b/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml index fdc96221e44..1cc8c1a47ae 100644 --- a/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml +++ b/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml @@ -82,7 +82,7 @@ products: - environment: [dev] scope: [mr] platforms: [dgx_h100] - - test_case: [gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_round_robin_zmq] + - test_case: [gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq] products: - environment: [dev] scope: [mr] diff --git a/tests/unit_tests/inference/coordinator_test_utils.py b/tests/unit_tests/inference/coordinator_test_utils.py index d33d8790ef9..93bf97dbc7b 100644 --- a/tests/unit_tests/inference/coordinator_test_utils.py +++ b/tests/unit_tests/inference/coordinator_test_utils.py @@ -64,7 +64,6 @@ def make_coordinator_direct( n_ranks = data_parallel_size coordinator._hash_table = {} coordinator._hash_assignment_counter = 0 - coordinator._round_robin_idx = 0 sorted_identities = sorted(coordinator.identities_of_data_parallel_ranks) coordinator.identity_to_rank_index = { diff --git a/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py b/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py index 8e9985b6dd5..584b616a544 100644 --- a/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py +++ b/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py @@ -776,22 +776,24 @@ def _make_routing_coordinator( class TestRoutingPolicies: """Unit tests for routing behavior under different policies and load conditions.""" - def test_no_prefix_caching_uses_round_robin(self): - """When prefix caching is off, round-robin is used regardless of load.""" + def test_no_prefix_caching_uses_load_balanced(self): + """When prefix caching is off, routing goes to the least-loaded rank.""" coord = _make_routing_coordinator(num_ranks=3, enable_prefix_caching=False) coord._pending_counts[coord.identity_to_rank_index[b"rank-0"]] = 2 coord._pending_counts[coord.identity_to_rank_index[b"rank-1"]] = 1 - results = [coord.get_best_data_parallel_rank([]) for _ in range(6)] - assert results == [b"rank-0", b"rank-1", b"rank-2", b"rank-0", b"rank-1", b"rank-2"] + # rank-2 has the fewest in-flight requests (0). + assert coord.get_best_data_parallel_rank([]) == b"rank-2" - def test_empty_hashes_uses_round_robin(self): - """Empty hash list falls back to round-robin.""" + def test_empty_hashes_uses_load_balanced(self): + """Empty hash list falls back to the least-loaded rank.""" coord = _make_routing_coordinator(num_ranks=4) + coord._pending_counts[coord.identity_to_rank_index[b"rank-0"]] = 3 coord._pending_counts[coord.identity_to_rank_index[b"rank-1"]] = 5 + coord._pending_counts[coord.identity_to_rank_index[b"rank-2"]] = 1 + coord._pending_counts[coord.identity_to_rank_index[b"rank-3"]] = 4 - results = [coord.get_best_data_parallel_rank([]) for _ in range(4)] - assert results == [b"rank-0", b"rank-1", b"rank-2", b"rank-3"] + assert coord.get_best_data_parallel_rank([]) == b"rank-2" def test_prefix_affinity_routing(self): """When prefix caching is on with hashes, scoring picks the best rank.""" @@ -846,18 +848,17 @@ def test_free_capacity_wins_when_prefix_rank_is_full(self): chosen = coord.get_best_data_parallel_rank([fake_hash]) assert chosen == b"rank-1" - def test_round_robin_policy_ignores_load(self): - """ROUND_ROBIN policy does naive round-robin regardless of load.""" + def test_load_balanced_policy_ignores_prefix(self): + """LOAD_BALANCED policy routes to the least-loaded rank, ignoring prefix affinity.""" coord = _make_routing_coordinator( num_ranks=3, enable_prefix_caching=True, - policy=PrefixCachingCoordinatorPolicy.ROUND_ROBIN, + policy=PrefixCachingCoordinatorPolicy.LOAD_BALANCED, ) - coord._pending_counts[coord.identity_to_rank_index[b"rank-0"]] = 1 + coord._pending_counts[coord.identity_to_rank_index[b"rank-0"]] = 2 coord._pending_counts[coord.identity_to_rank_index[b"rank-1"]] = 1 - coord._round_robin_idx = 0 - identities = list(coord.identities_of_data_parallel_ranks) - for i in range(len(identities)): - chosen = coord.get_best_data_parallel_rank([99]) - assert chosen == identities[i] + # Seed a prefix match on the most-loaded rank; load balancing must ignore it. + _set_hash_rank(coord, 99, b"rank-0", 1) + + assert coord.get_best_data_parallel_rank([99]) == b"rank-2" diff --git a/tests/unit_tests/inference/test_dynamic_prefix_caching_coordinator.py b/tests/unit_tests/inference/test_dynamic_prefix_caching_coordinator.py index b4b8da0e538..1d020ee1a79 100644 --- a/tests/unit_tests/inference/test_dynamic_prefix_caching_coordinator.py +++ b/tests/unit_tests/inference/test_dynamic_prefix_caching_coordinator.py @@ -351,23 +351,24 @@ def test_equal_scores_tiebreak_by_rank_index(self): selected = coordinator.get_best_data_parallel_rank(hashes) assert selected == rank_0 - def test_empty_hashes_uses_round_robin(self): - """Empty hash list falls back to round-robin.""" + def test_empty_hashes_uses_load_balanced(self): + """Empty hash list falls back to the least-loaded rank.""" coordinator = make_coordinator_direct() - for identity in coordinator.identities_of_data_parallel_ranks: - coordinator._pending_counts[coordinator.identity_to_rank_index[identity]] = 1 - rank1 = coordinator.get_best_data_parallel_rank([]) - rank2 = coordinator.get_best_data_parallel_rank([]) - assert rank1 != rank2 - - def test_disabled_prefix_caching_uses_round_robin(self): - """With prefix caching disabled, always uses round-robin.""" + identities = list(coordinator.identities_of_data_parallel_ranks) + for identity in identities: + coordinator._pending_counts[coordinator.identity_to_rank_index[identity]] = 2 + # Make the second rank the least loaded. + coordinator._pending_counts[coordinator.identity_to_rank_index[identities[1]]] = 0 + assert coordinator.get_best_data_parallel_rank([]) == identities[1] + + def test_disabled_prefix_caching_uses_load_balanced(self): + """With prefix caching disabled, always routes to the least-loaded rank.""" coordinator = make_coordinator_direct(enable_prefix_caching=False) - for identity in coordinator.identities_of_data_parallel_ranks: - coordinator._pending_counts[coordinator.identity_to_rank_index[identity]] = 1 - rank1 = coordinator.get_best_data_parallel_rank([1, 2, 3]) - rank2 = coordinator.get_best_data_parallel_rank([1, 2, 3]) - assert rank1 != rank2 + identities = list(coordinator.identities_of_data_parallel_ranks) + for identity in identities: + coordinator._pending_counts[coordinator.identity_to_rank_index[identity]] = 2 + coordinator._pending_counts[coordinator.identity_to_rank_index[identities[1]]] = 0 + assert coordinator.get_best_data_parallel_rank([1, 2, 3]) == identities[1] class TestCoordinatorShadowState: @@ -438,7 +439,7 @@ def test_routing_then_state_update_flow(self): tokens = [1, 2, 3, 4, 5, 6, 7, 8] hashes = coordinator.compute_request_hashes(tokens) - # First request: no matches, round-robin. + # First request: no matches, routed by load (least-loaded rank). rank = coordinator.get_best_data_parallel_rank(hashes) coordinator._update_rank_hashes(rank, hashes) From 3c4645201af547141cd5c91bfcfb3e05786fdce1 Mon Sep 17 00:00:00 2001 From: Min Htet Myet <88831350+Mattral@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:01:34 +0900 Subject: [PATCH 015/290] fix(clip_grads): handle empty grads_for_norm in inf-norm and p-norm paths (#5530) Signed-off-by: Min Htet Myet Signed-off-by: Mattral --- megatron/core/optimizer/clip_grads.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/megatron/core/optimizer/clip_grads.py b/megatron/core/optimizer/clip_grads.py index 3c5491d39a1..762d4aa8dcd 100644 --- a/megatron/core/optimizer/clip_grads.py +++ b/megatron/core/optimizer/clip_grads.py @@ -92,7 +92,7 @@ def get_grad_norm_fp32( # Calculate norm. if norm_type == inf: - total_norm = max(grad.abs().max() for grad in grads_for_norm) + total_norm = max((grad.abs().max() for grad in grads_for_norm), default=torch.tensor(0.0)) total_norm_cuda = torch.tensor([float(total_norm)], dtype=torch.float, device='cuda') # Take max across all data-parallel GPUs if using FSDP and then all model-parallel GPUs. if data_parallel_group: @@ -105,24 +105,20 @@ def get_grad_norm_fp32( total_norm = total_norm_cuda[0].item() else: - if norm_type == 2.0: + total_norm = torch.zeros(1, dtype=torch.float, device='cuda') + if not grads_for_norm: + pass + elif norm_type == 2.0: dummy_overflow_buf = torch.zeros(1, dtype=torch.int, device='cuda') # Use apex's multi-tensor applier for efficiency reasons. # Multi-tensor applier takes a function and a list of list # and performs the operation on that list all in one kernel. - if grads_for_norm: - grad_norm, _ = multi_tensor_applier( - l2_norm_impl, - dummy_overflow_buf, - [grads_for_norm], - False, # no per-parameter norm - ) - else: - grad_norm = torch.zeros(1, dtype=torch.float, device='cuda') + grad_norm, _ = multi_tensor_applier( + l2_norm_impl, dummy_overflow_buf, [grads_for_norm], False # no per-parameter norm + ) # Since we will be summing across data parallel groups, # we need the pow(norm-type). total_norm = grad_norm**norm_type - else: for grad in grads_for_norm: grad_norm = torch.norm(grad, norm_type) From c09022e9c07e2507fe925c510e6c3f50a4f042ba Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Tue, 14 Jul 2026 14:06:25 +0200 Subject: [PATCH 016/290] test(gpt): AUT-830 mark tp1_pp4_vp1_resume_torch_decoupled_lr flaky on h100 (#5801) Signed-off-by: svcnemo-autobot --- tests/test_utils/recipes/h100/gpt.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_utils/recipes/h100/gpt.yaml b/tests/test_utils/recipes/h100/gpt.yaml index 3e88e325048..39bdbde5819 100644 --- a/tests/test_utils/recipes/h100/gpt.yaml +++ b/tests/test_utils/recipes/h100/gpt.yaml @@ -192,8 +192,12 @@ products: scope: [nightly] - test_case: [gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr] products: + # Disabled (flaky): the exact/deterministic golden comparison intermittently + # fails on dgx_h100 while the approximate check passes and the run is + # reproducible in isolation. Kept in-tree for easy re-enable; see the + # existing "WAR to #513" note on this test case's model_config.yaml. - environment: [dev] - scope: [mr, mr-github] + scope: [mr-broken, mr-github-broken] platforms: [dgx_h100] - environment: [lts] scope: [nightly] From a79f49d37bd90ef434d22b40505bdbfbb5bfd557 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Tue, 14 Jul 2026 08:03:26 -0700 Subject: [PATCH 017/290] Delegate reasoning token retention to the chat template in multi-turn conversations (#5276) --- .../data_parallel_inference_coordinator.py | 4 -- .../endpoints/chat_completions.py | 48 +++++++++---------- megatron/rl/inference/megatron.py | 14 ++++-- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/megatron/core/inference/data_parallel_inference_coordinator.py b/megatron/core/inference/data_parallel_inference_coordinator.py index a35c20a8feb..57aa9122c2e 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator.py +++ b/megatron/core/inference/data_parallel_inference_coordinator.py @@ -626,10 +626,6 @@ def detokenize(self, finished_request): finished_request (dict): The serialized merged request containing the generated tokens to be detokenized. It is modified in place. """ - if finished_request["prompt"] is None: - finished_request["prompt"] = TextGenerationController.detokenize( - self.tokenizer, finished_request["prompt_tokens"][1], remove_EOD=False - ) detokenize_stop_sequence = (finished_request.get("sampling_params", {}) or {}).get( "detokenize_stop_sequence", False ) diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py index 96de2e19713..bb82c73ada8 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py @@ -323,22 +323,6 @@ def _sanitize_tools_for_template(tools): return sanitized -def _reconstruct_reasoning_content(messages: list[dict]) -> list[dict]: - """Reconstruct tags from reasoning_content fields on assistant messages. - - For parity with vLLM, assistant messages may carry reasoning in the reasoning_content field. - Before applying the chat template, we must inline those tags back into content. - """ - for message in messages: - if message.get("role") != "assistant": - continue - reasoning_content = message.pop("reasoning_content", None) - if reasoning_content is not None: - content = message.get("content") or "" - message["content"] = f"{reasoning_content}{content}" - return messages - - def _replace_prefix_tokens( eos_token_id, previous_turn_token_ids, @@ -462,7 +446,6 @@ async def chat_completions(): if not isinstance(messages, list): return Response("'messages' must be a list", status=400) template_messages = _sanitize_messages_for_template(messages) - template_messages = _reconstruct_reasoning_content(template_messages) template_tools = _sanitize_tools_for_template(tools) try: @@ -592,6 +575,7 @@ async def chat_completions(): prompt_tokens = [tokenizer.bos] + prompt_tokens max_tokens = req.get("max_completion_tokens", None) or req.get("max_tokens", None) + ignore_eos = bool(req.get("ignore_eos", False)) sampling_params = SamplingParams( temperature=temperature, @@ -602,6 +586,7 @@ async def chat_completions(): num_tokens_to_generate=(int(max_tokens) if max_tokens is not None else None), skip_prompt_log_probs=skip_prompt_log_probs, add_BOS=add_BOS, + termination_id=-1 if ignore_eos else None, ) except ValueError as e: return Response(f"Invalid sampling parameter: {e}", status=400) @@ -663,6 +648,14 @@ async def chat_completions(): total_completion_tokens = 0 prompt_tokens_counts = [] + prevent_retokenization = req.get("prevent_retokenization", True) + # return_tokenized_data controls whether prompt/generation token ids are + # included in the response. It is independent of prevent_retokenization + # (a client may want token ids without prevent_retokenization, or vice versa), + # but prevent_retokenization implicitly requires token ids so the client + # can echo them back next turn. + return_tokenized_data = req.get("return_tokenized_data", False) or prevent_retokenization + return_raw_text = req.get("return_raw_text", False) request_idx = 0 for result_item in batch_results: result = unwrap_serialized_tensors(result_item) @@ -736,9 +729,14 @@ async def chat_completions(): if "reasoning" in metadata: message["reasoning_content"] = metadata["reasoning"] - # Replicate data in the message field for compatibility. - message["prompt_token_ids"] = result["prompt_tokens"] - message["generation_token_ids"] = result["generated_tokens"] + if return_tokenized_data: + message["prompt_token_ids"] = result["prompt_tokens"] + message["generation_token_ids"] = result["generated_tokens"] + if return_raw_text: + prompt_str = tokenizer.detokenize(result["prompt_tokens"]) + message["raw_text"] = prompt_str + text_output + # Small RL/debug scalars (a few bytes each); harmless to keep for + # NeMo-RL compatibility. message["generation_log_probs"] = result.get("generated_log_probs", []) message["policy_epoch"] = result["policy_epoch"] message["kv_cache_epoch"] = result["kv_cache_epoch"] @@ -759,15 +757,13 @@ async def chat_completions(): else: finish_reason = "stop" + # Choice-level prompt/generation_token_ids, generation_log_probs and + # raw_text were duplicates of message-level data (or reconstructable); + # dropped to match vLLM's response shape and cut payload size. choice_data = { "index": request_idx, "message": message, - "prompt_token_ids": result["prompt_tokens"], - "generation_token_ids": result["generated_tokens"], - "generation_log_probs": result.get("generated_log_probs", []), - "raw_text": result["prompt"] + result["generated_text"], # 'logprobs' in chat API is an object containing 'content' - # "logprobs": {"content": logprobs_content} if logprobs_content else None, "logprobs": {"content": logprobs_content} if return_log_probs else None, "finish_reason": finish_reason, } @@ -782,7 +778,7 @@ async def chat_completions(): ] choices.append(choice_data) - if choice_data["generation_log_probs"] is None: + if result.get("generated_log_probs") is None: logger.warning( "Generation log probs is None for request:\n%s", json.dumps(_redact_token_id_lists_for_logging(result), indent=4), diff --git a/megatron/rl/inference/megatron.py b/megatron/rl/inference/megatron.py index 055b232d73f..b2a6b320a97 100644 --- a/megatron/rl/inference/megatron.py +++ b/megatron/rl/inference/megatron.py @@ -65,6 +65,12 @@ async def base_generate(self, request: InferenceRequest) -> InferenceResponse: extra_body={ "skip_prompt_log_probs": True, "add_BOS": (not args.rl_skip_bos_token and tokenizer.bos is not None), + # TODO: These are non-standard fields that add significant memory overheads to the + # chat completions payload. return_raw_text also wastes a lot of CPU cycles + # detokenizing prompt tokens, especially expensive for long prompts in agentic RL. + # Set to False if not needed in MRL. + "return_tokenized_data": True, + "return_raw_text": True, }, ) @@ -73,11 +79,11 @@ async def base_generate(self, request: InferenceRequest) -> InferenceResponse: return InferenceResponse( # TODO: Handle tool calls and reasoning in LLMChatMessage response=LLMChatMessage(**choice.message.model_dump(include={'role', 'content'})), - raw_text=choice.raw_text, - token_ids=choice.prompt_token_ids + choice.generation_token_ids, - logprobs=choice.generation_log_probs, + raw_text=choice.message.raw_text, + token_ids=choice.message.prompt_token_ids + choice.message.generation_token_ids, + logprobs=choice.message.generation_log_probs, finish_reason=choice.finish_reason, - prompt_length=len(choice.prompt_token_ids), + prompt_length=len(choice.message.prompt_token_ids), policy_epoch=choice.message.policy_epoch, kv_cache_epoch=choice.message.kv_cache_epoch, num_evictions=choice.message.num_evictions, From e97ac83603e4bd79a1fe27f015b176510c0a3e1d Mon Sep 17 00:00:00 2001 From: Asha Anoosheh Date: Tue, 14 Jul 2026 17:04:33 +0200 Subject: [PATCH 018/290] Various ModelOpt fixes: QAD test for CICD, use model builder config instead of model provider, allow loading teacher ckpt independently of student (#4520) Signed-off-by: Asha Anoosheh --- examples/post_training/modelopt/quantize.py | 2 +- megatron/post_training/checkpointing.py | 31 ++- megatron/post_training/model_builder.py | 166 ++++++++------ megatron/post_training/utils.py | 21 +- megatron/training/argument_utils.py | 83 ++++--- megatron/training/checkpointing.py | 19 -- megatron/training/training.py | 24 +- pretrain_gpt.py | 9 +- pretrain_hybrid.py | 9 +- .../get_test_results_from_tensorboard_logs.py | 1 + .../test_pretraining_regular_pipeline.py | 1 + .../golden_values_dev_dgx_a100.json | 109 --------- .../golden_values_dev_dgx_h100.json | 216 ++++++++++++++++++ .../golden_values_lts_dgx_a100.json | 1 - .../model_config.yaml | 7 +- .../golden_values_dev_dgx_h100.json | 36 +++ .../model_config.yaml | 190 +++++++++++++++ tests/test_utils/recipes/h100/mamba.yaml | 6 + tools/common_pile_dataset/README.md | 2 +- .../setup_common_pile_dataset.sh | 2 +- 20 files changed, 686 insertions(+), 249 deletions(-) delete mode 100644 tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_a100.json create mode 100644 tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_h100.json delete mode 100644 tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_lts_dgx_a100.json create mode 100644 tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json create mode 100644 tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml diff --git a/examples/post_training/modelopt/quantize.py b/examples/post_training/modelopt/quantize.py index 7e94a0be111..8c68399dfe6 100644 --- a/examples/post_training/modelopt/quantize.py +++ b/examples/post_training/modelopt/quantize.py @@ -344,7 +344,7 @@ def get_calib_dataloader( Supports either a local path (.jsonl) or a HuggingFace dataset name. """ - if os.path.isfile(dataset_path_or_name): + if os.path.isfile(dataset_path_or_name) and dataset_path_or_name.endswith(".jsonl"): # Local file print_rank_0(f"Loading calibration dataset from local file: {dataset_path_or_name}") all_texts = [] diff --git a/megatron/post_training/checkpointing.py b/megatron/post_training/checkpointing.py index 1e631b54932..1cb730bc450 100644 --- a/megatron/post_training/checkpointing.py +++ b/megatron/post_training/checkpointing.py @@ -8,12 +8,16 @@ import modelopt import modelopt.torch.opt as mto import torch.nn as nn -from modelopt.torch.opt.plugins import restore_sharded_modelopt_state as restore_sharded_modelopt_state_legacy -from modelopt.torch.opt.plugins.mcore_dist_checkpointing import _load_extra_state_from_sharded_checkpoint +from modelopt.torch.opt.plugins import ( + restore_sharded_modelopt_state as restore_sharded_modelopt_state_legacy, +) +from modelopt.torch.opt.plugins.mcore_dist_checkpointing import ( + _load_extra_state_from_sharded_checkpoint, +) from megatron.core import dist_checkpointing -from megatron.core.utils import get_torch_version, is_torch_min_version, unwrap_model from megatron.core.dist_checkpointing.serialization import _legacy_common_state_exists +from megatron.core.utils import unwrap_model from megatron.training import get_args from megatron.training.checkpointing import _load_base_checkpoint, load_checkpoint from megatron.training.utils import print_rank_0 @@ -226,3 +230,24 @@ def restore_sharded_modelopt_state(model: list[nn.Module], checkpoint_name: str model[0] = mto.restore_from_modelopt_state(model[0], common_modelopt_state) _load_extra_state_from_sharded_checkpoint(model[0], checkpoint_name, prefix="") + + +def load_kd_teacher_checkpoint(model) -> None: + """Load the teacher checkpoint for ModelOpt distillation if the model has one.""" + args = get_args() + if not getattr(args, "export_kd_teacher_load", None): + return + + teacher = unwrap_model(model[0]).teacher_model + print_rank_0(f"Loading teacher as {type(teacher).__name__} from {args.export_kd_teacher_load} ...") + # [WAR]: To avoid error out on loading teacher's checkpoint, we temporarily + # set args.finetune to True while loading the teacher checkpoint. + original_args_finetune, original_ckpt_format = args.finetune, args.ckpt_format + args.finetune = True + if args.export_kd_teacher_ckpt_format is not None: + args.ckpt_format = args.export_kd_teacher_ckpt_format + try: + load_checkpoint([teacher], None, None, load_arg='export_kd_teacher_load') + finally: + args.finetune, args.ckpt_format = original_args_finetune, original_ckpt_format + print_rank_0("... teacher loaded successfully.") diff --git a/megatron/post_training/model_builder.py b/megatron/post_training/model_builder.py index 0b411788115..cd497907406 100644 --- a/megatron/post_training/model_builder.py +++ b/megatron/post_training/model_builder.py @@ -5,11 +5,11 @@ import logging import os from argparse import Namespace -from typing import Any, Dict +from dataclasses import dataclass +from typing import Any, ClassVar, Dict import modelopt.torch.distill as mtd import modelopt.torch.distill.plugins.megatron as mtd_mcore -import modelopt.torch.opt as mto import yaml from megatron.core.models.gpt import GPTModel as MCoreGPTModel @@ -18,15 +18,83 @@ get_gpt_heterogeneous_layer_spec, ) from megatron.core.models.hybrid.hybrid_model import HybridModel as MCoreHybridModel +from megatron.core.pipeline_parallel.utils import is_pp_first_stage, is_pp_last_stage from megatron.core.post_training.modelopt.gpt.model_specs import get_gpt_modelopt_spec from megatron.core.post_training.modelopt.gpt.state_dict_hooks import ( mcore_gpt_load_te_state_dict_pre_hook, ) from megatron.core.post_training.modelopt.hybrid.model_specs import get_hybrid_stack_modelopt_spec +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.transformer.module import MegatronModule from megatron.post_training.checkpointing import load_modelopt_state -from megatron.post_training.utils import print_distributed_quant_summary from megatron.training import get_args, print_rank_0 from megatron.training.arguments import core_transformer_config_from_args +from megatron.training.models.gpt import GPTModelBuilder, GPTModelConfig +from megatron.training.models.hybrid import HybridModelBuilder, HybridModelConfig + + +@dataclass(kw_only=True) +class ModelOptModelConfig(GPTModelConfig): + """Config for the legacy ModelOpt model construction path. + + Identical to `GPTModelConfig` except for `builder` - construction still goes + through `gpt_config_from_args`, only the resolved builder class differs, since + ModelOpt-enabled runs need `ModelOptGPTModelBuilder` instead of `GPTModelBuilder`. + """ + + builder: ClassVar[str] = "megatron.post_training.model_builder.ModelOptGPTModelBuilder" + + +@dataclass(kw_only=True) +class ModelOptHybridModelConfig(HybridModelConfig): + """Config for the legacy ModelOpt model construction path, for hybrid models. + + Identical to `HybridModelConfig` except for `builder` - construction still goes + through `hybrid_config_from_args`. + """ + + builder: ClassVar[str] = "megatron.post_training.model_builder.ModelOptHybridModelBuilder" + + +class _ModelOptBuilderMixin: + """Shared `build_model()` override for the legacy ModelOpt model construction path. + + `modelopt_gpt_hybrid_builder` dispatches on `args.export_model_type` internally, so + the same implementation covers both GPT and hybrid models - only the parent + `ModelBuilder` (and its `build_distributed_models()`) differs per config type, so + each gets its own concrete class below rather than sharing one tied to `GPTModelBuilder`. + """ + + def build_model( + self, + pg_collection: ProcessGroupCollection, + pre_process: bool | None = None, + post_process: bool | None = None, + vp_stage: int | None = None, + ) -> MegatronModule: + args = get_args() + if pre_process is None: + pre_process = is_pp_first_stage(pg_collection.pp) + if post_process is None: + post_process = is_pp_last_stage(pg_collection.pp) + return modelopt_gpt_hybrid_builder( + args, + pre_process, + post_process, + vp_stage, + pg_collection=pg_collection, + ) + + +class ModelOptGPTModelBuilder(_ModelOptBuilderMixin, GPTModelBuilder): + """ModelBuilder adapter for the legacy ModelOpt model construction path.""" + + +class ModelOptHybridModelBuilder(_ModelOptBuilderMixin, HybridModelBuilder): + """ModelBuilder adapter for the legacy ModelOpt model construction path (hybrid).""" + + +logger = logging.getLogger(__name__) def count_parameters_in_layer(model, layer_name): @@ -50,69 +118,40 @@ def _load_teacher_model_config(checkpoint_path: str) -> Namespace: """Reads teacher config from a file. The config provided, either in the teacher checkpoint dir or via `--export-kd-teacher-model-config`, - should specify (in NeMo yaml config format) any model architecture settings which differ from the main student model's. - This function will translate NeMo field names to MCore as needed. + should specify any model architecture settings which differ from the main student model's. + The field names should match those returned by get_args() and not TransformerConfig. """ - required_teacher_fields = ( - "num_layers", - "hidden_size", - "ffn_hidden_size", - "num_attention_heads", - ) - args = get_args() + if args.export_kd_teacher_model_config is not None: config_path = args.export_kd_teacher_model_config + if not os.path.exists(config_path): + raise FileNotFoundError(f"Teacher model-config file ({config_path}) not found.") else: config_path = os.path.join(checkpoint_path, "model_config.yaml") - if not os.path.exists(config_path): - raise FileNotFoundError( - f"Teacher model-config file {config_path} not found.\n" - "Teacher checkpoint dir must contain a NeMo-format config named 'model_config.yaml'" - " or provide it via --export-kd-teacher-model-config." - ) - with open(config_path) as f: - config = yaml.safe_load(f) - - if missing_keys := [k for k in required_teacher_fields if k not in config]: - raise ValueError( - f"Teacher model config file ({config_path}) missing the following required fields: {missing_keys}" - ) - - if "encoder_seq_length" in config: - config["seq_length"] = config["encoder_seq_length"] - if "bias" in config: - config["disable_bias_linear"] = not config["bias"] - if config.get("activation") == "swiglu": - config["swiglu"] = True - if config.get("position_embedding_type", False) is None: - config["use_rotary_position_embeddings"] = config["no_position_embedding"] = True - if "share_embeddings_and_output_weights" in config: - config["untie_embeddings_and_output_weights"] = not config[ - "share_embeddings_and_output_weights" - ] - if "tokenizer" in config: - config["tokenizer_type"] = config["tokenizer"]["type"] - config["tokenizer_model"] = config["tokenizer"]["model"] - if "masked_softmax_fusion" in config: - config["no_masked_softmax_fusion"] = not config["masked_softmax_fusion"] - if config.get("normalization") == "layernorm1p": - config["apply_layernorm_1p"] = True - if "precision" in config: - config[config["precision"]] = True - if "mcore_gpt" in config: - config["use_mcore_models"] = config["mcore_gpt"] - - args_dict = vars(get_args()).copy() - del args_dict["kv_channels"] # not recalculated if present - # Setting teacher Flextron fields to false if training with Flextron, can be overridden - if "flextron" in args_dict: - config["flextron"] = False - if "enable_router" in args_dict: - config["enable_router"] = False - if "freeze_model" in args_dict: - config["freeze_model"] = False - args_dict.update(config) + if not os.path.exists(config_path): + logger.warning( + "No teacher config provided via --export-kd-teacher-model-config nor found at" + f" {checkpoint_path}/model_config.yaml. Assuming teacher model architecture same as student's." + ) # Useful for cases like QAD + config_path = None + + args_dict = vars(args).copy() + + if config_path is not None: + with open(config_path) as f: + config = yaml.safe_load(f) + + del args_dict["kv_channels"] # not recalculated if present + # Setting teacher Flextron fields to false if training with Flextron, can be overridden + if "flextron" in args_dict: + args_dict["flextron"] = False + if "enable_router" in args_dict: + args_dict["enable_router"] = False + if "freeze_model" in args_dict: + args_dict["freeze_model"] = False + + args_dict.update(config) # Backward compat: old checkpoints have hybrid_override_pattern but not hybrid_layer_pattern if (args_dict.get('hybrid_override_pattern') is not None @@ -152,7 +191,7 @@ def _build_teacher_model(config, config_raw: Namespace, model_kwargs: Dict[str, _add_load_convert_hooks(teacher) - # NOTE: Checkpoint loading now handled in `megatron/training/checkpointing.py`. + # NOTE: Checkpoint loading now handled by `megatron.post_training.checkpointing.load_kd_teacher_checkpoint()`. return teacher @@ -366,7 +405,7 @@ def modelopt_gpt_hybrid_builder( ) if args.export_default_te_spec and args.export_te_mcore_model: - logging.getLogger(__name__).warning( + logger.warning( "--export-default-te-spec and --export-te-mcore-model are mutually exclusive. " "Since --export-default-te-spec is given, --export-te-mcore-model will be disabled." ) @@ -466,10 +505,7 @@ def modelopt_gpt_hybrid_builder( # Additional tweaks needed for MCore. # (accounts for sharded state, pipeline parallel, and potentially skipping LM loss) mtd_mcore.adjust_distillation_model_for_mcore(model, distill_cfg) - # Also remove KD mode state to prevent issues with re-conversion after restore. - mto.ModeloptStateManager(model).state_dict().pop() # TODO(aanoosheh): remove once fixed in ModelOpt - print_distributed_quant_summary(model) return model diff --git a/megatron/post_training/utils.py b/megatron/post_training/utils.py index 7bb5261522f..cbfb54b2cde 100644 --- a/megatron/post_training/utils.py +++ b/megatron/post_training/utils.py @@ -9,8 +9,25 @@ from modelopt.torch.quantization.utils import is_quantized from packaging.version import Version -from megatron.core import parallel_state -from megatron.core.utils import unwrap_model + +def maybe_enable_modelopt(args): + """Set `args.modelopt_enabled` if a ModelOpt checkpoint or distillation teacher is + configured. Idempotent and safe to call multiple times (e.g. once early in + `pretrain_gpt.py` before building the model config, and again as a fallback in + `training.py` for callers that don't go through that entrypoint). + """ + if getattr(args, "modelopt_enabled", False): + return + + from megatron.post_training.checkpointing import has_modelopt_state + from megatron.training import print_rank_0 + + if args.load is not None and has_modelopt_state(args.load): + print_rank_0("ModelOpt checkpoint detected") + args.modelopt_enabled = True + if getattr(args, "export_kd_teacher_load", None): + # For distillation ckpts without ModelOpt state + args.modelopt_enabled = True def modelopt_version_higher_than(target_version: str): diff --git a/megatron/training/argument_utils.py b/megatron/training/argument_utils.py index d8a757ddfc6..70f26c64d56 100644 --- a/megatron/training/argument_utils.py +++ b/megatron/training/argument_utils.py @@ -1,38 +1,41 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +import ast +import builtins import dataclasses -import typing -import types -from typing import Any, Callable, Optional -from argparse import ArgumentParser, _ArgumentGroup, Namespace +import enum import inspect import itertools -import builtins -import ast -import enum -from dataclasses import Field, fields +import types +import typing import warnings -import torch.nn.functional as F +from argparse import ArgumentParser, Namespace, _ArgumentGroup +from dataclasses import Field, fields +from typing import Any, Callable, Optional + import torch +import torch.nn.functional as F from megatron.core.transformer import TransformerConfig from megatron.core.transformer.spec_utils import import_module - from megatron.training.config import ( - DistributedInitConfig, - InferenceSetupConfig, + CheckpointConfig, + DistributedInitConfig, InferenceConfigContainer, - PretrainConfigContainer, - SchedulerConfig, - TokenizerConfig, - TrainingConfig, - ValidationConfig, - RNGConfig, + InferenceSetupConfig, LoggerConfig, + PretrainConfigContainer, + ProfilingConfig, + RerunStateMachineConfig, + RNGConfig, + SchedulerConfig, StragglerDetectionConfig, - RerunStateMachineConfig, CheckpointConfig, ProfilingConfig + TokenizerConfig, + TrainingConfig, + ValidationConfig, ) -from megatron.training.models import HybridModelConfig, GPTModelConfig +from megatron.training.models import GPTModelConfig, HybridModelConfig + # TODO: support arg renames class TypeInferenceError(Exception): @@ -274,14 +277,14 @@ def _get_field_docstrings(self, src_cfg_class: type) -> dict[str, str]: def core_transformer_config_from_args(args, config_class=None): from megatron.core.activations import squared_relu from megatron.core.fusions.fused_bias_geglu import quick_gelu - from megatron.core.transformer import MLATransformerConfig - from megatron.core.transformer.heterogeneous.heterogeneous_config import ( - HeterogeneousTransformerConfig, - ) from megatron.core.quantization.utils import ( kitchen_quantization_recipe_config, load_quantization_recipe, ) + from megatron.core.transformer import MLATransformerConfig + from megatron.core.transformer.heterogeneous.heterogeneous_config import ( + HeterogeneousTransformerConfig, + ) # Config class. config_class = config_class or TransformerConfig @@ -427,8 +430,16 @@ def _default_config_from_args(cls: type, args: Namespace, return_instance: bool return kwargs -def gpt_config_from_args(args: Namespace, config: TransformerConfig | None=None) -> Any: - """Create a GPTModelConfig from the appropriate values in the `args` Namespace.""" +def gpt_config_from_args( + args: Namespace, config: TransformerConfig | None = None, model_config_cls: type = GPTModelConfig +) -> Any: + """Create a GPTModelConfig (or a compatible subclass) from the `args` Namespace. + + `model_config_cls` lets callers reuse this same arg-derivation logic for + subclasses that only override metadata (e.g. `builder`) and add no new fields, + such as `ModelOptModelConfig`. + """ + assert issubclass(model_config_cls, GPTModelConfig) kwargs = {} if config is None: @@ -445,7 +456,6 @@ def gpt_config_from_args(args: Namespace, config: TransformerConfig | None=None) if args.spec is not None: kwargs["transformer_layer_spec"] = import_module(args.spec) - kwargs["fp16_lm_cross_entropy"] = args.fp16_lm_cross_entropy kwargs["position_embedding_type"] = args.position_embedding_type kwargs["rotary_percent"] = args.rotary_percent @@ -468,11 +478,19 @@ def gpt_config_from_args(args: Namespace, config: TransformerConfig | None=None) kwargs["vocab_size"] = args.vocab_size kwargs["should_pad_vocab"] = True - return GPTModelConfig(**kwargs) - + return model_config_cls(**kwargs) -def hybrid_config_from_args(args: Namespace, config: TransformerConfig | None=None) -> Any: - """Create a HybridModelConfig from the appropriate values in the `args` Namespace.""" + +def hybrid_config_from_args( + args: Namespace, config: TransformerConfig | None = None, model_config_cls: type = HybridModelConfig +) -> Any: + """Create a HybridModelConfig (or a compatible subclass) from the `args` Namespace. + + `model_config_cls` lets callers reuse this same arg-derivation logic for + subclasses that only override metadata (e.g. `builder`) and add no new fields, + such as `ModelOptHybridModelConfig`. + """ + assert issubclass(model_config_cls, HybridModelConfig) kwargs = {} if config is None: @@ -488,7 +506,6 @@ def hybrid_config_from_args(args: Namespace, config: TransformerConfig | None=No elif args.spec is not None: kwargs["hybrid_stack_spec"] = import_module(args.spec) - kwargs["fp16_lm_cross_entropy"] = args.fp16_lm_cross_entropy kwargs["hybrid_layer_pattern"] = args.hybrid_layer_pattern kwargs["position_embedding_type"] = args.position_embedding_type @@ -511,7 +528,7 @@ def hybrid_config_from_args(args: Namespace, config: TransformerConfig | None=No kwargs["vocab_size"] = args.vocab_size kwargs["should_pad_vocab"] = True - return HybridModelConfig(**kwargs) + return model_config_cls(**kwargs) def pretrain_cfg_container_from_args(args: Namespace, model_cfg=None) -> PretrainConfigContainer: diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 243c0470be9..2350549ebf1 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -2210,25 +2210,6 @@ def load_model_state_dict(module, state_dict, strict: bool): if has_nvidia_modelopt: print_distributed_quant_summary(model, msg="After loading checkpoint") - # Load teacher model in Distillation mode. - if getattr(args, "export_kd_teacher_load", None): - from megatron.post_training.checkpointing import load_modelopt_checkpoint - - unwrapped_model = unwrap_model(model)[0] - # Note: load_modelopt_checkpoint may call this function so we prevent infinite recursion. - if hasattr(unwrapped_model, 'teacher_model'): - teacher = unwrapped_model.teacher_model - print_rank_0(f"Loading teacher as {type(teacher).__name__} from {args.export_kd_teacher_load} ...") - # [WAR]: To avoid error out on loading teacher's checkpoint, we temporarily - # set args.finetune to True while loading the teacher checkpoint. - original_args_finetune, original_ckpt_format = args.finetune, args.ckpt_format - args.finetune = True - if args.export_kd_teacher_ckpt_format is not None: - args.ckpt_format = args.export_kd_teacher_ckpt_format - load_modelopt_checkpoint([teacher], load_arg='export_kd_teacher_load') - args.finetune, args.ckpt_format = original_args_finetune, original_ckpt_format - print_rank_0("... teacher loaded successfully.") - return iteration, num_floating_point_operations_so_far diff --git a/megatron/training/training.py b/megatron/training/training.py index dde1585c602..7f91fb195a7 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -207,6 +207,8 @@ try: from modelopt.torch.distill.plugins.megatron import get_tensor_shapes_adjust_fn_for_distillation + from megatron.post_training.utils import maybe_enable_modelopt + has_nvidia_modelopt = True except ImportError: has_nvidia_modelopt = False @@ -1701,16 +1703,7 @@ def get_model(model_provider_func, model_type=ModelType.encoder_or_decoder, wrap print_rank_0("> including expert parallelism AG group") if has_nvidia_modelopt: - from megatron.post_training.checkpointing import has_modelopt_state - - # [ModelOpt]: Check if the checkpoint is a ModelOpt checkpoint and - # set a flag to use our model provider if so. - if args.load is not None and has_modelopt_state(args.load): - print_rank_0(f'ModelOpt checkpoint detected') - args.modelopt_enabled = True - elif getattr(args, "export_kd_teacher_load", None): - # For distillation ckpts without ModelOpt state - args.modelopt_enabled = True + maybe_enable_modelopt(args) # Build model. def build_model(): @@ -2017,6 +2010,9 @@ def setup_model_and_optimizer( skip_optimizer = not (has_normal_optimizer or has_rl_optimizer) wrap_with_ddp = not skip_optimizer + if has_nvidia_modelopt: + maybe_enable_modelopt(args) + def _build_model_wrapper(wrap_with_ddp: bool): if cfg_container is not None and getattr(cfg_container, "model", None) is not None: from megatron.training.utils import start_memory_history_recording @@ -2177,6 +2173,14 @@ def _build_model_wrapper(wrap_with_ddp: bool): args.iteration = 0 args.num_floating_point_operations_so_far = 0 + # [ModelOpt]: Load the teacher checkpoint for ModelOpt distillation if applicable. + # Import locally to prevent circular import: megatron.post_training.checkpointing + # imports `get_args` from megatron.training at module scope. + if has_nvidia_modelopt: + from megatron.post_training.checkpointing import load_kd_teacher_checkpoint + + load_kd_teacher_checkpoint(model) + # Validate that the world size can accommodate the current batch size. # This catches the case where GPUs were scaled up mid-training but the # current position in the batch size schedule yields a batch size that diff --git a/pretrain_gpt.py b/pretrain_gpt.py index 06bae9965f1..13c409d8ce6 100644 --- a/pretrain_gpt.py +++ b/pretrain_gpt.py @@ -66,6 +66,8 @@ try: from megatron.post_training.arguments import add_modelopt_args from megatron.post_training.loss_func import loss_func as loss_func_modelopt + from megatron.post_training.model_builder import ModelOptModelConfig + from megatron.post_training.utils import maybe_enable_modelopt has_nvidia_modelopt = True except ImportError: @@ -504,7 +506,12 @@ def get_embedding_ranks(pp_ranks: List[int]): extra_args_provider=add_modelopt_args if has_nvidia_modelopt else None, args_defaults={'tokenizer_type': 'GPT2BPETokenizer'}, ) - model_cfg = gpt_config_from_args(args) + if has_nvidia_modelopt: + maybe_enable_modelopt(args) + if has_nvidia_modelopt and getattr(args, "modelopt_enabled", False): + model_cfg = gpt_config_from_args(args, model_config_cls=ModelOptModelConfig) + else: + model_cfg = gpt_config_from_args(args) full_config = pretrain_cfg_container_from_args(args, model_cfg) pretrain( full_config, diff --git a/pretrain_hybrid.py b/pretrain_hybrid.py index 46f5e74d824..4298b23b9f0 100644 --- a/pretrain_hybrid.py +++ b/pretrain_hybrid.py @@ -66,6 +66,8 @@ try: from megatron.post_training.arguments import add_modelopt_args from megatron.post_training.loss_func import loss_func as loss_func_modelopt + from megatron.post_training.model_builder import ModelOptHybridModelConfig + from megatron.post_training.utils import maybe_enable_modelopt has_nvidia_modelopt = True except ImportError: @@ -450,7 +452,12 @@ def train_valid_test_datasets_provider(train_val_test_num_samples, vp_stage=None extra_args_provider=add_modelopt_args if has_nvidia_modelopt else None, args_defaults={'tokenizer_type': 'GPT2BPETokenizer'}, ) - model_cfg = hybrid_config_from_args(args) + if has_nvidia_modelopt: + maybe_enable_modelopt(args) + if has_nvidia_modelopt and getattr(args, "modelopt_enabled", False): + model_cfg = hybrid_config_from_args(args, model_config_cls=ModelOptHybridModelConfig) + else: + model_cfg = hybrid_config_from_args(args) full_config = pretrain_cfg_container_from_args(args, model_cfg) pretrain( full_config, diff --git a/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py b/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py index 091623b9b84..fcee30e61e6 100644 --- a/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py +++ b/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py @@ -50,6 +50,7 @@ def collect_train_test_metrics( "lm loss", "num-zeros", "mtp_1 loss", + "total loss", ] } diff --git a/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py b/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py index 68aa0db5622..a5ad326f49d 100644 --- a/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py +++ b/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py @@ -20,6 +20,7 @@ "num-zeros": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.20)], "generated_tokens": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], "logprobs": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], + "total loss": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], } diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_a100.json b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_a100.json deleted file mode 100644 index ae531bf007e..00000000000 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_a100.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "kd loss": { - "start_step": 1, - "end_step": 100, - "step_interval": 1, - "values": { - "1": 0.4930937, - "2": 0.4935429, - "3": 0.4938237, - "4": 0.4938229, - "5": 0.4924306, - "6": 0.4935288, - "7": 0.4928354, - "8": 0.4925097, - "9": 0.4936136, - "10": 0.4922911, - "11": 0.4934031, - "12": 0.4951033, - "13": 0.4918853, - "14": 0.4936183, - "15": 0.4926639, - "16": 0.4927304, - "17": 0.4925308, - "18": 0.4927951, - "19": 0.4938825, - "20": 0.4939776, - "21": 0.4933512, - "22": 0.4935322, - "23": 0.4937269, - "24": 0.4927326, - "25": 0.4927868, - "26": 0.4927689, - "27": 0.4924214, - "28": 0.4925573, - "29": 0.4917694, - "30": 0.4919884, - "31": 0.4929765, - "32": 0.4930308, - "33": 0.4928029, - "34": 0.4923102, - "35": 0.4918847, - "36": 0.4914086, - "37": 0.4929215, - "38": 0.4923307, - "39": 0.4910690, - "40": 0.4919418, - "41": 0.4913271, - "42": 0.4919568, - "43": 0.4903573, - "44": 0.4916522, - "45": 0.4915655, - "46": 0.4898856, - "47": 0.4899229, - "48": 0.4892673, - "49": 0.4894423, - "50": 0.4903796, - "51": 0.4907262, - "52": 0.4882944, - "53": 0.4877340, - "54": 0.4902404, - "55": 0.4881638, - "56": 0.4888564, - "57": 0.4882180, - "58": 0.4887677, - "59": 0.4883497, - "60": 0.4863744, - "61": 0.4875762, - "62": 0.4837778, - "63": 0.4867221, - "64": 0.4840697, - "65": 0.4840384, - "66": 0.4857976, - "67": 0.4837634, - "68": 0.4800620, - "69": 0.4781690, - "70": 0.4818793, - "71": 0.4796092, - "72": 0.4783594, - "73": 0.4789546, - "74": 0.4767389, - "75": 0.4774750, - "76": 0.4746155, - "77": 0.4745574, - "78": 0.4737080, - "79": 0.4718909, - "80": 0.4693059, - "81": 0.4696763, - "82": 0.4705839, - "83": 0.4661151, - "84": 0.4634311, - "85": 0.4654806, - "86": 0.4634389, - "87": 0.4595202, - "88": 0.4586285, - "89": 0.4595589, - "90": 0.4561397, - "91": 0.4547178, - "92": 0.4553441, - "93": 0.4545716, - "94": 0.4518545, - "95": 0.4504384, - "96": 0.4518549, - "97": 0.4450935, - "98": 0.4441919, - "99": 0.4442465, - "100": 0.4427010 - } - } -} diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_h100.json new file mode 100644 index 00000000000..8634a66847a --- /dev/null +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_h100.json @@ -0,0 +1,216 @@ +{ + "total loss": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 0.49197, + "2": 0.49244, + "3": 0.49244, + "4": 0.49309, + "5": 0.49199, + "6": 0.4925, + "7": 0.49298, + "8": 0.49175, + "9": 0.4929, + "10": 0.49224, + "11": 0.49354, + "12": 0.49217, + "13": 0.4921, + "14": 0.49196, + "15": 0.49207, + "16": 0.49239, + "17": 0.49231, + "18": 0.49241, + "19": 0.49228, + "20": 0.49096, + "21": 0.49281, + "22": 0.49273, + "23": 0.49169, + "24": 0.49298, + "25": 0.49222, + "26": 0.49219, + "27": 0.49351, + "28": 0.4928, + "29": 0.49313, + "30": 0.49276, + "31": 0.49254, + "32": 0.49177, + "33": 0.49254, + "34": 0.49255, + "35": 0.49246, + "36": 0.49135, + "37": 0.49143, + "38": 0.49129, + "39": 0.49205, + "40": 0.49215, + "41": 0.49167, + "42": 0.49202, + "43": 0.49159, + "44": 0.49097, + "45": 0.49001, + "46": 0.49096, + "47": 0.49014, + "48": 0.4894, + "49": 0.48931, + "50": 0.48995, + "51": 0.49027, + "52": 0.48921, + "53": 0.4916, + "54": 0.49015, + "55": 0.4892, + "56": 0.48764, + "57": 0.48865, + "58": 0.4877, + "59": 0.4865, + "60": 0.48435, + "61": 0.48678, + "62": 0.48624, + "63": 0.48259, + "64": 0.48274, + "65": 0.48095, + "66": 0.48127, + "67": 0.48124, + "68": 0.47975, + "69": 0.47882, + "70": 0.47826, + "71": 0.47797, + "72": 0.47728, + "73": 0.47533, + "74": 0.47329, + "75": 0.47452, + "76": 0.4729, + "77": 0.47196, + "78": 0.46773, + "79": 0.46857, + "80": 0.46752, + "81": 0.46539, + "82": 0.46683, + "83": 0.46365, + "84": 0.45854, + "85": 0.45937, + "86": 0.46228, + "87": 0.45535, + "88": 0.45485, + "89": 0.45797, + "90": 0.44956, + "91": 0.45188, + "92": 0.44878, + "93": 0.4514, + "94": 0.44644, + "95": 0.44778, + "96": 0.44672, + "97": 0.44107, + "98": 0.44625, + "99": 0.43963, + "100": 0.43588 + } + }, + "lm loss": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 0.0, + "2": 0.0, + "3": 0.0, + "4": 0.0, + "5": 0.0, + "6": 0.0, + "7": 0.0, + "8": 0.0, + "9": 0.0, + "10": 0.0, + "11": 0.0, + "12": 0.0, + "13": 0.0, + "14": 0.0, + "15": 0.0, + "16": 0.0, + "17": 0.0, + "18": 0.0, + "19": 0.0, + "20": 0.0, + "21": 0.0, + "22": 0.0, + "23": 0.0, + "24": 0.0, + "25": 0.0, + "26": 0.0, + "27": 0.0, + "28": 0.0, + "29": 0.0, + "30": 0.0, + "31": 0.0, + "32": 0.0, + "33": 0.0, + "34": 0.0, + "35": 0.0, + "36": 0.0, + "37": 0.0, + "38": 0.0, + "39": 0.0, + "40": 0.0, + "41": 0.0, + "42": 0.0, + "43": 0.0, + "44": 0.0, + "45": 0.0, + "46": 0.0, + "47": 0.0, + "48": 0.0, + "49": 0.0, + "50": 0.0, + "51": 0.0, + "52": 0.0, + "53": 0.0, + "54": 0.0, + "55": 0.0, + "56": 0.0, + "57": 0.0, + "58": 0.0, + "59": 0.0, + "60": 0.0, + "61": 0.0, + "62": 0.0, + "63": 0.0, + "64": 0.0, + "65": 0.0, + "66": 0.0, + "67": 0.0, + "68": 0.0, + "69": 0.0, + "70": 0.0, + "71": 0.0, + "72": 0.0, + "73": 0.0, + "74": 0.0, + "75": 0.0, + "76": 0.0, + "77": 0.0, + "78": 0.0, + "79": 0.0, + "80": 0.0, + "81": 0.0, + "82": 0.0, + "83": 0.0, + "84": 0.0, + "85": 0.0, + "86": 0.0, + "87": 0.0, + "88": 0.0, + "89": 0.0, + "90": 0.0, + "91": 0.0, + "92": 0.0, + "93": 0.0, + "94": 0.0, + "95": 0.0, + "96": 0.0, + "97": 0.0, + "98": 0.0, + "99": 0.0, + "100": 0.0 + } + } +} diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_lts_dgx_a100.json b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_lts_dgx_a100.json deleted file mode 100644 index 9e26dfeeb6e..00000000000 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_lts_dgx_a100.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml index c75a5a81414..2ba8cb329d0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml @@ -1,11 +1,10 @@ ENV_VARS: - SKIP_PYTEST: 1 CUDA_DEVICE_MAX_CONNECTIONS: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 ARTIFACTS_ROOT: /workspace/checkpoints - DISTILL_CONFIG: '{intermediate_layer_pairs: [["decoder.final_layernorm", "decoder.final_layernorm"]], logit_layers: ["output_layer", "output_layer"], skip_lm_loss: true, kd_loss_scale: 10.0}' + DISTILL_CONFIG: '{intermediate_layer_pairs: [["decoder.final_layernorm", "decoder.final_layernorm"]], logit_layers: ["output_layer", "output_layer"], skip_lm_loss: true, kd_loss_scale: 1.0}' BEFORE_SCRIPT: | mkdir -p ${DATA_CACHE_PATH}/distill && echo $DISTILL_CONFIG | yq -P > ${DATA_CACHE_PATH}/distill/distill_config.yaml MODEL_ARGS: @@ -68,4 +67,8 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --async-save: true --use-persistent-ckpt-worker: true + --exit-interval: 100 TEST_TYPE: ckpt-resume +METRICS: + - lm loss + - total loss diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json new file mode 100644 index 00000000000..2b2029d40f9 --- /dev/null +++ b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json @@ -0,0 +1,36 @@ +{ + "total loss": { + "start_step": 1, + "end_step": 10, + "step_interval": 1, + "values": { + "1": 1.58285, + "2": 1.6902, + "3": 0.1002, + "4": 0.06419, + "5": 0.06295, + "6": 0.06725, + "7": 0.16463, + "8": 0.06043, + "9": 0.06715, + "10": 0.05355 + } + }, + "lm loss": { + "start_step": 1, + "end_step": 10, + "step_interval": 1, + "values": { + "1": 0.0, + "2": 0.0, + "3": 0.0, + "4": 0.0, + "5": 0.0, + "6": 0.0, + "7": 0.0, + "8": 0.0, + "9": 0.0, + "10": 0.0 + } + } +} diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml new file mode 100644 index 00000000000..e2116268b4c --- /dev/null +++ b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml @@ -0,0 +1,190 @@ +ENV_VARS: + CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 + NCCL_ALGO: Ring + CUBLAS_WORKSPACE_CONFIG: ":4096:8" + TRITON_CACHE_AUTOTUNING: 0 + MAMBA_DETERMINISTIC: 1 + # Paths + MODEL_BF16_CKPT: "${DATA_PATH}/model/nemotron_v3_pico_7b-a1b/3T-token_deeparch" + PTQ_QUANTIZED_CKPT: "${DATA_CACHE_PATH}/ptq_quantized_ckpt" + TOKENIZER: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16" + HF_HOME: "${DATA_PATH}/hf_home" +BEFORE_SCRIPT: | + # Stage 1: PTQ quantization via quantize.py directly + echo -e "\n=== Stage 1: Running PTQ (NVFP4) ===\n" + cd /opt/megatron-lm + # Env vars that arguments.sh would normally set + export TOKENIZERS_PARALLELISM=False + export OMP_NUM_THREADS=1 + export NCCL_IB_SL=1 + export NCCL_IB_TIMEOUT=22 + uv run --no-sync python -m torch.distributed.run --nproc_per_node=8 \ + examples/post_training/modelopt/quantize.py \ + --deterministic-mode \ + --micro-batch-size 1 \ + --save-interval 100000 \ + --bf16 \ + --seq-length 4096 \ + --max-position-embeddings 4096 \ + --tokenizer-type HuggingFaceTokenizer \ + --tokenizer-model ${TOKENIZER} \ + --tensor-model-parallel-size 1 \ + --expert-model-parallel-size 8 \ + --expert-tensor-parallel-size 1 \ + --pipeline-model-parallel-size 1 \ + --context-parallel-size 1 \ + --hidden-size 1216 \ + --num-attention-heads 32 \ + --group-query-attention \ + --num-query-groups 2 \ + --ffn-hidden-size 896 \ + --kv-channels 128 \ + --squared-relu \ + --normalization RMSNorm \ + --disable-bias-linear \ + --attention-dropout 0.0 \ + --hidden-dropout 0.0 \ + --position-embedding-type none \ + --untie-embeddings-and-output-weights \ + --init-method-std 0.0256 \ + --hybrid-layer-pattern MEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEMEM*EMEMEMEME \ + --mamba-num-heads 64 \ + --export-model-type MambaModel \ + --num-experts 128 \ + --moe-router-topk 6 \ + --moe-aux-loss-coeff 1e-4 \ + --moe-router-topk-scaling-factor 2.5 \ + --moe-router-enable-expert-bias \ + --moe-router-dtype fp32 \ + --moe-router-score-function sigmoid \ + --moe-router-load-balancing-type seq_aux_loss \ + --moe-shared-expert-intermediate-size 3712 \ + --moe-token-dispatcher-type alltoall \ + --moe-grouped-gemm \ + --use-fused-weighted-squared-relu \ + --attention-backend fused \ + --disable-gloo-process-groups \ + --no-create-attention-mask-in-dataloader \ + --ckpt-format torch_dist \ + --ckpt-fully-parallel-load \ + --load ${MODEL_BF16_CKPT} \ + --save ${PTQ_QUANTIZED_CKPT} \ + --finetune \ + --auto-detect-ckpt-format \ + --distributed-timeout-minutes 30 \ + --export-quant-cfg MAMBA_MOE_NVFP4_CONSERVATIVE_CFG \ + --calib-dataset-path-or-name cnn_dailymail \ + --calib-size 256 \ + --calib-batch-size 8 \ + --skip-generate \ + --export-te-mcore-model + #--export-default-te-spec # TODO(aanoosheh): undo once TE fix is released + echo -e "\n=== Stage 1 complete ===\n" +MODEL_ARGS: + # KD teacher/config + --export-te-mcore-model: true + #--export-default-te-spec: true # TODO(aanoosheh): undo once TE fix is released + --export-kd-teacher-load: ${MODEL_BF16_CKPT} + --auto-detect-ckpt-format: true + --finetune: true + # Architecture + --hidden-size: 1216 + --num-attention-heads: 32 + --group-query-attention: true + --num-query-groups: 2 + --ffn-hidden-size: 896 + --kv-channels: 128 + --squared-relu: true + --normalization: RMSNorm + --disable-bias-linear: true + --attention-dropout: 0.0 + --hidden-dropout: 0.0 + --position-embedding-type: none + --untie-embeddings-and-output-weights: true + --init-method-std: 0.0256 + --hybrid-layer-pattern: MEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEMEM*EMEMEMEME + --mamba-num-heads: 64 + --export-model-type: MambaModel + # MoE + --num-experts: 128 + --moe-router-topk: 6 + --moe-aux-loss-coeff: 1e-4 + --moe-router-topk-scaling-factor: 2.5 + --moe-router-enable-expert-bias: true + --moe-router-dtype: fp32 + --moe-router-score-function: sigmoid + --moe-router-load-balancing-type: seq_aux_loss + --moe-shared-expert-intermediate-size: 3712 + --moe-token-dispatcher-type: alltoall + --moe-grouped-gemm: true + --use-fused-weighted-squared-relu: true + # Tokenizer + --tokenizer-type: SFTTokenizer + --tokenizer-model: ${TOKENIZER} + --sft: true + --sft-tokenizer-prompt-format: identity + --bf16: true + # Parallelism + --tensor-model-parallel-size: 1 # TODO(aanoosheh): can change to 2 once TE fix is released + --expert-model-parallel-size: 4 + --expert-tensor-parallel-size: 1 + --pipeline-model-parallel-size: 1 + --context-parallel-size: 2 + --sequence-parallel: true + # Infrastructure + --attention-backend: fused + --disable-gloo-process-groups: true + --no-create-attention-mask-in-dataloader: true + --ddp-num-buckets: 8 + --override-opt_param-scheduler: true + --num-workers: 1 + --ckpt-format: torch_dist + --ckpt-fully-parallel-save: true + --ckpt-fully-parallel-load: true + --ckpt-assume-constant-structure: true + # Training + --micro-batch-size: 1 + --global-batch-size: 8 + --seq-length: 2048 # TODO(aanoosheh): change to 4096 once TE fix is released + --max-position-embeddings: 2048 # TODO(aanoosheh): change to 4096 once TE fix is released + --train-iters: 10 + --lr: 0.00015 + --lr-decay-style: cosine + --lr-decay-iters: 320000 + --min-lr: 1.0e-5 + --weight-decay: 1e-2 + --clip-grad: 1.0 + --lr-warmup-fraction: .01 + --use-distributed-optimizer: true + --overlap-param-gather: true + --overlap-grad-reduce: true + # Checkpoint & data + --save: ${CHECKPOINT_SAVE_PATH} + --load: ${PTQ_QUANTIZED_CKPT} + --data-path: "${DATA_PATH}/text/nemotron-3-super-sft_train-sample.jsonl" + --split: "949,50,1" + --distributed-backend: nccl + --transformer-impl: transformer_engine + --data-cache-path: ${DATA_CACHE_PATH} + # Logging + --log-interval: 1 + --save-interval: 10 + --eval-interval: 10 + --eval-iters: 2 + --log-params-norm: true + --log-num-zeros-in-grad: true + --log-validation-ppl-to-tensorboard: true + --log-timers-to-tensorboard: true + --tensorboard-dir: ${TENSORBOARD_PATH} + --log-memory-to-tensorboard: true + --timing-log-level: 0 + --no-gradient-accumulation-fusion: true + --distributed-timeout-minutes: 30 + # Etc + --deterministic-mode: true + --exit-interval: 10 +TEST_TYPE: regular +METRICS: + - lm loss + - total loss diff --git a/tests/test_utils/recipes/h100/mamba.yaml b/tests/test_utils/recipes/h100/mamba.yaml index 77331a751e4..d0e0aba156a 100644 --- a/tests/test_utils/recipes/h100/mamba.yaml +++ b/tests/test_utils/recipes/h100/mamba.yaml @@ -96,3 +96,9 @@ products: platforms: [dgx_h100] # - environment: [lts] # disabled until triton is bumped # scope: [nightly] + + - test_case: [hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G] + products: + - environment: [dev] + scope: [mr, mr-github] + platforms: [dgx_h100] diff --git a/tools/common_pile_dataset/README.md b/tools/common_pile_dataset/README.md index 2431d1b01d3..dcc18fee24b 100644 --- a/tools/common_pile_dataset/README.md +++ b/tools/common_pile_dataset/README.md @@ -165,7 +165,7 @@ default. On HPC systems where `/home` is small, set `HF_HOME` to a path with sufficient space: ```bash -export HF_HOME=/lustre/path/to/.hf_cache +export HF_HOME=/lustre/path/to/hf_home ``` The setup script does this automatically. diff --git a/tools/common_pile_dataset/setup_common_pile_dataset.sh b/tools/common_pile_dataset/setup_common_pile_dataset.sh index cb869e28368..01c438cd21a 100644 --- a/tools/common_pile_dataset/setup_common_pile_dataset.sh +++ b/tools/common_pile_dataset/setup_common_pile_dataset.sh @@ -29,7 +29,7 @@ DATASET_NAME="common-pile/comma_v0.1_training_dataset" WORK_DIR="/tmp/mcore_dataset_setup_$$" # Redirect HuggingFace cache to lustre so it doesn't fill up /home -export HF_HOME="/lustre/fsw/portfolios/coreai/projects/coreai_dlalgo_mcore/mcore_ci/.hf_cache" +export HF_HOME="/lustre/fsw/portfolios/coreai/projects/coreai_dlalgo_mcore/mcore_ci/hf_home" export HF_DATASETS_CACHE="${HF_HOME}/datasets" echo "============================================================" From 97b25d9d7b86666f4aa898cb0a8d1e7754cbcec1 Mon Sep 17 00:00:00 2001 From: Ajay Date: Tue, 14 Jul 2026 09:27:45 -0700 Subject: [PATCH 019/290] build: Update Transformer Engine to 2.17 (#5680) Signed-off-by: Ajay Signed-off-by: Ajay Balasa Signed-off-by: [Your Name] --- docker/Dockerfile.ci.dev | 2 +- docker/Dockerfile.ci.lts | 11 +++++++---- pyproject.toml | 4 ++-- .../determinism/print_nsys_leaderboard.py | 2 +- .../test_cuda_graphed_schedule_chunk_1f1b.py | 3 +++ .../a2a_overlap/test_delay_wgrad_compute.py | 3 +++ .../unit_tests/a2a_overlap/test_fsdp_1f1b_overlap.py | 3 +++ .../a2a_overlap/test_schedule_chunk_1f1b.py | 3 +++ .../a2a_overlap/test_schedule_layer_1f1b.py | 3 +++ .../unit_tests/transformer/moe/test_paged_stashing.py | 2 ++ .../transformer/moe/test_token_dispatcher.py | 10 +++++++++- uv.lock | 8 ++++---- 12 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docker/Dockerfile.ci.dev b/docker/Dockerfile.ci.dev index 33be0e6397e..8a18b85356b 100644 --- a/docker/Dockerfile.ci.dev +++ b/docker/Dockerfile.ci.dev @@ -1,7 +1,7 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. # syntax=docker/dockerfile:1.3-labs -ARG FROM_IMAGE_NAME=nvcr.io/nvidia/pytorch:26.04-py3 +ARG FROM_IMAGE_NAME=nvcr.io/nvidia/pytorch:26.06-py3 FROM ${FROM_IMAGE_NAME} AS main ENV PIP_CONSTRAINT="" ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/Dockerfile.ci.lts b/docker/Dockerfile.ci.lts index 6a2042e345d..1e681e2ff81 100644 --- a/docker/Dockerfile.ci.lts +++ b/docker/Dockerfile.ci.lts @@ -46,10 +46,9 @@ COPY megatron/core/package_info.py /workspace/megatron/core/ ENV NVTE_BUILD_NUM_PHILOX_ROUNDS=3 RUN --mount=type=cache,target=/root/.cache/uv \ bash -ex <<"EOF" - export NVTE_CUDA_ARCHS="80;90;100" uv venv ${UV_PROJECT_ENVIRONMENT} --system-site-packages uv sync --only-group build - uv sync --extra mlm --extra ssm --extra te --link-mode copy --locked \ + uv sync --extra mlm --extra ssm --link-mode copy --locked \ --no-install-package torch \ --no-install-package torchvision \ --no-install-package triton \ @@ -72,12 +71,16 @@ EOF # # These used to live in `[project.optional-dependencies].lts` in pyproject.toml, # but were moved out so pyproject.toml can host meaningful per-module -# extras. The pinned set lives in `docker/lts/requirements.txt` and is reviewed -# at LTS bump time only. +# extras. Most of the pinned set lives in `docker/lts/requirements.txt` and is +# reviewed at LTS bump time only. Transformer Engine is installed separately +# because its source extension needs the existing PyTorch/CUDA build environment. COPY docker/lts/requirements.txt /workspace/docker/lts/requirements.txt RUN --mount=type=cache,target=/root/.cache/uv \ bash -ex <<"EOF" + export NVTE_CUDA_ARCHS="80;90;100" uv pip install -r /workspace/docker/lts/requirements.txt + uv pip install --no-build-isolation \ + "transformer-engine @ git+https://github.com/NVIDIA/TransformerEngine.git@b9d690e042b1c4e455214e7dab65d6d3512c05d6" EOF # Install DeepEP diff --git a/pyproject.toml b/pyproject.toml index 0fb5c927fe3..470099ca17e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -202,7 +202,7 @@ requires-dist = [] [[tool.uv.dependency-metadata]] name = "transformer-engine" -version = "2.16.0+4220403e" +version = "2.17.0+2e559f06" requires-dist = [ # Cap below 2.14: pydantic 2.14 breaks langchain_core's module-level # RunnablePassthrough() instantiation, which is imported transitively in @@ -227,7 +227,7 @@ requires-dist = ["torch", "packaging", "ninja"] flash_mla = [ { git = "https://github.com/deepseek-ai/FlashMLA", rev = "nv_dev" }, ] -transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "b9d690e042b1c4e455214e7dab65d6d3512c05d6" } +transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "2e559f062497bef768dfbe9d7e45548fadeca80a" } nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "17ae86b64d7f75653351664f5d8c9e466faede00" } emerging_optimizers = { git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git", rev = "v0.2.0" } fast-hadamard-transform = { git = "https://github.com/Dao-AILab/fast-hadamard-transform.git", rev = "f134af63deb2df17e1171a9ec1ea4a7d8604d5ca" } diff --git a/tests/performance_tests/shell_test_utils/determinism/print_nsys_leaderboard.py b/tests/performance_tests/shell_test_utils/determinism/print_nsys_leaderboard.py index da6a050d2d4..a95d976720d 100644 --- a/tests/performance_tests/shell_test_utils/determinism/print_nsys_leaderboard.py +++ b/tests/performance_tests/shell_test_utils/determinism/print_nsys_leaderboard.py @@ -11,7 +11,7 @@ import sys from pathlib import Path -MAX_DET_NONDET_RATIO = 1.25 +MAX_DET_NONDET_RATIO = 1.35 MEASUREMENT_ITER = 5 # steady-state; iter 7 is noisy under nsys profile teardown LEADERBOARD_TOP_N = 20 # Strip per-call-site ``, op_id = N`` and autograd-engine ``, seq = N`` so diff --git a/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py b/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py index 3db52946117..03cb5609109 100644 --- a/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py @@ -28,6 +28,9 @@ from megatron.training.training import setup_model_and_optimizer from tests.unit_tests.test_utilities import Utils +# Transformer Engine 2.17 aborts in the A2A overlap suite with a pybind11 GIL dec_ref failure. +pytestmark = pytest.mark.flaky_in_dev + def is_deep_ep_available(): from megatron.core.transformer.moe.fused_a2a import HAVE_DEEP_EP diff --git a/tests/unit_tests/a2a_overlap/test_delay_wgrad_compute.py b/tests/unit_tests/a2a_overlap/test_delay_wgrad_compute.py index 01b7768b341..141037bea0a 100644 --- a/tests/unit_tests/a2a_overlap/test_delay_wgrad_compute.py +++ b/tests/unit_tests/a2a_overlap/test_delay_wgrad_compute.py @@ -26,6 +26,9 @@ ) from tests.unit_tests.test_utilities import Utils +# Transformer Engine 2.17 aborts in the A2A overlap suite with a pybind11 GIL dec_ref failure. +pytestmark = pytest.mark.flaky_in_dev + NUM_STEPS = 3 SEQ_LEN = 128 VOCAB_SIZE = 512 diff --git a/tests/unit_tests/a2a_overlap/test_fsdp_1f1b_overlap.py b/tests/unit_tests/a2a_overlap/test_fsdp_1f1b_overlap.py index 956ebd2f1f3..243c7875e88 100644 --- a/tests/unit_tests/a2a_overlap/test_fsdp_1f1b_overlap.py +++ b/tests/unit_tests/a2a_overlap/test_fsdp_1f1b_overlap.py @@ -26,6 +26,9 @@ ) from tests.unit_tests.test_utilities import Utils +# Transformer Engine 2.17 aborts in the A2A overlap suite with a pybind11 GIL dec_ref failure. +pytestmark = pytest.mark.flaky_in_dev + SEQ_LEN = 32 VOCAB_SIZE = 128 NUM_STEPS = 3 diff --git a/tests/unit_tests/a2a_overlap/test_schedule_chunk_1f1b.py b/tests/unit_tests/a2a_overlap/test_schedule_chunk_1f1b.py index 30fd78c0649..bf8cf45f6be 100644 --- a/tests/unit_tests/a2a_overlap/test_schedule_chunk_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_schedule_chunk_1f1b.py @@ -23,6 +23,9 @@ ) from tests.unit_tests.test_utilities import Utils +# Transformer Engine 2.17 aborts in the A2A overlap suite with a pybind11 GIL dec_ref failure. +pytestmark = pytest.mark.flaky_in_dev + def build_model(config, use_padding_mask=False): seq_len = 32 diff --git a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py index d1bb97ca0cd..3151b42d22d 100644 --- a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py @@ -27,6 +27,9 @@ ) from tests.unit_tests.test_utilities import Utils +# Transformer Engine 2.17 aborts in the A2A overlap suite with a pybind11 GIL dec_ref failure. +pytestmark = pytest.mark.flaky_in_dev + def run_transformer_layer_ref_with_capture(model, input_tensors, iterations): """ diff --git a/tests/unit_tests/transformer/moe/test_paged_stashing.py b/tests/unit_tests/transformer/moe/test_paged_stashing.py index f93759109b5..0a985bc7e03 100644 --- a/tests/unit_tests/transformer/moe/test_paged_stashing.py +++ b/tests/unit_tests/transformer/moe/test_paged_stashing.py @@ -449,6 +449,8 @@ def teardown_method(self, method): Utils.destroy_model_parallel() @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + # NCCL EP static-shape paged stashing aborts in dev CI with a pybind11 GIL dec_ref failure. + @pytest.mark.flaky_in_dev @pytest.mark.internal def test_forward_backward_4_layers(self): """Test paged stashing with 4 MoE layers on ncclep static shape: two passes match.""" diff --git a/tests/unit_tests/transformer/moe/test_token_dispatcher.py b/tests/unit_tests/transformer/moe/test_token_dispatcher.py index 1d6504ba4fa..20839310ce2 100644 --- a/tests/unit_tests/transformer/moe/test_token_dispatcher.py +++ b/tests/unit_tests/transformer/moe/test_token_dispatcher.py @@ -441,7 +441,15 @@ def teardown_method(self, method): @pytest.mark.internal @pytest.mark.parametrize("tp_size,ep_size", [(1, 8), (8, 1), (4, 2)]) @pytest.mark.parametrize("permute_fusion", permute_fusion_params) - @pytest.mark.parametrize("moe_flex_dispatcher_backend", ["deepep", "hybridep", "ncclep"]) + @pytest.mark.parametrize( + "moe_flex_dispatcher_backend", + [ + "deepep", + "hybridep", + # NCCL EP aborts in dev CI with a pybind11 GIL dec_ref failure. + pytest.param("ncclep", marks=pytest.mark.flaky_in_dev), + ], + ) @pytest.mark.parametrize("moe_permute_fusion_into_hybridep", [True, False]) def test_forward_backward( self, diff --git a/uv.lock b/uv.lock index f7c09e7b472..2c39a56ab86 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ version = "1.0.0+9edee0c" [[manifest.dependency-metadata]] name = "transformer-engine" -version = "2.16.0+4220403e" +version = "2.17.0+2e559f06" requires-dist = ["pydantic<2.14", "importlib-metadata>=1.0", "packaging", "torch>=2.1", "einops", "onnxscript", "onnx", "nvdlfw-inspect"] [[package]] @@ -2320,7 +2320,7 @@ requires-dist = [ { name = "tiktoken", marker = "extra == 'training'" }, { name = "torch", specifier = ">=2.6.0" }, { name = "tqdm", marker = "extra == 'dev'" }, - { name = "transformer-engine", extras = ["core-cu13", "pytorch"], marker = "extra == 'te'", git = "https://github.com/NVIDIA/TransformerEngine.git?rev=b9d690e042b1c4e455214e7dab65d6d3512c05d6" }, + { name = "transformer-engine", extras = ["core-cu13", "pytorch"], marker = "extra == 'te'", git = "https://github.com/NVIDIA/TransformerEngine.git?rev=2e559f062497bef768dfbe9d7e45548fadeca80a" }, { name = "transformers", marker = "extra == 'mlm'" }, { name = "transformers", marker = "extra == 'training'" }, { name = "wandb", marker = "extra == 'mlm'" }, @@ -5196,8 +5196,8 @@ wheels = [ [[package]] name = "transformer-engine" -version = "2.16.0+4220403e" -source = { git = "https://github.com/NVIDIA/TransformerEngine.git?rev=b9d690e042b1c4e455214e7dab65d6d3512c05d6#b9d690e042b1c4e455214e7dab65d6d3512c05d6" } +version = "2.17.0+2e559f06" +source = { git = "https://github.com/NVIDIA/TransformerEngine.git?rev=2e559f062497bef768dfbe9d7e45548fadeca80a#2e559f062497bef768dfbe9d7e45548fadeca80a" } dependencies = [ { name = "einops" }, { name = "importlib-metadata" }, From e5344abbdf8193c503032218f118a861690e7b29 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 14 Jul 2026 11:09:27 -0700 Subject: [PATCH 020/290] Mamba prefix caching fixes (#5502) Signed-off-by: Keshav Santhanam Co-authored-by: Claude Opus 4.8 --- .../attention_context/mamba_metadata.py | 11 +- .../inference/contexts/dynamic_context.py | 83 +++++++++---- .../contexts/mamba_slot_allocator.py | 48 +++++--- .../core/inference/engines/dynamic_engine.py | 88 ++++++++++++-- .../text_generation_controller.py | 8 +- megatron/core/ssm/mamba_mixer.py | 10 ++ .../contexts/test_dynamic_prefix_caching.py | 110 ++++++++++++++++++ .../test_prefix_caching_cuda_graphs.py | 66 +++++++++-- 8 files changed, 360 insertions(+), 64 deletions(-) diff --git a/megatron/core/inference/contexts/attention_context/mamba_metadata.py b/megatron/core/inference/contexts/attention_context/mamba_metadata.py index 3e98f0324e6..3d8c6d4f5b8 100644 --- a/megatron/core/inference/contexts/attention_context/mamba_metadata.py +++ b/megatron/core/inference/contexts/attention_context/mamba_metadata.py @@ -445,8 +445,11 @@ def _update_intermediate_metadata( # Pad unused slots with safe defaults for CUDA graph replay: # - chunk_indices=0: reads from chunk 0 (always exists), output ignored - # - abs_positions=d_conv: conv gather reads tokens [0..d_conv-1], - # which are within bounds and produce a valid but unused state + # - abs_positions=d_conv: conv gather reads tokens [0..d_conv-1]. + # These are within bounds only when the prefill has at least + # d_conv tokens; shorter sequences (e.g. small CUDA-graph warmup + # buckets) would overrun the token axis, so _ssm_prefill clamps + # the gather positions into range. The gathered state is unused. if real_count < max_count: self._intermediate_chunk_indices_buffer[real_count:max_count].fill_(0) self._intermediate_abs_positions_buffer[real_count:max_count].fill_(self.d_conv) @@ -464,7 +467,9 @@ def _update_intermediate_metadata( self.intermediate_abs_positions = self._intermediate_abs_positions_buffer[:max_count] else: # No extraction: fill with safe defaults for CUDA graph warmup - # (same rationale as padding comment above) + # (same rationale as padding comment above; abs_positions=d_conv may + # exceed a sub-d_conv warmup sequence, so _ssm_prefill clamps the + # gather positions into range and the gathered state is unused) self._intermediate_chunk_indices_buffer[:max_count] = 0 self._intermediate_abs_positions_buffer[:max_count] = self.d_conv self.intermediate_count = 0 diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 8e8db5d603d..edb632fb74a 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -278,6 +278,13 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC # Prefix caching hit tracking (accumulated, reset by engine after logging). self.prefix_cache_hits = 0 # requests that matched at least one cached block self.prefix_cache_blocks_matched = 0 # total matched blocks across all requests + # Prefill compute accounting (drained into engine accumulators each step). + # computed = prompt tokens actually run through the model this step; + # skipped = prompt tokens whose prefill was skipped via a prefix-cache hit. + # A high skipped fraction confirms prefix caching is saving prefill compute + # (so any per-step latency growth is attention-over-context, not re-prefill). + self.prefix_cache_prefill_computed_tokens = 0 + self.prefix_cache_prefill_skipped_tokens = 0 # Engine step counter (used for logging, metrics, and event tracking) self.step_count = 0 @@ -2539,12 +2546,23 @@ def reset_tensors(self) -> None: self.token_to_block_idx.fill_(-1) self.token_to_local_position_within_kv_block.fill_(0) - def reset_metadata(self) -> None: + def reset_metadata(self, preserve_prefix_cache: bool = False) -> None: """Reset all bookkeeping state: counters, block allocator, attention/mamba state. This must be called after ``initialize_all_tensors()`` and after any suspend/resume cycle to bring the context back to a clean state. + + Args: + preserve_prefix_cache: When True, keep the KV block allocator's prefix-cache + state (hash index, ref counts, cached blocks) intact. Used by the idle + ``dummy_forward`` path, which only needs to clear the transient one-token + step state -- wiping the allocator there would destroy cross-request prefix + reuse for any subsequent request (the engine idles between requests at low + concurrency, especially with EP > 1). """ + # No cache to preserve when prefix caching is off: fall back to a full + # reset so the disabled path is byte-identical to the original behavior. + preserve_prefix_cache = preserve_prefix_cache and self.enable_prefix_caching # Reset request/token counts. self.total_request_count = 0 @@ -2567,7 +2585,8 @@ def reset_metadata(self) -> None: # Reset attention, mamba, and block allocator state. self.reset_attention_state() self.reset_mamba_state() - self.kv_block_allocator.reset() + if not preserve_prefix_cache: + self.kv_block_allocator.reset() self.request_to_kv_block_ids.fill_(-1) # Reset chunked prefill state @@ -2579,7 +2598,7 @@ def reset_metadata(self) -> None: token_count=0, prefill_req_count=0, decode_req_count=0 ) - def reset(self) -> None: + def reset(self, preserve_prefix_cache: bool = False) -> None: """Reset entire context. This method does: @@ -2590,18 +2609,31 @@ def reset(self) -> None: This method is useful after cuda graph warmup iterations, where the context's memory buffer is referenced by the cuda graph system and cannot be deallocated. + + Args: + preserve_prefix_cache: When True, keep the KV and Mamba prefix-cache + state (hash indices, cached blocks/slots, LRU clock) intact. Used by + the idle ``dummy_forward`` path so an idle step between requests does + not destroy cross-request prefix reuse. """ + # No cache to preserve when prefix caching is off: fall back to a full + # reset so the disabled path is byte-identical to the original behavior. + preserve_prefix_cache = preserve_prefix_cache and self.enable_prefix_caching self.reset_tensors() - self.reset_metadata() + self.reset_metadata(preserve_prefix_cache=preserve_prefix_cache) # Reset lifetime counters (not reset in reset_metadata, which is also # called during suspend/resume where these must persist). - self.step_count = 0 - self.prefix_cache_lru_clock = 0 + if not preserve_prefix_cache: + self.step_count = 0 + self.prefix_cache_lru_clock = 0 - # Reset Mamba cache state - if self.mamba_slot_allocator is not None: - self.mamba_slot_allocator.reset() + # Reset Mamba cache state + if self.mamba_slot_allocator is not None: + self.mamba_slot_allocator.reset() + # When preserving prefix cache (idle dummy_forward), keep step_count + # monotonic so the engine's periodic logging cadence + # (step_count % logging_step_interval) still fires for short requests. def current_input_and_position_ids( self, *, num_warmup_tokens: Optional[int] = None @@ -3072,23 +3104,28 @@ def _register_range(start: int, end: int): else: self._pending_mamba_zeros.append(mamba_idx) - # compute_and_store_offsets sets both CPU state (hash_to_block_id, - # _eos_cache_block_id_gpu) and GPU staging buffers. Runs immediately - # because commit_intermediate_states() reads the CPU state after the - # forward pass. - if self.mamba_slot_allocator is not None: - self.mamba_slot_allocator.compute_and_store_offsets( - req, - current_id, - prefix_skip_tokens, - prefill_chunk_length, - num_matched_blocks, - matched_block_ids, - overall_required_blocks, - ) + # compute_and_store_offsets sets CPU state + GPU staging buffers that + # commit_intermediate_states() consumes after the forward pass. Run it for + # EVERY prefill chunk (not just the first): the last complete block of a + # multi-chunk prompt falls in a continuation chunk, and caching its Mamba + # state is precisely what lets a later turn skip prefill on a hybrid model. + # Mamba slot allocation / state restore above stays first-chunk-only. + if self.is_hybrid_model and self.mamba_slot_allocator is not None: + self.mamba_slot_allocator.compute_and_store_offsets( + req, + current_id, + prefix_skip_tokens, + prefill_chunk_length, + num_matched_blocks, + matched_block_ids, + overall_required_blocks, + ) self.active_token_count += effective_prefill_chunk_length self.lifetime_prefill_token_count += effective_prefill_chunk_length + if self.enable_prefix_caching: + self.prefix_cache_prefill_computed_tokens += effective_prefill_chunk_length + self.prefix_cache_prefill_skipped_tokens += prefix_skip_tokens self.total_request_count += 1 self.num_prefill_requests += 1 diff --git a/megatron/core/inference/contexts/mamba_slot_allocator.py b/megatron/core/inference/contexts/mamba_slot_allocator.py index d7f9c055c60..69f19d442bc 100644 --- a/megatron/core/inference/contexts/mamba_slot_allocator.py +++ b/megatron/core/inference/contexts/mamba_slot_allocator.py @@ -410,24 +410,36 @@ def compute_and_store_offsets( overall_required_blocks: Total blocks needed for this request. """ ctx = self.context + bs = ctx.block_size_tokens prompt_len = len(req.prompt_tokens) - num_kv_matched = num_matched_blocks - kv_div_abs = num_kv_matched * ctx.block_size_tokens - last_aligned_abs = (prompt_len // ctx.block_size_tokens) * ctx.block_size_tokens - seq_len = prefill_chunk_length - skip_tokens # effective prefill length - # Compute relative offsets (relative to prefill start after skip) - kv_div_rel = kv_div_abs - skip_tokens - last_aligned_rel = last_aligned_abs - skip_tokens - penultimate_abs = (overall_required_blocks - 1) * ctx.block_size_tokens - penultimate_rel = penultimate_abs - skip_tokens + # Absolute token position (from the prompt start) where THIS chunk's + # computed tokens begin. The first chunk computes from `skip_tokens` (the + # prefix that was skipped); continuation chunks compute from + # `finished_chunk_token_count` (with skip_tokens == 0). Framing the + # boundary offsets against this chunk start -- rather than assuming the + # first chunk -- lets us extract Mamba state at block boundaries that fall + # in ANY chunk. In particular the last complete block of a multi-chunk + # prompt lives in a continuation chunk; it was previously unreachable, so + # non-block-aligned prompts never cached a usable resume boundary and + # later turns could not skip prefill. + chunk_start = req.finished_chunk_token_count + skip_tokens + seq_len = prefill_chunk_length - skip_tokens # tokens computed this chunk + is_last_chunk = req.finished_chunk_token_count + prefill_chunk_length >= prompt_len + + # Candidate absolute block boundaries at which to cache Mamba state. + kv_div_abs = num_matched_blocks * bs + last_aligned_abs = (prompt_len // bs) * bs # last complete block boundary + penultimate_abs = (overall_required_blocks - 1) * bs # Determine mamba_chunk_size from mamba config (128 is the standard SSM kernel chunk size) mamba_chunk_size = 128 - # Build offset list: include if > 0, < seq_len, and % mamba_chunk_size == 0 + # Keep only boundaries that land inside this chunk's computed tokens and on + # a mamba-chunk boundary (required for mid-sequence state extraction). offsets_set = set() - for offset in [kv_div_rel, last_aligned_rel, penultimate_rel]: + for abs_pos in (kv_div_abs, last_aligned_abs, penultimate_abs): + offset = abs_pos - chunk_start if offset > 0 and offset < seq_len and offset % mamba_chunk_size == 0: offsets_set.add(offset) @@ -436,8 +448,8 @@ def compute_and_store_offsets( # CPU bookkeeping writes (no GPU kernel launches). if count > 0: - abs_tokens_cpu = torch.tensor([skip_tokens + o for o in offsets], dtype=torch.int64) - block_indices_cpu = abs_tokens_cpu // ctx.block_size_tokens - 1 + abs_tokens_cpu = torch.tensor([chunk_start + o for o in offsets], dtype=torch.int64) + block_indices_cpu = abs_tokens_cpu // bs - 1 bids_cpu = ctx.request_to_kv_block_ids[current_id][block_indices_cpu] self._intermediate_offsets_cpu[current_id, :count] = torch.tensor( @@ -447,9 +459,13 @@ def compute_and_store_offsets( self._has_intermediates = True self._intermediate_counts_cpu[current_id] = count - # Block-aligned EOS: prompt_len is exactly block-aligned - if last_aligned_abs == prompt_len and prompt_len > 0: - last_block_idx = prompt_len // ctx.block_size_tokens - 1 + # Block-aligned EOS: when the prompt length is exactly block-aligned, the + # request's live final state IS the last block boundary's state and can be + # cached directly. Only valid on the final chunk (otherwise the live state + # is mid-prompt). Non-block-aligned prompts cache their last complete block + # via the intermediate-extraction path above instead. + if is_last_chunk and last_aligned_abs == prompt_len and prompt_len > 0: + last_block_idx = prompt_len // bs - 1 if last_block_idx >= 0: self._eos_cache_block_id_cpu[current_id] = ctx.request_to_kv_block_ids[current_id][ last_block_idx diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 01a130656a4..80cc133c6b5 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -352,6 +352,8 @@ def reset(self) -> None: # Prefix caching tracking. self._prefix_cache_hits = 0 self._prefix_cache_blocks_matched = 0 + self._prefill_tokens_computed = 0 + self._prefill_tokens_skipped = 0 self._prefix_coordination_waits = 0 # Coordinator state. @@ -1818,31 +1820,67 @@ def schedule_chunked_prefill(self): if request_can_be_added and kv_cache_available and token_partially_can_be_added: # How many tokens we can admit this step. token_budget = self.context.max_tokens - self.context.active_token_count - max_chunk = min(remaining_len, token_budget) + + # Prefix-cache skip: on a request's first chunk, the tokens covered + # by a cached prefix are reused rather than recomputed, so they do + # NOT consume the compute budget. Extend this chunk's SPAN to cover + # the entire skippable prefix plus up to `token_budget` newly computed + # tokens. Without this the span is capped at the budget, forcing the + # rest of a long cached prefix to be re-prefilled over many chunks + # (latency then scales with prompt length instead of the delta). + # add_request() only computes `effective = span - skip` tokens. + prefix_skip = 0 + if prefix_caching_enabled and not is_continuing_chunked_prefill: + (_, _, _, _, prefix_skip, _) = self.context._compute_prefix_match( + req, remaining_len + ) + prefix_skip = min(prefix_skip, remaining_len - 1) # keep >=1 token to run + + computed_budget = min(remaining_len - prefix_skip, token_budget) # Skip CG gating for the continuation of an in-flight chunked prefill: # the request is already mid-flight, deferring it would deadlock progress. if self._cg_admission_gating_active() and not is_continuing_chunked_prefill: - # Snap chunk size to the largest captured-CG boundary within budget. - # Fall back to eager (max_chunk) if no CG shape covers the budget. - snapped_chunk = self._find_cg_chunk_size(max_chunk) - prefill_chunk_length = snapped_chunk if snapped_chunk is not None else max_chunk + # Snap the COMPUTED chunk size to the largest captured-CG boundary + # within budget (skipped tokens don't affect the CG batch shape). + # Fall back to eager (computed_budget) if no CG shape covers it. + snapped_chunk = self._find_cg_chunk_size(computed_budget) + computed_chunk = snapped_chunk if snapped_chunk is not None else computed_budget req.cg_wait_iters = 0 else: - prefill_chunk_length = max_chunk + computed_chunk = computed_budget + + prefill_chunk_length = prefix_skip + computed_chunk # Flash-attn guard: if this chunk would leave exactly 1 token for the - # final chunk, reduce by 1 (or defer if we only have 1 token of budget). + # final chunk, reduce by 1 (or defer if we only have 1 computed token). # See https://github.com/Dao-AILab/flash-attention/issues/1537 # The -1 is safe after CG snapping: is_applicable_for_batch_dim matches on # cg.token_count >= real.token_count, so the snapped CG still covers token_count-1. if remaining_len - prefill_chunk_length == 1: - if prefill_chunk_length > 1: + if computed_chunk > 1: prefill_chunk_length -= 1 else: can_schedule = False break + # add_request recomputes the skip for this exact chunk and applies a + # ">= 2 computed tokens" clamp. When the chunk would compute fewer than + # 2 tokens (tight budget late in a batched step, or a prompt that is + # all-but-one cached) that clamp shrinks the skip and grows the computed + # count by up to one block, which can exceed the token budget + # (TokenOverflowError). Only then re-derive the exact effective length + # add_request will use and defer on overflow (a later full-budget step + # admits the request). For >= 2 computed tokens add_request computes + # exactly this chunk, which already fits the budget. + if prefix_skip > 0 and (prefill_chunk_length - prefix_skip) < 2: + (_, _, _, _, _, actual_effective) = self.context._compute_prefix_match( + req, prefill_chunk_length + ) + if self.context.active_token_count + actual_effective > self.context.max_tokens: + can_schedule = False + break + # Add hashes to pending set (prefix-caching bookkeeping). if prefix_caching_enabled: for block_hash in req.precomputed_block_hashes: @@ -2078,8 +2116,12 @@ async def async_bookkeep( if self.context.enable_prefix_caching: self._prefix_cache_hits += self.context.prefix_cache_hits self._prefix_cache_blocks_matched += self.context.prefix_cache_blocks_matched + self._prefill_tokens_computed += self.context.prefix_cache_prefill_computed_tokens + self._prefill_tokens_skipped += self.context.prefix_cache_prefill_skipped_tokens self.context.prefix_cache_hits = 0 self.context.prefix_cache_blocks_matched = 0 + self.context.prefix_cache_prefill_computed_tokens = 0 + self.context.prefix_cache_prefill_skipped_tokens = 0 # Log KV cache utilization stats to W&B nvtx_range_push("wandb_logging") @@ -2207,6 +2249,36 @@ async def async_bookkeep( self._prefix_cache_hits, self._prefix_cache_blocks_matched, ) + if self.context.enable_prefix_caching: + # Prefill compute actually saved by prefix caching (cumulative). + # computed = prompt tokens run through the model; skipped = prompt + # tokens whose prefill was reused from cache. If skipped% stays high + # while per-step latency grows, the growth is attention over the + # growing KV context, NOT re-prefilling skipped tokens. + _computed = self._prefill_tokens_computed + _skipped = self._prefill_tokens_skipped + _total = _computed + _skipped + output_str += " ... prefill (cumul): computed %d, skipped %d (%.1f%% skipped)" % ( + _computed, + _skipped, + (100.0 * _skipped / _total) if _total > 0 else 0.0, + ) + # Current cache occupancy (utilization). A Mamba durable-slot count + # near its max indicates the cache is saturating and will start + # LRU-evicting cached prefixes (hybrid models can only skip prefill + # where Mamba state is still cached). + kv_alloc = self.context.kv_block_allocator + output_str += " ... prefix cache util: KV %d/%d blocks cached (%d evictable)" % ( + len(kv_alloc.kv_hash_to_block_id), + kv_alloc.total_count, + int(kv_alloc.get_evictable_block_count()), + ) + msa = self.context.mamba_slot_allocator + if msa is not None: + output_str += ", mamba %d/%d durable slots" % ( + msa.max_slots - msa.free_count, + msa.max_slots, + ) if context_state["is_decode_only"]: output_str = f"\033[94m{output_str}\033[0m" logging.info(output_str) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index a9c5a6a92f7..d325ee21497 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -1632,8 +1632,12 @@ def dummy_forward(self): # collectives to avoid a hang. self._dummy_serial_mtp_forward() - # clear the context of any temporary state from the dummy forward - context.reset() + # clear the context of any temporary state from the dummy forward, but + # preserve prefix-cache state: a dummy forward runs when the engine is idle + # (e.g. between requests, or to keep EP collectives alive with EP > 1) and + # must not wipe cached KV/Mamba prefixes, or cross-request prefix reuse would + # be destroyed every time the engine briefly idles. + context.reset(preserve_prefix_cache=True) @torch.inference_mode() def _dummy_serial_mtp_forward(self): diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index d2c3d3b1c8d..9ac04a60dd5 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -1027,6 +1027,16 @@ def _ssm_prefill( intermediate_abs_positions.unsqueeze(1).long() + conv_gather_offsets.unsqueeze(0).long() ) # [n, d_conv] + # Clamp into the valid token range. Padding/warmup slots use the + # safe-default abs_position == d_conv, which yields gather indices + # [0..d_conv-1]; when the prefill sequence is shorter than d_conv + # (e.g. a small CUDA-graph warmup bucket with fewer than d_conv + # tokens), those indices overrun the token axis. Clamping keeps the + # gather in bounds. Real slots are always in range, so this is a + # no-op for them, and padding-slot results are never read (callers + # consult per_request_intermediate_counts). + seq_len = xBC_pre_conv.shape[1] + gather_positions = gather_positions.clamp_(0, seq_len - 1) intermediate_conv = xBC_pre_conv[0, gather_positions, :] # [n, d_conv, conv_dim] intermediate_conv_out[:n].copy_(intermediate_conv.transpose(1, 2)) diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index f808cbedcd9..66d3a963786 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -1367,3 +1367,113 @@ def test_routing_survives_prefix_match_lru(self): assert np.allclose(alloc.get_block_routing(b0), routing_b0) assert alloc.get_block_routing(b1) is not None assert np.allclose(alloc.get_block_routing(b1), routing_b1) + + +class TestPrefixCacheReuse(PrefixCachingTestBase): + """Cross-request prefix reuse on hybrid (Mamba) models: + + - reset(preserve_prefix_cache=True) keeps the cache; a plain reset() clears it. + - Per-context prefill token accounting (computed vs skipped). + - Mamba state is extracted for the last complete block of a multi-chunk prompt. + """ + + @pytest.mark.internal + def test_reset_preserves_prefix_cache_when_requested(self): + # LRU + prefix caching enabled: reset(preserve_prefix_cache=True) keeps the + # KV hash index (so an idle dummy_forward does not wipe cross-request reuse), + # while a plain reset() clears it. + ctx = self._ctx(enable_prefix_caching=True) + bs = ctx.block_size_tokens + ctx.add_request(self._req(ctx, self._prompt(bs * 2))) + cached = dict(ctx.kv_block_allocator.kv_hash_to_block_id) + assert len(cached) == 2 + + ctx.reset(preserve_prefix_cache=True) + assert ctx.kv_block_allocator.kv_hash_to_block_id == cached # preserved + + ctx.reset() # default: full reset + assert len(ctx.kv_block_allocator.kv_hash_to_block_id) == 0 # cleared + + @pytest.mark.internal + def test_reset_disabled_ignores_preserve_flag(self): + # When prefix caching is disabled, preserve_prefix_cache=True still performs + # a full reset: step_count returns to 0. + ctx_off = self._ctx(enable_prefix_caching=False) + ctx_off.step_count = 7 + ctx_off.reset(preserve_prefix_cache=True) + assert ctx_off.step_count == 0 + + # With caching ON, preserve keeps step_count monotonic (for logging cadence). + ctx_on = self._ctx(enable_prefix_caching=True) + ctx_on.step_count = 7 + ctx_on.reset(preserve_prefix_cache=True) + assert ctx_on.step_count == 7 + + @pytest.mark.internal + def test_prefill_computed_and_skipped_counters(self): + # A second request that shares a cached prefix should skip that prefix's + # prefill; the per-context counters must reflect computed vs skipped tokens. + ctx = self._ctx(enable_prefix_caching=True) + bs = ctx.block_size_tokens + + ctx.add_request(self._req(ctx, self._prompt(bs * 4), request_id=1)) + assert ctx.prefix_cache_prefill_skipped_tokens == 0 + assert ctx.prefix_cache_prefill_computed_tokens == bs * 4 + + # request 2 shares the first 4 blocks, adds 2 new blocks + req2 = self._req(ctx, self._prompt(bs * 6), request_id=2) + (matched, _, _, _, prefix_skip, _) = ctx._compute_prefix_match(req2, bs * 6) + assert len(matched) == 4 and prefix_skip == bs * 4 + ctx.add_request(req2) + + assert ctx.prefix_cache_prefill_skipped_tokens == bs * 4 + assert ctx.prefix_cache_prefill_computed_tokens == bs * 6 # 4bs + 2bs + + @pytest.mark.internal + def test_mamba_extraction_covers_last_block_of_continuation_chunk(self): + # For a non-block-aligned, multi-chunk prompt, the last complete block lies + # in a continuation chunk. Extraction offsets are chunk-relative, so that + # boundary's Mamba state is recorded when its chunk is scheduled. + ctx = self._ctx( + mamba_config=self._mamba_config(), + prefix_caching_mamba_gb=0.01, + block_size_tokens=256, + max_sequence_length=4096, + ) # mamba prefix caching enabled + bs = ctx.block_size_tokens + assert bs == 256 + msa = ctx.mamba_slot_allocator + + prompt_len = bs * 3 + 64 # 3 complete blocks + a 64-token remainder + req = self._req(ctx, self._prompt(prompt_len)) + ctx.add_request(req) # populates request_to_kv_block_ids[0] + overall_blocks = ctx.request_kv_block_counts[0].item() + assert overall_blocks == 4 # ceil(832 / 256) + + # Simulate the continuation chunk that covers tokens [2*bs, prompt_len): + # finished=2*bs, no prefix skip, the rest of the prompt as the chunk. + req.finished_chunk_token_count = 2 * bs + cont_chunk = prompt_len - 2 * bs + msa.compute_and_store_offsets( + req, + current_id=0, + skip_tokens=0, + prefill_chunk_length=cont_chunk, + num_matched_blocks=0, + matched_block_ids=[], + overall_required_blocks=overall_blocks, + ) + + # last complete block boundary = 3*bs (768); chunk-relative offset = 768-512=256. + last_aligned_abs = (prompt_len // bs) * bs + expected_offset = last_aligned_abs - 2 * bs + count = msa._intermediate_counts_cpu[0].item() + assert count >= 1 + recorded = msa._intermediate_offsets_cpu[0, :count].tolist() + assert expected_offset in recorded + # the recorded boundary maps to the last complete block (index 2) + idx = recorded.index(expected_offset) + assert ( + msa._intermediate_block_ids_cpu[0, idx].item() + == ctx.request_to_kv_block_ids[0][last_aligned_abs // bs - 1].item() + ) diff --git a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py index b14747d7070..52e231c1c68 100644 --- a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py +++ b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py @@ -12,7 +12,6 @@ 2. context.using_cuda_graph_this_step() returned True at expected steps. """ -import os import random import types @@ -44,7 +43,7 @@ from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version -from tests.unit_tests.test_utilities import Utils +from tests.unit_tests.test_utilities import Utils, clear_nvte_env_vars BLOCK_SIZE = 256 VOCAB_SIZE = 10000 @@ -323,6 +322,11 @@ class TestHybridChunkedPrefillIntermediateState: @classmethod def setup_class(cls): Utils.initialize_model_parallel() + random.seed(123) + torch.manual_seed(123) + model_parallel_cuda_manual_seed( + seed=123, inference_rng_tracker=True, use_cudagraphable_rng=False, force_reset_rng=True + ) @classmethod def teardown_class(cls): @@ -450,16 +454,7 @@ def test_hybrid_chunked_prefill_intermediate_state(self): if not sequence_packing_available: pytest.skip(reason) - # Clear NVTE env vars set by conftest set_env fixture. - os.environ.pop('NVTE_FLASH_ATTN', None) - os.environ.pop('NVTE_FUSED_ATTN', None) - os.environ.pop('NVTE_UNFUSED_ATTN', None) - - random.seed(123) - torch.manual_seed(123) - model_parallel_cuda_manual_seed( - seed=123, inference_rng_tracker=True, use_cudagraphable_rng=False, force_reset_rng=True - ) + clear_nvte_env_vars() # conftest's set_env fixture re-sets these per test model = self._create_hybrid_model() mamba_config = MambaInferenceStateConfig.from_model(model) @@ -541,3 +536,50 @@ def collect_finished(result): f"req {req_id}: baseline {baseline_outputs[req_id]} != " f"test {test_outputs[req_id]}" ) + + @torch.inference_mode() + def test_prefill_shorter_than_conv_window(self): + """A prefill captured into a CUDA graph whose token bucket is smaller than the + Mamba conv window (d_conv) generates correctly. + + Conv-state extraction gathers d_conv positions per slot, and unused slots use + abs_position == d_conv (gather indices up to d_conv-1). The CUDA-graph bucket + list always includes a size-1 (tp_size) graph, so a prompt shorter than d_conv + is captured at a bucket whose token layout is shorter than the gather window. + CUDA graphs (num_cuda_graphs) are required to exercise this capture path. + """ + sequence_packing_available, reason = _check_mamba_sequence_packing_support() + if not sequence_packing_available: + pytest.skip(reason) + + clear_nvte_env_vars() # conftest's set_env fixture re-sets these per test + + model = self._create_hybrid_model(num_cuda_graphs=2) + mamba_config = MambaInferenceStateConfig.from_model(model) + device = torch.cuda.current_device() + + d_conv = mamba_config.conv_states_shape[-1] + if d_conv < 2: + pytest.skip(f"d_conv={d_conv} too small to exercise a sub-window prefill") + + # Prompt shorter than the conv window: its prefill chunk snaps to a CUDA-graph + # bucket < d_conv, so the captured graph's token layout is < d_conv. + engine = self._build_engine( + model, + mamba_config, + enable_prefix_caching=True, + enable_chunked_prefill=True, + num_cuda_graphs=2, + ) + short_prompt = torch.arange(0, d_conv - 1, dtype=torch.int64, device=device) + + engine._add_request(self._make_request(0, short_prompt, enable_pc=True)) + outputs = {} + while engine.has_unfinished_requests(): + result = engine.step_modern() + for record in result["finished_request_records"]: + merged = record.merge() + outputs[merged.request_id] = list(merged.generated_tokens) + + # Generation completes and produces the requested number of tokens. + assert len(outputs[0]) == NUM_TOKENS_TO_GENERATE From a3279434e64b839993323316afb592244bd20b45 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 14 Jul 2026 15:56:29 -0700 Subject: [PATCH 021/290] Exercise nested MFSDP CUDA graph capture (#5796) Signed-off-by: Jingyue Wu --- .../distributed/mfsdp_v2/test_cuda_graph.py | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py b/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py index 910c13c6fd3..08920bcea98 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py @@ -4,7 +4,6 @@ import logging -import pytest import torch from torch import nn from torch.distributed.device_mesh import init_device_mesh @@ -18,6 +17,22 @@ logger = logging.getLogger(__name__) +class NestedModel(nn.Module): + """Model with a root FSDP unit and multiple child FSDP units.""" + + def __init__(self, dim: int, num_children: int) -> None: + super().__init__() + self.bias = nn.Parameter(torch.zeros(dim)) + self.layers = nn.ModuleList([nn.Linear(dim, dim, bias=False) for _ in range(num_children)]) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Run through every child layer with a root-owned bias.""" + x = x + self.bias + for layer in self.layers: + x = layer(x) + return x + + def _flat_placements() -> Placements: return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) @@ -26,20 +41,21 @@ def test_captures_full_iteration(distributed_setup): """A full training iteration should be CUDA-graphable.""" world_size = distributed_setup.world_size device = distributed_setup.device - if world_size < 2: - pytest.skip("This test requires at least 2 ranks.") mesh = init_device_mesh(device.type, (world_size,)) torch.manual_seed(1234) - model = nn.Linear(4, 2, bias=False).to(device) + dim = 8 + model = NestedModel(dim=dim, num_children=2).to(device) - fully_shard(model, mesh=mesh, placements=_flat_placements()) - optimizer = torch.optim.SGD(model.parameters(), lr=0.25, foreach=False) + static_input = torch.eye(dim, device=device) + static_target = torch.zeros_like(static_input) - static_input = torch.eye(4, device=device) - static_target = torch.tensor( - [[1.0, -0.5], [-0.25, 0.75], [0.5, 0.25], [-0.75, -1.0]], device=device - ) + placements = _flat_placements() + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements) + fully_shard(model, mesh=mesh, placements=placements) + + optimizer = torch.optim.SGD(model.parameters(), lr=0.25, foreach=False) def train_iteration() -> torch.Tensor: optimizer.zero_grad(set_to_none=False) @@ -49,22 +65,26 @@ def train_iteration() -> torch.Tensor: optimizer.step() return loss.detach() - warmup_stream = torch.cuda.Stream() - warmup_stream.wait_stream(torch.cuda.current_stream()) - # Warm up before capture. torch.cuda.graph() uses an internal side stream - # when `stream` is omitted, so `stream=` is only needed when callers must - # control the capture stream, such as when reusing an explicit stream with - # a shared graph memory pool across captures. - with torch.cuda.stream(warmup_stream): + capture_stream = torch.cuda.Stream() + capture_stream.wait_stream(torch.cuda.current_stream()) + + # Warmup + with torch.cuda.stream(capture_stream): + # See: https://docs.nvidia.com/dl-cuda-graph/troubleshooting/memory-issues.html#gradient-accumulator-cross-stream-memory-growth + # Warm up on the same stream used for capture so autograd's accumulation + # path does not create cross-stream gradient-memory growth. # The first warmup installs the reusable sharded gradient views; subsequent # iterations zero them in place for CUDA graph replay. for _ in range(3): train_iteration() + # Capture graph = torch.cuda.CUDAGraph() - with torch.cuda.graph(graph): + with torch.cuda.graph(graph, stream=capture_stream): static_loss = train_iteration() + torch.cuda.current_stream().wait_stream(capture_stream) + # Replay losses = [] for _ in range(5): graph.replay() From 33706f968e0eabec6699626fe51e6cbfd826ae94 Mon Sep 17 00:00:00 2001 From: Haoran Zhang Date: Tue, 14 Jul 2026 17:34:30 -0700 Subject: [PATCH 022/290] Implement Quantile Balancing in MoE (#5349) Signed-off-by: Haoran Zhang --- .../core/distributed/finalize_model_grads.py | 48 ++++++ megatron/core/transformer/moe/moe_utils.py | 63 +++++++- megatron/core/transformer/moe/router.py | 131 +++++++++++++++++ .../core/transformer/transformer_config.py | 9 ++ megatron/training/arguments.py | 4 +- .../distributed/test_finalize_model_grads.py | 102 ++++++++++++- .../models/test_hybrid_moe_model.py | 1 + .../transformer/moe/test_qb_routing.py | 139 ++++++++++++++++++ .../transformer/moe/test_routers.py | 43 +++++- .../transformer/moe/test_shared_experts.py | 6 +- 10 files changed, 536 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/transformer/moe/test_qb_routing.py diff --git a/megatron/core/distributed/finalize_model_grads.py b/megatron/core/distributed/finalize_model_grads.py index 51660d12c7d..12253e6c4b6 100644 --- a/megatron/core/distributed/finalize_model_grads.py +++ b/megatron/core/distributed/finalize_model_grads.py @@ -326,6 +326,9 @@ def reset_model_temporary_tensors(config: TransformerConfig, model: List[torch.n or "global_aux_loss" in config.moe_router_load_balancing_type ) and hasattr(module, 'reset_global_aux_loss_tracker'): module.reset_global_aux_loss_tracker() + if getattr(module, 'qb_beta_accum', None) is not None: + module.qb_beta_accum.zero_() + module.qb_beta_count.zero_() def _update_router_expert_bias( @@ -368,6 +371,48 @@ def _update_router_expert_bias( expert_bias.copy_(updated_expert_bias) +def _update_router_qb_beta( + model: List[torch.nn.Module], + config: TransformerConfig, + dp_cp_group: Optional[torch.distributed.ProcessGroup] = None, +): + """Update the quantile-balancing per-expert bias once per global batch. + + Averages each router's accumulated quantile (qb_beta_accum/qb_beta_count) across + DP, EMA-blends it with the current qb_beta, re-centers, and writes it back. + """ + qb_beta_list = [] + qb_beta_accum_list = [] + qb_beta_count_list = [] + for model_chunk in model: + for module in get_attr_wrapped_model(model_chunk, 'modules')(): + if getattr(module, 'qb_beta_accum', None) is not None and module.training: + qb_beta_list.append(module.qb_beta) + qb_beta_accum_list.append(module.qb_beta_accum) + qb_beta_count_list.append(module.qb_beta_count) + + if len(qb_beta_list) == 0: + return + + stacked_beta = torch.stack(qb_beta_list, dim=0) + local_avg_list = [ + accum / count.clamp(min=1).to(accum.dtype) + for accum, count in zip(qb_beta_accum_list, qb_beta_count_list) + ] + stacked_local_avg = torch.stack(local_avg_list, dim=0) + + torch.distributed.all_reduce( + stacked_local_avg, op=torch.distributed.ReduceOp.AVG, group=dp_cp_group + ) + + ema = config.moe_router_quantile_balancing_ema + stacked_new_beta = ema * stacked_beta + (1.0 - ema) * stacked_local_avg + stacked_new_beta = stacked_new_beta - stacked_new_beta.mean(dim=-1, keepdim=True) + + for qb_beta, new_beta in zip(qb_beta_list, stacked_new_beta): + qb_beta.copy_(new_beta) + + def _allreduce_non_tensor_model_parallel_grads( model: List[torch.nn.Module], config: TransformerConfig, @@ -542,6 +587,9 @@ def finalize_model_grads( ) _update_router_expert_bias(model, config, tp_dp_cp_group=tp_dp_cp_group) + if config.moe_router_load_balancing_type == "quantile_balancing": + _update_router_qb_beta(model, config, dp_cp_group=dp_cp_group) + reset_model_temporary_tensors(config, model) # normalize gradients for per-token loss normalization. diff --git a/megatron/core/transformer/moe/moe_utils.py b/megatron/core/transformer/moe/moe_utils.py index 79b45156ed8..5c053adb6b1 100644 --- a/megatron/core/transformer/moe/moe_utils.py +++ b/megatron/core/transformer/moe/moe_utils.py @@ -200,6 +200,44 @@ def sinkhorn(cost: torch.Tensor, tol: float = 0.0001) -> torch.Tensor: return d1 * cost * d0.unsqueeze(1) +def qb_dual_update( + scores: torch.Tensor, k: int, beta: torch.Tensor, update_beta: bool = True +) -> Tuple[torch.Tensor, torch.Tensor]: + """Dual coordinate-descent quantile-balancing routing assignment. + + Picks the top-k experts per token from ``scores - beta``. When ``update_beta`` is + True, also returns the raw column quantile of ``scores`` that drives each expert + toward ``m * k / n`` tokens. + + Args: + scores (torch.Tensor): Scores of shape ``[m, n]`` (tokens, experts). + k (int): Experts to select per token. + beta (torch.Tensor): Current per-expert bias of shape ``[n]``. + update_beta (bool): If False, return ``beta`` unchanged (eval/inference). + + Returns: + Tuple[torch.Tensor, torch.Tensor]: indices of shape ``[m, k]`` and either + ``beta`` (when ``update_beta`` is False) or the column quantile ``[n]``. + """ + num_tokens, num_experts = scores.shape + + topk_result = (scores - beta).topk(k + 1, dim=1) + indices = topk_result.indices[:, :-1] + + if not update_beta: + return indices, beta + + assert (num_tokens * k) % num_experts == 0, ( + "Quantile balancing requires the number of routed assignments " + f"({num_tokens} tokens * top-{k}) to be divisible by " + f"{num_experts} experts." + ) + col_target = num_tokens * k // num_experts + alpha = topk_result.values[:, -1:] + beta_local = (scores - alpha).topk(col_target + 1, dim=0).values[-1].contiguous() + return indices, beta_local + + def get_capacity( num_tokens: int, num_experts: int, capacity_factor: float, min_capacity: Optional[int] = None ) -> int: @@ -681,6 +719,7 @@ def topk_routing_with_score_function( fused: bool = False, router_replay: Optional['RouterReplay'] = None, dense_output: bool = False, + precomputed_indices: Optional[torch.Tensor] = None, ) -> Tuple[torch.Tensor, torch.Tensor]: """Compute the routing probabilities and map for top-k selection with score function. @@ -705,6 +744,10 @@ def topk_routing_with_score_function( Defaults to None. dense_output (bool, optional): If True, return dense tensors [num_tokens, topk] instead of sparse tensors [num_tokens, num_experts]. Defaults to False. + precomputed_indices (torch.Tensor, optional): Top-k indices [num_tokens, topk] + selected by the caller. When given, the score function's + own top-k is bypassed and probs are computed at these + indices (e.g. for quantile balancing). Defaults to None. Returns: Tuple[torch.Tensor, torch.Tensor]: @@ -723,6 +766,9 @@ def topk_routing_with_score_function( """ assert logits.dim() == 2, f"Expected 2D logits [num_tokens, num_experts], got {logits.dim()}." num_tokens, num_experts = logits.shape + assert not ( + fused and precomputed_indices is not None + ), "precomputed_indices is not supported with the fused top-k score function." if fused: if not HAVE_TE or fused_topk_with_score_function is None: raise ValueError( @@ -793,16 +839,27 @@ def compute_topk(scores, topk, num_groups=None, group_topk=None): if score_function == "softmax": if use_pre_softmax: scores = torch.softmax(logits, dim=-1, dtype=torch.float32) - probs, top_indices = compute_topk(scores, topk, num_groups, group_topk) + if precomputed_indices is not None: + top_indices = precomputed_indices + probs = torch.gather(scores, dim=1, index=top_indices) + else: + probs, top_indices = compute_topk(scores, topk, num_groups, group_topk) else: - scores, top_indices = compute_topk(logits, topk, num_groups, group_topk) + if precomputed_indices is not None: + top_indices = precomputed_indices + scores = torch.gather(logits, dim=1, index=top_indices) + else: + scores, top_indices = compute_topk(logits, topk, num_groups, group_topk) probs = torch.softmax(scores, dim=-1, dtype=torch.float32) elif score_function in ("sigmoid", "sqrtsoftplus"): if score_function == "sigmoid": scores = torch.sigmoid(logits.float()) else: scores = torch.nn.functional.softplus(logits.float()).sqrt() - if expert_bias is not None: + if precomputed_indices is not None: + top_indices = precomputed_indices + scores = torch.gather(scores, dim=1, index=top_indices) + elif expert_bias is not None: scores_for_routing = scores + expert_bias.float() _, top_indices = compute_topk(scores_for_routing, topk, num_groups, group_topk) scores = torch.gather(scores, dim=1, index=top_indices) diff --git a/megatron/core/transformer/moe/router.py b/megatron/core/transformer/moe/router.py index 7414c8a7ab0..6273a520588 100644 --- a/megatron/core/transformer/moe/router.py +++ b/megatron/core/transformer/moe/router.py @@ -17,6 +17,7 @@ apply_router_token_dropping, compute_routing_scores_for_aux_loss, get_tokens_per_expert_and_token_count, + qb_dual_update, router_gating_linear, sinkhorn, switch_load_balancing_loss_func, @@ -216,6 +217,41 @@ def __init__( self.global_tokens_per_expert = None self.ga_steps = None + # Quantile balancing replaces the aux loss with a per-expert bias `qb_beta`. + # `qb_beta_accum`/`qb_beta_count` collect the per-microbatch quantile, reduced + # and reset each global batch. + if self.routing_type == "quantile_balancing": + assert not self.is_aux_loss_enabled(), ( + "Quantile balancing handles load balance via the bias update; " + "aux losses must be disabled (set moe_aux_loss_coeff to 0)." + ) + self.register_buffer( + 'qb_beta', + torch.zeros( + self.config.num_moe_experts, + dtype=torch.float32, + device=torch.cuda.current_device(), + ), + ) + self.register_buffer( + 'qb_beta_accum', + torch.zeros( + self.config.num_moe_experts, + dtype=torch.float32, + device=torch.cuda.current_device(), + ), + persistent=False, + ) + self.register_buffer( + 'qb_beta_count', + torch.zeros((), dtype=torch.long, device=torch.cuda.current_device()), + persistent=False, + ) + else: + self.qb_beta = None + self.qb_beta_accum = None + self.qb_beta_count = None + self.router_replay = None if self.config.moe_enable_routing_replay: self.router_replay = RouterReplay() @@ -230,6 +266,13 @@ def _maintain_float32_expert_bias(self): if hasattr(self, 'expert_bias') and self.expert_bias is not None: if self.expert_bias.dtype != torch.float32: self.expert_bias.data = self.expert_bias.data.to(torch.float32) + # Keep the QB bias in fp32 for the same reason. + if hasattr(self, 'qb_beta') and self.qb_beta is not None: + if self.qb_beta.dtype != torch.float32: + self.qb_beta.data = self.qb_beta.data.to(torch.float32) + if hasattr(self, 'qb_beta_accum') and self.qb_beta_accum is not None: + if self.qb_beta_accum.dtype != torch.float32: + self.qb_beta_accum.data = self.qb_beta_accum.data.to(torch.float32) def sinkhorn_load_balancing(self, logits: torch.Tensor): """Apply sinkhorn routing to the logits tensor. @@ -264,6 +307,81 @@ def _sinkhorn_activation(logits): scores = logits * map return scores, map + def quantile_balancing(self, logits: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Apply quantile-balancing (QB) routing to the logits tensor. + + Selects top-k experts per token using a dual coordinate-descent update on + a per-expert bias ``qb_beta``. Load balance is handled entirely by the bias + update; auxiliary losses must be disabled when QB is active. + + Args: + logits (torch.Tensor): The logits tensor, shape ``[num_tokens, num_experts]``. + + Returns: + Tuple[torch.Tensor, torch.Tensor]: Sparse routing probs and boolean + routing map, each shaped ``[num_tokens, num_experts]``. + """ + assert ( + not self.config.moe_router_fusion + ), "Quantile balancing routing does not support moe_router_fusion." + assert ( + self.config.moe_router_num_groups is None and self.config.moe_router_group_topk is None + ), "Quantile balancing routing does not support group-limited routing." + + local_num_tokens = logits.shape[0] + # Gather logits across TP/CP so the quantile sees a whole sequence's tokens. + # The DP reduction and qb_beta update run at the global-batch boundary in + # finalize_model_grads._update_router_qb_beta. + gather_group = self.tp_cp_group + gather_size = gather_group.size() if gather_group is not None else 1 + + should_update_beta = self.training and torch.is_grad_enabled() + + with torch.no_grad(): + logits_fp32 = logits.detach().to(dtype=torch.float32) + + if gather_size > 1: + full_logits = torch.empty( + (local_num_tokens * gather_size, self.config.num_moe_experts), + dtype=logits_fp32.dtype, + device=logits_fp32.device, + ) + torch.distributed.all_gather_into_tensor( + full_logits, logits_fp32.contiguous(), group=gather_group + ) + gather_rank = torch.distributed.get_rank(group=gather_group) + else: + full_logits = logits_fp32 + gather_rank = 0 + + # Route with the previous batch's qb_beta; in training, accumulate this + # microbatch's quantile for the next update. + full_indices, beta_local = qb_dual_update( + full_logits, self.topk, self.qb_beta, update_beta=should_update_beta + ) + if should_update_beta: + self.qb_beta_accum.add_(beta_local) + self.qb_beta_count.add_(1) + + # Take this rank's rows (all_gather orders rows by rank). + if gather_size > 1: + indices = full_indices[ + gather_rank * local_num_tokens : (gather_rank + 1) * local_num_tokens + ].contiguous() + else: + indices = full_indices + + # QB only picks the experts; reuse the shared score function for the probs. + return topk_routing_with_score_function( + logits, + self.topk, + use_pre_softmax=self.config.moe_router_pre_softmax, + scaling_factor=self.config.moe_router_topk_scaling_factor, + score_function=self.score_function, + fused=self.config.moe_router_fusion, + precomputed_indices=indices, + ) + def get_aux_loss_coeff(self, aux_loss_type: str) -> float: """Return the aux loss coeff for the given auxiliary loss type. If the auxiliary loss type is not found, return 0.0. @@ -641,6 +759,11 @@ def routing(self, logits: torch.Tensor, padding_mask: Optional[torch.Tensor] = N # Calculate probs and routing_map for token dispatching if self.routing_type == "sinkhorn": probs, routing_map = self.sinkhorn_load_balancing(logits) + elif self.routing_type == "quantile_balancing": + assert ( + padding_mask is None + ), "Quantile balancing routing does not support padding masks yet." + probs, routing_map = self.quantile_balancing(logits) else: probs, routing_map = topk_routing_with_score_function( logits, @@ -799,6 +922,7 @@ def _compiled_topk_routing( fused, router_replay, dense_output, + precomputed_indices, ): return topk_routing_with_score_function( logits, @@ -812,11 +936,17 @@ def _compiled_topk_routing( fused=fused, router_replay=router_replay, dense_output=dense_output, + precomputed_indices=precomputed_indices, ) def _forward(self, input: torch.Tensor, padding_mask: Optional[torch.Tensor] = None): logits = self.gating(input).squeeze(1) # [num_tokens, num_experts] + # QB selects on (logits - qb_beta); at inference qb_beta is fixed, so it's per-token. + precomputed_indices = None + if self.qb_beta is not None: + precomputed_indices = (logits - self.qb_beta).topk(self.topk, dim=1).indices + probs, top_indices = self._compiled_topk_routing( logits, self.topk, @@ -829,6 +959,7 @@ def _forward(self, input: torch.Tensor, padding_mask: Optional[torch.Tensor] = N fused=self.config.moe_router_fusion, router_replay=self.router_replay, dense_output=True, + precomputed_indices=precomputed_indices, ) return probs.squeeze(1), top_indices.squeeze(1) diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index f8779d674e2..761504e614e 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -718,6 +718,9 @@ class TransformerConfig(ModelParallelConfig): for each individual sample. - "global_aux_loss": Load balancing loss calculated at global batch level. - "sinkhorn": Balancing algorithm used in S-BASE. + - "quantile_balancing": Dual coordinate-descent quantile balancing (QB). Load balance is + handled entirely by an internal per-expert bias update; auxiliary losses must be disabled + (`moe_aux_loss_coeff` = 0) when QB is selected. - "none": No load balancing. A list of strings can be provided to combine multiple aux-loss load balancing types. The default is "aux_loss". @@ -790,6 +793,12 @@ class TransformerConfig(ModelParallelConfig): and decreased for the experts with more assigned tokens. The default value 1e-3 is same as that used in DeepSeekV3.""" + moe_router_quantile_balancing_ema: float = 0.0 + """EMA coefficient for the quantile-balancing per-expert bias (`qb_beta`), used only when + `moe_router_load_balancing_type` is "quantile_balancing". At each global batch the bias is + updated as `qb_beta = ema * qb_beta + (1 - ema) * local_quantile`. The default 0.0 means + no memory: the bias is replaced by the latest global-batch quantile estimate each step.""" + moe_router_force_load_balancing: bool = False """[Experimental] Force load balancing with random logits for MoE router, supports naive topk and group-limited topk. This is an experimental feature and only for benchmark.""" diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 4f3c0af7b30..2c3cd1cd531 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -3205,9 +3205,9 @@ def _add_moe_args(parser): 'Upcycling is implemented on the top of distributed checkpointing, so it supports parallel modes different from the dense model.') # Router arguments group.add_argument('--moe-router-load-balancing-type', nargs='+', type=str, - choices=['aux_loss', 'seq_aux_loss', 'global_aux_loss', 'sinkhorn', 'none'], + choices=['aux_loss', 'seq_aux_loss', 'global_aux_loss', 'sinkhorn', 'quantile_balancing', 'none'], default='aux_loss', - help='Determines the load balancing strategy for the router. "aux_loss" corresponds to the load balancing loss used in GShard and SwitchTransformer; "seq_aux_loss" corresponds to the load balancing loss used in DeepSeekV2, which computes the loss for each individual sample; "sinkhorn" corresponds to the balancing algorithm used in S-BASE, and "none" implies no load balancing. The default is "aux_loss".') + help='Determines the load balancing strategy for the router. "aux_loss" corresponds to the load balancing loss used in GShard and SwitchTransformer; "seq_aux_loss" corresponds to the load balancing loss used in DeepSeekV2, which computes the loss for each individual sample; "sinkhorn" corresponds to the balancing algorithm used in S-BASE; "quantile_balancing" (QB) uses dual coordinate descent on a per-expert bias to handle load balance internally; "none" implies no load balancing. The default is "aux_loss".') group.add_argument('--moe-aux-loss-coeff', type=float, nargs='+', default=0.0, help='Scaling coefficient for the aux loss: a starting value of 1e-2 is recommended.') # Token dispatcher arguments diff --git a/tests/unit_tests/distributed/test_finalize_model_grads.py b/tests/unit_tests/distributed/test_finalize_model_grads.py index ee535c29baf..80d143a89a3 100644 --- a/tests/unit_tests/distributed/test_finalize_model_grads.py +++ b/tests/unit_tests/distributed/test_finalize_model_grads.py @@ -11,13 +11,21 @@ from megatron.core.distributed.finalize_model_grads import ( _allreduce_non_tensor_model_parallel_grads, _allreduce_word_embedding_grads, + _update_router_qb_beta, finalize_model_grads, + reset_model_temporary_tensors, +) +from megatron.core.models.gpt.gpt_layer_specs import ( + get_gpt_layer_local_submodules, + get_gpt_layer_with_transformer_engine_spec, ) -from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec from megatron.core.models.gpt.gpt_model import GPTModel from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.moe.moe_layer import MoELayer +from megatron.core.transformer.spec_utils import get_submodules from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.training.initialize import _set_random_seed from tests.unit_tests.test_utilities import Utils @@ -117,6 +125,98 @@ def test_finalize_model_grads_requires_custom_group_before_grad_sync(self): assert model.finish_grad_sync_calls == 0 +class TestUpdateRouterQBBeta: + """Exercises the QB bias update in finalize_model_grads against a real MoE router.""" + + def setup_method(self, method): + os.environ.pop('NVTE_FUSED_ATTN', None) + os.environ.pop('NVTE_FLASH_ATTN', None) + os.environ.pop('NVTE_UNFUSED_ATTN', None) + Utils.destroy_model_parallel() + Utils.initialize_model_parallel(1, 1) + _set_random_seed(seed_=123, data_parallel_random_init=False) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def _build_moe_layer(self, ema): + num_experts = 8 + config = TransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + num_moe_experts=num_experts, + use_cpu_initialization=True, + moe_router_load_balancing_type="quantile_balancing", + moe_router_score_function="softmax", + moe_router_topk=2, + moe_aux_loss_coeff=0, + moe_router_quantile_balancing_ema=ema, + bf16=True, + params_dtype=torch.bfloat16, + add_bias_linear=False, + ) + submodules = get_submodules( + get_gpt_layer_local_submodules(num_experts=num_experts, moe_grouped_gemm=False).mlp + ) + return config, MoELayer(config, submodules).cuda() + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.parametrize("ema", [0.0, 0.9]) + def test_update_router_qb_beta(self, ema): + config, moe_layer = self._build_moe_layer(ema) + router = moe_layer.router + router.train() + # Non-zero prior bias so the EMA term is actually exercised. + router.qb_beta.copy_(torch.randn_like(router.qb_beta)) + + # The real router forward populates qb_beta_accum / qb_beta_count. + hidden = torch.randn((32, 2, config.hidden_size)).cuda().bfloat16() + router(hidden) + router(hidden) + assert router.qb_beta_count.item() == 2 + assert router.qb_beta_accum.abs().sum().item() > 0 + + # Expected from the real accumulators: DP-avg(accum/count), EMA-blend, re-center. + local_avg = router.qb_beta_accum / router.qb_beta_count.clamp(min=1).to(torch.float32) + torch.distributed.all_reduce( + local_avg, op=torch.distributed.ReduceOp.AVG, group=dist.group.WORLD + ) + blended = ema * router.qb_beta + (1.0 - ema) * local_avg + expected = blended - blended.mean(dim=-1, keepdim=True) + + _update_router_qb_beta([moe_layer], config, dp_cp_group=dist.group.WORLD) + + torch.testing.assert_close(router.qb_beta, expected) + torch.testing.assert_close( + router.qb_beta.mean(), torch.zeros((), device=router.qb_beta.device) + ) + + # reset_model_temporary_tensors clears the accumulators for the next global batch. + reset_model_temporary_tensors(config, [moe_layer]) + torch.testing.assert_close(router.qb_beta_accum, torch.zeros_like(router.qb_beta_accum)) + assert router.qb_beta_count.item() == 0 + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + def test_update_router_qb_beta_skips_eval(self): + config, moe_layer = self._build_moe_layer(ema=0.0) + router = moe_layer.router + # Non-zero prior + non-uniform accumulator, so a broken eval guard would visibly + # change qb_beta (a uniform accumulator re-centers to zero and hides the bug). + router.qb_beta.copy_(torch.ones_like(router.qb_beta)) + router.qb_beta_accum.copy_( + torch.arange(router.qb_beta.numel(), dtype=torch.float32, device=router.qb_beta.device) + ) + router.qb_beta_count.fill_(1) + before = router.qb_beta.clone() + router.eval() + + _update_router_qb_beta([moe_layer], config, dp_cp_group=dist.group.WORLD) + + # Eval-mode modules are skipped, so qb_beta is unchanged. + torch.testing.assert_close(router.qb_beta, before) + + class TestAllReduceLNGrads: def init_model(self, share_embeddings_and_output_weights: bool = False): diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index 194cb2a285b..f7dc78ce9a2 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -211,6 +211,7 @@ "moe_router_padding_for_fp8": False, "moe_router_padding_for_quantization": False, "moe_router_pre_softmax": False, + "moe_router_quantile_balancing_ema": 0.0, "moe_router_score_function": "sigmoid", "moe_router_topk": 6, "moe_router_topk_limited_devices": None, diff --git a/tests/unit_tests/transformer/moe/test_qb_routing.py b/tests/unit_tests/transformer/moe/test_qb_routing.py new file mode 100644 index 00000000000..5ad26487fb6 --- /dev/null +++ b/tests/unit_tests/transformer/moe/test_qb_routing.py @@ -0,0 +1,139 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. + +from typing import cast + +import pytest +import torch + +from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_submodules +from megatron.core.transformer.moe.moe_layer import MoELayer, MoESubmodules +from megatron.core.transformer.moe.moe_utils import qb_dual_update +from megatron.core.transformer.moe.router import Router +from megatron.core.transformer.spec_utils import get_submodules +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.training.initialize import _set_random_seed +from tests.unit_tests.test_utilities import Utils + + +class TestQBDualUpdate: + """Pure-tensor tests for the quantile-balancing dual update (CPU, no distributed).""" + + @pytest.mark.internal + @pytest.mark.parametrize("m,n,k", [(64, 8, 2), (40, 8, 1), (12, 4, 1)]) + def test_column_quantile_contract(self, m, n, k): + """qb_beta_local is the (col_target+1)-th largest score minus alpha per expert.""" + torch.manual_seed(123) + scores = torch.randn(m, n) + beta = torch.zeros(n) + + _, beta_local = qb_dual_update(scores, k, beta, update_beta=True) + + alpha = (scores - beta).topk(k + 1, dim=1).values[:, -1:] + adjusted = scores - alpha + col_target = m * k // n + expected = adjusted.sort(dim=0, descending=True).values[col_target] + torch.testing.assert_close(beta_local, expected) + + +class TestQuantileBalancingRouter: + def setup_method(self, method): + Utils.initialize_model_parallel(1, 1) + _set_random_seed(seed_=123, data_parallel_random_init=False) + self.num_moe_experts = 8 + self.transformer_config = TransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + num_moe_experts=self.num_moe_experts, + use_cpu_initialization=True, + moe_router_load_balancing_type="quantile_balancing", + moe_router_score_function="softmax", + moe_router_topk=2, + moe_aux_loss_coeff=0, + bf16=True, + params_dtype=torch.bfloat16, + add_bias_linear=False, + ) + self.submodules = get_submodules( + get_gpt_layer_local_submodules( + num_experts=self.num_moe_experts, moe_grouped_gemm=False + ).mlp + ) + assert isinstance(self.submodules, MoESubmodules) + self.moe_layer = MoELayer(self.transformer_config, self.submodules) + self.router = cast(Router, self.moe_layer.router) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + @pytest.mark.internal + def test_non_qb_router_has_no_qb_buffers(self): + config = TransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + num_moe_experts=self.num_moe_experts, + use_cpu_initialization=True, + moe_router_load_balancing_type="aux_loss", + moe_router_topk=2, + moe_aux_loss_coeff=0, + bf16=True, + params_dtype=torch.bfloat16, + add_bias_linear=False, + ) + router = MoELayer(config, self.submodules).router + assert router.qb_beta is None + assert router.qb_beta_accum is None + assert router.qb_beta_count is None + + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.parametrize("moe_router_pre_softmax", [True, False]) + @pytest.mark.parametrize("score_function", ["softmax", "sigmoid"]) + def test_qb_router_forward(self, score_function, moe_router_pre_softmax): + self.router = self.router.cuda() + self.router.config.moe_router_score_function = score_function + self.router.score_function = score_function + self.router.config.moe_router_pre_softmax = moe_router_pre_softmax + + num_tokens = 32 * 2 + hidden_states = torch.randn((32, 2, self.router.config.hidden_size)).cuda().bfloat16() + with torch.no_grad(): + probs, routing_map = self.router(hidden_states) + + assert probs.shape == (num_tokens, self.num_moe_experts) + assert routing_map.shape == (num_tokens, self.num_moe_experts) + # Each token selects exactly topk distinct experts. + assert routing_map.sum().item() == num_tokens * self.router.topk + + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + def test_qb_beta_accumulates_in_training(self): + self.router = self.router.cuda() + self.router.train() + hidden_states = torch.randn((32, 2, self.router.config.hidden_size)).cuda().bfloat16() + + assert self.router.qb_beta_count.item() == 0 + self.router(hidden_states) + assert self.router.qb_beta_count.item() == 1 + assert self.router.qb_beta_accum.abs().sum().item() > 0 + self.router(hidden_states) + assert self.router.qb_beta_count.item() == 2 + + # No accumulation outside the training path (eval / recompute). + accum_before = self.router.qb_beta_accum.clone() + with torch.no_grad(): + self.router(hidden_states) + assert self.router.qb_beta_count.item() == 2 + torch.testing.assert_close(self.router.qb_beta_accum, accum_before) + + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + def test_qb_router_rejects_padding_mask(self): + self.router = self.router.cuda() + hidden_states = torch.randn((32, 2, self.router.config.hidden_size)).cuda().bfloat16() + padding_mask = torch.zeros((32, 2), dtype=torch.bool, device=hidden_states.device) + padding_mask[-2:] = True + + with pytest.raises(AssertionError, match="does not support padding masks"): + self.router(hidden_states, padding_mask=padding_mask) diff --git a/tests/unit_tests/transformer/moe/test_routers.py b/tests/unit_tests/transformer/moe/test_routers.py index 9f33dd01920..b215b59cfa0 100644 --- a/tests/unit_tests/transformer/moe/test_routers.py +++ b/tests/unit_tests/transformer/moe/test_routers.py @@ -8,7 +8,11 @@ from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_submodules from megatron.core.transformer.moe.moe_layer import MoELayer, MoESubmodules -from megatron.core.transformer.moe.moe_utils import get_updated_expert_bias, router_gating_linear +from megatron.core.transformer.moe.moe_utils import ( + get_updated_expert_bias, + router_gating_linear, + topk_routing_with_score_function, +) from megatron.core.transformer.moe.router import Router from megatron.core.transformer.spec_utils import get_submodules from megatron.core.transformer.transformer_config import TransformerConfig @@ -574,3 +578,40 @@ def test_router_gating_linear_bias(router_dtype): assert torch.allclose(inp.grad, ref_inp.grad, **tols) assert torch.allclose(weight.grad, ref_weight.grad, **tols) assert torch.allclose(bias.grad, ref_bias.grad, **tols) + + +@pytest.mark.internal +@pytest.mark.parametrize("score_function", ["softmax", "sigmoid", "sqrtsoftplus"]) +@pytest.mark.parametrize("use_pre_softmax", [True, False]) +@pytest.mark.parametrize("topk", [1, 2]) +def test_topk_routing_precomputed_indices_equivalence(score_function, use_pre_softmax, topk): + """Passing precomputed_indices that match the function's own selection must reproduce + the standard output. Guards the shared post-top-k path reused by quantile balancing.""" + if score_function != "softmax" and use_pre_softmax: + pytest.skip("pre_softmax only applies to softmax scoring") + + torch.manual_seed(123) + num_tokens, num_experts = 64, 8 + logits = torch.randn(num_tokens, num_experts) + + kwargs = dict(use_pre_softmax=use_pre_softmax, score_function=score_function, fused=False) + probs_ref, map_ref = topk_routing_with_score_function(logits, topk, **kwargs) + _, top_indices = topk_routing_with_score_function(logits, topk, dense_output=True, **kwargs) + probs_pre, map_pre = topk_routing_with_score_function( + logits, topk, precomputed_indices=top_indices, **kwargs + ) + + # Natural top-k indices reproduce the standard output. + assert torch.equal(map_ref, map_pre) + torch.testing.assert_close(probs_ref, probs_pre) + + # Indices that differ from the natural top-k must route to exactly those experts. This + # catches a regression where the precomputed_indices branch is dropped and the function + # silently recomputes its own top-k instead of honoring the caller's indices. Bottom-k is + # disjoint from top-k since 2 * topk <= num_experts. + alt_indices = logits.topk(topk, dim=1, largest=False).indices + _, map_alt = topk_routing_with_score_function( + logits, topk, precomputed_indices=alt_indices, **kwargs + ) + expected_map = torch.zeros_like(logits, dtype=torch.bool).scatter(1, alt_indices, True) + assert torch.equal(map_alt, expected_map) diff --git a/tests/unit_tests/transformer/moe/test_shared_experts.py b/tests/unit_tests/transformer/moe/test_shared_experts.py index c4429220181..8c84aae7097 100644 --- a/tests/unit_tests/transformer/moe/test_shared_experts.py +++ b/tests/unit_tests/transformer/moe/test_shared_experts.py @@ -10,12 +10,12 @@ from megatron.core.models.gpt import moe_module_specs from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_submodules from megatron.core.parallel_state import get_tensor_model_parallel_world_size -from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer.moe import shared_experts as shared_experts_module from megatron.core.transformer.moe.moe_layer import MoELayer, MoESubmodules from megatron.core.transformer.moe.shared_experts import FusedSharedExpertMLP, SharedExpertMLP from megatron.core.transformer.spec_utils import get_submodules from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.training.initialize import _set_random_seed from tests.unit_tests.test_utilities import Utils @@ -353,13 +353,13 @@ def test_shared_expert_forward_backward(self, dispatcher_type: str, tp_size, ep_ tensor_model_parallel_size=tp_size, expert_model_parallel_size=ep_size ) # Create MoE layer with shared expert overlap enabled. - model_parallel_cuda_manual_seed(123) + _set_random_seed(seed_=123, data_parallel_random_init=False) moe_layer_overlap = self.get_moe_layer( moe_shared_expert_overlap=True, moe_token_dispatcher_type=dispatcher_type ).to(dtype=torch.bfloat16) # Create MoE layer with shared expert overlap disabled. - model_parallel_cuda_manual_seed(123) + _set_random_seed(seed_=123, data_parallel_random_init=False) moe_layer_no_overlap = self.get_moe_layer( moe_shared_expert_overlap=False, moe_token_dispatcher_type=dispatcher_type ).to(dtype=torch.bfloat16) From f8c9911b28988a171ad953b4dc3e67ff52b1086e Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 14 Jul 2026 19:15:09 -0700 Subject: [PATCH 023/290] Unset NCCL overrides for MFSDP v2 tests (#5794) Signed-off-by: Jingyue Wu --- tests/unit_tests/distributed/mfsdp_v2/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit_tests/distributed/mfsdp_v2/conftest.py b/tests/unit_tests/distributed/mfsdp_v2/conftest.py index cff48b29fce..0d04b78f624 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/conftest.py +++ b/tests/unit_tests/distributed/mfsdp_v2/conftest.py @@ -21,6 +21,12 @@ class DistributedSetup: @pytest.fixture(scope="function") def distributed_setup() -> Iterator[DistributedSetup]: """Read torchrun rank state and set up this rank's local device.""" + # Some MFSDP v2 tests are sensitive to NCCL algorithm/channel choices. Clear + # CI launcher overrides before init_device_mesh initializes NCCL communicators + # so this bucket uses NCCL settings closer to production. + os.environ.pop("NCCL_MAX_NCHANNELS", None) + os.environ.pop("NCCL_NVLS_ENABLE", None) + if "RANK" not in os.environ or "WORLD_SIZE" not in os.environ: pytest.skip("Not running under torchrun. Use torchrun to run this test file.") From 4bf7fca050ae55ef58e299003fd12d769aac81e0 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 14 Jul 2026 21:27:49 -0700 Subject: [PATCH 024/290] Fix MegatronFSDP root module hook dispatch (#5808) Signed-off-by: Jingyue Wu --- .../fsdp/src/megatron_fsdp/megatron_fsdp.py | 2 +- .../mfsdp_v1/test_mfsdp_fully_shard.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/megatron_fsdp.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/megatron_fsdp.py index 58f4a2d8206..f2df87ff256 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/megatron_fsdp.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/megatron_fsdp.py @@ -1487,7 +1487,7 @@ def forward(self, *inputs, **kwargs): self._replace_param_with_raw_if_needed() with torch.autograd.profiler.record_function("CustomFSDP.forward"): # Call the forward pass of the wrapped module. - output = self.module.forward(*inputs, **kwargs) + output = self.module(*inputs, **kwargs) return output diff --git a/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py index 2bc198695b0..7b3a8fd9c9a 100644 --- a/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py @@ -136,6 +136,23 @@ def forward(self, x, y): return x +class RootParamModel(torch.nn.Module): + """Toy model with parameters owned directly by the root module.""" + + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.empty(DIM_SIZE, DIM_SIZE)) + self.bias = torch.nn.Parameter(torch.empty(DIM_SIZE)) + self.reset_parameters() + + def reset_parameters(self): + torch.nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5)) + torch.nn.init.zeros_(self.bias) + + def forward(self, x): + return torch.nn.functional.linear(x, self.weight, self.bias) + + class ToyTETransformer(torch.nn.Module): """Toy Transformer model for testing Megatron-FSDP with Transformer Engine.""" @@ -731,6 +748,33 @@ def test_fully_shard_ez(self, shard_strategy): optimizer.step() optimizer.zero_grad() + def test_root_module_forward_uses_gathered_parameters(self): + """ + Test that root-owned parameters are gathered before the root forward. + """ + + model = RootParamModel().cuda() + with torch.no_grad(): + model.weight.copy_( + torch.arange(DIM_SIZE * DIM_SIZE, dtype=torch.float32, device="cuda").view( + DIM_SIZE, DIM_SIZE + ) + ) + model.bias.copy_(torch.arange(DIM_SIZE, dtype=torch.float32, device="cuda")) + + model_input = torch.arange(DIM_SIZE * DIM_SIZE, dtype=torch.float32, device="cuda").view( + DIM_SIZE, DIM_SIZE + ) + expected_output = model(model_input) + + mfsdp_model = fully_shard_model( + module=model, fsdp_unit_modules=[RootParamModel], zero_dp_strategy=OPTIM_GRADS_PARAMS + ) + + output = mfsdp_model(model_input) + + torch.testing.assert_close(output, expected_output) + @pytest.mark.skipif( version.parse(torch.__version__) < version.parse('2.4.0'), reason="Megatron-FSDP requires PyTorch 2.4.0 or later.", From 834abc19502574cf91365f929762a0a6be0fcdca Mon Sep 17 00:00:00 2001 From: Ajay Date: Tue, 14 Jul 2026 23:01:27 -0700 Subject: [PATCH 025/290] Pin cudnn-fe and cuTeDSL version (#5812) Signed-off-by: Ajay Balasa --- pyproject.toml | 1 + uv.lock | 127 +++++++++++++++++++++---------------------------- 2 files changed, 55 insertions(+), 73 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 470099ca17e..f35e7627ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ dev = [ "megatron-energon[av_decode]~=7.0", "av", "flashinfer-python>=0.5.0,<0.7.0", + "nvidia-cudnn-frontend[cutedsl]==1.26.0", "wget", "onnxscript", "fastapi~=0.50", # Forcing a little bit more recent version of fastapi to be compatible with pydantic 2.0 diff --git a/uv.lock b/uv.lock index 2c39a56ab86..d9b0e8c417c 100644 --- a/uv.lock +++ b/uv.lock @@ -2194,6 +2194,7 @@ dev = [ { name = "hypercorn" }, { name = "megatron-energon", extra = ["av-decode"] }, { name = "multi-storage-client" }, + { name = "nvidia-cudnn-frontend", extra = ["cutedsl"] }, { name = "nvidia-modelopt", marker = "sys_platform != 'darwin'" }, { name = "nvidia-resiliency-ext" }, { name = "onnxscript" }, @@ -2303,6 +2304,7 @@ requires-dist = [ { name = "megatron-energon", extras = ["av-decode"], marker = "extra == 'dev'", specifier = "~=7.0" }, { name = "multi-storage-client", marker = "extra == 'dev'", specifier = "~=0.50" }, { name = "numpy" }, + { name = "nvidia-cudnn-frontend", extras = ["cutedsl"], marker = "extra == 'dev'", specifier = "==1.26.0" }, { name = "nvidia-modelopt", extras = ["torch"], marker = "sys_platform != 'darwin' and extra == 'dev'", specifier = ">=0.44" }, { name = "nvidia-resiliency-ext", marker = "extra == 'dev'", specifier = "==0.6.0" }, { name = "omegaconf", marker = "extra == 'mlm'" }, @@ -2829,105 +2831,84 @@ wheels = [ ] [[package]] -name = "nvidia-cuda-nvdisasm" -version = "13.3.73" +name = "nvidia-cudnn-frontend" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/be/e9de501cb71b10f7654381a485fa4ebf470ea25c3dce018cccaecf8a8f9a/nvidia_cuda_nvdisasm-13.3.73-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dd4751884f9016b9b6dbf007abdeb5681d0a2edc731dd3d2fda9d6d878e88f73", size = 4744517, upload-time = "2026-06-29T16:48:33.527Z" }, - { url = "https://files.pythonhosted.org/packages/86/3e/88460ebd737e559e8e9843db7a63f8ced9ec7be1882344438819dd13aebc/nvidia_cuda_nvdisasm-13.3.73-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa17084b07c0dca68a42892f771b4b1b40fbe9b91660209623e61cea611cae8c", size = 4782824, upload-time = "2026-06-29T16:49:05.109Z" }, - { url = "https://files.pythonhosted.org/packages/32/63/00d687730b124f94345f83023363251310ccc08eeac269ec2eeff6b097f3/nvidia_cuda_nvdisasm-13.3.73-py3-none-win_amd64.whl", hash = "sha256:da2fab133c3d095d83f13587eb87149beabd199b34a3cc270d0aa99449a628c3", size = 5015368, upload-time = "2026-06-29T17:22:50.207Z" }, + { url = "https://files.pythonhosted.org/packages/19/c4/3f587b73ac2eb6e391aebffb7a7a9ac9ed70e1e0e6a1d90ec0fdcb1a516f/nvidia_cudnn_frontend-1.26.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee50df3468f672aa31402fde4c911ad545d08e1d5e133e15bc55c3679d9fd9ca", size = 3471242, upload-time = "2026-07-07T20:55:30.929Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/64818fbaa117456349241b42ddfaef3ae6b050f5e99bb0e9d78eb831279b/nvidia_cudnn_frontend-1.26.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a1223c4e2e8bbe6f620148d6848f4eb773dd94bef534d5b748e91232b5be618", size = 3627033, upload-time = "2026-07-07T20:55:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/f9/99/04a37f34b271ed157c6108c3191bd1993ab3fad8d5d66516970bd1e7c12d/nvidia_cudnn_frontend-1.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f104a40cb6f25cf01b7d7e84cb99af7dedd32b1cb8264384c2f363b7a3f7fb6", size = 2998730, upload-time = "2026-07-07T20:56:09.848Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/b09fa3625b8ab915ef4925e8704bf189754dad8386208fa22304171b81b9/nvidia_cudnn_frontend-1.26.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fee9922c6be2c1b43cb10162e7cd63ce102b0509c0f028e37ac23a959e761b4", size = 3470545, upload-time = "2026-07-07T20:56:30.992Z" }, + { url = "https://files.pythonhosted.org/packages/48/6d/b85a9b36948c0f176a0ea1e137e60e64ea8dc2b2cefc4560b7070536afdc/nvidia_cudnn_frontend-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf064832e29d74ab5bafa341b1793650496ebfe0c94292ee00b61008ecf9aac4", size = 3626160, upload-time = "2026-07-07T20:56:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/32f6a54f1283952c205680f892bd9d5fce111bb949063133a58c7f5cf2c7/nvidia_cudnn_frontend-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:067e5bd08a1d25391188eb7206f711d88b916a926b929aec3609af3e43dbae0b", size = 2998579, upload-time = "2026-07-07T20:57:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/fb/99/4c1aba5701e3befeeab953b9ebfd3c1396ffb0dd5fbfc5cca1634f3bdb5e/nvidia_cudnn_frontend-1.26.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2ea02bdad5081210830cd577de86bd0ae73836ffeb3485211753621b145e9d3", size = 3473588, upload-time = "2026-07-07T20:57:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/8a9aa4d0c1b6e289262c66db3f9a00d076254b28094bc41e2faab6188671/nvidia_cudnn_frontend-1.26.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1e78e77bb145ec9162f2e166241f4c82ca1779a8a545bc3fe0870813c55d5cc", size = 3626696, upload-time = "2026-07-07T20:57:57.742Z" }, + { url = "https://files.pythonhosted.org/packages/29/78/40ab818ac4f4656d2de0a547643feb1d6d68e7dfe3729642eb2f47841193/nvidia_cudnn_frontend-1.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:1f88e462c6c2cabb08da9b40a5025cbc86274ad3779c421c2a69a405e900278f", size = 2999640, upload-time = "2026-07-07T20:58:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/44/6d/8d2fb9b1933e182db9f98d94c12b3e0765dfe92f40e1870cb19dac046776/nvidia_cudnn_frontend-1.26.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce41d00f7b80171c7e3705f0b519ace6b40a2dc8b372206e120f3930568a9afe", size = 3475859, upload-time = "2026-07-07T20:58:42.302Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/8865ae8a32055d9827a492355ed30b1d8cd38863d13440fc5494fb9cba02/nvidia_cudnn_frontend-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e4ee11bb4d3f912f9f7751f0b88ee08bb04c171d1edf63618fbabf63ae75271", size = 3630207, upload-time = "2026-07-07T20:59:05.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/27eed2c212f6e4fe8f336655fbf3a5345818278d30a9b6efefd8e552d20f/nvidia_cudnn_frontend-1.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:67dd4f7116171567a0e3f1864ecb2933e31997a333bf551ace7ca5e36578387b", size = 3023017, upload-time = "2026-07-07T20:59:25.296Z" }, ] -[[package]] -name = "nvidia-cudnn-frontend" -version = "1.25.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/0f/df39a194f2529093db737d43cc4cbf594c6a79712a09aa104b999e4d95d4/nvidia_cudnn_frontend-1.25.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09e6e1bc48ce1235743f89d8ea699c52b3008fd6dae7f2ecadb744bebf272a2b", size = 3263306, upload-time = "2026-06-10T21:07:48.093Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/3b45941d8a22128b971e910f2e9af6bf5ef453e92cc329c56b6eb53c53de/nvidia_cudnn_frontend-1.25.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a94a72d736bd79eb35f451aaf26d9493778e02ecabccc92c05425508c9e7a83", size = 3414884, upload-time = "2026-06-10T21:08:08.603Z" }, - { url = "https://files.pythonhosted.org/packages/2e/45/69517e8f028573a150e82b71205c920e78ebbe83ff0d073eaeee2ada18dc/nvidia_cudnn_frontend-1.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1bfdc795a8bda570ca80ef2287e83f00974857a9a086c1653d2a28099496fee", size = 2798190, upload-time = "2026-06-10T21:08:30.506Z" }, - { url = "https://files.pythonhosted.org/packages/0e/37/ea07ff3578cb3cc847fbed4e7eb84bf02afa81bed437dc0a5e8b7f040c9f/nvidia_cudnn_frontend-1.25.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8a223ef2e821bbe89fdc6a461cbce36b509e2daaa0a4425eb387060e0cc6ec7", size = 3263340, upload-time = "2026-06-10T21:08:53.528Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ff/072bc1ed5e6d7efaf785498e6610a0b2152603c1e020faa6e6b8761d4505/nvidia_cudnn_frontend-1.25.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ab349dfc871a4e7e5b7ceaed649a22b968ce02bca9be1d0a0a577e80fa6832", size = 3414303, upload-time = "2026-06-10T21:09:15.951Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7f/098f9d195f31e2d5ed5da91aa5dea693b582c70a8db0829ba9128a5f0cb2/nvidia_cudnn_frontend-1.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:18cd90ff8429bc65888d7aeb0f9763a237667be652d286be7878ed0dc83216dd", size = 2798191, upload-time = "2026-06-10T21:09:39.559Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/36d8027a6387a9db29f597bdd4070d0a277978331039d563fba104fc53a9/nvidia_cudnn_frontend-1.25.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88edbf97e49d3b303af60c19e147a87bd0c34a7cdb14462009d488b7aa4bac41", size = 3265214, upload-time = "2026-06-10T21:10:01.283Z" }, - { url = "https://files.pythonhosted.org/packages/3a/97/5b41722547c894511bcb2064065534ab5e14404b673aff6a33098c12e223/nvidia_cudnn_frontend-1.25.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ddfcff04af7193af05be8e8ef144bc62c99f9f78d7ddbe4e1977c52769c872f", size = 3414357, upload-time = "2026-06-10T21:10:21.76Z" }, - { url = "https://files.pythonhosted.org/packages/f3/24/0abdc3c6247e3691365200e3592dea1631baa1746944ad4d9aed5ed25411/nvidia_cudnn_frontend-1.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:1cbbee1343b1c4037ea08058d16301e45e033916ce8908f583cef5f0905caf9d", size = 2799220, upload-time = "2026-06-10T21:10:42.824Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ca/1a6fcdd672f27de3c0b14f5e19c29d2dabce09bfbe283df2fd04fed13a48/nvidia_cudnn_frontend-1.25.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5169603a011772b09d27f7652fb1c03e8ffe236e871c79cd6a3f63e74160aad7", size = 3269600, upload-time = "2026-06-10T21:11:04.691Z" }, - { url = "https://files.pythonhosted.org/packages/87/76/aa46edfa4a4ec8f90549340ff3738337691ff7c7064eb68c8b0821e62291/nvidia_cudnn_frontend-1.25.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47b6df3bae7eef03040a62b38e7f8dc2323b16af5437729127488375d7fbba9", size = 3419590, upload-time = "2026-06-10T21:11:27.831Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f0/d38cd0f8bd2412088ef3eb62e473a98d1d3baf7d4f0784f7645500bb4b5a/nvidia_cudnn_frontend-1.25.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85852265fca494909467900a2cb739959da43686ce065d516c7cdb6e9add62e5", size = 2823738, upload-time = "2026-06-10T21:11:48.844Z" }, +[package.optional-dependencies] +cutedsl = [ + { name = "apache-tvm-ffi" }, + { name = "cuda-python" }, + { name = "nvidia-cutlass-dsl", extra = ["cu13"] }, + { name = "torch", marker = "sys_platform == 'never'" }, + { name = "torch-c-dlpack-ext" }, ] [[package]] name = "nvidia-cutlass-dsl" -version = "4.6.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nvidia-cutlass-dsl-libs-base" }, - { name = "nvidia-cutlass-dsl-libs-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/1c/fbddb760a0228df87a9e9d1e60b76ecbe6e18035f5853efe0b4563651b2b/nvidia_cutlass_dsl-4.6.0-py3-none-any.whl", hash = "sha256:e3e0e4d8df20d82c8401fa013f4d82021f41daa5fca3d24b55d4a677f2308ca8", size = 10459, upload-time = "2026-07-02T03:23:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/71/a3/46fdf77d373b06bc65a0eda6c921c746985fb3e496c90a09be476291ea80/nvidia_cutlass_dsl-4.5.0-py3-none-any.whl", hash = "sha256:3b051fe02ca69422ab840e64d9865667aba288a3984a7ca4ccd038a82aef1344", size = 10178, upload-time = "2026-05-06T01:17:33.592Z" }, ] -[[package]] -name = "nvidia-cutlass-dsl-libs-base" -version = "4.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cuda-python" }, - { name = "numpy" }, - { name = "nvidia-cuda-nvdisasm" }, - { name = "nvidia-cutlass-dsl-libs-core" }, - { name = "protobuf" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/f8/22653971fcab2a7ed581934f7a2708c9873fa6a8e8eb285422c8eed4ae01/nvidia_cutlass_dsl_libs_base-4.6.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6412572899b1c6d182e516b20f2b0a21874ec88d25234e6040fb2a4381de7a1a", size = 3321728, upload-time = "2026-07-02T03:25:28.888Z" }, - { url = "https://files.pythonhosted.org/packages/ce/38/e91f66739d2f8711d1a2457e68cd86d6fbae307ce66ce270a405d4dc6dc7/nvidia_cutlass_dsl_libs_base-4.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e41cd5db4de4b535c30ae9ca4412b957800a62560019ae91fa51cf3ea89bf254", size = 2824817, upload-time = "2026-07-02T03:25:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5c/0a82b9b2fee054788944d0e7a97b5e63ed0d304969c3b5c4168bed86b71c/nvidia_cutlass_dsl_libs_base-4.6.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7b5f5502cc827039f42789e1e2ac9aef7010f4d26ef4b9d66f4ab082da0bcd2c", size = 3321628, upload-time = "2026-07-02T03:26:10.487Z" }, - { url = "https://files.pythonhosted.org/packages/7f/67/6c21b2d140bbd1ad94a2e22a0e2881457f9e363bdff1b35898a2d7d25aa2/nvidia_cutlass_dsl_libs_base-4.6.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:7f27357b87c5c797344cca073f1dcf00232aef427daf161adb3cd87b043e37c9", size = 2824911, upload-time = "2026-07-02T03:26:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/afc7477620f7898b6e940f0a6faa58cbcd21e33e6d25031a874bc865f347/nvidia_cutlass_dsl_libs_base-4.6.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a0cde09b71670822baf41e9d02e76883f226ac979b1859ca32fdd51cd34720ea", size = 3321812, upload-time = "2026-07-02T03:26:45.151Z" }, - { url = "https://files.pythonhosted.org/packages/81/f3/72b53467741043e45a2d776485753b37bca6a3466d9964a75aaccccd82db/nvidia_cutlass_dsl_libs_base-4.6.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:777e03b7e1d85085196eaa1512cbc13b7a552ed8b5755e3893e547ca84eaacb7", size = 2824936, upload-time = "2026-07-02T03:27:07.387Z" }, - { url = "https://files.pythonhosted.org/packages/d5/20/c3ed8187e6a69326fc492d98bec31648217f3c6fc74180ae18f9535e8c68/nvidia_cutlass_dsl_libs_base-4.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:87d323cef2c439601f3bcf50a63a9608de322f25adb2ad2a29ea9696657d0e8e", size = 3329994, upload-time = "2026-07-02T03:27:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/bd/4e/69dc9f38b3c9d4ba93e1a71c8b02a29ac1e88f20c57d6b85e2d3e80778bd/nvidia_cutlass_dsl_libs_base-4.6.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:163ed08ea2bbe206e96661ff064e31ece3955e077ed8dd3fb206271333db1610", size = 2837583, upload-time = "2026-07-02T03:27:51.38Z" }, +[package.optional-dependencies] +cu13 = [ + { name = "nvidia-cutlass-dsl-libs-cu13" }, ] [[package]] -name = "nvidia-cutlass-dsl-libs-core" -version = "4.6.0" +name = "nvidia-cutlass-dsl-libs-base" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-python" }, { name = "numpy" }, - { name = "nvidia-cuda-nvdisasm" }, - { name = "protobuf" }, { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/84/94/e4e2404ac06a477096ccf8127bf5d391510d36cafb4be86c8c15b4873b0d/nvidia_cutlass_dsl_libs_core-4.6.0-py3-none-any.whl", hash = "sha256:f9ea6d313a03cb11fa177da32e8747ad0cac51358850810f36aa6c4736192c27", size = 767713, upload-time = "2026-07-02T03:23:39.876Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d0/924048cfa43e1cb546735cb332b05a4fb92c63c1a1ac566f06445f9eca58/nvidia_cutlass_dsl_libs_base-4.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f7c133d31fa82ae7db697fd6943a5f9a2c97c8a40ee1056c67ef29fe00974d8", size = 75630723, upload-time = "2026-05-06T01:24:49.842Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8b/2c187400d85f7d2acb328f20499b7b05745dca8485cf6ad247d5f2b434cf/nvidia_cutlass_dsl_libs_base-4.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bd18322d9247f8c033a10ed4e519c4985ca6b4fb578ade382e5a264422ebd915", size = 74505487, upload-time = "2026-05-06T01:26:52.755Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2c/21d5fc62e030a43c0f1a3dab6749fb632026a27d6a60f59975cd29a5d165/nvidia_cutlass_dsl_libs_base-4.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:90a4d802a03963fa36eb287fbc9b40a1374590fc7e8cc1b9673dee8872f75713", size = 75632646, upload-time = "2026-05-06T01:24:19.623Z" }, + { url = "https://files.pythonhosted.org/packages/1c/79/0dca3b465711ffb4c44b4252940cc5f51d2d4905e405707e5c6c2a83d3d6/nvidia_cutlass_dsl_libs_base-4.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8e58b016da5bb09bd1d809d0c025433edb36b279adfbcd107e96361b214bd8bc", size = 74505936, upload-time = "2026-05-06T01:25:52.728Z" }, + { url = "https://files.pythonhosted.org/packages/59/85/2799e4de2fe7070cc4126ac501443d1cd7796b07ed880118e31956ae266a/nvidia_cutlass_dsl_libs_base-4.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2e121b20f0a48122c9b48227d00a7d681189e1de2fd4d211f9661a4e1658f066", size = 75634241, upload-time = "2026-05-06T01:22:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/04/c6/5aaa2dff6dfc615a83687df4462a91dad2ac1af85d6a9c91d9a6b9760a02/nvidia_cutlass_dsl_libs_base-4.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0a60dfce3349984315306ef719ed1edf0e225527158f26019a5cf266e06cc45d", size = 74504851, upload-time = "2026-05-06T01:23:48.613Z" }, ] [[package]] -name = "nvidia-cutlass-dsl-libs-cu12" -version = "4.6.0" +name = "nvidia-cutlass-dsl-libs-cu13" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-python" }, { name = "numpy" }, - { name = "nvidia-cuda-nvdisasm" }, { name = "nvidia-cutlass-dsl-libs-base" }, - { name = "protobuf" }, { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/11/38/62def848b65bf067f434df7680c7e8c48519b25bbd3f03f9cdff3606353b/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:87f132ccc30946949868989f3b1b1adaa714ccdf5c636e5379b54909cc29576c", size = 86992102, upload-time = "2026-07-02T03:29:41.47Z" }, - { url = "https://files.pythonhosted.org/packages/bf/64/f3f8962a9b91dd9368b90e23b2ac81614d6e9df72b55365ec0c216c3f8f9/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:abc341ff0fce40ed0bdadf160f6afac07fb9d01768d4daebd1628c330b3e4210", size = 88436835, upload-time = "2026-07-02T03:30:13.676Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/773b79f50ca59ca878a5e6be53d7f407deeef56f0b8000bb8cddc2b66d9e/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:45b72e41d343f6b0c1a98669719e03dca4769d7285ae85b7d2a5292168fc73ec", size = 86992268, upload-time = "2026-07-02T03:30:47.112Z" }, - { url = "https://files.pythonhosted.org/packages/07/c1/2521ce3d3f46731d0563bf7e5e0fa6b6ea42c31bcb6763cf4bde16d7b3ce/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:22028842dd9c6064a3de7756b301650be77ddebf6f2acd5336bfbcd05aaf4c02", size = 88437265, upload-time = "2026-07-02T03:31:07.225Z" }, - { url = "https://files.pythonhosted.org/packages/11/35/81556560c4e01c0dbb0746b63529d4513db2e9b0e5f74f641580e3674fda/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:af1428709b6cf37b8aed62f065a708acbd51eb2e68b5828bb8b61cd674ce57ed", size = 86992479, upload-time = "2026-07-02T03:31:33.866Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/aef0960124c15d7b615e6b0fbd382a6b9650cd4bfb0a1a9eaf2c112fb511/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:18f2aa81d5eeeadcde520b07ce369dc947abf3e29d6655ace9bdedcafb57ac8b", size = 88437698, upload-time = "2026-07-02T03:31:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/68/91/9dc39b2f65715ca47d8c084364650be8fbeeac63affba01d5e1d16ff5a77/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f1dfb31449fe0a24131c53b9d4f957ff6bf3e52ea8709473b0972bf030929e6b", size = 87007515, upload-time = "2026-07-02T03:32:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/17/c3/425b2d64c1da0a1a6017e98d3d2aa69145bf77cf3a095aaa45e065abb4ba/nvidia_cutlass_dsl_libs_cu12-4.6.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e8dabef7651ce49aa9659f4500a8b43c73325ac08ca1d88d75f383ae8711664d", size = 88455596, upload-time = "2026-07-02T03:32:38.656Z" }, + { url = "https://files.pythonhosted.org/packages/a1/bd/36767220a6ecd4284496708976017ef85053563971a13b95aa8b84d316d7/nvidia_cutlass_dsl_libs_cu13-4.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a799eb9d65ba03095444907b8bf617b5a8bd7d03d1b95cec9637c558af6d0b60", size = 79118756, upload-time = "2026-05-06T01:18:58.118Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f2/2c976759dff8836e41a8ff3716db4fc72b01969ea6ee062ae22877008030/nvidia_cutlass_dsl_libs_cu13-4.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fc0b5a81ff591db72489134ca206ae886f0cce43f20863010a7f30fcfe484a7b", size = 78787917, upload-time = "2026-05-06T06:58:30.829Z" }, + { url = "https://files.pythonhosted.org/packages/e2/41/f8817a6ef5c93ae1009f7e6abe4e205f0014ae51faf8d0aa5817d7bf64f5/nvidia_cutlass_dsl_libs_cu13-4.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f2b821671add2e69a1377e7fd87e6261995db281b2f8e516ddae2fd6b7a6c1d0", size = 79119918, upload-time = "2026-05-06T01:19:31.84Z" }, + { url = "https://files.pythonhosted.org/packages/08/48/13386e28bf2b724268d7ac95c41d0c718e91118bf89218b6b0471e5fa595/nvidia_cutlass_dsl_libs_cu13-4.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:554b775069d093f308949a65880bf9c9bfd48b1f4b2fd0e0d97aa2f608e6eea1", size = 78788617, upload-time = "2026-05-06T01:21:35.563Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/f303a5732763b72cee6f485c0fa1d4972b159770eb869f789a873f98f330/nvidia_cutlass_dsl_libs_cu13-4.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:652e34d8a78accceab321da0232157e493b113e417306269d700a297a7d62447", size = 79118174, upload-time = "2026-05-06T01:18:28.675Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6e/6371065485ec91a75176b6b850a7bee31fd57bcb9045a3e07d7a8fd06c85/nvidia_cutlass_dsl_libs_cu13-4.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3d460d03d6ea0a463e262ee07964ccafffde8eeefe058c9615d77da1ddd6d003", size = 78784876, upload-time = "2026-05-06T01:22:14.041Z" }, ] [[package]] @@ -4182,7 +4163,7 @@ wheels = [ [[package]] name = "quack-kernels" -version = "0.6.1" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-tvm-ffi" }, @@ -4191,9 +4172,9 @@ dependencies = [ { name = "torch", marker = "sys_platform == 'never'" }, { name = "torch-c-dlpack-ext" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/17/890875f88f4d7da28faec9e6cf0a0cc565715b01474e50501fafc5bc71b4/quack_kernels-0.6.1.tar.gz", hash = "sha256:a694f89c91d137478de523c0227365a331ac9cb66790cfb08baa3dbfaafc71e7", size = 387353, upload-time = "2026-07-05T11:50:19.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/58/58b82e91b236539f424ff5681e7095b1f2860ddfb7778fe0be14d8fb58de/quack_kernels-0.4.1.tar.gz", hash = "sha256:9d7d6ba412bc0c8a9b1331c52a73db76280adb9dc2f2750df4851ddabef1466b", size = 274766, upload-time = "2026-04-30T14:37:55.65Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/65/a38a30a6ac96a757363a5be9d09cef799640bb143a64ba5a2f4d400d95d9/quack_kernels-0.6.1-py3-none-any.whl", hash = "sha256:266705ea82117e9b1c8a9e44d68a458519f2498d966c0efffd6812120c3995ad", size = 358439, upload-time = "2026-07-05T11:50:18.502Z" }, + { url = "https://files.pythonhosted.org/packages/38/e4/a6c3bbbe3d4242fa412454b8e8069a079e500be331aecf8f2aa666164e9c/quack_kernels-0.4.1-py3-none-any.whl", hash = "sha256:c1c8df2935bf5156ec47d2c5384ac08b411fd0ee702d80ae916dbf6d6f5ae813", size = 260827, upload-time = "2026-04-30T14:37:54.584Z" }, ] [[package]] @@ -5129,14 +5110,14 @@ version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "setuptools" }, - { name = "sympy" }, + { name = "filelock", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "fsspec", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "jinja2", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "networkx", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "setuptools", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "sympy", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "triton", marker = "sys_platform == 'never'" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] [[package]] From 5abab9387c713e85641e9c4c4d9b8e3b74393899 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 09:37:54 +0000 Subject: [PATCH 026/290] chore: rotate oncall schedule --- .github/oncall_schedule.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/oncall_schedule.json b/.github/oncall_schedule.json index 7ba2c00c095..0d0c4e9f9a4 100644 --- a/.github/oncall_schedule.json +++ b/.github/oncall_schedule.json @@ -1,8 +1,4 @@ [ - { - "user": "cspades", - "date": "2026-07-08" - }, { "user": "dimapihtar", "date": "2026-07-15" @@ -46,5 +42,9 @@ { "user": "cspades", "date": "2026-09-23" + }, + { + "user": "dimapihtar", + "date": "2026-09-30" } ] From 82e9dc69c9e6f8c27681f2cb6856a188187edf6b Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Wed, 15 Jul 2026 03:08:04 -0700 Subject: [PATCH 027/290] Inference: Add the nemotron_v3 reasoning parser (#5634) Signed-off-by: Siddharth Singh --- .../endpoints/chat_completions.py | 14 ++- .../core/tokenizers/text/parsers/__init__.py | 4 + .../parsers/nemotron_v3_reasoning_parser.py | 62 ++++++++++++ .../tokenizers/test_text_parsers.py | 98 +++++++++++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 megatron/core/tokenizers/text/parsers/nemotron_v3_reasoning_parser.py create mode 100644 tests/unit_tests/tokenizers/test_text_parsers.py diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py index bb82c73ada8..97ca9c99093 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py @@ -394,7 +394,9 @@ def _coerce_to_token_id_list(result): bp = Blueprint('chat_completions_api', __name__) - def apply_parsers(message_text, tools, parsers_list, tools_requested): + def apply_parsers( + message_text, tools, parsers_list, tools_requested, chat_template_kwargs=None + ): """Runs CPU-intensive text parsing.""" meta = {} for parser in parsers_list: @@ -402,7 +404,9 @@ def apply_parsers(message_text, tools, parsers_list, tools_requested): raise ValueError(f"Parser {parser} not found in PARSER_MAPPING") prev_text = message_text - parsed_text, new_info = PARSER_MAPPING[parser].parse(message_text, tools=tools) + parsed_text, new_info = PARSER_MAPPING[parser].parse( + message_text, tools=tools, chat_template_kwargs=chat_template_kwargs + ) if "tool_calls" in new_info: new_info["tool_calls"] = _normalize_tool_calls( new_info.get("tool_calls", []), tools=tools @@ -704,7 +708,11 @@ async def chat_completions(): if parsers: message_text, metadata = apply_parsers( - message_text, tools, parsers, tools_requested + message_text, + tools, + parsers, + tools_requested, + chat_template_kwargs=chat_template_kwargs, ) normalized_tool_calls = metadata.get("tool_calls", []) diff --git a/megatron/core/tokenizers/text/parsers/__init__.py b/megatron/core/tokenizers/text/parsers/__init__.py index dc27763f905..d541cb4a74d 100644 --- a/megatron/core/tokenizers/text/parsers/__init__.py +++ b/megatron/core/tokenizers/text/parsers/__init__.py @@ -2,11 +2,15 @@ from megatron.core.tokenizers.text.parsers.deepseek_r1_reasoning_parser import ( DeepSeekR1ReasoningParser, ) +from megatron.core.tokenizers.text.parsers.nemotron_v3_reasoning_parser import ( + NemotronV3ReasoningParser, +) from megatron.core.tokenizers.text.parsers.qwen3_coder_tool_parser import Qwen3CoderToolParser PARSER_MAPPING = { "deepseek-r1-reasoning": DeepSeekR1ReasoningParser, "qwen3-coder-tool": Qwen3CoderToolParser, + "nemotron-v3-reasoning": NemotronV3ReasoningParser, } __all__ = ["PARSER_MAPPING"] diff --git a/megatron/core/tokenizers/text/parsers/nemotron_v3_reasoning_parser.py b/megatron/core/tokenizers/text/parsers/nemotron_v3_reasoning_parser.py new file mode 100644 index 00000000000..c5468059240 --- /dev/null +++ b/megatron/core/tokenizers/text/parsers/nemotron_v3_reasoning_parser.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +from megatron.core.tokenizers.text.parsers.deepseek_r1_reasoning_parser import ( + DeepSeekR1ReasoningParser, +) + + +class NemotronV3ReasoningParser(DeepSeekR1ReasoningParser): + """Parser for NVIDIA Nemotron 3 (Super, Ultra) reasoning output. + + Behaves like `DeepSeekR1ReasoningParser`, except when reasoning is disabled + via `enable_thinking=False`, or the caller passes `force_nonempty_content=True`: + in that case, if no content would otherwise be returned (either because + `` never closes, e.g. reasoning exceeded the max length, or because + it closes with nothing following it), the reasoning text is returned as + content instead of being discarded, so callers always get a non-empty + response. + """ + + @staticmethod + def _should_force_content(chat_template_kwargs: "dict | None") -> bool: + """Whether would-be-empty content should be backfilled from reasoning. + + Mirrors vLLM's `SuperV3ReasoningParser._should_force_content`: force + content when reasoning is disabled (`enable_thinking is False`) or the + caller explicitly requests it (`force_nonempty_content is True`). Both + flags are supplied by the client inside `chat_template_kwargs`. + """ + return bool( + chat_template_kwargs + and ( + chat_template_kwargs.get("enable_thinking") is False + or chat_template_kwargs.get("force_nonempty_content") is True + ) + ) + + @staticmethod + def parse(text: str, **kwargs) -> tuple[str, dict[str, str]]: + """Extract reasoning content delimited by `...` tags. + + Delegates the ``/`` split to `DeepSeekR1ReasoningParser`, + then surfaces the reasoning as content (instead of discarding it) when + reasoning was disabled or the caller forced non-empty content. + + Args: + text (str): The text to parse. + chat_template_kwargs (dict, optional): The request's + `chat_template_kwargs`. When it sets `enable_thinking=False` or + `force_nonempty_content=True`, reasoning is surfaced as content + rather than discarded if there would otherwise be no content. + + Returns: + tuple[str, dict[str, str]]: A tuple containing the unprocessed text + and a dictionary with the extracted reasoning content. + """ + content, info = DeepSeekR1ReasoningParser.parse(text, **kwargs) + if ( + content == "" + and info.get("reasoning") + and NemotronV3ReasoningParser._should_force_content(kwargs.get("chat_template_kwargs")) + ): + return info["reasoning"], {} + return content, info diff --git a/tests/unit_tests/tokenizers/test_text_parsers.py b/tests/unit_tests/tokenizers/test_text_parsers.py new file mode 100644 index 00000000000..62ed6797149 --- /dev/null +++ b/tests/unit_tests/tokenizers/test_text_parsers.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Parity tests for the ``/`` reasoning parsers. + +Ground truth for `NemotronV3ReasoningParser` is derived from vLLM's actual +implementation: + +- Base extraction: `BaseThinkingReasoningParser.extract_reasoning` in + `vllm/reasoning/basic_parsers.py` (used unmodified by `DeepSeekR1ReasoningParser` + for non-streaming extraction). Notably `final_content = content or None`, so an + empty string after a closing `` collapses to `None`, same as a missing + closing tag entirely. +- Override: `SuperV3ReasoningParser`/`UltraV3ReasoningParser.extract_reasoning` in + `super_v3_reasoning_parser.py`/`ultra_v3_reasoning_parser.py` (from + huggingface.co/nvidia/NVIDIA-Nemotron-3-{Super,Ultra}-*), which swaps all text + into content when `final_content is None` and either `enable_thinking is False` + or `force_nonempty_content is True`. + +""" + +import pytest + +from megatron.core.tokenizers.text.parsers import PARSER_MAPPING +from megatron.core.tokenizers.text.parsers.deepseek_r1_reasoning_parser import ( + DeepSeekR1ReasoningParser, +) +from megatron.core.tokenizers.text.parsers.nemotron_v3_reasoning_parser import ( + NemotronV3ReasoningParser, +) + +# (text, kwargs, expected_content, expected_info) +# `kwargs` is expanded into `parse(text, **kwargs)`; the override flags reach the +# parser inside `chat_template_kwargs`, exactly as the chat-completions endpoint +# forwards them from the request. +NEMOTRON_V3_CASES = [ + # No chat_template_kwargs override: behaves exactly like DeepSeekR1ReasoningParser. + ("hello", {}, "", {"reasoning": "hello"}), + ("helloworld", {}, "world", {"reasoning": "hello"}), + # Closing tag present but nothing follows it: vLLM's `content or None` treats + # this the same as a missing closing tag, so it is empty here too. + ("hello", {}, "", {"reasoning": "hello"}), + # No `` tag at all: vLLM assumes the whole string is reasoning. + ("just an answer", {}, "", {"reasoning": "just an answer"}), + # enable_thinking=False surfaces would-be-empty content as the reasoning text, + # for both the "unterminated" and "closes with nothing following" cases. + ("hello", {"chat_template_kwargs": {"enable_thinking": False}}, "hello", {}), + ("hello", {"chat_template_kwargs": {"enable_thinking": False}}, "hello", {}), + # force_nonempty_content=True has the same effect as enable_thinking=False. + ( + "hello", + {"chat_template_kwargs": {"force_nonempty_content": True}}, + "hello", + {}, + ), + ("hello", {"chat_template_kwargs": {"force_nonempty_content": True}}, "hello", {}), + # The override only fires when there would otherwise be no content. + ( + "helloworld", + {"chat_template_kwargs": {"enable_thinking": False}}, + "world", + {"reasoning": "hello"}, + ), + # Text preceding `` is discarded, override still applies past it. + ( + "prefixhello", + {"chat_template_kwargs": {"enable_thinking": False}}, + "hello", + {}, + ), + # enable_thinking=True (or omitted) must not trigger the override. + ( + "hello", + {"chat_template_kwargs": {"enable_thinking": True}}, + "", + {"reasoning": "hello"}, + ), +] + + +@pytest.mark.parametrize("text,kwargs,expected_content,expected_info", NEMOTRON_V3_CASES) +def test_nemotron_v3_reasoning_parser_matches_vllm(text, kwargs, expected_content, expected_info): + content, info = NemotronV3ReasoningParser.parse(text, **kwargs) + assert content == expected_content + assert info == expected_info + + +@pytest.mark.parametrize( + "text", ["hello", "helloworld", "hello", "just an answer"] +) +def test_nemotron_v3_reasoning_parser_without_override_matches_deepseek_r1(text): + """With no `enable_thinking`/`force_nonempty_content` kwargs, the Nemotron 3 + parser must be observably identical to the DeepSeek R1 parser it extends.""" + assert NemotronV3ReasoningParser.parse(text) == DeepSeekR1ReasoningParser.parse(text) + + +def test_parser_mapping_registers_nemotron_v3_reasoning(): + """Super and Ultra share identical reasoning-extraction logic upstream, so + both models are served by a single consolidated parser and registry key.""" + assert PARSER_MAPPING["nemotron-v3-reasoning"] is NemotronV3ReasoningParser From ccdfa7dccd1094f38e8cb96e6168b77a1c9e19ac Mon Sep 17 00:00:00 2001 From: Jinhang Choi Date: Wed, 15 Jul 2026 09:52:13 -0700 Subject: [PATCH 028/290] Missing moe_router_dtype causes unexpected downcast in ModelOpt example (#5810) Signed-off-by: Jinhang Choi Co-authored-by: Cory Ye <44509866+cspades@users.noreply.github.com> --- examples/post_training/modelopt/quantize.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/post_training/modelopt/quantize.py b/examples/post_training/modelopt/quantize.py index 8c68399dfe6..b4ae90b7b70 100644 --- a/examples/post_training/modelopt/quantize.py +++ b/examples/post_training/modelopt/quantize.py @@ -518,6 +518,11 @@ def forward_backward_step(model, batch): import_kwargs = {"dtype": import_dtype} if "trust_remote_code" in inspect.signature(import_mcore_gpt_from_hf).parameters: import_kwargs.update({"trust_remote_code": args.trust_remote_code}) + if ( + "moe_router_dtype" in inspect.signature(import_mcore_gpt_from_hf).parameters + and getattr(args, "moe_router_dtype", None) + ): + import_kwargs.update({"moe_router_dtype": args.moe_router_dtype}) import_mcore_gpt_from_hf( unwrapped_model, args.pretrained_model_path, workspace_dir, **import_kwargs ) From 5e45ccff530dcddd493ac7d2b6fe501a1fefc09b Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 15 Jul 2026 11:32:18 -0700 Subject: [PATCH 029/290] Avoid FSDP unit terminology in MFSDP v2 (#5793) Signed-off-by: Jingyue Wu Signed-off-by: svcnvidia-nemo-ci Co-authored-by: svcnvidia-nemo-ci --- .../megatron_fsdp/experimental/fully_shard.py | 8 ++++---- .../src/megatron_fsdp/experimental/layout.py | 2 +- .../src/megatron_fsdp/experimental/module.py | 16 +++++++++------- .../src/megatron_fsdp/experimental/placement.py | 2 +- .../distributed/mfsdp_v2/test_context.py | 4 ++-- .../distributed/mfsdp_v2/test_fully_shard.py | 6 +++--- .../mfsdp_v2/test_symmetric_memory.py | 4 ++-- 7 files changed, 22 insertions(+), 20 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py index 0ab256a7ef4..9bcfca26fab 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py @@ -32,13 +32,13 @@ def fully_shard( mixed_precision_policy: MixedPrecisionPolicy | None = None, use_symm_mem: bool = False, ) -> None: - """Shard one module as a per-module FSDP unit. + """Apply FSDP to a module in place. This attaches the FSDP mixin to the original module instance, so parent modules do not need to replace existing child-module references. Args: - module: Module whose currently unowned parameters become this FSDP unit. + module: Module whose currently unowned parameters are managed by FSDP. mesh: Device mesh used for sharding. placements: Parameter, gradient, and optimizer placements. mixed_precision_policy: Optional precision policy. Defaults to FP32 main weights @@ -68,10 +68,10 @@ def fully_shard( @contextmanager def microbatch(module: nn.Module, is_last: bool) -> Iterator[None]: - """Scope experimental FSDP state to one microbatch. + """Scope FSDP state to one microbatch. Args: - module: Module tree whose experimental FSDP roots should use this microbatch state. + module: Module tree whose FSDP roots should use this microbatch state. is_last: Whether forwards in this scope are for the last microbatch. """ contexts: list[FsdpContext] = [] diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/layout.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/layout.py index 11070495e4d..e775e595cc4 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/layout.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/layout.py @@ -88,7 +88,7 @@ def build(cls, shapes: Iterable[Shape], dp_size: int) -> "GlobalLayout": ) chunk_size = math.lcm(chunk_size, row_size) - # chunk_size is the packing unit. Since every tensor row size divides it, + # chunk_size is the packing granularity. Since every tensor row size divides it, # DP shard boundaries that are multiples of chunk_size avoid splitting dim-0 rows. UNASSIGNED_OFFSET = -1 tensor_to_offset: list[int] = [UNASSIGNED_OFFSET] * len(tensor_shapes) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 3c97fda3242..be2c50fe277 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -167,14 +167,14 @@ def context(self) -> FsdpContext: @property def name(self) -> str: - """Return this FSDP unit's name.""" + """Return this FsdpModule's name.""" name = self._name if name is None: raise RuntimeError("FSDP module name has not been initialized.") return name def is_root(self) -> bool: - """Return whether this module is the outermost FSDP unit in its context.""" + """Return whether this module is the outermost FsdpModule in its context.""" return self.context.root_module is self def _register_hooks(self) -> None: @@ -183,7 +183,7 @@ def _register_hooks(self) -> None: module.register_forward_hook(lambda _module, _args, _output: self.post_forward()) module.register_full_backward_pre_hook(lambda _module, _grad_output: self.pre_backward()) # Gradient reduction is parameter-completion based: once every owned - # Parameter has accumulated its grad, this FSDP unit can reduce and + # Parameter has accumulated its grad, this FsdpModule can reduce and # reshard. Module full-backward hooks can fire before that when module # inputs do not require grad. for group in self._parameter_groups: @@ -211,7 +211,7 @@ def pre_forward(self) -> None: self._unshard_parameter_groups(sync_model_weight=True) def _unshard_parameter_groups(self, *, sync_model_weight: bool) -> None: - """Materialize full parameters for this FSDP unit.""" + """Materialize full parameters for this FsdpModule.""" self.context.drain_delayed_releases(target_length=1) allgather_stream = self.context.allgather_stream @@ -256,13 +256,13 @@ def post_backward(self) -> None: torch.cuda.nvtx.range_pop() def release_unsharded_storage(self) -> None: - """Release unsharded storage owned by this FSDP unit.""" + """Release unsharded storage owned by this FsdpModule.""" for group in self._parameter_groups: group.release_unsharded_storage() @property def parameter_groups(self) -> tuple[FsdpParameterGroup, ...]: - """Parameter groups owned by this FSDP unit.""" + """Parameter groups owned by this FsdpModule.""" return self._parameter_groups def _nvtx_label(self, phase: Literal["forward", "backward"]) -> str: @@ -296,7 +296,9 @@ def visit(submodule: nn.Module, submodule_fqn: str) -> None: f"{submodule_fqn}.{local_parameter_name}" if submodule_fqn else local_parameter_name ) if contained_in_parameter_group(parameter): - raise ValueError(f"Parameter {parameter_fqn!r} is already owned by an FSDP unit.") + raise ValueError( + f"Parameter {parameter_fqn!r} is already owned by another FsdpModule." + ) parameters[parameter_fqn] = parameter for child_name, child_module in submodule.named_children(): diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py index 75d3af4368c..6f99ff1a06c 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py @@ -56,7 +56,7 @@ class Partial(Placement): @dataclasses.dataclass(frozen=True) class Flat(Placement): - """Flat per-unit dim-0 sharded local buffer placement.""" + """Flat dim-0 sharded local buffer placement.""" def changed_mesh_axis( diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_context.py b/tests/unit_tests/distributed/mfsdp_v2/test_context.py index 9ffd6bd8cdb..6a99a2d19a8 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_context.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_context.py @@ -27,7 +27,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class MultiChildModel(nn.Module): - """Model with direct parameters and multiple child FSDP units.""" + """Model with direct parameters and multiple child FsdpModules.""" def __init__(self, dim: int, num_children: int) -> None: super().__init__() @@ -47,7 +47,7 @@ def _flat_placements() -> Placements: def test_child_then_parent_share_one_context(distributed_setup): - """A parent FSDP unit should lazily create one context for its subtree.""" + """A parent FsdpModule should lazily create one context for its subtree.""" device = distributed_setup.device mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index ff892ec74ee..20686b0e57d 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -23,7 +23,7 @@ class TinyModel(nn.Module): - """Small model with two separately shardable units.""" + """Small model with two separately shardable modules.""" def __init__(self) -> None: super().__init__() @@ -50,7 +50,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class MultiChildModel(nn.Module): - """Model with direct parameters and multiple child FSDP units.""" + """Model with direct parameters and multiple child FsdpModules.""" def __init__(self, dim: int, num_children: int) -> None: super().__init__() @@ -169,7 +169,7 @@ def train(model, optimizer, log_prefix) -> list[torch.Tensor]: def test_nested_fully_shard_excludes_child_owned_parameters(distributed_setup): - """An outer FSDP unit owns direct parameters but not nested child-unit parameters.""" + """An outer FsdpModule owns direct parameters but not nested child FsdpModule parameters.""" world_size = distributed_setup.world_size device = distributed_setup.device if world_size < 2: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py index 5e84ad77c75..2d3ecafd048 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py @@ -18,13 +18,13 @@ # Each sharded Linear's collective must be large enough that NCCL selects its # symmetric-memory (ncclSymk*) kernels over ring. Sub-KB collectives fall back to # ring on some platforms (e.g. CI with NCCL_NVLS_ENABLE=0), which would make the -# symmetric-kernel assertions below fail; 1024-wide units (a few-MiB bf16 weight) +# symmetric-kernel assertions below fail; 1024-wide layers (a few-MiB bf16 weight) # reliably engage the symmetric kernels. _HIDDEN = 1024 class TinyModel(nn.Module): - """Two separately shardable units, sized so NCCL selects symmetric-memory kernels.""" + """Two separately shardable Linear modules, sized so NCCL selects symmetric-memory kernels.""" def __init__(self) -> None: super().__init__() From 7711346974fcbcbe0bf7edd8bdf2d25e2b7d933e Mon Sep 17 00:00:00 2001 From: Shanhao Date: Thu, 16 Jul 2026 02:41:24 +0800 Subject: [PATCH 030/290] Fix configured norm epsilon in MambaLayer (#5750) Signed-off-by: Shanhao <5197744+shanhaoli@users.noreply.github.com> --- megatron/core/ssm/mamba_layer.py | 16 +++++++--------- tests/unit_tests/ssm/test_mamba_layer.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/megatron/core/ssm/mamba_layer.py b/megatron/core/ssm/mamba_layer.py index d3b04e59c29..68d41c56a31 100644 --- a/megatron/core/ssm/mamba_layer.py +++ b/megatron/core/ssm/mamba_layer.py @@ -6,7 +6,7 @@ # LICENSE file in the root directory of this source tree. from dataclasses import dataclass, field -from typing import Dict, Optional, Protocol, Tuple, Union +from typing import Dict, Optional, Tuple, Union import torch from torch import Tensor @@ -21,18 +21,12 @@ from megatron.core.transformer.identity_op import IdentityOp from megatron.core.transformer.module import GraphableMegatronModule from megatron.core.transformer.spec_utils import ModuleSpec, build_module -from megatron.core.transformer.torch_norm import LayerNormInterface +from megatron.core.transformer.torch_norm import LayerNormBuilder from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.typed_torch import apply_module from megatron.core.utils import deprecate_inference_params -class LayerNormBuilder(Protocol): - """A protocol showing how MambaLayer expects to construct its LayerNorm.""" - - def __call__(self, config: TransformerConfig, hidden_size: int, /) -> LayerNormInterface: ... - - @dataclass class MambaLayerSubmodules: """ @@ -96,7 +90,11 @@ def __init__( pp_layer_offset=pp_layer_offset, name=(name + f".mixer") if name is not None else None, ) - self.norm = submodules.norm(self.config, self.config.hidden_size) + self.norm = submodules.norm( + config=self.config, + hidden_size=self.config.hidden_size, + eps=self.config.layernorm_epsilon, + ) self.mamba_bda = build_module(submodules.mamba_bda) self.bias_dropout_add_exec_handler = torch.enable_grad diff --git a/tests/unit_tests/ssm/test_mamba_layer.py b/tests/unit_tests/ssm/test_mamba_layer.py index 8d6e0ab8c91..aad23ce02d6 100644 --- a/tests/unit_tests/ssm/test_mamba_layer.py +++ b/tests/unit_tests/ssm/test_mamba_layer.py @@ -1,5 +1,7 @@ # Copyright (c) 2024-2026, NVIDIA CORPORATION. All rights reserved. +from dataclasses import replace + import pytest import torch @@ -9,6 +11,7 @@ from megatron.core.ssm.mamba_layer import MambaLayer, MambaLayerSubmodules from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer import TransformerConfig +from megatron.core.transformer.torch_norm import WrappedTorchNorm from tests.unit_tests.test_utilities import Utils @@ -24,20 +27,24 @@ def setup_method(self, method): # will generate errors. num_layers=1, num_attention_heads=1, + layernorm_epsilon=1e-6, use_cpu_initialization=True, ) assert isinstance(hybrid_stack_spec.submodules, HybridStackSubmodules) assert isinstance(hybrid_stack_spec.submodules.mamba_layer.submodules, MambaLayerSubmodules) - pg_collection = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp']) - self.layer = MambaLayer( - transformer_config, - hybrid_stack_spec.submodules.mamba_layer.submodules, - pg_collection=pg_collection, + # Use an explicit norm so the test can verify the configured epsilon. + mamba_submodules = replace( + hybrid_stack_spec.submodules.mamba_layer.submodules, norm=WrappedTorchNorm ) + pg_collection = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp']) + self.layer = MambaLayer(transformer_config, mamba_submodules, pg_collection=pg_collection) def teardown_method(self, method): Utils.destroy_model_parallel() + def test_configured_layernorm_epsilon(self): + assert self.layer.norm.eps == self.layer.config.layernorm_epsilon + def test_gpu_forward(self): layer = self.layer layer.cuda() From ffbe018c8e461137a7de86e8bea5b8da5b296d0e Mon Sep 17 00:00:00 2001 From: Yan Xu <45385219+Connor-XY@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:42:22 -0700 Subject: [PATCH 031/290] [refactor] Common combined-1F1B schedule-plan base (1/4 of #4798) (#4941) Signed-off-by: Yan Xu Co-authored-by: Claude Opus 4.7 (1M context) --- .../models/common/fine_grained_callables.py | 169 +++++ .../common/model_chunk_schedule_plan.py | 126 ++-- megatron/core/models/common/utils.py | 436 +++++++++++++ .../core/models/gpt/fine_grained_callables.py | 597 +----------------- .../core/pipeline_parallel/combined_1f1b.py | 6 - megatron/core/transformer/module.py | 16 +- .../test_cuda_graphed_schedule_chunk_1f1b.py | 34 +- .../transformer/test_submodule_callables.py | 71 ++- 8 files changed, 798 insertions(+), 657 deletions(-) create mode 100644 megatron/core/models/common/fine_grained_callables.py create mode 100644 megatron/core/models/common/utils.py diff --git a/megatron/core/models/common/fine_grained_callables.py b/megatron/core/models/common/fine_grained_callables.py new file mode 100644 index 00000000000..8f46711d553 --- /dev/null +++ b/megatron/core/models/common/fine_grained_callables.py @@ -0,0 +1,169 @@ +# Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Layer-callable builders for the combined-1F1B fine-grained schedule plan. + +These build_* functions assemble the per-layer ``(forward_funcs, backward_dw)`` +tuple that the schedule plan plugs into ``TransformerLayerNode``. + +The TransformerLayer-specific builder lives in ``gpt/fine_grained_callables.py`` +because it depends on GPT's MoE wiring; the MTP builder and the dispatcher +``build_layer_callables`` are model-agnostic — both GPTModel and HybridModel +schedule MTP layers identically — so they live here. +""" + +from contextlib import nullcontext +from functools import partial + +import torch + +from megatron.core import tensor_parallel +from megatron.core.models.gpt.fine_grained_callables import build_transformer_layer_callables +from megatron.core.transformer.moe.moe_layer import MoELayer +from megatron.core.transformer.multi_token_prediction import ( + MultiTokenPredictionLayer, + get_mtp_layer_offset, +) +from megatron.core.transformer.transformer_layer import TransformerLayer, make_viewless_tensor + + +def build_mtp_layer_callables(layer): + """Callables for multi-token prediction layer nodes. + + Wraps the inner ``layer.mtp_model_layer``'s callables with MTP-specific + pre-process (chunk and concat embeddings) and post-process (gather across + depths) steps. + """ + + forward_funcs, backward_dw = build_layer_callables(layer.mtp_model_layer) + is_moe, _ = get_layer_moe_metadata(layer.mtp_model_layer) + (pre_dispatch_forward, dispatch_forward, mlp_forward, combine_forward, _) = forward_funcs + assert is_moe, "MTP layer in a2a overlap only supports MoE layer for now." + + def submodule_mtp_pre_dispatch_forward(node, hidden_states): + # MTP Block Preprocess + if node.is_first_layer: + # Apply the main decoder's final_norm if this VPP chunk owns it but + # holds no main HybridStack layers — without this, ``_maybe_apply_final_norm`` + # never fires for the main path and the unnormalized hidden_states feed + # straight into the LM head (lm_loss explodes by ~10x; grads diverge). + # Restricted to HybridModel because GPT models go through a different + # MTP wiring path. Must run before ``torch.chunk`` so every chunk — + # including the main-decoder slice consumed by the LM head — sees + # the norm; the MTP slices then go through MTP's own ``hnorm`` as usual. + from megatron.core.models.hybrid.hybrid_model import HybridModel + + model = node.chunk_state.model + if isinstance(model, HybridModel) and len(model.decoder.layers) == 0: + final_norm = getattr(model.decoder, "final_norm", None) or getattr( + model.decoder, "final_layernorm", None + ) + if final_norm is not None: + hidden_states = final_norm(hidden_states) + hidden_states = make_viewless_tensor( + inp=hidden_states, requires_grad=True, keep_graph=True + ) + + offset = get_mtp_layer_offset(layer.config, node.chunk_state.model.vp_stage) + node.chunk_state.mtp_hidden_states = list(torch.chunk(hidden_states, 1 + offset, dim=0)) + hidden_states = node.chunk_state.mtp_hidden_states[offset] + + input_ids, position_ids, padding_mask, decoder_input, hidden_states = layer._get_embeddings( + input_ids=node.chunk_state.input_ids, + position_ids=node.chunk_state.position_ids, + embedding=node.chunk_state.model.embedding, + hidden_states=hidden_states, + packed_seq_params=node.chunk_state.packed_seq_params, + padding_mask=node.chunk_state.padding_mask, + ) + node.chunk_state.input_ids = input_ids + node.chunk_state.position_ids = position_ids + node.chunk_state.padding_mask = padding_mask + + # MTP Layer Preprocess + # norm, linear projection and transformer + assert ( + node.chunk_state.context is None + ), f"multi token prediction + cross attention is not yet supported." + assert ( + node.chunk_state.packed_seq_params is None + ), f"multi token prediction + sequence packing is not yet supported." + + if layer.config.sequence_parallel: + rng_context = tensor_parallel.get_cuda_rng_tracker().fork() + else: + rng_context = nullcontext() + + # fp8 context is added in 1f1b schedule, so we don't need to add it here + with rng_context: + hidden_states = layer._concat_embeddings(hidden_states, decoder_input) + return pre_dispatch_forward(node, hidden_states) + + def submodule_mtp_postprocess_forward(node, hidden_states): + hidden_states = layer._postprocess(hidden_states) + node.chunk_state.mtp_hidden_states.append(hidden_states) + if node.is_last_layer: + hidden_states = torch.cat(node.chunk_state.mtp_hidden_states, dim=0) + node.chunk_state.mtp_hidden_states = None + return hidden_states + + def rng_context_wrapper(func, *args, **kwargs): + """ + Wrapper to add rng context to submodule callables + """ + if layer.config.sequence_parallel: + rng_context = tensor_parallel.get_cuda_rng_tracker().fork() + else: + rng_context = nullcontext() + with rng_context: + return func(*args, **kwargs) + + # Build forward and backward callable functions. + # pre_dispatch_func already has rng context (rolled into + # submodule_mtp_pre_dispatch_forward), so it does not need to be wrapped. + pre_dispatch_func = submodule_mtp_pre_dispatch_forward + dispatch_func = partial(rng_context_wrapper, dispatch_forward) + mlp_func = partial(rng_context_wrapper, mlp_forward) + combine_func = partial(rng_context_wrapper, combine_forward) + mtp_post_process_func = submodule_mtp_postprocess_forward + + forward_funcs = [ + pre_dispatch_func, + dispatch_func, + mlp_func, + combine_func, + mtp_post_process_func, + ] + pre_dispatch_bwd = backward_dw["pre_dispatch_computation"] + if isinstance(pre_dispatch_bwd, list): + pre_dispatch_bwd.append(layer.eh_proj) + else: + backward_dw["pre_dispatch_computation"] = [pre_dispatch_bwd, layer.eh_proj] + + return forward_funcs, backward_dw + + +def get_layer_moe_metadata(layer): + """Return ``(is_moe, num_local_experts)`` for schedule-node construction.""" + + if isinstance(layer, MultiTokenPredictionLayer): + return get_layer_moe_metadata(layer.mtp_model_layer) + if isinstance(layer, TransformerLayer): + is_moe = isinstance(layer.mlp, MoELayer) + num_local_experts = layer.mlp.num_local_experts if is_moe else None + return is_moe, num_local_experts + + raise ValueError(f"Unsupported layer type: {type(layer)}") + + +def build_layer_callables(layer): + """Dispatch to the appropriate layer-callable builder. + + Returns ``(forward_funcs, backward_dw)``. + """ + + if isinstance(layer, MultiTokenPredictionLayer): + return build_mtp_layer_callables(layer) + if isinstance(layer, TransformerLayer): + return build_transformer_layer_callables(layer) + + raise ValueError(f"Unsupported layer type: {type(layer)}") diff --git a/megatron/core/models/common/model_chunk_schedule_plan.py b/megatron/core/models/common/model_chunk_schedule_plan.py index 8358e05a612..35fa97b3d38 100644 --- a/megatron/core/models/common/model_chunk_schedule_plan.py +++ b/megatron/core/models/common/model_chunk_schedule_plan.py @@ -35,20 +35,23 @@ class TransformerLayerSchedulePlan: mtp post process nodes. layer (TransformerLayerSchedulePlan) - ├── attn (TransformerLayerNode): attention -> layernorm -> router -> dispatch preprocess + ├── pre_dispatch_computation (TransformerLayerNode): + │ attention -> layernorm -> router -> dispatch preprocess ├── moe_dispatch (TransformerLayerNode): dispatch All2All ├── mlp (TransformerLayerNode): mlp module ├── moe_combine (TransformerLayerNode): combine All2All └── mtp_post_process (PostProcessNode): mtp post process Note that MTP layer has the same operation and execution order with TransformerLayer regarding - moe_dispatch, mlp, moe_combine, but contains extra operations in attn and mtp_post_process: - * mtp.attn wraps around transformer_layer.attn with extra norm, proj and embedding operations. + moe_dispatch, mlp, moe_combine, but contains extra operations in + pre_dispatch_computation and mtp_post_process: + * mtp.pre_dispatch_computation wraps around transformer_layer.pre_dispatch_computation with + extra norm, proj and embedding operations. * mtp.mtp_post_process contains output_layer, mtp loss operations, whereas transformer_layer.mtp_post_process is empty. """ - attn = None + pre_dispatch_computation = None moe_dispatch = None mlp = None moe_combine = None @@ -70,10 +73,10 @@ def __init__(self, layer, event, chunk_state, comp_stream, comm_stream, extra_ar The event and chunk_state are binded to the TransformerModelChunkSchedulePlan and shared across all layers in the model chunk. """ - from megatron.core.models.gpt.fine_grained_callables import TransformerLayerState + from megatron.core.models.common.utils import LayerState self.config = layer.config - self.layer_state = TransformerLayerState() + self.layer_state = LayerState() self.chunk_state = chunk_state self.layer = layer self.event = event @@ -85,9 +88,9 @@ def __init__(self, layer, event, chunk_state, comp_stream, comm_stream, extra_ar def release_state(self): """Release reference, this helps avoid memory leak.""" - if hasattr(self, 'attn') and self.attn is not None: - del self.attn - self.attn = None + if hasattr(self, 'pre_dispatch_computation') and self.pre_dispatch_computation is not None: + del self.pre_dispatch_computation + self.pre_dispatch_computation = None if hasattr(self, 'moe_dispatch') and self.moe_dispatch is not None: del self.moe_dispatch self.moe_dispatch = None @@ -109,23 +112,20 @@ def release_state(self): def _build_callable_nodes(self, event, comp_stream, comm_stream, extra_args): """ Builds the callable nodes for the transformer/mtp layer: - attn, mlp, moe_dispatch and moe_combine, and mtp_post_process. + pre_dispatch_computation, moe_dispatch, mlp, moe_combine, + and mtp_post_process. """ - from megatron.core.models.gpt.fine_grained_callables import ( - TransformerLayerNode, + from megatron.core.models.common.fine_grained_callables import ( build_layer_callables, + get_layer_moe_metadata, ) - from megatron.core.transformer.moe.moe_layer import MoELayer + from megatron.core.models.common.utils import TransformerLayerNode from megatron.core.transformer.multi_token_prediction import MultiTokenPredictionLayer - # build the forward and backward callables for the transformer/mtp layer fwd_callables, bwd_dw_callable_map = build_layer_callables(self.layer) + is_moe, num_local_experts = get_layer_moe_metadata(self.layer) - # get flags for latter use is_mtp = isinstance(self.layer, MultiTokenPredictionLayer) - transformer_layer = self.layer.mtp_model_layer if is_mtp else self.layer - is_moe = isinstance(transformer_layer.mlp, MoELayer) - num_local_experts = transformer_layer.mlp.num_local_experts if is_moe else None extra_args["config"] = self.layer.config extra_args["is_moe"] = is_moe @@ -148,7 +148,7 @@ def create_node(stream, module, name): ) ( - attn_module, + pre_dispatch_module, moe_dispatch_module, mlp_module, moe_combine_module, @@ -157,7 +157,9 @@ def create_node(stream, module, name): # Create nodes for different operations in the layer # Each node type has a predefined name that determines its memory strategy - self.attn = create_node(comp_stream, attn_module, "attn") + self.pre_dispatch_computation = create_node( + comp_stream, pre_dispatch_module, "pre_dispatch_computation" + ) self.mlp = create_node(comp_stream, mlp_module, "mlp") if is_moe: self.moe_dispatch = create_node(comm_stream, moe_dispatch_module, "moe_dispatch") @@ -188,21 +190,25 @@ def set_fsdp_reshard_hooks(self, post_forward_hook, post_backward_hook): post_backward_hook: Callable(module) that releases backward-pass params (bwd=True). Typically ``fsdp_wrapper.post_backward_release_module``. """ + from megatron.core.models.hybrid.hybrid_block import HybridStack from megatron.core.transformer.multi_token_prediction import MultiTokenPredictionLayer from megatron.core.transformer.transformer_layer import TransformerLayer - assert isinstance(self.layer, (TransformerLayer, MultiTokenPredictionLayer)), ( + assert isinstance(self.layer, (TransformerLayer, HybridStack, MultiTokenPredictionLayer)), ( f"Megatron FSDP with EP Overlap only supports TransformerLayer, " + f"HybridStack and MultiTokenPredictionLayer, " f"but got {type(self.layer).__name__}." ) - if isinstance(self.layer, TransformerLayer): + if isinstance(self.layer, (TransformerLayer, HybridStack)): hook_module = self.layer else: hook_module = self.layer.mtp_model_layer - # After the last backward op (attn), release backward-pass params. - self.attn.set_post_backward_hook(lambda: post_backward_hook(hook_module)) + # After the last backward op (pre_dispatch_computation), release backward-pass params. + self.pre_dispatch_computation.set_post_backward_hook( + lambda: post_backward_hook(hook_module) + ) # Determine the last node in forward order. if isinstance(self.moe_combine, NoopScheduleNode): @@ -231,12 +237,12 @@ def run(f_layer, b_layer, f_input=None, b_grad=None, is_last_layer_in_bwd=False) """Schedule one-forward-one-backward operations for a single transformer layer. This function interleaves forward and backward operations, overlapping the communications - (dispatch or combine) of one with the computations (att or mlp) of the other + (dispatch or combine) of one with the computations (pre_dispatch or mlp) of the other to maximize parallelism and efficiency. When f_layer and b_layer are not None, forward and backward pass are overlapped as follows: - comm_stream: combine_bwd | dispatch_fwd->dispatch_bwd | combine_fwd - comp_stream: attn_fwd | mlp_bwd->mlp_bwd_dw->mlp_fwd| attn_bwd + comm_stream: combine_bwd | dispatch_fwd->dispatch_bwd | combine_fwd + comp_stream: pre_dispatch_fwd | mlp_bwd->mlp_bwd_dw->mlp_fwd| pre_dispatch_bwd For MTP, mtp_post_process_fwd is executed after the combine_fwd in the comp_stream, and mtp_post_process_bwd is executed before the combine_bwd in the comp_stream. @@ -258,7 +264,7 @@ def run(f_layer, b_layer, f_input=None, b_grad=None, is_last_layer_in_bwd=False) if f_layer is not None: with f_layer.get_fp8_context(): - f_input = f_layer.attn.forward(f_input) + f_input = f_layer.pre_dispatch_computation.forward(f_input) if b_layer is not None: b_grad = b_layer.mlp.backward(b_grad) @@ -272,7 +278,7 @@ def run(f_layer, b_layer, f_input=None, b_grad=None, is_last_layer_in_bwd=False) b_grad = b_layer.moe_dispatch.backward(b_grad) if b_layer is not None and b_layer.config.ep_overlap_early_attn_memory_release: - b_grad = b_layer.attn.backward(b_grad) + b_grad = b_layer.pre_dispatch_computation.backward(b_grad) if f_layer is not None: with f_layer.get_fp8_context(): @@ -283,16 +289,16 @@ def run(f_layer, b_layer, f_input=None, b_grad=None, is_last_layer_in_bwd=False) f_input = f_layer.moe_combine.forward(f_input) if b_layer is not None and not b_layer.config.ep_overlap_early_attn_memory_release: - b_grad = b_layer.attn.backward(b_grad) + b_grad = b_layer.pre_dispatch_computation.backward(b_grad) if f_layer is not None: with f_layer.get_fp8_context(): f_input = f_layer.mtp_post_process.forward(f_input) - # Delay the last attn_dw in backward pass (attn_dw of the first layer) - # for overlapping with the p2p comm + # Delay the last pre_dispatch_computation wgrad in backward pass (wgrad + # of the first layer) for overlapping with the p2p comm. if b_layer is not None and not is_last_layer_in_bwd: - b_layer.attn.backward_dw() + b_layer.pre_dispatch_computation.backward_dw() return f_input, b_grad @@ -310,8 +316,27 @@ class TransformerModelChunkSchedulePlan(AbstractSchedulePlan): │ ├── layer[1]: TransformerLayerSchedulePlan │ └── ... └── post_process: PostProcessNode + + Subclasses can swap the per-layer schedule plan by overriding the + ``LAYER_SCHEDULE_PLAN_CLASS`` class attribute (e.g. HybridStack uses a + layer plan that understands grouped/inferred layer types). They can also + swap the pre/post-process node classes via ``PRE_PROCESS_NODE_CLASS`` / + ``POST_PROCESS_NODE_CLASS`` so each model owns its own embedding / output + layer node implementations. """ + #: The TransformerLayerSchedulePlan-compatible class used to build per-layer + #: schedule plans. Subclasses override this to inject a layer-plan variant. + LAYER_SCHEDULE_PLAN_CLASS = None + + #: Pre/post-process node classes. Defaults below pull in the GPT-side + #: ``PreProcessNode`` / ``PostProcessNode`` (which call ``GPTModel._preprocess`` / + #: ``GPTModel._postprocess``). Subclasses set these to model-specific node + #: classes so the node calls the right model's ``_preprocess`` / + #: ``_postprocess`` methods. + PRE_PROCESS_NODE_CLASS = None + POST_PROCESS_NODE_CLASS = None + def __init__( self, model, @@ -353,7 +378,10 @@ def __init__( Returns: The model chunk schedule plan. """ - from megatron.core.models.gpt.fine_grained_callables import PostProcessNode, PreProcessNode + from megatron.core.models.common.utils import PostProcessNode, PreProcessNode + + pre_process_cls = self.PRE_PROCESS_NODE_CLASS or PreProcessNode + post_process_cls = self.POST_PROCESS_NODE_CLASS or PostProcessNode self._model_chunk_state = ModelChunkState() self._transformer_layers = [] @@ -382,7 +410,7 @@ def __init__( self._model_chunk_state.attention_bias = None # build preprocess - self.pre_process = PreProcessNode( + self.pre_process = pre_process_cls( model, self._model_chunk_state, self._event, get_comp_stream ) @@ -396,20 +424,18 @@ def __init__( # build post process if model.post_process: - self.post_process = PostProcessNode( + self.post_process = post_process_cls( model, self._model_chunk_state, self._event, get_comp_stream ) def _build_layer_schedule_plan(self, module, comp_stream, comm_stream): if module is None: return + plan_cls = self.LAYER_SCHEDULE_PLAN_CLASS or TransformerLayerSchedulePlan num_layers = len(module.layers) for layer_idx in range(num_layers): - extra_args = { - "is_first_layer": layer_idx == 0, - "is_last_layer": layer_idx == num_layers - 1, - } - layer_plan = TransformerLayerSchedulePlan( + extra_args = self._extra_args_for_layer(module, layer_idx, num_layers) + layer_plan = plan_cls( module.layers[layer_idx], self.event, self.state, @@ -419,6 +445,14 @@ def _build_layer_schedule_plan(self, module, comp_stream, comm_stream): ) self._transformer_layers.append(layer_plan) + def _extra_args_for_layer(self, module, layer_idx, num_layers): + """Per-layer ``extra_args`` dict passed to the layer plan constructor. + + Subclasses extend this hook to thread additional metadata (e.g. hybrid + layer-type symbols) without overriding ``_build_layer_schedule_plan``. + """ + return {"is_first_layer": layer_idx == 0, "is_last_layer": layer_idx == num_layers - 1} + @property def event(self): """Gets the CUDA event for synchronization.""" @@ -561,22 +595,22 @@ def run( if f_schedule_plan is not None and post_forward is not None: # post_forward()/send_forward_recv_forward() is running in the communication stream, - # so the p2p comm could be overlapped with the attn backward + # so the p2p comm could be overlapped with the pre_dispatch backward with torch.cuda.stream(get_comm_stream()): f_schedule_plan.wait_current_stream() post_forward(f_input, f_schedule_plan.vp_stage) # post_backward()/send_backward_recv_backward() is running in the computation stream, - # so the p2p comm could be overlapped with the wgrad of attn backward + # so the p2p comm could be overlapped with the wgrad of pre_dispatch backward if b_schedule_plan is not None and post_backward is not None: b_schedule_plan.wait_current_stream() post_backward(b_grad, b_schedule_plan.vp_stage) - # Delay the last attn_dw in backward pass (attn_dw of the first layer) - # for overlapping with the p2p comm + # Delay the last pre_dispatch_computation wgrad in backward pass (wgrad + # of the first layer) for overlapping with the p2p comm. if b_num_layers > 0: assert b_layer is not None - b_layer.attn.backward_dw() + b_layer.pre_dispatch_computation.backward_dw() b_layer.release_state() # post process forward diff --git a/megatron/core/models/common/utils.py b/megatron/core/models/common/utils.py new file mode 100644 index 00000000000..186a0c882fc --- /dev/null +++ b/megatron/core/models/common/utils.py @@ -0,0 +1,436 @@ +# Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Schedule-plan helpers shared by GPTModel and HybridModel. + +These pieces used to live in ``core/models/gpt/fine_grained_callables.py`` and +were imported by ``core/models/common/model_chunk_schedule_plan.py`` and the +hybrid schedule plan via that path. They are model-agnostic in practice — the +``Pre/PostProcessNode`` classes call the model's ``_preprocess`` / +``_postprocess`` methods and don't otherwise care which model implements +them — so they live here now. +""" + +import weakref +from functools import partial +from typing import Callable + +import torch + +from megatron.core.pipeline_parallel.utils import ScheduleNode, make_viewless +from megatron.core.transformer.enums import CudaGraphModule +from megatron.core.transformer.module import GraphableMegatronModule, float16_to_fp32 +from megatron.core.transformer.transformer_layer import TransformerLayer, make_viewless_tensor +from megatron.core.utils import internal_api, nvtx_range_pop, nvtx_range_push + + +def weak_method(method): + """Wrap ``method`` in a weakref-keyed dispatcher to break refcycles. + + ``ScheduleNode`` keeps a reference to the bound forward / backward functions + of every node in the plan; using a strong reference would keep the layer + plan (and the model chunk through it) alive after the iteration completes. + The ``weakref.WeakMethod`` lets the schedule plan be torn down between + iterations without manual ``del`` chains. + """ + method_ref = weakref.WeakMethod(method) + del method + + def wrapped_func(*args, **kwarg): + return method_ref()(*args, **kwarg) + + return wrapped_func + + +@internal_api +def should_free_input(name, is_moe, config, num_local_experts): + """Whether the schedule node named ``name`` can free its input after forward. + + The schedule decomposes a transformer layer into ``pre_dispatch_computation``, + ``moe_dispatch``, ``mlp``, and ``moe_combine`` nodes; the inputs to some of + those nodes are not needed in backward and can be released early to lower + peak activation memory. Dense layers and the ``pre_dispatch_computation`` + node always need their input retained (the attention residual flows through + the post-MLP BDA). + + Args: + name: Schedule node name. + is_moe: True for MoE layers; dense layers always retain inputs. + config: ``TransformerConfig`` for the layer. + num_local_experts: Local expert count on this rank (None for dense). + + Returns: + True iff the named node may free its input after forward. + """ + # For dense layers [pre_dispatch_computation, fake, mlp, fake], the input is needed + # during backward pass + if not is_moe: + return False + enable_deepep = ( + config.moe_token_dispatcher_type == "flex" + and config.moe_flex_dispatcher_backend == "deepep" + ) + enable_hybridep = ( + config.moe_token_dispatcher_type == "flex" + and config.moe_flex_dispatcher_backend == "hybridep" + ) + enable_ncclep = ( + config.moe_token_dispatcher_type == "flex" + and config.moe_flex_dispatcher_backend == "ncclep" + ) + # Define which nodes should free input memory. + # Since we split the computing graph into multiple nodes, we can manually control + # when and how to free the input memory. + # The input and output of A2A are not needed anymore after the forward pass, + # so we can free the input memory after the forward pass. + + # When low precision fp8/4 is enabled, the casted tensors are saved and the + # original bf16 tensors are safe to be freed. + free_mlp = config.fp8 is not None or config.fp4 is not None + if not free_mlp: + # AlltoAll dispatcher with local_num_experts=1, HybridEP, and NCCL EP all use + # identity operation for `dispatch_postprocess`, hence the mlp inputs will be + # directly passed to GroupedGemm and should be saved for backward pass. + free_mlp = num_local_experts > 1 or config.moe_token_dispatcher_type != "alltoall" + free_mlp = free_mlp and not (enable_hybridep or enable_ncclep) + + free_input_nodes = { + "mlp": free_mlp, + "moe_combine": True, + # For non-DeepEP/HybridEP/NCCL-EP dispatcher mode, the input is the un-dispatched + # tokens and probs before dispatch A2A and it's not needed anymore after the + # forward pass. For DeepEP, HybridEP, and NCCL EP dispatcher mode, they are all + # needed in backward pass and cannot be freed. + # If moe_preprocess is in cuda graph scope, tokens and probs are fixed size + # tensors, so they cannot be freed. + "moe_dispatch": not (enable_deepep or enable_hybridep or enable_ncclep) + and (CudaGraphModule.moe_preprocess not in config.cuda_graph_modules), + } + + return free_input_nodes.get(name, False) + + +class LayerState: + """State shared between the schedule nodes that come from one logical layer. + + Empty placeholder; nodes attach their own attributes (residual, dispatched + probs, shared-expert outputs) for downstream nodes in the same layer to + consume. Kept as a real class so weakrefs work uniformly. + """ + + pass + + +class PreProcessNode(ScheduleNode): + """Run the model's ``_preprocess`` (embedding + rotary + padding mask). + + The schedule plan wraps a model that exposes a ``_preprocess`` method + returning the canonical 6-tuple ``(decoder_input, rotary_pos_emb, + rotary_pos_cos, rotary_pos_sin, sequence_len_offset, padding_mask)`` + (slots a given model doesn't use are returned as ``None``). The chunk + state is mutated in-place so layer nodes can read the same fields by + name. + """ + + def __init__(self, model, chunk_state, event, stream): + super().__init__(weak_method(self.forward_impl), stream, event, name="pre_process") + self.model = model + self.chunk_state = chunk_state + + def forward_impl(self): + """Run model preprocessing and store chunk-level inputs for layer nodes.""" + if not self.model.pre_process: + self.chunk_state.decoder_input = self.model.decoder.input_tensor + ( + decoder_input, + rotary_pos_emb, + rotary_pos_cos, + rotary_pos_sin, + sequence_len_offset, + padding_mask, + ) = self.model._preprocess( + input_ids=self.chunk_state.input_ids, + position_ids=self.chunk_state.position_ids, + decoder_input=self.chunk_state.decoder_input, + packed_seq_params=self.chunk_state.packed_seq_params, + padding_mask=self.chunk_state.padding_mask, + ) + + self.chunk_state.decoder_input = decoder_input + self.chunk_state.rotary_pos_emb = rotary_pos_emb + self.chunk_state.rotary_pos_cos = rotary_pos_cos + self.chunk_state.rotary_pos_sin = rotary_pos_sin + self.chunk_state.sequence_len_offset = sequence_len_offset + self.chunk_state.padding_mask = padding_mask + return decoder_input + + +class PostProcessNode(ScheduleNode): + """Run the model's ``_postprocess`` (final norm, output layer, loss). + + Calls ``_postprocess`` with ``mtp_in_postprocess=False`` because the + schedule plan handles MTP layers as sibling layer nodes inside the same + chunk; the model's MTP block is not invoked here. The optional final + layernorm — applied only when this rank holds an empty decoder shard + (early stage of pipeline parallel) — is handled here so the chunk plan + does not need a separate node for it. + """ + + def __init__(self, model, chunk_state, event, stream): + super().__init__(weak_method(self.forward_impl), stream, event, name="post_process") + self.model = model + self.chunk_state = chunk_state + + def forward_impl(self, hidden_states): + """Run model postprocessing for the chunk's final hidden states.""" + empty_decoder = len(self.model.decoder.layers) == 0 + layer_norm = getattr(self.model.decoder, "final_norm", None) or getattr( + self.model.decoder, "final_layernorm", None + ) + if not self.model.config.mtp_num_layers and empty_decoder and layer_norm: + hidden_states = layer_norm(hidden_states) + hidden_states = make_viewless_tensor( + inp=hidden_states, requires_grad=True, keep_graph=True + ) + + loss = self.model._postprocess( + hidden_states=hidden_states, + input_ids=self.chunk_state.input_ids, + position_ids=self.chunk_state.position_ids, + labels=self.chunk_state.labels, + decoder_input=self.chunk_state.decoder_input, + rotary_pos_emb=self.chunk_state.rotary_pos_emb, + rotary_pos_cos=self.chunk_state.rotary_pos_cos, + rotary_pos_sin=self.chunk_state.rotary_pos_sin, + mtp_in_postprocess=False, + loss_mask=self.chunk_state.loss_mask, + attention_mask=self.chunk_state.attention_mask, + packed_seq_params=self.chunk_state.packed_seq_params, + sequence_len_offset=self.chunk_state.sequence_len_offset, + runtime_gather_output=self.chunk_state.runtime_gather_output, + extra_block_kwargs=self.chunk_state.extra_block_kwargs, + output_processor=self.chunk_state.output_processor, + output_processor_context=self.chunk_state.output_processor_context, + ) + + # combined-1F1B currently expects fp32 loss output. + return float16_to_fp32(loss) + + +class TransformerLayerNode(ScheduleNode): + """Schedule node for one slot of a fine-grained transformer layer plan. + + Each transformer layer is decomposed into ``pre_dispatch_computation``, + ``moe_dispatch``, ``mlp``, and ``moe_combine`` slots; this class is the scheduler-side + handle for one slot. It owns the slot's stream / event, the per-slot + ``free_input`` policy, and the optional delayed weight-gradient hook. + Subclasses override ``_resolve_free_input`` to specialize the policy + (HybridStackNode does this for grouped layers). + """ + + def __init__( + self, + stream, + event, + layer_state, + chunk_state, + submodule, + name="default", + bwd_dw_callables=None, + extra_args={}, + ): + config = extra_args.get("config", None) + assert config is not None, "model config must be passed to TransformerLayerNode." + is_moe = extra_args.get("is_moe", False) + num_local_experts = extra_args.get("num_local_experts", None) + free_input = self._resolve_free_input(name, is_moe, config, num_local_experts) + self.delay_wgrad_compute = extra_args.get("delay_wgrad_compute", False) + + super().__init__( + weak_method(self.forward_impl), + stream, + event, + weak_method(self.backward_impl), + free_input=free_input, + name=name, + ) + self.layer_state = layer_state + self.chunk_state = chunk_state + self.submodule = submodule + self.detached = tuple() + self.before_detached = tuple() + self.is_mtp = extra_args.get("is_mtp", False) + self.post_wgrad_grad_acc_hooks = None + + self.is_first_layer = extra_args.get("is_first_layer", False) + self.is_last_layer = extra_args.get("is_last_layer", False) + + # Whether this slot is the first/last node of its TransformerLayer in + # forward / backward order. Set by ``set_post_*_hook``; used to decide + # when to invoke the layer-level FSDP reshard hooks. + self.is_layer_first_node = None + self.is_layer_last_node = None + + self.bwd_dw_callables = [] + if bwd_dw_callables is not None: + self.bwd_dw_callables = ( + bwd_dw_callables if isinstance(bwd_dw_callables, list) else [bwd_dw_callables] + ) + + @staticmethod + def _resolve_free_input(name, is_moe, config, num_local_experts): + """Free-input policy hook. Subclasses override to specialize.""" + return should_free_input(name, is_moe, config, num_local_experts) + + def detach(self, t): + """Detach a tensor and remember it for backward through the schedule node.""" + detached = make_viewless(t).detach() + detached.requires_grad = t.requires_grad + self.before_detached = self.before_detached + (t,) + self.detached = self.detached + (detached,) + return detached + + def forward_impl(self, *args): + """Invoke the slot's submodule forward.""" + return self.submodule(self, *args) + + def backward_impl(self, outputs, output_grad): + """Run the slot's backward and return the input grads.""" + detached_grad = tuple([e.grad for e in self.detached]) + grads = output_grad + detached_grad + self.default_backward_func(outputs + self.before_detached, grads) + + return grads + + def forward(self, *inputs): + """Execute forward and fire the per-layer post-forward hook on the last slot.""" + output = super().forward(*inputs) + if self.is_layer_last_node: + self._post_forward_hook() + return output + + def backward(self, *output_grad): + """Execute backward and fire the per-layer post-backward hook on the first slot. + + When ``delay_wgrad_compute`` is set, the hook fires after ``backward_dw`` + instead, because the wgrad work has not yet run when ``backward`` returns. + """ + grads = super().backward(*output_grad) + if not self.delay_wgrad_compute and self.is_layer_first_node: + self._post_backward_hook() + return grads + + def backward_dw(self): + """Run the slot's delayed weight-gradient callables on the slot's stream.""" + if not self.delay_wgrad_compute: + return + if isinstance(self.stream, Callable): + self.stream = self.stream() + with torch.cuda.stream(self.stream): + nvtx_msg = f"{self.name} wgrad" + nvtx_range_push(nvtx_msg) + for module in self.bwd_dw_callables: + module.backward_dw() + nvtx_range_pop(nvtx_msg) + + # Collect ``post_wgrad_grad_acc_hook`` from params whose grads were + # produced by *this* slot's wgrad callables. The hook must run on the + # same stream right after the wgrad it depends on; collecting on the + # first invocation makes the per-iteration hook order deterministic. + if self.post_wgrad_grad_acc_hooks is None: + self.post_wgrad_grad_acc_hooks = [] + for module in self.bwd_dw_callables: + for param in module.parameters(): + if ( + getattr(param, "post_wgrad_grad_acc_hook", False) + and param.requires_grad + and param.grad is not None + ): + self.post_wgrad_grad_acc_hooks.append(param.post_wgrad_grad_acc_hook) + + if self.post_wgrad_grad_acc_hooks: + with torch.cuda.stream(self.stream): + for hook in self.post_wgrad_grad_acc_hooks: + hook() + + if self.is_layer_first_node: + self._post_backward_hook() + self.bwd_dw_callables = None + + def set_post_forward_hook(self, hook): + """Mark this slot as the layer's last fwd node and register the hook.""" + self.is_layer_last_node = True + self._post_forward_hook = hook + + def set_post_backward_hook(self, hook): + """Mark this slot as the layer's first bwd node and register the hook.""" + self.is_layer_first_node = True + self._post_backward_hook = hook + + def __del__(self): + # Release references early to help avoid leaks across iterations. + self.before_detached = None + self.detached = None + self.layer_state = None + self.chunk_state = None + self.submodule = None + + +class _BackwardDWWrapper: + """Backward weight-gradient wrapper for a transformer pre-dispatch slot. + + Runs the layer's ``self_attention.backward_dw`` plus, on MoE layers, the + shared-expert ``backward_dw``; coordinates with the cuda-graph wgrad + capture (``set_graphed_backward_dw_callable``) so that scopes covered by + the graph are not re-run eagerly. Used when + ``overlap_moe_expert_parallel_comm`` and ``delay_wgrad_compute`` are both + enabled. + """ + + def __init__(self, layer): + assert isinstance( + layer, GraphableMegatronModule + ), "cuda graphed ep overlap only supports GraphableMegatronModule." + assert isinstance( + layer, TransformerLayer + ), "cuda graphed ep overlap only supports TransformerLayer for now." + self.layer = layer + self.graphed_backward_dw_callable = None + self.attn_dw_callable = layer.self_attention.backward_dw + self.submodules = [layer.self_attention] + if layer.is_moe_layer: + self.shared_expert_dw_callable = partial( + layer.mlp.backward_dw, routed_experts=False, shared_experts=True + ) + if layer.mlp.use_shared_expert: + self.submodules.append(layer.mlp.shared_experts) + else: + self.shared_expert_dw_callable = None + self.cuda_graph_modules = layer.config.cuda_graph_modules + + def backward_dw(self): + """Run eager or graphed backward wgrad callables for the wrapped layer.""" + is_replay = hasattr(self.layer, 'cuda_graphs') and self.layer.cuda_graphs + if self.shared_expert_dw_callable is not None and ( + not is_replay or CudaGraphModule.moe_router not in self.cuda_graph_modules + ): + self.shared_expert_dw_callable() + if not is_replay or CudaGraphModule.attn not in self.cuda_graph_modules: + self.attn_dw_callable() + if is_replay and self.graphed_backward_dw_callable is not None: + self.graphed_backward_dw_callable() + self.layer = None + + def set_graphed_backward_dw_callable(self, graphed_backward_dw_callable): + """Plug the cuda-graph backward wgrad replay callable.""" + self.graphed_backward_dw_callable = graphed_backward_dw_callable + + def parameters(self): + """Yield parameters from the wrapped layer's wgrad submodules. + + Mirrors ``torch.nn.Module.parameters`` so callers (notably + ``TransformerLayerNode.backward_dw``) can collect ``post_wgrad_grad_acc_hook`` + without knowing the concrete layer layout. + """ + for module in self.submodules: + for param in module.parameters(): + yield param diff --git a/megatron/core/models/gpt/fine_grained_callables.py b/megatron/core/models/gpt/fine_grained_callables.py index e9f0739af59..61648d7b602 100644 --- a/megatron/core/models/gpt/fine_grained_callables.py +++ b/megatron/core/models/gpt/fine_grained_callables.py @@ -1,9 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -import weakref -from contextlib import nullcontext -from functools import partial -from typing import Callable, Optional +from typing import Optional import torch from torch import Tensor @@ -13,459 +10,11 @@ from megatron.core.pipeline_parallel.fine_grained_activation_offload import ( FineGrainedActivationOffloadingInterface as off_interface, ) -from megatron.core.pipeline_parallel.utils import ScheduleNode, make_viewless -from megatron.core.transformer.enums import CudaGraphModule -from megatron.core.transformer.module import GraphableMegatronModule, float16_to_fp32 +from megatron.core.pipeline_parallel.utils import ScheduleNode +from megatron.core.transformer.module import GraphableMegatronModule from megatron.core.transformer.moe.moe_layer import MoELayer -from megatron.core.transformer.multi_token_prediction import ( - MultiTokenPredictionLayer, - get_mtp_layer_offset, -) from megatron.core.transformer.transformer_layer import TransformerLayer, make_viewless_tensor from megatron.core.typed_torch import apply_module, copy_signature -from megatron.core.utils import internal_api, nvtx_range_pop, nvtx_range_push - - -def weak_method(method): - """Creates a weak reference to a method to prevent circular references. - - This function creates a weak reference to a method and returns a wrapper function - that calls the method when invoked. This helps prevent memory leaks from circular - references. - """ - method_ref = weakref.WeakMethod(method) - del method - - def wrapped_func(*args, **kwarg): - # nonlocal object_ref - return method_ref()(*args, **kwarg) - - return wrapped_func - - -@internal_api -def should_free_input(name, is_moe, config, num_local_experts): - """Determine if the node should free its input memory. - - Args: - name: Node name - is_moe: Whether it's a MoE model - config: TransformerConfig object - num_local_experts: Number of local experts in MoE module - - Returns: - bool: Whether to free input memory - """ - # For dense layers [attn, fake, mlp, fake], the input is needed during backward pass - if not is_moe: - return False - enable_deepep = ( - config.moe_token_dispatcher_type == "flex" - and config.moe_flex_dispatcher_backend == "deepep" - ) - enable_hybridep = ( - config.moe_token_dispatcher_type == "flex" - and config.moe_flex_dispatcher_backend == "hybridep" - ) - enable_ncclep = ( - config.moe_token_dispatcher_type == "flex" - and config.moe_flex_dispatcher_backend == "ncclep" - ) - # Define which nodes should free input memory - # Since we split the computing graph into multiple nodes, we can manually control - # when and how to free the input memory. - # The input and output of A2A are not needed anymore after the forward pass, - # so we can free the input memory after the forward pass. - - # When low precision fp8/4 is enabled, the casted tensors are saved and the - # original bf16 tensors are safe to be freed. - free_mlp = config.fp8 is not None or config.fp4 is not None - if not free_mlp: - # AlltoAll dispatcher with local_num_experts=1, HybridEP, and NCCL EP all use - # identity operation for `dispatch_postprocess`, hence the mlp inputs will be - # directly passed to GroupedGemm and should be saved for backward pass. - free_mlp = num_local_experts > 1 or config.moe_token_dispatcher_type != "alltoall" - free_mlp = free_mlp and not (enable_hybridep or enable_ncclep) - - free_input_nodes = { - "mlp": free_mlp, - "moe_combine": True, - # For non-DeepEP/HybridEP/NCCL-EP dispatcher mode, the input is the un-dispatched tokens - # and probs before dispatch A2A and it's not needed anymore after the forward pass - # For DeepEP, HybridEP, and NCCL EP dispatcher mode, they are both needed in backward - # pass and cannot be freed. - # If moe_preprocess is in cuda graph scope, tokens and probs are fixed size tensors, - # so they cannot be freed. - "moe_dispatch": not (enable_deepep or enable_hybridep or enable_ncclep) - and (CudaGraphModule.moe_preprocess not in config.cuda_graph_modules), - } - - return free_input_nodes.get(name, False) - - -class TransformerLayerState: - """State shared within a transformer layer. - - This class holds state that is shared between different nodes - within a transformer layer. - """ - - pass - - -class PreProcessNode(ScheduleNode): - """Node responsible for preprocessing operations in the model. - - This node handles embedding and rotary positional embedding computations - before the main transformer layers. - """ - - def __init__(self, gpt_model, chunk_state, event, stream): - """Initializes a preprocessing node. - - Args: - gpt_model: The GPT model instance. - chunk_state (TransformerChunkState): State shared within a chunk - event: CUDA event for synchronization. - stream: CUDA stream for execution. - """ - super().__init__(weak_method(self.forward_impl), stream, event, name="pre_process") - self.gpt_model = gpt_model - self.chunk_state = chunk_state - - def forward_impl(self): - """forward pass for pre-processing. - - This method handles: - 1. Decoder embedding computation - 2. Rotary positional embedding computation - 3. Sequence length offset computation for flash decoding - - Returns: - The processed decoder input tensor. - """ - # Get decoder input - if not self.gpt_model.pre_process: - self.chunk_state.decoder_input = self.gpt_model.decoder.input_tensor - # Run GPTModel._preprocess - ( - decoder_input, - rotary_pos_emb, - rotary_pos_cos, - rotary_pos_sin, - sequence_len_offset, - padding_mask, - ) = self.gpt_model._preprocess( - input_ids=self.chunk_state.input_ids, - position_ids=self.chunk_state.position_ids, - decoder_input=self.chunk_state.decoder_input, - packed_seq_params=self.chunk_state.packed_seq_params, - padding_mask=self.chunk_state.padding_mask, - ) - - # Saved for later use - self.chunk_state.decoder_input = decoder_input - self.chunk_state.rotary_pos_emb = rotary_pos_emb - self.chunk_state.rotary_pos_cos = rotary_pos_cos - self.chunk_state.rotary_pos_sin = rotary_pos_sin - self.chunk_state.sequence_len_offset = sequence_len_offset - self.chunk_state.padding_mask = padding_mask - return decoder_input - - -class PostProcessNode(ScheduleNode): - """Node responsible for postprocessing operations in the model. - - This node handles final layer normalization and output layer computation - after the main transformer layers. - """ - - def __init__(self, gpt_model, chunk_state, event, stream): - """Initializes a postprocessing node. - - Args: - gpt_model: The GPT model instance. - chunk_state (TransformerChunkState): State shared within a chunk - event: CUDA event for synchronization. - stream: CUDA stream for execution. - """ - super().__init__(weak_method(self.forward_impl), stream, event, name="post_process") - self.gpt_model = gpt_model - self.chunk_state = chunk_state - - def forward_impl(self, hidden_states): - """Implements the forward pass for postprocessing. - - This method handles: - 1. Output layer computation - 2. Loss computation if labels are provided - - Args: - hidden_states: The hidden states from the transformer layers. - - Returns: - The logits or loss depending on whether labels are provided. - """ - - empty_decoder = len(self.gpt_model.decoder.layers) == 0 - layer_norm = self.gpt_model.decoder.final_layernorm - if not self.gpt_model.config.mtp_num_layers and empty_decoder and layer_norm: - hidden_states = layer_norm(hidden_states) - hidden_states = make_viewless_tensor( - inp=hidden_states, requires_grad=True, keep_graph=True - ) - - # Run GPTModel._postprocess - loss = self.gpt_model._postprocess( - hidden_states=hidden_states, - input_ids=self.chunk_state.input_ids, - position_ids=self.chunk_state.position_ids, - labels=self.chunk_state.labels, - decoder_input=self.chunk_state.decoder_input, - rotary_pos_emb=self.chunk_state.rotary_pos_emb, - rotary_pos_cos=self.chunk_state.rotary_pos_cos, - rotary_pos_sin=self.chunk_state.rotary_pos_sin, - mtp_in_postprocess=False, - loss_mask=self.chunk_state.loss_mask, - attention_mask=self.chunk_state.attention_mask, - packed_seq_params=self.chunk_state.packed_seq_params, - sequence_len_offset=self.chunk_state.sequence_len_offset, - runtime_gather_output=self.chunk_state.runtime_gather_output, - extra_block_kwargs=self.chunk_state.extra_block_kwargs, - output_processor=self.chunk_state.output_processor, - output_processor_context=self.chunk_state.output_processor_context, - ) - - # For now, 1f1b only supports fp16 module - return float16_to_fp32(loss) - - -class TransformerLayerNode(ScheduleNode): - """Base class for transformer layer computation nodes. - - This class provides common functionality for different types of - transformer layer nodes (attention, MLP, etc.) - """ - - def __init__( - self, - stream, - event, - layer_state, - chunk_state, - submodule, - name="default", - bwd_dw_callables=None, - extra_args={}, - ): - """Initialize a transformer layer node. - - Args: - stream (torch.cuda.Stream): CUDA stream for execution - event (torch.cuda.Event): Synchronization event - layer_state (TransformerLayerState): State shared within a layer - chunk_state (TransformerChunkState): State shared within a chunk - submodule (function): The submodule contain forward and dw function - it's the per_batch_state_context, o.w. nullcontext - name (str): Node name, also used to determine memory strategy - bwd_dw_callables (list): List of weight gradient functions for the layer. - extra_args (dict): Extra arguments for the node: is_moe, config. - """ - # Determine whether to free input memory - config = extra_args.get("config", None) - assert config is not None, "model config must be passed to TransformerLayerNode." - is_moe = extra_args.get("is_moe", False) - num_local_experts = extra_args.get("num_local_experts", None) - free_input = should_free_input(name, is_moe, config, num_local_experts) - self.delay_wgrad_compute = extra_args.get("delay_wgrad_compute", False) - - self.is_layer_first_node = None - self.is_layer_last_node = None - - super().__init__( - weak_method(self.forward_impl), - stream, - event, - weak_method(self.backward_impl), - free_input=free_input, - name=name, - ) - self.layer_state = layer_state - self.chunk_state = chunk_state - self.submodule = submodule - self.detached = tuple() - self.before_detached = tuple() - self.is_mtp = extra_args.get("is_mtp", False) - self.post_wgrad_grad_acc_hooks = None - - # Create flags to indicate first and last layer - self.is_first_layer = extra_args.get("is_first_layer", False) - self.is_last_layer = extra_args.get("is_last_layer", False) - - # Initialize list to store registered dw callables - self.bwd_dw_callables = [] - if bwd_dw_callables is not None: - self.bwd_dw_callables = ( - bwd_dw_callables if isinstance(bwd_dw_callables, list) else [bwd_dw_callables] - ) - - def detach(self, t): - """Detaches a tensor and stores it for backward computation.""" - detached = make_viewless(t).detach() - detached.requires_grad = t.requires_grad - self.before_detached = self.before_detached + (t,) - self.detached = self.detached + (detached,) - return detached - - def forward_impl(self, *args): - """Calls the submodule as the forward pass.""" - return self.submodule(self, *args) - - def backward_impl(self, outputs, output_grad): - """Implements the backward pass for the transformer layer node.""" - detached_grad = tuple([e.grad for e in self.detached]) - grads = output_grad + detached_grad - self.default_backward_func(outputs + self.before_detached, grads) - - # return grads for record stream - return grads - - def forward(self, *inputs): - """Execute forward pass and corresponding hooks.""" - output = super().forward(*inputs) - if self.is_layer_last_node: - self._post_forward_hook() - return output - - def backward(self, *output_grad): - """Execute backward pass and corresponding hooks.""" - grads = super().backward(*output_grad) - if not self.delay_wgrad_compute and self.is_layer_first_node: - self._post_backward_hook() - return grads - - def backward_dw(self): - """Computes the weight gradients for the transformer layer node.""" - if not self.delay_wgrad_compute: - return - if isinstance(self.stream, Callable): - self.stream = self.stream() - with torch.cuda.stream(self.stream): - nvtx_msg = f"{self.name} wgrad" - nvtx_range_push(nvtx_msg) - for module in self.bwd_dw_callables: - module.backward_dw() - nvtx_range_pop(nvtx_msg) - - # Collecting gradient acc hooks if there is `post_wgrad_grad_acc_hook` - # attribute attached to param, o.w. the wgrad hook wouldn't be fired. - if self.post_wgrad_grad_acc_hooks is None: - self.post_wgrad_grad_acc_hooks = [] - for module in self.bwd_dw_callables: - for param in module.parameters(): - # Collect hook only if the gradient is generated in current - # TransformerLayerNode, because the grad_acc hook needs - # to be executed right after `backward_dw` finishes. - # For example: Shared expert's hook should be collected in - # `attn` Node, even if the param belongs to `mlp` Node. - if ( - getattr(param, "post_wgrad_grad_acc_hook", False) - and param.requires_grad - and param.grad is not None - ): - self.post_wgrad_grad_acc_hooks.append(param.post_wgrad_grad_acc_hook) - - # Execute gradient accumulation hooks after wgrad compute. - if self.post_wgrad_grad_acc_hooks: - with torch.cuda.stream(self.stream): - for hook in self.post_wgrad_grad_acc_hooks: - hook() - - # Execute TransformerLayer backward hook. - if self.is_layer_first_node: - self._post_backward_hook() - self.bwd_dw_callables = None - - def set_post_forward_hook(self, hook): - """Register post_forward_hook at TransformerLayer level.""" - self.is_layer_last_node = True - self._post_forward_hook = hook - - def set_post_backward_hook(self, hook): - """Register post_backward_hook at TransformerLayer level.""" - self.is_layer_first_node = True - self._post_backward_hook = hook - - def __del__(self): - # Release reference as early as possible, this helps avoid memory leak. - self.before_detached = None - self.detached = None - self.layer_state = None - self.chunk_state = None - self.submodule = None - - -class _BackwardDWWrapper: - """Wrapper for managing backward weight gradient computation of attn module. - - This class handles the execution of weight gradient computations for transformer layers, - coordinating between CUDA graphed and non-graphed components. It is used when - overlap_moe_expert_parallel_comm and delay_wgrad_compute are enabled to manage - the delayed weight gradient computation in MoE models. - - The wrapper stores references to the attention and shared expert backward weight gradient - callables, and determines which components should be executed based on whether CUDA graphs - are being replayed and which scopes are covered by the graphs. - """ - - def __init__(self, layer): - assert isinstance( - layer, GraphableMegatronModule - ), "cuda graphed ep overlap only supports GraphableMegatronModule." - assert isinstance( - layer, TransformerLayer - ), "cuda graphed ep overlap only supports TransformerLayer for now." - self.layer = layer - self.graphed_backward_dw_callable = None - self.attn_dw_callable = layer.self_attention.backward_dw - self.submodules = [layer.self_attention] - if layer.is_moe_layer: - self.shared_expert_dw_callable = partial( - layer.mlp.backward_dw, routed_experts=False, shared_experts=True - ) - if layer.mlp.use_shared_expert: - self.submodules.append(layer.mlp.shared_experts) - else: - self.shared_expert_dw_callable = None - self.cuda_graph_modules = layer.config.cuda_graph_modules - - def backward_dw(self): - """Execute weight gradients, skipping CUDA graphed components during replay.""" - is_replay = hasattr(self.layer, 'cuda_graphs') and self.layer.cuda_graphs - if self.shared_expert_dw_callable is not None and ( - not is_replay or CudaGraphModule.moe_router not in self.cuda_graph_modules - ): - self.shared_expert_dw_callable() - if not is_replay or CudaGraphModule.attn not in self.cuda_graph_modules: - self.attn_dw_callable() - if is_replay and self.graphed_backward_dw_callable is not None: - self.graphed_backward_dw_callable() - self.layer = None - - def set_graphed_backward_dw_callable(self, graphed_backward_dw_callable): - """Store the CUDA graphed backward weight gradient callable.""" - self.graphed_backward_dw_callable = graphed_backward_dw_callable - - def parameters(self): - """Returns an iterator over module parameters. - - This method mimics the behavior of torch.nn.Module.parameters() by yielding - all parameters from the submodules managed by this wrapper. It is used to - collect parameters that require gradient computation during the backward pass. - """ - for module in self.submodules: - for param in module.parameters(): - yield param def build_transformer_layer_callables(layer: TransformerLayer): @@ -474,12 +23,15 @@ def build_transformer_layer_callables(layer: TransformerLayer): functions. This decomposition separates computation-heavy tasks (e.g., self-attention, MLP) from communication-heavy tasks (e.g., MoE's All-to-All). - The five callables are: - 1. Attention (computation) - 2. Post-Attention (computation) - 3. MoE Dispatch (communication) - 4. MLP / MoE Experts (computation) - 5. MoE Combine (communication) + The five callables align with the schedule plan's slot order: + 1. pre_dispatch_computation (computation): + attention -> pre-MLP layernorm -> router -> dispatch preprocess. + For dense layers this is just the attention pass. + 2. moe_dispatch (communication): MoE dispatch All-to-All. + 3. mlp / moe_experts (computation): dense MLP or routed-experts compute. + 4. moe_combine (communication): MoE combine All-to-All + post-MLP residual. + 5. mtp_post_process (computation): always ``None`` here; only the MTP + wrapper in ``common/fine_grained_callables.py`` fills this slot. By assigning these functions to different CUDA streams (e.g., a compute stream and a communication stream), the scheduler can overlap their execution, preventing @@ -491,8 +43,11 @@ def build_transformer_layer_callables(layer: TransformerLayer): Returns: A tuple containing: - - forward_funcs: List of callable functions for the layer - - backward_dw: Dict of weight gradient functions for the layer + - forward_funcs: List of 5 callables, one per slot in the schedule plan + (pre_dispatch_computation, moe_dispatch, mlp, moe_combine, + mtp_post_process=None). + - backward_dw: Dict mapping slot name to the delayed-wgrad callable + (keys: "pre_dispatch_computation", "mlp"). """ is_moe = isinstance(layer.mlp, MoELayer) @@ -509,9 +64,9 @@ def build_transformer_layer_callables(layer: TransformerLayer): and layer.config.moe_flex_dispatcher_backend == "ncclep" ) - def submodule_attn_forward(node: ScheduleNode, hidden_states: torch.Tensor): + def submodule_pre_dispatch_forward(node: ScheduleNode, hidden_states: torch.Tensor): """ - Performs same attnention forward logic as GPT Model and forward pass for + Performs the same attention forward logic as GPTModel and the forward pass for computations between attention and dispatch: pre mlp layernorm->router->dispatch preprocess """ @@ -607,7 +162,7 @@ def submodule_dispatch_forward( token_dispatcher = layer.mlp.token_dispatcher if enable_deepep or enable_hybridep or enable_ncclep: # update token_probs to be the detached version, prevents - # backward graph from connecting to attn submodule + # backward graph from connecting to pre_dispatch_computation submodule token_dispatcher._comm_manager.token_probs = probs dispatched_tokens, dispatched_probs = layer.mlp.dispatch(local_tokens, probs) @@ -704,119 +259,13 @@ def raise_not_implemented(*args): raise NotImplementedError("This callable is not implemented for Dense layer.") # Build forward and backward callable functions - attn_func = submodule_attn_forward + pre_dispatch_func = submodule_pre_dispatch_forward dispatch_func = submodule_dispatch_forward if is_moe else raise_not_implemented mlp_func = submodule_moe_forward if is_moe else mlp_wrapper combine_func = submodule_combine_forward if is_moe else raise_not_implemented layer.init_backward_dw_wrapper() - forward_funcs = [attn_func, dispatch_func, mlp_func, combine_func, None] - backward_dw = {"attn": layer.backward_dw_wrapper, "mlp": layer.mlp} - return forward_funcs, backward_dw - - -def build_mtp_layer_callables(layer): - """Callables for multi-token prediction layer nodes. - - This class contains the callable functions for different types of - multi-token prediction layer nodes (attention, MLP, etc.) - """ - - forward_funcs, backward_dw = build_transformer_layer_callables(layer.mtp_model_layer) - attn_forward, dispatch_forward, mlp_forward, combine_forward, _ = forward_funcs - is_moe = isinstance(layer.mtp_model_layer.mlp, MoELayer) - assert is_moe, "MTP layer in a2a overlap only supports MoE layer for now." - - def submodule_mtp_attn_forward(node, hidden_states): - # MTP Block Preprocess - if node.is_first_layer: - offset = get_mtp_layer_offset(layer.config, node.chunk_state.model.vp_stage) - node.chunk_state.mtp_hidden_states = list(torch.chunk(hidden_states, 1 + offset, dim=0)) - hidden_states = node.chunk_state.mtp_hidden_states[offset] - - input_ids, position_ids, padding_mask, decoder_input, hidden_states = layer._get_embeddings( - input_ids=node.chunk_state.input_ids, - position_ids=node.chunk_state.position_ids, - embedding=node.chunk_state.model.embedding, - hidden_states=hidden_states, - packed_seq_params=node.chunk_state.packed_seq_params, - padding_mask=node.chunk_state.padding_mask, - ) - node.chunk_state.input_ids = input_ids - node.chunk_state.position_ids = position_ids - node.chunk_state.padding_mask = padding_mask - - # MTP Layer Preprocess - # norm, linear projection and transformer - assert ( - node.chunk_state.context is None - ), f"multi token prediction + cross attention is not yet supported." - assert ( - node.chunk_state.packed_seq_params is None - ), f"multi token prediction + sequence packing is not yet supported." - - if layer.config.sequence_parallel: - rng_context = tensor_parallel.get_cuda_rng_tracker().fork() - else: - rng_context = nullcontext() - - # fp8 context is added in 1f1b schedule, so we don't need to add it here - with rng_context: - hidden_states = layer._concat_embeddings(hidden_states, decoder_input) - return attn_forward(node, hidden_states) - - def submodule_mtp_postprocess_forward(node, hidden_states): - hidden_states = layer._postprocess(hidden_states) - node.chunk_state.mtp_hidden_states.append(hidden_states) - if node.is_last_layer: - hidden_states = torch.cat(node.chunk_state.mtp_hidden_states, dim=0) - node.chunk_state.mtp_hidden_states = None - return hidden_states - - def rng_context_wrapper(func, *args, **kwargs): - """ - Wrapper to add rng context to submodule callables - """ - if layer.config.sequence_parallel: - rng_context = tensor_parallel.get_cuda_rng_tracker().fork() - else: - rng_context = nullcontext() - with rng_context: - return func(*args, **kwargs) - - # Build forward and backward callable functions - # attn_forward already has rng context, no need to wrap - attn_func = submodule_mtp_attn_forward - dispatch_func = partial(rng_context_wrapper, dispatch_forward) - mlp_func = partial(rng_context_wrapper, mlp_forward) - combine_func = partial(rng_context_wrapper, combine_forward) - mtp_post_process_func = submodule_mtp_postprocess_forward - - forward_funcs = [attn_func, dispatch_func, mlp_func, combine_func, mtp_post_process_func] - if isinstance(backward_dw["attn"], list): - backward_dw["attn"].append(layer.eh_proj) - else: - backward_dw["attn"] = [backward_dw["attn"], layer.eh_proj] - + forward_funcs = [pre_dispatch_func, dispatch_func, mlp_func, combine_func, None] + backward_dw = {"pre_dispatch_computation": layer.backward_dw_wrapper, "mlp": layer.mlp} return forward_funcs, backward_dw - - -def build_layer_callables(layer): - """ - Builds the callable functions(forward and dw) for the given layer. - For now, 1f1b overlap only support TransformerLayer and MultiTokenPredictionLayer. - - Args: - layer: The layer to build callables for. - - Returns: - forward_funcs: list of callable functions for the layer. - backward_dw: dict of weight gradient functions for the layer. - """ - if isinstance(layer, TransformerLayer): - return build_transformer_layer_callables(layer) - elif isinstance(layer, MultiTokenPredictionLayer): - return build_mtp_layer_callables(layer) - - raise ValueError(f"Unsupported layer type: {type(layer)}") diff --git a/megatron/core/pipeline_parallel/combined_1f1b.py b/megatron/core/pipeline_parallel/combined_1f1b.py index 3caaa900468..cb7d02c19b9 100644 --- a/megatron/core/pipeline_parallel/combined_1f1b.py +++ b/megatron/core/pipeline_parallel/combined_1f1b.py @@ -381,12 +381,6 @@ def forward_backward_step(): unwrapped_model = get_attr_wrapped_model( f_model, "build_schedule_plan", return_model_obj=True ) - from megatron.core.models.gpt.gpt_model import GPTModel - - assert isinstance(unwrapped_model, GPTModel), ( - "The final unwrapped model must be a GPTModel instance " - "since only GPTModel is supported for EP A2A overlapping." - ) f_schedule_plan, loss_func = forward_step_func( data_iterator, unwrapped_model, return_schedule_plan=True ) diff --git a/megatron/core/transformer/module.py b/megatron/core/transformer/module.py index 6a55bfbc348..558b1b07a15 100644 --- a/megatron/core/transformer/module.py +++ b/megatron/core/transformer/module.py @@ -197,12 +197,22 @@ def __init__(self, config: TransformerConfig, vp_stage: Optional[int] = None): self.cuda_graph_backward_dw_wrapper = None def init_backward_dw_wrapper(self): - """Initialize the backward_dw_wrapper.""" - from megatron.core.models.gpt.fine_grained_callables import _BackwardDWWrapper + """Initialize ``self.backward_dw_wrapper`` for delayed-wgrad scheduling. + + The wrapper coordinates the per-layer wgrad callables (attention + wgrad, optional shared-expert wgrad) with cuda-graph replay scope so + captured components are not re-run eagerly. The method is defined on + ``GraphableMegatronModule`` so any graphable subclass can opt in; + ``_BackwardDWWrapper`` itself currently asserts the underlying layer + is a ``TransformerLayer``, so MambaLayer-derived modules implement + ``backward_dw`` directly and skip this helper. + """ + from megatron.core.models.common.utils import _BackwardDWWrapper config = getattr(self, 'config', None) assert config is not None, ( - "TransformerLayer must be initialized before calling " "`init_backward_dw_wrapper`." + "Module must be fully constructed (config set) before calling " + "`init_backward_dw_wrapper`." ) self.backward_dw_wrapper = _BackwardDWWrapper(self) diff --git a/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py b/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py index 03cb5609109..b4351cbbe1e 100644 --- a/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_cuda_graphed_schedule_chunk_1f1b.py @@ -26,24 +26,16 @@ set_global_variables, ) from megatron.training.training import setup_model_and_optimizer +from tests.unit_tests.a2a_overlap.utils import ( + get_valid_flex_dispatcher_backend, + get_valid_token_dispatcher_types, +) from tests.unit_tests.test_utilities import Utils # Transformer Engine 2.17 aborts in the A2A overlap suite with a pybind11 GIL dec_ref failure. pytestmark = pytest.mark.flaky_in_dev -def is_deep_ep_available(): - from megatron.core.transformer.moe.fused_a2a import HAVE_DEEP_EP - - return HAVE_DEEP_EP - - -def is_hybrid_ep_available(): - from megatron.core.transformer.moe.fused_a2a import HAVE_HYBRIDEP - - return HAVE_HYBRIDEP - - def save(fn, message): with open(fn, 'w') as f: f.write(message) @@ -342,22 +334,12 @@ def _run_test_helper( not (HAVE_TE and is_te_min_version("2.10.0")), reason="Partial CUDA graph support requires TransformerEngine version >= 2.10.0", ) - @pytest.mark.parametrize("moe_dispatcher_type", ["alltoall", "deepep"]) + @pytest.mark.parametrize("moe_dispatcher_type", get_valid_token_dispatcher_types()) def test_moe_partial_cudagraph_with_ep_overlap(self, moe_dispatcher_type): extra_kwargs = {"moe_layer_freq": 1} - if moe_dispatcher_type == "deepep": - if not is_deep_ep_available(): - pytest.skip("Deep EP is not available") - extra_kwargs["moe_token_dispatcher_type"] = "flex" - extra_kwargs["moe_flex_dispatcher_backend"] = "deepep" - extra_kwargs["moe_router_dtype"] = "fp32" - elif moe_dispatcher_type == "hybridep": - if not is_hybrid_ep_available(): - pytest.skip("Hybrid EP is not available") - extra_kwargs["moe_token_dispatcher_type"] = "flex" - extra_kwargs["moe_flex_dispatcher_backend"] = "hybridep" - else: - extra_kwargs["moe_token_dispatcher_type"] = moe_dispatcher_type + extra_kwargs["moe_token_dispatcher_type"] = moe_dispatcher_type + if moe_dispatcher_type == "flex": + extra_kwargs["moe_flex_dispatcher_backend"] = get_valid_flex_dispatcher_backend() loss_list_ref = self._run_test_helper(4, "none", None, 3, **extra_kwargs) for cuda_graph_modules in [ diff --git a/tests/unit_tests/transformer/test_submodule_callables.py b/tests/unit_tests/transformer/test_submodule_callables.py index 7b41b3ca197..42ba73bc92e 100644 --- a/tests/unit_tests/transformer/test_submodule_callables.py +++ b/tests/unit_tests/transformer/test_submodule_callables.py @@ -2,7 +2,8 @@ import pytest import torch -from megatron.core.models.gpt.fine_grained_callables import build_layer_callables +from megatron.core.models.common import fine_grained_callables as common_callables +from megatron.core.models.common.fine_grained_callables import build_layer_callables from megatron.core.models.gpt.gpt_layer_specs import ( get_gpt_layer_with_transformer_engine_submodules, ) @@ -15,6 +16,7 @@ compare_captures, deterministic_mode, get_test_config, + get_valid_flex_dispatcher_backend, get_valid_token_dispatcher_types, reset_model, ) @@ -100,6 +102,71 @@ def run_model_submodules_with_capture(model, input_tensors, microbatches): return capture +def test_mtp_pre_dispatch_applies_hybrid_empty_decoder_final_norm(monkeypatch): + """Covers the HybridModel empty-decoder MTP pre-dispatch final_norm path.""" + + from megatron.core.models.hybrid.hybrid_model import HybridModel + + def inner_pre_dispatch(_node, hidden_states): + return hidden_states + + def unused_forward(*_args, **_kwargs): + raise AssertionError("only MTP pre-dispatch should run in this test") + + def fake_build_layer_callables(_layer): + return ( + [inner_pre_dispatch, unused_forward, unused_forward, unused_forward, None], + {"pre_dispatch_computation": object()}, + ) + + class FakeMTPConfig: + sequence_parallel = False + + class FakeMTPLayer: + config = FakeMTPConfig() + eh_proj = object() + mtp_model_layer = object() + + def _get_embeddings( + self, input_ids, position_ids, embedding, hidden_states, packed_seq_params, padding_mask + ): + return input_ids, position_ids, padding_mask, None, hidden_states + + def _concat_embeddings(self, hidden_states, decoder_input): + return hidden_states + + def _postprocess(self, hidden_states): + return hidden_states + + monkeypatch.setattr(common_callables, "build_layer_callables", fake_build_layer_callables) + monkeypatch.setattr(common_callables, "get_layer_moe_metadata", lambda _layer: (True, 1)) + monkeypatch.setattr(common_callables, "get_mtp_layer_offset", lambda _config, _vp_stage: 0) + + model = HybridModel.__new__(HybridModel) + torch.nn.Module.__init__(model) + model.decoder = DummyState() + model.decoder.layers = [] + model.decoder.final_norm = lambda hidden_states: hidden_states + 4.0 + model.embedding = object() + model.vp_stage = None + + node = DummyNode() + node.chunk_state = DummyState() + node.chunk_state.model = model + node.chunk_state.context = None + node.chunk_state.packed_seq_params = None + node.is_first_layer = True + + hidden_states = torch.arange(6, dtype=torch.float32).reshape(3, 1, 2).requires_grad_() + expected = hidden_states + 4.0 + forward_funcs, _ = common_callables.build_mtp_layer_callables(FakeMTPLayer()) + + output = forward_funcs[0](node, hidden_states) + + torch.testing.assert_close(output, expected) + torch.testing.assert_close(node.chunk_state.mtp_hidden_states[0], expected) + + class TestTransformerLayerSubmoduleCallables: """ Test class for transformer layer submodule callables. @@ -137,7 +204,7 @@ def test_1f1b_overlap(self, dispatcher_type, grouped_gemm, permute_fusion): "moe_permute_fusion": permute_fusion, } if dispatcher_type == "flex": - extra_kwargs["moe_flex_dispatcher_backend"] = "deepep" + extra_kwargs["moe_flex_dispatcher_backend"] = get_valid_flex_dispatcher_backend() config = get_test_config(extra_kwargs=extra_kwargs, moe_grouped_gemm=grouped_gemm) microbatches = 4 with deterministic_mode(): From ecf55477df08a00e67434df223369cb07921e7eb Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 15 Jul 2026 20:42:38 +0200 Subject: [PATCH 032/290] chore(tests): AUT-851 move NCCL defaults from run_ci_test.sh to conftest (#5826) Signed-off-by: svcnemo-autobot --- tests/unit_tests/conftest.py | 16 ++++++++++++++++ .../unit_tests/distributed/mfsdp_v2/conftest.py | 5 +++-- tests/unit_tests/run_ci_test.sh | 3 --- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index ef3d87c7c6d..c207fc2e262 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -15,6 +15,22 @@ from tests.unit_tests.test_utilities import Utils +def pytest_configure(config): + """Set NCCL defaults for the unit-test suite. + + These previously lived as ``export``s in ``tests/unit_tests/run_ci_test.sh``. + They reduce NCCL memory usage / SM contention and were originally added to + fix NCCL hangs observed for FSDP v1 (among other MCore algorithms). Setting + them here — at session start, before any test initializes NCCL communicators + — keeps that default while moving the test-bucket configuration out of the + CI launch script and into pytest. Individual buckets that want + production-like NCCL settings (e.g. MFSDP v2) can pop these in their own + conftest before initializing their process group. + """ + os.environ.setdefault("NCCL_MAX_NCHANNELS", "1") + os.environ.setdefault("NCCL_NVLS_ENABLE", "0") + + def pytest_addoption(parser): """ Additional command-line arguments passed to pytest. diff --git a/tests/unit_tests/distributed/mfsdp_v2/conftest.py b/tests/unit_tests/distributed/mfsdp_v2/conftest.py index 0d04b78f624..741c0f57e82 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/conftest.py +++ b/tests/unit_tests/distributed/mfsdp_v2/conftest.py @@ -22,8 +22,9 @@ class DistributedSetup: def distributed_setup() -> Iterator[DistributedSetup]: """Read torchrun rank state and set up this rank's local device.""" # Some MFSDP v2 tests are sensitive to NCCL algorithm/channel choices. Clear - # CI launcher overrides before init_device_mesh initializes NCCL communicators - # so this bucket uses NCCL settings closer to production. + # the suite-wide NCCL defaults (set in the top-level conftest.py) before + # init_device_mesh initializes NCCL communicators so this bucket uses NCCL + # settings closer to production. os.environ.pop("NCCL_MAX_NCHANNELS", None) os.environ.pop("NCCL_NVLS_ENABLE", None) diff --git a/tests/unit_tests/run_ci_test.sh b/tests/unit_tests/run_ci_test.sh index a51f7a21449..817dde579ed 100755 --- a/tests/unit_tests/run_ci_test.sh +++ b/tests/unit_tests/run_ci_test.sh @@ -145,9 +145,6 @@ DISTRIBUTED_ARGS=( --redirects "3" ) -# Reduce memory usage by NCCL -export NCCL_MAX_NCHANNELS=1 -export NCCL_NVLS_ENABLE=0 export ONE_LOGGER_JOB_CATEGORY=test # Run a pytest command. On marker-driven platforms a bucket can legitimately From 8bf73659f692a58f96ae342cfda37a8db4e1bf94 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 15 Jul 2026 14:28:29 -0700 Subject: [PATCH 033/290] Set num_splits to 0 for FA4 inference (#5804) Signed-off-by: Keshav Santhanam --- megatron/core/transformer/attention.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py index 92e2bccb8cf..7b1a09ea333 100644 --- a/megatron/core/transformer/attention.py +++ b/megatron/core/transformer/attention.py @@ -82,10 +82,22 @@ except ImportError as e: pass +# The FA4 version is tracked by the `flash-attn-4` distribution metadata, +# not `flash_attn.__version__` (which reports the 2.x version) or +# `flash_attn.cute.__version__` (which is 0.0.0), so we cannot use +# `is_fa_min_version` here. +_MIN_FA4_VERSION = "4.0.0b20" try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _get_dist_version + from flash_attn.cute import flash_attn_varlen_func as flash_attn4_varlen_func + from packaging.version import Version as _Version - HAVE_FA4 = True + try: + HAVE_FA4 = _Version(_get_dist_version("flash-attn-4")) >= _Version(_MIN_FA4_VERSION) + except PackageNotFoundError: + HAVE_FA4 = False except ImportError: HAVE_FA4 = False @@ -1061,7 +1073,7 @@ def flash_decode_and_prefill( softmax_scale=softmax_scale, causal=True, window_size=window_size, - num_splits=1, + num_splits=0 if not self.batch_invariant_mode else 1, ) elif HAVE_FA3: # TODO(ksanthanam): Replace with call to flash_attn_varlen_func once @@ -1183,7 +1195,7 @@ def flash_decode_and_prefill( softmax_scale=softmax_scale, causal=True, window_size=window_size, - num_splits=1, + num_splits=0 if not self.batch_invariant_mode else 1, ) if need_lse: # output_total: (B*S, H, D); softmax_lse: (H, B*S) From 06bb3997afc4861dc27156578ba5d699b2af0f87 Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 15 Jul 2026 14:55:23 -0700 Subject: [PATCH 034/290] ci: Enhance nightly/mr/weekly error reporting (#5831) Signed-off-by: Ajay Balasa --- .gitlab/scripts/build.sh | 5 + .gitlab/stages/02.test.yml | 1 + .gitlab/stages/03.integration-tests.yml | 1 + .gitlab/stages/04.functional-tests.yml | 2 + docker/Dockerfile.linting | 10 ++ .../generate_jet_trigger_job.py | 38 ++++- tests/test_utils/python_scripts/notify.py | 130 +++++++--------- tests/test_utils/test_ci_triage.py | 147 ++++++++++++++++++ 8 files changed, 257 insertions(+), 77 deletions(-) create mode 100644 tests/test_utils/test_ci_triage.py diff --git a/.gitlab/scripts/build.sh b/.gitlab/scripts/build.sh index 15c926ed51f..c72bd38a581 100644 --- a/.gitlab/scripts/build.sh +++ b/.gitlab/scripts/build.sh @@ -48,6 +48,11 @@ if [[ -n "$TE_GIT_REF" ]]; then ADDITIONAL_PARAMS+=("--build-arg TE_COMMIT=${TE_GIT_REF}") fi +if [[ "$FILE" == "Dockerfile.linting" ]]; then + ADDITIONAL_PARAMS+=("--build-arg CI_SERVER_URL=${CI_SERVER_URL}") + ADDITIONAL_PARAMS+=("--secret id=NEMO_CI_TRIAGE_TOKEN,env=PAT") +fi + echo $(git rev-parse HEAD) JET_API_VERSION=$(curl -s -u "$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN" "https://sc-hw-artf.nvidia.com/artifactory/api/pypi/hw-joc-pypi/simple/jet-api/" | grep -o 'href="../../jet-api/[0-9.]*/' | sed 's|href="../../jet-api/||;s|/||' | sort -V -r | head -n1) diff --git a/.gitlab/stages/02.test.yml b/.gitlab/stages/02.test.yml index a324ce037fb..d81e1be4857 100644 --- a/.gitlab/stages/02.test.yml +++ b/.gitlab/stages/02.test.yml @@ -82,6 +82,7 @@ test:unit_tests_configure: "--dependent-job test:unit_tests_configure" "--slurm-account ${CI_SLURM_ACCOUNT}" "--no-enable-warmup" + "--enable-error-extraction" ) - | export PYTHONPATH=$(pwd) diff --git a/.gitlab/stages/03.integration-tests.yml b/.gitlab/stages/03.integration-tests.yml index 70fa345e513..603c6e09b52 100644 --- a/.gitlab/stages/03.integration-tests.yml +++ b/.gitlab/stages/03.integration-tests.yml @@ -56,6 +56,7 @@ integration:configure: "--no-enable-warmup" "--dependent-job integration:configure" "--enable-lightweight-mode" + "--enable-error-extraction" ) - | export PYTHONPATH=$(pwd) diff --git a/.gitlab/stages/04.functional-tests.yml b/.gitlab/stages/04.functional-tests.yml index 515aa3e7f7f..f83a2de4563 100644 --- a/.gitlab/stages/04.functional-tests.yml +++ b/.gitlab/stages/04.functional-tests.yml @@ -87,6 +87,7 @@ functional:configure: "--record-checkpoints ${RECORD_CHECKPOINTS}" "--slurm-account ${CI_SLURM_ACCOUNT}" "--no-enable-warmup" + "--enable-error-extraction" ) - | SMOKE_ARGS=( @@ -101,6 +102,7 @@ functional:configure: "--record-checkpoints false" "--slurm-account ${CI_SLURM_ACCOUNT}" "--no-enable-warmup" + "--enable-error-extraction" ) - | export PYTHONPATH=$(pwd) diff --git a/docker/Dockerfile.linting b/docker/Dockerfile.linting index bf27b768374..15cea7b6b57 100644 --- a/docker/Dockerfile.linting +++ b/docker/Dockerfile.linting @@ -21,3 +21,13 @@ ARG JET_API_VERSION RUN --mount=type=secret,id=JET_INDEX_URLS \ JET_INDEX_URLS=$(cat /run/secrets/JET_INDEX_URLS) && \ uv pip install --no-cache-dir "jet-client~=2.0" --upgrade $JET_INDEX_URLS + +# Keep this in the internal-only stage so public CI has no internal service dependency. +ARG CI_SERVER_URL +ARG NEMO_CI_TRIAGE_COMMIT=8e65fa4ae20b58578d0e0f20ebea37ee7d92c8ea +RUN --mount=type=secret,id=NEMO_CI_TRIAGE_TOKEN \ + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraHeader \ + GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'oauth2:%s' "$(cat /run/secrets/NEMO_CI_TRIAGE_TOKEN)" | base64 -w0)" \ + uv pip install --no-cache-dir \ + "nemo-ci-triage @ git+${CI_SERVER_URL}/dl/nemo/nemo-ci-triage.git@${NEMO_CI_TRIAGE_COMMIT}" diff --git a/tests/test_utils/python_scripts/generate_jet_trigger_job.py b/tests/test_utils/python_scripts/generate_jet_trigger_job.py index aca23ad9fee..e3b471a629d 100644 --- a/tests/test_utils/python_scripts/generate_jet_trigger_job.py +++ b/tests/test_utils/python_scripts/generate_jet_trigger_job.py @@ -9,6 +9,26 @@ from tests.test_utils.python_scripts import recipe_parser BASE_PATH = pathlib.Path(__file__).parent.resolve() +TRIAGE_LOG_PATH = "jet_workload.log" +TRIAGE_REPORT_PATH = "error_report.json" + + +def build_test_script(command: str) -> str: + """Wrap a workload command with non-blocking error extraction.""" + return "\n".join( + [ + "set +e", + "set -o pipefail", + f"{command} 2>&1 | tee {TRIAGE_LOG_PATH}", + 'exit_code=${PIPESTATUS[0]}', + "set -e", + ( + f"extract-errors {TRIAGE_LOG_PATH} --output {TRIAGE_REPORT_PATH} " + '--exit-code "$exit_code" || true' + ), + 'exit "$exit_code"', + ] + ) @click.command() @@ -70,6 +90,11 @@ "Empty/unset disables the cadence filter." ), ) +@click.option( + "--enable-error-extraction/--no-enable-error-extraction", + default=False, + help="Extract a structured error report from GitLab child-job output.", +) def main( scope: str, environment: str, @@ -91,7 +116,8 @@ def main( enable_lightweight_mode: bool = False, enable_warmup: Optional[bool] = None, cadence: Optional[str] = None, -): + enable_error_extraction: bool = False, +) -> None: # Treat empty string as "no cadence filter" so callers can wire shell # variables in directly without conditional flag emission. cadence_arg = cadence or None @@ -217,14 +243,20 @@ def main( elif warmup_job != "": needs.append({"job": warmup_job}) + test_script = " ".join(script) + artifact_paths = ["results/"] + if enable_error_extraction: + test_script = build_test_script(test_script) + artifact_paths.extend([TRIAGE_LOG_PATH, TRIAGE_REPORT_PATH]) + gitlab_pipeline[test_case['spec']['test_case']] = { "stage": f"{test_case['spec']['model']}", "image": f"{container_image}:{container_tag}", "tags": job_tags, "timeout": "7 days", "needs": needs, - "script": [" ".join(script)], - "artifacts": {"paths": ["results/"], "when": "always"}, + "script": [test_script], + "artifacts": {"paths": artifact_paths, "when": "always"}, "allow_failure": test_case["spec"].get("allow_failure", False) or test_case["spec"]["model"] == "gpt-nemo", "retry": { diff --git a/tests/test_utils/python_scripts/notify.py b/tests/test_utils/python_scripts/notify.py index 103badc6ce5..db875271d89 100644 --- a/tests/test_utils/python_scripts/notify.py +++ b/tests/test_utils/python_scripts/notify.py @@ -5,50 +5,67 @@ import click import gitlab -import pandas as pd -import requests -import slack_sdk +from nemo_ci_triage.slack_notification import notification PROJECT_ID = int(os.getenv("CI_PROJECT_ID", 19378)) WEBHOOK_URL = os.getenv("WEBHOOK_URL", "") -GITLAB_ENDPOINT = os.getenv('GITLAB_ENDPOINT') -TAG_TEAM = bool(os.getenv('TAG_TEAM', 0)) -TEAM_SLUG = str(os.getenv('TEAM_SLUG')) +GITLAB_ENDPOINT = os.getenv("GITLAB_ENDPOINT") +if not GITLAB_ENDPOINT: + raise ValueError("GITLAB_ENDPOINT is required") +SERVER_URL = f"https://{GITLAB_ENDPOINT}" +PROJECT_URL = os.getenv("CI_PROJECT_URL", f"{SERVER_URL}/ADLR/megatron-lm") +TAG_TEAM = os.getenv("TAG_TEAM", "0") == "1" +TEAM_SLUG = os.getenv("TEAM_SLUG", "") + +JOB_PREFIXES = { + "unit-tests": "test:unit_tests", + "integration-tests": "integration:run_", + "functional-tests": "functional:run_", + "smoke-tests": "functional:smoke-", +} logging.basicConfig() logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -def get_gitlab_handle(): - return gitlab.Gitlab(f"https://{GITLAB_ENDPOINT}", private_token=os.getenv("RO_API_TOKEN")) +def get_gitlab_handle() -> gitlab.Gitlab: + return gitlab.Gitlab(SERVER_URL, private_token=os.getenv("RO_API_TOKEN")) -def get_jobs_per_bridge(pipeline_id: int, type_of_job: str): - bridge = {} - for pipeline_bridge in ( - get_gitlab_handle() - .projects.get(PROJECT_ID) - .pipelines.get(pipeline_id) - .bridges.list(get_all=True) - ): - if ( - not pipeline_bridge.name.startswith(type_of_job) - or pipeline_bridge.attributes['downstream_pipeline'] is None - ): +def _bridge_gpu(bridge_name: str) -> str: + for gpu in ("GB200", "H100", "A100"): + if gpu.lower() in bridge_name.lower(): + return gpu + return "Unknown" + + +def get_pipeline_jobs(pipeline_id: int, job_prefix: str) -> list[tuple[str, int, list[dict]]]: + """Collect Megatron-LM's direct child pipelines using nemo-ci-triage-2.""" + project = get_gitlab_handle().projects.get(PROJECT_ID) + root_pipeline = project.pipelines.get(pipeline_id) + pipeline_jobs = [] + + for bridge in root_pipeline.bridges.list(get_all=True): + downstream = bridge.attributes.get("downstream_pipeline") + if not bridge.name.startswith(job_prefix) or downstream is None: continue - if pipeline_bridge.name not in bridge: - bridge[pipeline_bridge.name] = [] + child_pipeline_id = downstream["id"] + jobs = notification.get_jobs_from_pipeline(project, child_pipeline_id) + bridge_gpu = _bridge_gpu(bridge.name) + for job in jobs: + if job["gpu"] == "Unknown": + job["gpu"] = bridge_gpu + pipeline_jobs.append((bridge.name, child_pipeline_id, jobs)) + + return pipeline_jobs - for job in ( - get_gitlab_handle() - .projects.get(PROJECT_ID) - .pipelines.get(pipeline_bridge.attributes['downstream_pipeline']['id']) - .jobs.list(get_all=True) - ): - bridge[pipeline_bridge.name].append(job) - return bridge + +def configure_notification_urls() -> None: + """Point nemo-ci-triage-2's notification links at Megatron-LM.""" + notification.JOB_URL_TEMPLATE = f"{PROJECT_URL}/-/jobs/{{}}" + notification.PIPELINE_URL_TEMPLATE = f"{PROJECT_URL}/-/pipelines/{{}}" @click.command() @@ -59,59 +76,24 @@ def get_jobs_per_bridge(pipeline_id: int, type_of_job: str): type=click.Choice(["unit-tests", "integration-tests", "functional-tests", "smoke-tests"]), ) @click.option("--pipeline-context", required=True, type=str) -@click.option("--pipeline-created-at", required=True, type=str) -def main(pipeline_id: int, check_for: str, pipeline_context: str, pipeline_created_at: str): - if check_for == "unit-tests": - bridges = get_jobs_per_bridge(pipeline_id, "test:unit_tests") - - if check_for == "integration-tests": - bridges = get_jobs_per_bridge(pipeline_id, "integration:run_") - - if check_for == "functional-tests": - bridges = get_jobs_per_bridge(pipeline_id, "functional:run_") +@click.option("--pipeline-created-at", required=True, type=str, expose_value=False) +def main(pipeline_id: int, check_for: str, pipeline_context: str) -> None: + pipeline_jobs = get_pipeline_jobs(pipeline_id, JOB_PREFIXES[check_for]) if check_for == "smoke-tests": - bridges = get_jobs_per_bridge(pipeline_id, "functional:smoke-") - if all(job.status == "success" for jobs in bridges.values() for job in jobs): + if all(job["status"] == "success" for _, _, jobs in pipeline_jobs for job in jobs): logger.info("All smoke tests passed, skipping Slack notification") return - pipeline_created_at_day = pd.Timestamp(pipeline_created_at).strftime("%Y-%m-%d") - - messages = [] - - for bridge_name in bridges.keys(): - - total_num_jobs = len(bridges[bridge_name]) - if all(job.status == "success" for job in bridges[bridge_name]): - messages.append( - f":doge3d: : All {total_num_jobs} passed." - ) - continue - - unsuccessful_jobs = [job for job in bridges[bridge_name] if job.status != "success"] - messages.append( - f":doctorge: : {len(unsuccessful_jobs)} of {total_num_jobs} failed." - ) - if TAG_TEAM: - messages.append( - f"cc {TEAM_SLUG} <@U09TX0DHZ97>: Critical event, please react as soon as possible." - ) - - for job in unsuccessful_jobs: - messages.append( - f"\tJob: " - ) - - messages.append("===============================================") - if not WEBHOOK_URL: logger.info("No webhook URL configured, skipping Slack notification") return - for message in messages: - response = slack_sdk.webhook.WebhookClient(WEBHOOK_URL).send(text=message) - logger.info(response.status_code) + configure_notification_urls() + slack_mentions = f"{TEAM_SLUG} <@U09TX0DHZ97>" if TAG_TEAM else None + notification.send_slack_notification( + "megatron-lm", pipeline_context, pipeline_jobs, slack_mentions, webhook_url=WEBHOOK_URL + ) if __name__ == "__main__": diff --git a/tests/test_utils/test_ci_triage.py b/tests/test_utils/test_ci_triage.py new file mode 100644 index 00000000000..74ca1bc354a --- /dev/null +++ b/tests/test_utils/test_ci_triage.py @@ -0,0 +1,147 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import yaml +from click.testing import CliRunner + +from tests.test_utils.python_scripts import generate_jet_trigger_job, recipe_parser + + +@pytest.fixture +def notify_module(): + pytest.importorskip("nemo_ci_triage.slack_notification") + from tests.test_utils.python_scripts import notify + + return notify + + +def test_build_test_script_preserves_workload_exit_code(): + script = generate_jet_trigger_job.build_test_script("python workload.py") + + assert "set +e" in script + assert "set -o pipefail" in script + assert "python workload.py 2>&1 | tee jet_workload.log" in script + assert "exit_code=${PIPESTATUS[0]}" in script + assert "set -e" in script + assert "extract-errors jet_workload.log" in script + assert "--output error_report.json" in script + assert 'exit "$exit_code"' in script + + +@pytest.mark.parametrize("enable_error_extraction", [False, True]) +def test_error_extraction_is_opt_in_for_generated_jobs( + monkeypatch, tmp_path, enable_error_extraction +): + workload = recipe_parser.dotdict( + type="basic", + spec=recipe_parser.dotdict(model="gpt", environment="dev", test_case="triage-test"), + ) + monkeypatch.setattr( + generate_jet_trigger_job.recipe_parser, "load_workloads", lambda **_kwargs: [workload] + ) + output_path = tmp_path / "pipeline.yml" + args = [ + "--scope", + "mr", + "--environment", + "dev", + "--n-repeat", + "1", + "--time-limit", + "60", + "--test-cases", + "all", + "--platform", + "dgx_h100", + "--cluster", + "ghci", + "--output-path", + str(output_path), + "--container-image", + "utility", + "--container-tag", + "test", + "--dependent-job", + "functional:configure", + "--record-checkpoints", + "false", + "--slurm-account", + "mcore", + "--no-enable-warmup", + ] + if enable_error_extraction: + args.append("--enable-error-extraction") + + result = CliRunner().invoke(generate_jet_trigger_job.main, args) + + assert result.exit_code == 0, result.output + job = yaml.safe_load(output_path.read_text())["triage-test"] + if enable_error_extraction: + assert "extract-errors jet_workload.log" in job["script"][0] + assert job["artifacts"]["paths"] == ["results/", "jet_workload.log", "error_report.json"] + else: + assert "extract-errors" not in job["script"][0] + assert job["artifacts"]["paths"] == ["results/"] + + +def test_get_pipeline_jobs_uses_triage_collector(monkeypatch, notify_module): + notify = notify_module + bridge = SimpleNamespace( + name="functional:run_dev_dgx_h100", attributes={"downstream_pipeline": {"id": 101}} + ) + root_pipeline = Mock() + root_pipeline.bridges.list.return_value = [bridge] + project = Mock() + project.pipelines.get.return_value = root_pipeline + handle = Mock() + handle.projects.get.return_value = project + jobs = [{"status": "failed", "gpu": "Unknown"}] + + monkeypatch.setattr(notify, "get_gitlab_handle", lambda: handle) + collector = Mock(return_value=jobs) + monkeypatch.setattr(notify.notification, "get_jobs_from_pipeline", collector) + + assert notify.get_pipeline_jobs(123, "functional:run_") == [ + ("functional:run_dev_dgx_h100", 101, [{"status": "failed", "gpu": "H100"}]) + ] + collector.assert_called_once_with(project, 101) + + +def test_notification_delegates_to_triage_package(monkeypatch, notify_module): + notify = notify_module + pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [{"status": "failed"}])] + sender = Mock() + + monkeypatch.setattr(notify, "WEBHOOK_URL", "https://slack.invalid/webhook") + monkeypatch.setattr(notify, "PROJECT_URL", "https://ci.example.com/ADLR/megatron-lm") + monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args: pipeline_jobs) + monkeypatch.setattr(notify.notification, "send_slack_notification", sender) + + result = CliRunner().invoke( + notify.main, + [ + "--pipeline-id", + "123", + "--check-for", + "functional-tests", + "--pipeline-context", + "mr", + "--pipeline-created-at", + "2026-07-12T00:00:00Z", + ], + ) + + assert result.exit_code == 0, result.output + assert ( + notify.notification.JOB_URL_TEMPLATE == "https://ci.example.com/ADLR/megatron-lm/-/jobs/{}" + ) + assert ( + notify.notification.PIPELINE_URL_TEMPLATE + == "https://ci.example.com/ADLR/megatron-lm/-/pipelines/{}" + ) + sender.assert_called_once_with( + "megatron-lm", "mr", pipeline_jobs, None, webhook_url="https://slack.invalid/webhook" + ) From 3927d9b961824892b45b9f6e497ef0ff770ce495 Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 15 Jul 2026 15:06:12 -0700 Subject: [PATCH 035/290] feat(docker): Add NCCL installation script and install NCCL 2.30.4 (#5815) Signed-off-by: Ajay Balasa --- docker/Dockerfile.ci.dev | 5 ++++ docker/common/install_nccl.sh | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 docker/common/install_nccl.sh diff --git a/docker/Dockerfile.ci.dev b/docker/Dockerfile.ci.dev index 8a18b85356b..bf9ddbe8ed4 100644 --- a/docker/Dockerfile.ci.dev +++ b/docker/Dockerfile.ci.dev @@ -7,6 +7,8 @@ ENV PIP_CONSTRAINT="" ENV DEBIAN_FRONTEND=noninteractive ARG UV_VERSION=0.7.2 ARG YQ_VERSION=4.44.1 +# NCCL 2.30.4 supports CUDA 13.x; NVIDIA publishes its CUDA 13 package as cuda13.2. +ARG NCCL_VERSION=2.30.4-1+cuda13.2 ENV PATH="/root/.local/bin:$PATH" ARG UV_PROJECT_ENVIRONMENT=/opt/venv ENV UV_PROJECT_ENVIRONMENT=${UV_PROJECT_ENVIRONMENT} @@ -31,6 +33,9 @@ RUN bash -ex <<"EOF" curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | sh EOF +RUN --mount=type=bind,source=docker/common/install_nccl.sh,target=/opt/install_nccl.sh \ + bash /opt/install_nccl.sh --NCCL_VER=${NCCL_VERSION} + COPY README.md pyproject.toml uv.lock /workspace/ COPY megatron/core/__init__.py /workspace/megatron/core/ COPY megatron/core/package_info.py /workspace/megatron/core/ diff --git a/docker/common/install_nccl.sh b/docker/common/install_nccl.sh new file mode 100644 index 00000000000..b303009b625 --- /dev/null +++ b/docker/common/install_nccl.sh @@ -0,0 +1,48 @@ +# Copyright (c) 2026, 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. + +#!/bin/bash + +set -ex + +NCCL_VER="2.30.4-1+cuda13.2" + +for i in "$@"; do + case $i in + --NCCL_VER=?*) NCCL_VER="${i#*=}";; + *) ;; + esac + shift +done + +ARCH=$(uname -m) +if [ "$ARCH" = "amd64" ];then ARCH="x86_64";fi +if [ "$ARCH" = "aarch64" ];then ARCH="sbsa";fi + +curl -fsSLO https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/${ARCH}/cuda-keyring_1.1-1_all.deb +dpkg -i cuda-keyring_1.1-1_all.deb +rm cuda-keyring_1.1-1_all.deb + +apt-get update + +if [[ $(apt list --installed | grep libnccl) ]]; then + apt-get remove --purge -y --allow-change-held-packages libnccl* +fi + +apt-get install -y --no-install-recommends \ + libnccl2=${NCCL_VER} \ + libnccl-dev=${NCCL_VER} \ + +apt-get clean +rm -rf /var/lib/apt/lists/* From 53a2dd56fec6bd0938bedf0207cce052944f3bcf Mon Sep 17 00:00:00 2001 From: Shanmugam Ramasamy <111910568+shanmugamr1992@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:30 -0700 Subject: [PATCH 036/290] IMA fix by making the copy of book keeping buffer to GPU blocking (#5715) Signed-off-by: Shanmugam Ramasamy <111910568+shanmugamr1992@users.noreply.github.com> --- .../inference/contexts/dynamic_context.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index edb632fb74a..e7c381bb859 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -2455,13 +2455,15 @@ def transfer_bookkeeping_to_gpu(self) -> None: """Batch transfer CPU bookkeeping state to GPU staging buffers. Called after initialize_attention_state() and before the forward pass. - All copies use non_blocking=True with pinned CPU memory. CUDA stream - ordering guarantees the forward pass sees completed transfers. + The coalesced H2D from the pinned `_cpu_bookkeeping_buf` uses + ``non_blocking=False``: that buffer is re-staged in place on the next + step, so an async copy can race with host writes and corrupt GPU + bookkeeping (see the inline comment at the copy site). The bookkeeping fields are backed by one contiguous pinned CPU buffer - and one contiguous GPU buffer; a single cudaMemcpyAsync suffices. - Request-level staging slots are refreshed from the persistent CPU - tensors immediately before the H2D (GPU reads them at `[:n_active]` + and one contiguous GPU buffer; a single memcpy covers the whole + transfer. Request-level staging slots are refreshed from the persistent + CPU tensors immediately before the H2D (GPU reads them at `[:n_active]` while CPU bookkeeping keeps them at `[paused_count:total_count)`). """ n_active = self.total_request_count - self.paused_request_count @@ -2509,7 +2511,17 @@ def transfer_bookkeeping_to_gpu(self) -> None: # Copying the whole (max_tokens + max_requests)-sized buffer including # unused slots is cheap (~71 KB total, ~3-5 us on PCIe Gen4) and saves # 8 redundant launch overheads vs. the prior per-field copies. - self.gpu_view._buf.copy_(self._cpu_bookkeeping_buf, non_blocking=True) + # This copy MUST be blocking. `_cpu_bookkeeping_buf` is a pinned host + # buffer that is re-staged in place on the very next step (the staging + # writes above plus `initialize_attention_state()`). A non_blocking copy + # lets the host overwrite those bytes while the async H2D is still in + # flight, so the GPU reads corrupted bookkeeping (token/block indices) + # and dereferences out-of-bounds memory -> async `CUDA error: an illegal + # memory access`. The CUDA-graph warmup loop makes the race fire + # reliably. Blocking costs a per-step host<->device sync, but that is + # negligible relative to the forward pass (benchmarked: no measurable + # generation-throughput difference vs. an async double-buffered copy). + self.gpu_view._buf.copy_(self._cpu_bookkeeping_buf, non_blocking=False) # MHA metadata GPU views were already bound to state_data in # initialize_attention_state(); the H2D above populates the underlying From 802da56ffb018925d97e7c49bfe3fae0897803cf Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Wed, 15 Jul 2026 20:30:49 -0400 Subject: [PATCH 037/290] Add GPTModel to HybridModel migration guide (#5698) Signed-off-by: Philip Petrakian --- docs/index.md | 1 + docs/user-guide/hybrid-model-migration.md | 274 ++++++++++++++++++++ docs/user-guide/index.md | 1 + skills/mcore-migrate-gpt-to-hybrid/SKILL.md | 43 +++ 4 files changed, 319 insertions(+) create mode 100644 docs/user-guide/hybrid-model-migration.md create mode 100644 skills/mcore-migrate-gpt-to-hybrid/SKILL.md diff --git a/docs/index.md b/docs/index.md index 11337315588..623f1514614 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,7 @@ get-started/quickstart user-guide/data-preparation user-guide/training-examples user-guide/parallelism-guide +user-guide/hybrid-model-migration ``` ```{toctree} diff --git a/docs/user-guide/hybrid-model-migration.md b/docs/user-guide/hybrid-model-migration.md new file mode 100644 index 00000000000..445a93e81e2 --- /dev/null +++ b/docs/user-guide/hybrid-model-migration.md @@ -0,0 +1,274 @@ + + +# Migrate from GPTModel to HybridModel + +This guide describes how to replace a Megatron Core `GPTModel` with a +`HybridModel`, convert an existing distributed checkpoint, and start or resume +training with the converted weights. The conversion stays in Megatron's +distributed-checkpoint format; it does not use Hugging Face as an intermediate +format. + +## 1. What Is HybridModel? + +A standard `GPTModel` decoder layer contains both a self-attention sublayer and +an MLP or MoE sublayer under one layer index. `HybridModel` instead builds an +ordered stack in which every position represents one layer family. The order is +described by `--hybrid-layer-pattern`: + +| Symbol | Layer family | +|--------|--------------| +| `M` | Mamba-2 state-space layer | +| `G` | Gated Delta Network (GDN) layer | +| `*` | Self-attention layer | +| `D` | DeepSeek Sparse Attention (DSA) layer | +| `-` | Dense MLP layer | +| `E` | Mixture-of-Experts (MoE) layer | + +One pattern symbol is one HybridModel layer. Consequently, one GPT transformer +block becomes two HybridModel layers when preserving the original architecture: + +| Source architecture | Equivalent HybridModel pattern | Hybrid layer count | +|---------------------|--------------------------------|--------------------| +| Two dense GPT blocks | `*-*-` | 4 | +| Two all-layer MoE GPT blocks | `*E*E` | 4 | + +For example, source GPT layer 0 is split between Hybrid layers 0 and 1: +its attention parameters move to the first `*`, and its MLP parameters move to +the first `-` or `E`. Source GPT layer 1 maps to the next pair, and so on. + +The pattern can also describe execution layout. A `|` marks a pipeline segment +boundary, and `/` introduces a repeated Multi-Token Prediction (MTP) pattern. +For example, `*-*-|*-*-` places four GPT-equivalent blocks across two pipeline +segments. Separators do not count as layers. + +HybridModel provides the following benefits: + +- Different layer families can be composed in one model without forcing every + decoder block to have the same structure. +- Attention and dense or expert MLP layers can be placed and configured + independently. +- Mamba, GDN, standard or DeepSeek attention, dense MLP, and MoE layers can use + one pattern-driven model interface. +- Patterns that include Mamba can replace some quadratic attention layers with + subquadratic sequence mixing and fixed-size recurrent inference state. +- Pipeline and virtual-pipeline segmentation can be expressed with the model + pattern instead of a separate layer layout. +- The same model abstraction can describe a pure transformer (`*-` repeated), + a pure Mamba model, or a heterogeneous architecture. + +These capabilities do not imply an automatic throughput or quality improvement. +An architecture-preserving `*-` or `*E` migration should be validated for +numerical equivalence, and a pattern that adds another layer family should be +treated as a new architecture and benchmarked independently. + +## 2. How to Convert a Checkpoint + +Use +[`tools/checkpoint/gpt_hybrid_conversion.py`](https://github.com/NVIDIA/Megatron-LM/blob/main/tools/checkpoint/gpt_hybrid_conversion.py) +to convert a `GPTModel` checkpoint directly to `HybridModel` state-dict keys. + +### Choose an architecture-preserving pattern + +For a source checkpoint with *N* GPT layers: + +- Use `*-` repeated *N* times for a dense GPT model. +- Use `*E` repeated *N* times for a GPT model whose MLP in every layer is MoE. + +The converter maps parameters by occurrence, not merely by numeric layer index: + +| Source parameter | Target parameter | +|------------------|------------------| +| Attention from GPT layer *i* | The *i*-th `*` layer | +| MLP or MoE from GPT layer *i* | The *i*-th `-` or `E` layer | +| Embedding and output weights | Copied without changing their model role | +| `decoder.final_layernorm` | Renamed to `decoder.final_norm` | + +### Check the prerequisites + +The source must use one of these distributed-checkpoint formats: + +- `torch_dist` +- `fsdp_dtensor` + +Prefer a top-level checkpoint root containing +`latest_checkpointed_iteration.txt` as `--load-dir`. If `--load-dir` points +directly to a directory containing `metadata.json`, the converter writes a flat +target without a tracker file; the standard training entry point expects a +checkpoint root and tracker. + +Run the converter from the repository root in a Megatron environment. A plain +`python` process is sufficient; `torchrun` and a GPU are not required. The tool +gathers full logical tensors on CPU, so the host must have enough memory for the +unsharded source and target model state dicts. Always use a target directory +that is different from the source directory. + +```{warning} +This is a weights conversion, not a resumable full training-state conversion. +Sharded optimizer, RNG, rerun, and Transformer Engine `_extra_state` tensors are +not converted. Some non-tensor entries can remain in `common.pt`, but they do +not constitute a converted optimizer or RNG state. Start the converted model +with a fresh optimizer and RNG state. +``` + +### Run the conversion + +The following example converts a four-layer dense GPT model. Its equivalent +HybridModel has the eight-layer pattern `*-*-*-*-`: + +```bash +uv run python tools/checkpoint/gpt_hybrid_conversion.py \ + --direction gpt-to-hybrid \ + --load-dir /path/to/gpt-checkpoints \ + --save-dir /path/to/hybrid-checkpoints \ + --hybrid-layer-pattern '*-*-*-*-' \ + --reset-iterations +``` + +Always quote the pattern because `*` and `|` have special meaning to a shell. +`--input-format auto` and `--output-format auto` are the defaults: the tool +detects the source backend and writes the same backend. `--reset-iterations` +resets the checkpoint iteration, consumed-sample counters, and cached +`train_iters` and `train_samples`; omit it when the new run must retain that +schedule metadata. + +The number of `*` positions and the number of `-` or `E` positions must both +equal the source GPT layer count. The pattern validator rejects GDN, DSA, and +mixed dense/MoE layouts. When cached training arguments are present, the tool +also rejects interleaved MoE, experimental or linear attention, heterogeneous +block specifications, Multi-Latent Attention, and MTP checkpoints. That +source-feature validation is incomplete when `common.pt` has no cached `args` +or an older checkpoint lacks a field, so verify those features manually. + +The conversion recognizes standard attention and MLP/MoE state-dict keys only. +Other layer-local tensors are omitted. The documented `hybrid_stack_spec` also +uses Transformer Engine's fused layernorm/linear layout; a local or otherwise +non-TE source layout requires a compatible custom Hybrid stack and key +conversion. Always perform the strict-load check described below. + +Do not append an MTP `/...` suffix during conversion. The converter only maps +the main pattern before the first `/`, so it does not create MTP parameters. + +When the source path is a checkpoint root with +`latest_checkpointed_iteration.txt`, the output contains an iteration directory +and a matching tracker file. The saved full-shape tensors can be resharded by a +later Megatron load for a different tensor, pipeline, expert, or FSDP layout. + +## 3. How to Train a Model + +### Update the training command + +Start with the command that trained the GPT model and make these changes: + +1. Replace `pretrain_gpt.py` with `pretrain_hybrid.py`. +2. Remove `--num-layers` and add the same ordered main-layer symbols used for + conversion. Pipeline `|` separators may be added or moved. The command-line + parser derives `num_layers` from the pattern. +3. Select the HybridModel stack specification with + `--spec megatron.core.models.hybrid.hybrid_layer_specs hybrid_stack_spec`. +4. Load the converted weights with a fresh optimizer and write new training + checkpoints to a separate directory. Set `--ckpt-format` to the converter's + `torch_dist` or `fsdp_dtensor` output format. + +A minimal migration of the model and checkpoint arguments looks like this: + +```diff +- torchrun --nproc_per_node=8 pretrain_gpt.py \ +- --num-layers 4 \ +- --load /path/to/gpt-checkpoints \ +- --save /path/to/gpt-checkpoints ++ torchrun --nproc_per_node=8 pretrain_hybrid.py \ ++ --hybrid-layer-pattern '*-*-*-*-' \ ++ --pretrained-checkpoint /path/to/hybrid-checkpoints \ # first-time only ++ --load /path/to/new-training-checkpoints \ ++ --save /path/to/new-training-checkpoints +``` + +Keep the existing architecture, optimizer, precision, data, and basic +TP/DP/EP/CP arguments unless this guide identifies a required change. Review +pattern-driven pipeline layout and GPT-specific dataset features separately. + +```{warning} +`pretrain_hybrid.py` does not select `GPTFIMDataset` when `--fim-data` is set. +A GPT training workflow that uses fill-in-the-middle data needs a custom dataset +path or equivalent Hybrid entry-point support before migration. +``` + +With an empty `--load` directory, `--pretrained-checkpoint` loads the converted +weights with finetuning semantics: iteration starts at zero, and optimizer and +RNG state are not restored. After the job writes a checkpoint to `--load`, later +launches resume the new HybridModel training state normally. + +### Train from scratch + +To initialize every layer from scratch, use the same `pretrain_hybrid.py`, +`--hybrid-layer-pattern`, and `--spec` arguments, but omit +`--pretrained-checkpoint`. Point `--load` and `--save` at the new run directory +if later launches should resume it. Unlike checkpoint conversion, training from +scratch can use compatible layer families supported by the selected HybridModel +stack specification and can include an MTP suffix such as `M*M*/MM/MM`. +Pattern constraints still apply; for example, standard attention `*` and DSA +`D` cannot be used in the same model. + +### Account for expanded layer indices + +Any setting, mapping, or callback indexed by decoder layer must use HybridModel +indices. For the pattern `*E*E`, source layer 0 attention is Hybrid layer 0 and +its MoE is Hybrid layer 1; source layer 1 attention is Hybrid layer 2 and its +MoE is Hybrid layer 3. Expand attention-only lists with inactive entries for +the intervening MLP or MoE positions. + +### Configure pipeline parallelism + +For pipeline parallelism, add `|` separators without changing the ordered layer +symbols. For example, the converted pattern `*-*-*-*-` can be trained with two +pipeline segments as `*-*-|*-*-`. The number of pipe-delimited segments must be +divisible by `--pipeline-model-parallel-size`. + +The pattern replaces conventional pipeline layout controls. Remove +`--num-layers-per-virtual-pipeline-stage`, +`--num-virtual-stages-per-pipeline-rank`, `--pipeline-model-parallel-layout`, +`--account-for-embedding-in-pipeline-split`, and +`--account-for-loss-in-pipeline-split`. When the pattern contains `|`, also +remove `--decoder-first-pipeline-num-layers` and +`--decoder-last-pipeline-num-layers`. Express virtual-pipeline segmentation +with additional pipe-delimited segments instead. + +The declarative `HybridModelBuilder` currently rejects virtual pipeline +parallelism. Pipe-defined virtual stages are supported by the +`pretrain_hybrid.py` CLI builder, but custom builder users must avoid VPP or use +a path that explicitly supports it. + +### Update custom providers and conversion mappings + +Custom providers and conversion mappings also need to account for these API and +state-dict differences: + +- Build or register `HybridModel` instead of `GPTModel`. +- Supply a `hybrid_stack_spec` instead of a GPT transformer-layer spec. +- Set programmatic `num_layers` to the number of layer symbols in the main + pattern; unlike the CLI path, a custom provider might not derive it. +- Map attention and MLP/MoE parameters to their separate Hybrid layer indices. +- Use `decoder.final_norm` in HybridModel mappings instead of + `decoder.final_layernorm`. +- Expand per-layer settings such as attention-window schedules to the full + HybridModel pattern. + +### Validate before scaling up + +Before starting a long run: + +- Load the converted checkpoint strictly and confirm that no model keys or + tensor shapes are missing or unexpected. +- For a `*-` or `*E` migration, compare logits on a fixed batch against the + source GPT model within the expected precision tolerance. +- Run a few training iterations and inspect the loss, gradient norms, and + parameter counts by layer. +- Save and reload one new checkpoint to confirm that the new optimizer and RNG + state resume correctly. diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 2262709bec4..522b29299da 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -24,4 +24,5 @@ training-examples parallelism-guide deterministic-training features/index +hybrid-model-migration ``` diff --git a/skills/mcore-migrate-gpt-to-hybrid/SKILL.md b/skills/mcore-migrate-gpt-to-hybrid/SKILL.md new file mode 100644 index 00000000000..486519ecfb9 --- /dev/null +++ b/skills/mcore-migrate-gpt-to-hybrid/SKILL.md @@ -0,0 +1,43 @@ +--- +name: mcore-migrate-gpt-to-hybrid +description: Migration guide for moving Megatron Core GPTModel checkpoints, model providers, training commands, and layer mappings to HybridModel. +license: Apache-2.0 +when_to_use: Migrating or reviewing a GPTModel checkpoint or training workflow for HybridModel; choosing or reviewing a hybrid layer pattern; running gpt_hybrid_conversion.py; loading a converted checkpoint; diagnosing GPT-to-Hybrid migration issues; 'migrate GPTModel to HybridModel', 'convert GPT checkpoint to HybridModel', 'hybrid layer pattern'. +metadata: + author: Philip Petrakian +--- + +# GPTModel to HybridModel Migration + +## Answer-First Migration Guidance + +- The canonical source is + [`docs/user-guide/hybrid-model-migration.md`](../../docs/user-guide/hybrid-model-migration.md). +- Read the canonical document completely before answering, planning, reviewing, + editing, converting, or training. +- Keep migration behavior, commands, mappings, prerequisites, limitations, and + validation in the canonical document only. Do not duplicate them in this + skill. + +--- + +## Workflow + +1. Pull the task artifact first: checkpoint metadata, model provider or config, + training command, conversion log, diff, or failure output. +2. Read the canonical migration document completely. +3. Follow only the relevant document sections. Do not invent an unsupported + migration path or silently change the target architecture. +4. Validate the result proportionately, invoking the relevant repository build + and testing skills when applicable. +5. Report the outcome and link the canonical document for human readers. + +--- + +## Documentation Drift + +If the implementation and migration guide disagree: + +1. Report the discrepancy before continuing. +2. If the task authorizes a correction, update the canonical document first. +3. Do not add a competing migration rule to this skill. From 38626c28808021544bb1b2d258bb1d49d71b3c8b Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 15 Jul 2026 20:12:59 -0700 Subject: [PATCH 038/290] Pair frozen FSDP backward hooks (#5710) Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 20 +++-- .../{mfsdp_v1 => mfsdp_v2}/test_annotation.py | 77 ++++++++++++++++--- 2 files changed, 79 insertions(+), 18 deletions(-) rename tests/unit_tests/distributed/{mfsdp_v1 => mfsdp_v2}/test_annotation.py (59%) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index be2c50fe277..71144b311d9 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -87,7 +87,7 @@ class FsdpModule: _parameter_groups: tuple[FsdpParameterGroup, ...] _context: FsdpContext | None _ready_grad_parameters: set[nn.Parameter] - _num_training_parameters: int + _num_trainable_parameters: int def __init__( self, @@ -117,7 +117,7 @@ def __init__( ] self._parameter_groups = tuple(parameter_groups) self._ready_grad_parameters = set() - self._num_training_parameters = sum( + self._num_trainable_parameters = sum( len(group.sharded_parameters) for group in self._parameter_groups if group.requires_grad ) self._register_hooks() @@ -182,10 +182,16 @@ def _register_hooks(self) -> None: module.register_forward_pre_hook(lambda _module, _args: self.pre_forward()) module.register_forward_hook(lambda _module, _args, _output: self.post_forward()) module.register_full_backward_pre_hook(lambda _module, _grad_output: self.pre_backward()) - # Gradient reduction is parameter-completion based: once every owned - # Parameter has accumulated its grad, this FsdpModule can reduce and - # reshard. Module full-backward hooks can fire before that when module - # inputs do not require grad. + if self._num_trainable_parameters == 0: + module.register_full_backward_hook( + lambda _module, _grad_input, _grad_output: self.post_backward() + ) + return + + # Gradient reduction for trainable parameters is parameter-completion + # based: once every owned Parameter has accumulated its grad, this + # FsdpModule can reduce and reshard. Module full-backward hooks can fire + # before that when module inputs do not require grad. for group in self._parameter_groups: if not group.requires_grad: continue @@ -195,7 +201,7 @@ def _register_hooks(self) -> None: def _make_grad_hook(self, parameter: nn.Parameter) -> Callable[[nn.Parameter], None]: def grad_hook(_parameter: nn.Parameter) -> None: self._ready_grad_parameters.add(parameter) - if len(self._ready_grad_parameters) == self._num_training_parameters: + if len(self._ready_grad_parameters) == self._num_trainable_parameters: self.post_backward() return grad_hook diff --git a/tests/unit_tests/distributed/mfsdp_v1/test_annotation.py b/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py similarity index 59% rename from tests/unit_tests/distributed/mfsdp_v1/test_annotation.py rename to tests/unit_tests/distributed/mfsdp_v2/test_annotation.py index 9aee7734172..cdb6db0f182 100644 --- a/tests/unit_tests/distributed/mfsdp_v1/test_annotation.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py @@ -38,6 +38,17 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x +class FrozenFirstLayerModel(nn.Module): + def __init__(self, dim: int) -> None: + super().__init__() + self.bias = nn.Parameter(torch.ones(dim)) + self.layers = nn.ModuleList([nn.Linear(dim, dim, bias=False) for _ in range(2)]) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = torch.relu(self.layers[0](x)) + return torch.relu(self.layers[1](x + self.bias)) + + def _flat_placements() -> Placements: return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) @@ -63,16 +74,10 @@ def record_pop() -> None: monkeypatch.setattr(torch.cuda.nvtx, "range_pop", record_pop) -def _get_distributed_setup(request: pytest.FixtureRequest): - try: - return request.getfixturevalue("distributed_setup") - except pytest.FixtureLookupError: - pytest.skip("distributed_setup fixture is only available in the Megatron-FSDP test bucket") - - -def test_fsdp_sibling_roots_emit_root_nvtx_ranges_after_training_step(request, monkeypatch): +def test_fsdp_sibling_roots_emit_root_nvtx_ranges_after_training_step( + distributed_setup, monkeypatch +): """Independent FSDP roots should each emit root-labeled NVTX ranges.""" - distributed_setup = _get_distributed_setup(request) events: list[NvtxEvent] = [] _setup_nvtx_recording(monkeypatch, events) model = NestedLinearModel(dim=4).to(distributed_setup.device) @@ -94,9 +99,8 @@ def test_fsdp_sibling_roots_emit_root_nvtx_ranges_after_training_step(request, m ] -def test_fsdp_training_hooks_emit_stacked_nvtx_ranges(request, monkeypatch): +def test_fsdp_training_hooks_emit_stacked_nvtx_ranges(distributed_setup, monkeypatch): """Nested training hooks should emit concise NVTX ranges.""" - distributed_setup = _get_distributed_setup(request) events: list[NvtxEvent] = [] _setup_nvtx_recording(monkeypatch, events) model = NestedLinearModel(dim=4).to(distributed_setup.device) @@ -121,3 +125,54 @@ def test_fsdp_training_hooks_emit_stacked_nvtx_ranges(request, monkeypatch): ("pop", "layers.0", "backward"), ("pop", "", "backward"), ] + + +def test_fsdp_frozen_parameters_emit_balanced_backward_nvtx_range(distributed_setup, monkeypatch): + """Frozen FSDP units should still balance backward NVTX ranges.""" + events: list[NvtxEvent] = [] + _setup_nvtx_recording(monkeypatch, events) + model = nn.Linear(4, 4, bias=False).to(distributed_setup.device) + for parameter in model.parameters(): + parameter.requires_grad_(False) + mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + x = torch.ones(2, 4, device=distributed_setup.device, requires_grad=True) + model(x).sum().backward() + + assert [(event.kind, event.name, event.phase) for event in events] == [ + ("push", "", "forward"), + ("pop", "", "forward"), + ("push", "", "backward"), + ("pop", "", "backward"), + ] + + +def test_fsdp_frozen_child_without_grad_inputs_skips_backward_nvtx_range( + distributed_setup, monkeypatch +): + """Frozen FSDP children outside the backward graph should not emit backward ranges.""" + events: list[NvtxEvent] = [] + _setup_nvtx_recording(monkeypatch, events) + model = FrozenFirstLayerModel(dim=4).to(distributed_setup.device) + for parameter in model.layers[0].parameters(): + parameter.requires_grad_(False) + mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) + fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + model(torch.ones(2, 4, device=distributed_setup.device)).sum().backward() + + assert [(event.kind, event.name, event.phase) for event in events] == [ + ("push", "", "forward"), + ("push", "layers.0", "forward"), + ("pop", "layers.0", "forward"), + ("push", "layers.1", "forward"), + ("pop", "layers.1", "forward"), + ("pop", "", "forward"), + ("push", "", "backward"), + ("push", "layers.1", "backward"), + ("pop", "layers.1", "backward"), + ("pop", "", "backward"), + ] From c2f0df5ecce6da6b82498f4698b5718a8540d04c Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 15 Jul 2026 21:03:33 -0700 Subject: [PATCH 039/290] Clarify NVIDIA email signing guidance (#5699) Signed-off-by: Jingyue Wu --- AGENTS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e747867f8b0..996c38c17bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,12 @@ skill keyword — infer it from the artifact you read. - All PRs must be created as **drafts**. Use `gh pr create --draft` or the GitHub UI draft option. - Never push branches directly to `https://github.com/NVIDIA/Megatron-LM`. You must push your branch to a personal fork (e.g. `https://github.com//Megatron-LM`), then open a PR from the fork's branch against `NVIDIA/Megatron-LM`. -- Commit PR changes with both `-s` and `-S`: `-s` adds the required `Signed-off-by` trailer, and `-S` signs the commit so copy-pr-bot and `/ok to test` can verify the pushed commit without manually specifying the SHA. +- Commit PR changes with both `-s` and `-S`: `-s` adds the required + `Signed-off-by` trailer, and `-S` signs the commit so copy-pr-bot and `/ok to +test` can verify the pushed commit without manually specifying the SHA. +Megatron Core engineers at NVIDIA should sign using their NVIDIA emails so they +are automatically added to the right user groups on the internal Slack +workspace. - Read @docs/developer/contribute.md for the full contribution policy, including code style, commit message conventions, and issue guidelines. ### Code Quality From d981f66bebc9f1c46ae80ddf39d66f7efaa6bc02 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 15 Jul 2026 23:26:17 -0700 Subject: [PATCH 040/290] Overlap FSDP communication with compute (#5719) Signed-off-by: Jingyue Wu --- .../experimental/indexed_order.py | 53 ++++++ .../src/megatron_fsdp/experimental/module.py | 180 +++++++++++++----- .../experimental/parameter_group.py | 55 +++--- .../distributed/mfsdp_v2/test_context.py | 47 +++++ .../distributed/mfsdp_v2/test_fully_shard.py | 45 +++-- 5 files changed, 294 insertions(+), 86 deletions(-) create mode 100644 megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py new file mode 100644 index 00000000000..d7c9c63ed0c --- /dev/null +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py @@ -0,0 +1,53 @@ +# Copyright (c) 2026, 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. + +"""Ordered sequence with indexed item lookup.""" + +from collections.abc import Iterator +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class IndexedOrder(Generic[T]): + """Insertion order with constant-time successor lookup by item.""" + + def __init__(self) -> None: + """Create an empty indexed order.""" + self._items: list[T] = [] + self._index_by_item: dict[T, int] = {} + + def append(self, item: T) -> None: + """Append ``item`` to the order. + + Args: + item: Item to append. + + Raises: + ValueError: If ``item`` is already present in the order. + """ + if item in self._index_by_item: + raise ValueError("IndexedOrder does not support duplicate items.") + self._index_by_item[item] = len(self._items) + self._items.append(item) + + def __iter__(self) -> Iterator[T]: + """Iterate over items in order.""" + return iter(self._items) + + def next_item(self, item: T) -> T | None: + """Return the item that follows ``item``, if any.""" + index = self._index_by_item[item] + next_index = index + 1 + return self._items[next_index] if next_index < len(self._items) else None diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 71144b311d9..a1c4999d141 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -14,8 +14,6 @@ """Module mixin for the minimal Megatron-FSDP path.""" -import dataclasses -from collections import deque from collections.abc import Callable from typing import Literal, cast @@ -24,28 +22,26 @@ from torch.distributed import DeviceMesh from ..mixed_precision import MixedPrecisionPolicy +from .indexed_order import IndexedOrder from .parameter_group import FsdpParameterGroup, contained_in_parameter_group from .placement import MeshAxis, Placements -@dataclasses.dataclass(frozen=True) -class DelayedRelease: - """A module whose unsharded storage can be released after its consumer event.""" - - consumer_event: torch.cuda.Event | None - module: "FsdpModule" - - class FsdpContext: - """Runtime state, stream, and release scheduler shared by one FSDP subtree.""" + """Runtime stream and prefetch state shared by one FSDP subtree.""" allgather_stream: torch.cuda.Stream - delayed_releases: deque[DelayedRelease] + reduce_scatter_stream: torch.cuda.Stream # HFSDP/HSDP need explicit last-microbatch state. First-microbatch state is # unnecessary because it can be detected when ``model_weight``, after syncing # from ``main_weight``, has placements different from ``Placements.optimizer``. is_last_microbatch: bool root_module: "FsdpModule" + # Static orders used to drive all-gather prefetch. We may want to switch to + # capturing runtime order if static module order proves too fragile. Each + # FsdpModule tracks its own materialized state via ``FsdpModule._unshard_event``. + forward_order: IndexedOrder["FsdpModule"] + backward_order: IndexedOrder["FsdpModule"] def __init__(self, device: torch.device, root_module: "FsdpModule") -> None: """Create rank-local runtime state for a root FSDP subtree. @@ -56,26 +52,29 @@ def __init__(self, device: torch.device, root_module: "FsdpModule") -> None: """ self.root_module = root_module self.is_last_microbatch = True - self.delayed_releases = deque() + self.forward_order = IndexedOrder() + self.backward_order = IndexedOrder() with torch.cuda.device(device): self.allgather_stream = torch.cuda.Stream() + self.reduce_scatter_stream = torch.cuda.Stream() + + def current_stream(self) -> torch.cuda.Stream: + """Current stream on this context's device.""" + return torch.cuda.current_stream(self.allgather_stream.device) + + def register_post_backward_final_callback(self) -> None: + """Register this root context's final callback for the current backward. - def enqueue_release(self, module: "FsdpModule") -> None: - """Queue a module's unsharded storage for delayed release.""" - consumer_event = torch.cuda.current_stream(self.allgather_stream.device).record_event() - self.delayed_releases.append(DelayedRelease(consumer_event=consumer_event, module=module)) + Root ``post_backward()`` means only that root-owned parameters have + accumulated gradients; it may run before descendant reductions, or not + run at all when the root owns no trainable parameters. Waiting at + autograd completion orders consumers after every descendant reduction. + """ - def drain_delayed_releases(self, target_length: int) -> None: - """Release queued module storages FIFO until the queue reaches ``target_length``.""" - if target_length < 0: - raise ValueError(f"target_length must be non-negative, got {target_length}.") + def post_backward_final_callback() -> None: + self.current_stream().wait_stream(self.reduce_scatter_stream) - while len(self.delayed_releases) > target_length: - delayed_release = self.delayed_releases.popleft() - with torch.cuda.stream(self.allgather_stream): - if delayed_release.consumer_event is not None: - self.allgather_stream.wait_event(delayed_release.consumer_event) - delayed_release.module.release_unsharded_storage() + torch.autograd.Variable._execution_engine.queue_callback(post_backward_final_callback) class FsdpModule: @@ -88,6 +87,10 @@ class FsdpModule: _context: FsdpContext | None _ready_grad_parameters: set[nn.Parameter] _num_trainable_parameters: int + # Event recorded after this FsdpModule's full parameters are materialized. + # ``None`` lets pre_forward enqueue an all-gather unless an earlier FsdpModule + # already prefetched this module. + _unshard_event: torch.cuda.Event | None def __init__( self, @@ -99,6 +102,7 @@ def __init__( """Initialize FSDP runtime state on an already-constructed module.""" self._context = None self._name = None + self._unshard_event = None owned_parameters = _collect_owned_parameters(self) axis_indices = tuple(_axis_index(mesh, axis) for axis in placements.dp_axes) assert axis_indices == tuple( @@ -147,8 +151,11 @@ def _lazy_init_context(self) -> None: if self._context is not None: return + root_module = cast(nn.Module, self) context = FsdpContext(device=self._parameter_groups[0].main_weight.device, root_module=self) - for submodule_name, submodule in cast(nn.Module, self).named_modules(): + # named_modules() yields FsdpModules in registration order, which is the static + # forward execution order used to prefetch the next FsdpModule's all-gather. + for submodule_name, submodule in root_module.named_modules(): if not isinstance(submodule, FsdpModule): continue if submodule._context is not None: @@ -158,6 +165,11 @@ def _lazy_init_context(self) -> None: ) submodule._context = context submodule._name = submodule_name + context.forward_order.append(submodule) + + # Backward starts from the root pre-backward hook before visiting child + # subtrees in reverse module order. + _collect_backward_order(root_module, context.backward_order) @property def context(self) -> FsdpContext: @@ -207,22 +219,43 @@ def grad_hook(_parameter: nn.Parameter) -> None: return grad_hook def pre_forward(self) -> None: - """Prepare full parameters for forward compute.""" + """Prepare full parameters for forward compute and prefetch the next FsdpModule. + + While this FsdpModule computes, we issue the next FsdpModule's all-gather + on the comm stream, so ``AG_{i+1}`` is launched before ``F_i`` finishes. + """ self._lazy_init_context() torch.cuda.nvtx.range_push(self._nvtx_label("forward")) self._ready_grad_parameters.clear() + context = self.context + allgather_stream = context.allgather_stream + current_stream = context.current_stream() + if self.is_root(): - allgather_stream = self.context.allgather_stream - allgather_stream.wait_stream(torch.cuda.current_stream(allgather_stream.device)) + allgather_stream.wait_stream(current_stream) + self._unshard_parameter_groups(sync_model_weight=True) + assert self._unshard_event is not None + # Compute waits only for this FsdpModule's all-gather (the prefetch below is + # issued afterwards, so it is free to run concurrently with this FsdpModule). + current_stream.wait_event(self._unshard_event) + + next_module = context.forward_order.next_item(self) + if next_module is not None: + next_module._unshard_parameter_groups(sync_model_weight=True) def _unshard_parameter_groups(self, *, sync_model_weight: bool) -> None: - """Materialize full parameters for this FsdpModule.""" - self.context.drain_delayed_releases(target_length=1) + """Unshard this FsdpModule's parameter groups on the all-gather stream. - allgather_stream = self.context.allgather_stream - current_stream = torch.cuda.current_stream(allgather_stream.device) + If ``_unshard_event`` is already set, this FsdpModule was already + unsharded or prefetched and this method is a no-op. Otherwise, this + method records ``_unshard_event`` after materialization so compute + can wait without depending on later release work. + """ + if self._unshard_event is not None: + return + allgather_stream = self.context.allgather_stream with torch.cuda.stream(allgather_stream): for group in self._parameter_groups: if sync_model_weight: @@ -230,41 +263,81 @@ def _unshard_parameter_groups(self, *, sync_model_weight: bool) -> None: # optimizer post-step hook instead of running it every microbatch. group.sync_model_weight_from_main_weight() group.unshard_parameters() - current_stream.wait_stream(allgather_stream) + self._unshard_event = allgather_stream.record_event() def post_forward(self) -> None: """Return parameters to their sharded resting state after forward compute.""" self._reshard_parameter_groups() - self.context.enqueue_release(self) - if self.is_root(): - self.context.drain_delayed_releases(target_length=0) torch.cuda.nvtx.range_pop() def _reshard_parameter_groups(self) -> None: + """Reshard parameter groups and release unsharded storage after compute. + + This method clears ``_unshard_event`` after queuing the release, so + future users enqueue a fresh all-gather. + """ for group in self._parameter_groups: group.reshard_parameters() + allgather_stream = self.context.allgather_stream + allgather_stream.wait_stream(self.context.current_stream()) + # Release on the all-gather stream where unsharded storage was allocated, + # so no record_stream() call is required for the storage. + with torch.cuda.stream(allgather_stream): + for group in self._parameter_groups: + group.release_unsharded_storage() + self._unshard_event = None + def pre_backward(self) -> None: - """Prepare full parameters for backward compute.""" + """Prepare full parameters and prefetch the next FsdpModule in backward order.""" torch.cuda.nvtx.range_push(self._nvtx_label("backward")) + context = self.context + current_stream = context.current_stream() + if self.is_root(): + context.register_post_backward_final_callback() + # Fork the reduce-scatter stream from the current stream once, at the + # start of backward, so every module's post-backward reduce-scatter is + # part of any active CUDA-graph capture. A stream only joins the + # capture via this wait_stream edge; without it the first allocation on + # the reduce-scatter stream falls back to a raw cudaMalloc, which is + # illegal during capture. Later modules are covered by the post-copy + # fork each preceding module issues before its collective. + context.reduce_scatter_stream.wait_stream(current_stream) + self._unshard_parameter_groups(sync_model_weight=False) + assert self._unshard_event is not None + current_stream.wait_event(self._unshard_event) + + next_module = context.backward_order.next_item(self) + if next_module is not None: + next_module._unshard_parameter_groups(sync_model_weight=False) def post_backward(self) -> None: """Reduce gradients and return parameters to their sharded resting state.""" - for group in self._parameter_groups: - if group.requires_grad: - group.reduce_gradients() + self._reduce_gradient_groups() self._reshard_parameter_groups() - self.context.enqueue_release(self) - if self.is_root(): - self.context.drain_delayed_releases(target_length=0) self._ready_grad_parameters.clear() torch.cuda.nvtx.range_pop() - def release_unsharded_storage(self) -> None: - """Release unsharded storage owned by this FsdpModule.""" + def _reduce_gradient_groups(self) -> None: + """Pack gradients and immediately launch their reduce-scatters.""" + context = self.context + reduce_scatter_stream = context.reduce_scatter_stream + current_stream = context.current_stream() + for group in self._parameter_groups: - group.release_unsharded_storage() + if not group.requires_grad: + continue + + with torch.cuda.stream(reduce_scatter_stream): + partial_grad = group.allocate_partial_grad_buffer() + + current_stream.wait_stream(reduce_scatter_stream) + group.copy_gradients_to_partial_buffer(partial_grad) + + reduce_scatter_stream.wait_stream(current_stream) + with torch.cuda.stream(reduce_scatter_stream): + group.reduce_partial_gradients(partial_grad) @property def parameter_groups(self) -> tuple[FsdpParameterGroup, ...]: @@ -276,6 +349,15 @@ def _nvtx_label(self, phase: Literal["forward", "backward"]) -> str: return f"MFSDP {name} {phase}" +def _collect_backward_order(module: nn.Module, order: IndexedOrder["FsdpModule"]) -> None: + """Collect FsdpModules in static backward prefetch order.""" + if isinstance(module, FsdpModule): + order.append(module) + + for child in reversed(list(module.children())): + _collect_backward_order(child, order) + + def _axis_index(mesh: DeviceMesh, axis: MeshAxis) -> int: if isinstance(axis, int): axis_index = axis diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index eeec848416b..3e5b7a49392 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -14,7 +14,6 @@ """Parameter-group runtime state for the minimal Megatron-FSDP path.""" -from collections.abc import Iterable from contextlib import nullcontext import torch @@ -245,11 +244,39 @@ def release_unsharded_storage(self) -> None: # so keep the shared storage-release path. self._unsharded_model_weight.release_storage() - def reduce_gradients(self) -> None: - """Reduce full local gradients into sharded parameter gradients.""" + def allocate_partial_grad_buffer(self) -> DBuffer: + """Allocate the unreduced reduce-scatter input buffer.""" assert self.main_grad is not None - def has_grad(parameters: Iterable[nn.Parameter]) -> bool: + # NCCL symmetric-memory reduce-scatter only selects the symmetric kernel for SUM today. + # Preserve AVG semantics by reducing SUM and scaling the output below. + partial_op = dist.ReduceOp.AVG if self._symm_mem_pool is None else dist.ReduceOp.SUM + grads: list[torch.Tensor] = [] + for name, parameter in zip(self.parameter_names, self.unsharded_parameters, strict=True): + if parameter.grad is None: + raise RuntimeError(f"Missing gradient for FSDP parameter {name!r}.") + grads.append(parameter.grad) + with self._symmetric_memory_context(): + return DBuffer( + mesh=self.mesh, + placements=[Partial(partial_op)] * self.mesh.ndim, + tensor_shapes=tuple(grad.shape for grad in grads), + dtype=grads[0].dtype, + device=grads[0].device, + ) + + def copy_gradients_to_partial_buffer(self, partial_grad: DBuffer) -> None: + """Pack full local gradients into an existing reduce-scatter input buffer.""" + # A future fused-wgrad path can write directly into these buffer views. + for index, parameter in enumerate(self.unsharded_parameters): + partial_grad.get_local_tensor(index).copy_(parameter.grad) + parameter.grad = None + + def reduce_partial_gradients(self, partial_grad: DBuffer) -> None: + """Reduce a packed partial gradient buffer into sharded parameter gradients.""" + assert self.main_grad is not None + + def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: has_any_grad = False has_any_missing_grad = False for parameter in parameters: @@ -261,20 +288,6 @@ def has_grad(parameters: Iterable[nn.Parameter]) -> bool: raise RuntimeError("FSDP sharded gradients must be either all set or all None.") return has_any_grad - grads: list[torch.Tensor] = [] - for name, parameter in zip(self.parameter_names, self.unsharded_parameters, strict=True): - if parameter.grad is None: - raise RuntimeError(f"Missing gradient for FSDP parameter {name!r}.") - grads.append(parameter.grad) - - # NCCL symmetric-memory reduce-scatter only selects the symmetric kernel for SUM today. - # Preserve AVG semantics by reducing SUM and scaling the output below. - partial_op = dist.ReduceOp.AVG if self._symm_mem_pool is None else dist.ReduceOp.SUM - with self._symmetric_memory_context(): - partial_grad = DBuffer.distribute_tensors( - grads, mesh=self.mesh, placements=[Partial(partial_op)] * self.mesh.ndim - ) - # zero_grad(set_to_none=True) clears sharded parameter grads, so the next # backward can reduce directly into main_grad. zero_grad(set_to_none=False) # leaves sharded grads installed, so this backward accumulates into main_grad. @@ -285,7 +298,8 @@ def has_grad(parameters: Iterable[nn.Parameter]) -> bool: reduce_axis = changed_mesh_axis(partial_grad.placements, self.main_grad.placements) if reduce_axis is None: raise RuntimeError("FSDP gradient reduction requires a changed placement axis.") - grad_divisor = self.mesh.size(reduce_axis) if partial_op == dist.ReduceOp.SUM else 1 + partial_reduce_op = partial_grad.placements[reduce_axis].reduce_op + grad_divisor = self.mesh.size(reduce_axis) if partial_reduce_op == dist.ReduceOp.SUM else 1 if self._symm_mem_pool is not None: partial_grad.rendezvous(reduce_axis) if can_reduce_into_main_grad: @@ -305,9 +319,6 @@ def has_grad(parameters: Iterable[nn.Parameter]) -> bool: for index, parameter in enumerate(self.sharded_parameters): parameter.grad = self.main_grad.get_dtensor(index) - for parameter in self.unsharded_parameters: - parameter.grad = None - def _get_parameter_owner(module: nn.Module, name: str) -> tuple[nn.Module, str]: """Resolve a root-module-relative parameter FQN to its direct owner.""" diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_context.py b/tests/unit_tests/distributed/mfsdp_v2/test_context.py index 6a99a2d19a8..102b2fde332 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_context.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_context.py @@ -42,6 +42,33 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x +class BranchModel(nn.Module): + """Nested branch with its own child FsdpModule.""" + + def __init__(self, dim: int) -> None: + super().__init__() + self.bias = nn.Parameter(torch.ones(dim)) + self.inner = nn.Linear(dim, dim, bias=False) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Run the nested branch.""" + return torch.relu(self.inner(x) + self.bias) + + +class NestedSiblingModel(nn.Module): + """Model with a nested left subtree and a right sibling.""" + + def __init__(self, dim: int) -> None: + super().__init__() + self.bias = nn.Parameter(torch.ones(dim)) + self.left = BranchModel(dim) + self.right = nn.Linear(dim, dim, bias=False) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Run the nested subtree before the right sibling.""" + return self.right(self.left(x) + self.bias) + + def _flat_placements() -> Placements: return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) @@ -98,3 +125,23 @@ def test_sibling_roots_without_parent_keep_separate_contexts(distributed_setup): assert model.layers[0].context is not model.layers[1].context assert model.layers[0].is_root() assert model.layers[1].is_root() + + +def test_nested_prefetch_orders_use_dfs(distributed_setup): + """Nested FsdpModules should use DFS orders for one-step prefetch.""" + device = distributed_setup.device + + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + model = NestedSiblingModel(dim=4).to(device) + + fully_shard(model.left.inner, mesh=mesh, placements=_flat_placements()) + fully_shard(model.left, mesh=mesh, placements=_flat_placements()) + fully_shard(model.right, mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + with torch.no_grad(): + model(torch.ones(2, 4, device=device)) + + context = model.context + assert list(context.forward_order) == [model, model.left, model.left.inner, model.right] + assert list(context.backward_order) == [model, model.right, model.left, model.left.inner] diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 20686b0e57d..c26a2ab9130 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -311,8 +311,8 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): ) -def test_overlaps_all_gather_and_compute(distributed_setup): - """A shared root context should let child all-gathers overlap GEMM compute.""" +def test_overlaps_communication_and_compute(distributed_setup): + """Forward and backward communication should overlap GEMM compute.""" world_size = distributed_setup.world_size device = distributed_setup.device if world_size < 2: @@ -352,6 +352,12 @@ def train_one_iteration() -> None: for event in cuda_events if "nccl" in event.name.lower() and "allgather" in event.name.lower() ] + reduce_scatter_events = [ + event + for event in cuda_events + if "nccl" in event.name.lower() + and ("reducescatter" in event.name.lower() or "reduce_scatter" in event.name.lower()) + ] # GEMM device-kernel names vary across CUDA/cuBLAS versions and GPU archs # (e.g. "*gemm*", "cutlass*", "cublas*", and cuBLASLt's Hopper "nvjet_sm90_*"). gemm_events = [ @@ -360,29 +366,38 @@ def train_one_iteration() -> None: if any(token in event.name.lower() for token in ("gemm", "cutlass", "cublas", "nvjet")) ] assert all_gather_events, [event.name for event in cuda_events] + assert reduce_scatter_events, [event.name for event in cuda_events] assert gemm_events, [event.name for event in cuda_events] all_gather_streams = {event.device_resource_id for event in all_gather_events} + reduce_scatter_streams = {event.device_resource_id for event in reduce_scatter_events} gemm_streams = {event.device_resource_id for event in gemm_events} assert len(all_gather_streams) == 1 + assert len(reduce_scatter_streams) == 1 + assert all_gather_streams.isdisjoint(reduce_scatter_streams) assert all_gather_streams.isdisjoint(gemm_streams) + assert reduce_scatter_streams.isdisjoint(gemm_streams) - overlap_count = sum( + all_gather_overlap_count = sum( any(_events_overlap(all_gather_event, gemm_event) for gemm_event in gemm_events) for all_gather_event in all_gather_events ) - # This profiles a full forward/backward iteration, so backward all-gathers are - # included in all_gather_events. The expected overlap count is from the forward - # child pipeline: each child after the first can all-gather while the previous - # child computes, giving num_children - 1 overlaps. Backward does not overlap - # in this all-gather-only path because gradient reduction is not delayed: - # each module synchronously reduces gradients in post_backward before autograd - # reaches the next module's pre_backward all-gather. The next PR addresses - # this by delaying gradient reduction. - expected_overlap_count = num_children - 1 - assert overlap_count >= expected_overlap_count, ( - f"Expected at least {expected_overlap_count} all-gather events to overlap compute, " - f"got {overlap_count}/{len(all_gather_events)}." + reduce_scatter_overlap_count = sum( + any(_events_overlap(reduce_scatter_event, gemm_event) for gemm_event in gemm_events) + for reduce_scatter_event in reduce_scatter_events + ) + # Communication overlaps compute only partially due to SM contention (the + # SM-based NCCL collectives share SMs with the GEMMs) and the count varies + # run to run, so assert only that overlap meaningfully happens rather than the + # theoretical maximum (2*(num_children - 1) / num_children - 1). Symmetric-memory + # collectives (use_symm_mem, ~SM-free) would let these thresholds be tightened. + assert all_gather_overlap_count >= 2, ( + f"Expected all-gather to overlap compute, " + f"got {all_gather_overlap_count}/{len(all_gather_events)}." + ) + assert reduce_scatter_overlap_count >= 1, ( + f"Expected reduce-scatter to overlap compute, " + f"got {reduce_scatter_overlap_count}/{len(reduce_scatter_events)}." ) From 7b2a97b7997e61af77cc8e6957a72a05f774c45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Thu, 16 Jul 2026 10:23:59 +0200 Subject: [PATCH 041/290] chore(skills): add Regent Open Plugin manifest (#5840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig Co-authored-by: Claude Fable 5 --- .plugin/plugin.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .plugin/plugin.json diff --git a/.plugin/plugin.json b/.plugin/plugin.json new file mode 100644 index 00000000000..b053224a345 --- /dev/null +++ b/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "megatron-lm", + "description": "Megatron-LM repository skills (build and dependency, CI/CD, testing, golden values, linting, base-image bumps, Slurm runs) for Regent agents working on this repo.", + "version": "0.1.0" +} From 2aa3645b2b31d748d543bd8066fb81cf1f1502d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Thu, 16 Jul 2026 11:43:23 +0200 Subject: [PATCH 042/290] chore(skills): remove Open Plugin manifest (superseded) (#5842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .plugin/plugin.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .plugin/plugin.json diff --git a/.plugin/plugin.json b/.plugin/plugin.json deleted file mode 100644 index b053224a345..00000000000 --- a/.plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "megatron-lm", - "description": "Megatron-LM repository skills (build and dependency, CI/CD, testing, golden values, linting, base-image bumps, Slurm runs) for Regent agents working on this repo.", - "version": "0.1.0" -} From edd45620b7fd212a67262c24207cdcdd8012e1d8 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 16 Jul 2026 02:16:48 -0700 Subject: [PATCH 043/290] return prefix cache hits data from the chat completions api (#5609) Signed-off-by: Siddharth Singh --- megatron/core/inference/contexts/dynamic_context.py | 6 +++++- megatron/core/inference/inference_request.py | 2 ++ .../dynamic_text_gen_server/endpoints/chat_completions.py | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index e7c381bb859..cffea724884 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -2968,10 +2968,14 @@ def add_request( num_matched_blocks = len(matched_block_ids) effective_kv_offset = req.finished_chunk_token_count + prefix_skip_tokens - # Track prefix cache hits. + # Track prefix cache hits. num_cached_tokens accumulates across prefill + # chunks: each chunk matches a disjoint block range (start advances with + # finished_chunk_token_count), so a long cached prefix is discovered + # incrementally and must be summed, not overwritten. if num_matched_blocks > 0: self.prefix_cache_hits += 1 self.prefix_cache_blocks_matched += num_matched_blocks + req.num_cached_tokens += num_matched_blocks * self.block_size_tokens # Slice tokens to skip matched prefix this_round_tokens = req.remaining_prompt_tokens[prefix_skip_tokens:prefill_chunk_length] diff --git a/megatron/core/inference/inference_request.py b/megatron/core/inference/inference_request.py index f9fd60f1033..3ed4b89bf94 100644 --- a/megatron/core/inference/inference_request.py +++ b/megatron/core/inference/inference_request.py @@ -380,6 +380,7 @@ class DynamicInferenceRequest(InferenceRequest): # Prefix caching fields block_size_tokens: Optional[int] = None # Block size for hash computation enable_prefix_caching: bool = False # Whether prefix caching is enabled + num_cached_tokens: int = 0 # Tokens served from prefix cache (set by context on first match) # Computed field - not passed by caller precomputed_block_hashes: List[int] = field(default_factory=list) @@ -740,6 +741,7 @@ def merge_lists(key): block_size_tokens=self.requests[0].block_size_tokens, enable_prefix_caching=self.requests[0].enable_prefix_caching, precomputed_block_hashes=self.requests[0].precomputed_block_hashes, + num_cached_tokens=self.requests[0].num_cached_tokens, ) return request diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py index 97ca9c99093..1f0cace28b5 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py @@ -651,6 +651,7 @@ async def chat_completions(): choices = [] total_completion_tokens = 0 prompt_tokens_counts = [] + cached_tokens_counts = [] prevent_retokenization = req.get("prevent_retokenization", True) # return_tokenized_data controls whether prompt/generation token ids are @@ -668,6 +669,7 @@ async def chat_completions(): text_output = result["generated_text"] prompt_tokens_count = len(prompt_tokens_out) if prompt_tokens_out is not None else 0 prompt_tokens_counts.append(prompt_tokens_count) + cached_tokens_counts.append(result.get("num_cached_tokens", 0)) logprobs_content = None if sampling_params.return_log_probs: @@ -795,6 +797,7 @@ async def chat_completions(): request_idx += 1 prompt_token_count = max(prompt_tokens_counts) if prompt_tokens_counts else 0 + cached_token_count = max(cached_tokens_counts) if cached_tokens_counts else 0 response = { "id": f"chatcmpl-{uuid.uuid4().hex}", "created": int(time.time()), @@ -805,6 +808,7 @@ async def chat_completions(): "prompt_tokens": prompt_token_count, "completion_tokens": total_completion_tokens, "total_tokens": prompt_token_count + total_completion_tokens, + "prompt_tokens_details": {"cached_tokens": cached_token_count}, }, } From f8e1ac64b0587ff7002a18fbaa5ecdeaeb8491be Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 16 Jul 2026 03:08:47 -0700 Subject: [PATCH 044/290] Refactor data parallel coordinator to enable modular handlers (#5550) Signed-off-by: Keshav Santhanam --- .../__init__.py | 22 ++ .../coordinator.py} | 239 +++--------------- .../handlers.py | 227 +++++++++++++++++ .../state.py | 63 +++++ .../engines/async_zmq_communicator.py | 6 + megatron/core/inference/headers.py | 147 +++++++++-- 6 files changed, 481 insertions(+), 223 deletions(-) create mode 100644 megatron/core/inference/data_parallel_inference_coordinator/__init__.py rename megatron/core/inference/{data_parallel_inference_coordinator.py => data_parallel_inference_coordinator/coordinator.py} (68%) create mode 100644 megatron/core/inference/data_parallel_inference_coordinator/handlers.py create mode 100644 megatron/core/inference/data_parallel_inference_coordinator/state.py diff --git a/megatron/core/inference/data_parallel_inference_coordinator/__init__.py b/megatron/core/inference/data_parallel_inference_coordinator/__init__.py new file mode 100644 index 00000000000..097b9f28f72 --- /dev/null +++ b/megatron/core/inference/data_parallel_inference_coordinator/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Data parallel inference coordinator package. + +The coordinator class itself lives in coordinator.py; message handlers are in +handlers.py and the control-signal state machine in state.py. This module +re-exports the public names so existing imports of +``megatron.core.inference.data_parallel_inference_coordinator`` keep working. +""" + +from .coordinator import DataParallelInferenceCoordinator +from .handlers import HANDLERS, message_handler +from .state import CONTROL_TRANSITIONS, ControlTransition, CoordinatorState + +__all__ = [ + "DataParallelInferenceCoordinator", + "CoordinatorState", + "ControlTransition", + "CONTROL_TRANSITIONS", + "HANDLERS", + "message_handler", +] diff --git a/megatron/core/inference/data_parallel_inference_coordinator.py b/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py similarity index 68% rename from megatron/core/inference/data_parallel_inference_coordinator.py rename to megatron/core/inference/data_parallel_inference_coordinator/coordinator.py index 57aa9122c2e..4e1026206c6 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator.py +++ b/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py @@ -7,7 +7,6 @@ import signal import socket from collections import deque -from enum import Enum, auto from multiprocessing import Event from multiprocessing.connection import Connection @@ -21,6 +20,9 @@ TextGenerationController, ) +from .handlers import HANDLERS +from .state import CoordinatorState + try: import zmq @@ -63,6 +65,10 @@ class DataParallelInferenceCoordinator: 5. **Control Signal Broadcasting**: It relays control signals (e.g., PAUSE, STOP) from a client to all connected data parallel ranks. + Message handling is split out into handlers.py: the event loop in start() + dispatches each message to the handler registered for its header, so + supporting a new message type requires no changes here. + Attributes: router_socket (zmq.Socket): The central ZMQ ROUTER socket for all communication. data_parallel_size (int): The number of data parallel workers to expect. @@ -75,13 +81,9 @@ class DataParallelInferenceCoordinator: next_request_id (int): A counter for generating unique server-side request IDs. """ - class CoordinatorState(Enum): - """State machine for the coordinator.""" - - RUNNING = auto() - PAUSED = auto() - SUSPENDED = auto() - STOPPING = auto() + # Exposed as a class attribute for backwards compatibility; the canonical + # definition lives in state.py. + CoordinatorState = CoordinatorState def __init__( self, @@ -189,7 +191,7 @@ def __init__( self.next_request_id = 0 self.tokenizer = tokenizer - self.state = self.CoordinatorState.RUNNING + self.state = CoordinatorState.RUNNING # Prefix caching state for routing. self.block_size_tokens = block_size_tokens @@ -220,6 +222,12 @@ def __init__( self._hash_table: dict[int, dict[int, int]] = {} self._hash_assignment_counter = 0 + # Clients that have completed the CONNECT handshake. + self.known_clients = set() + + # Header -> handler dispatch table, sourced from the handler registry. + self._handlers = dict(HANDLERS) + def get_least_loaded_data_parallel_rank(self): """ Selects the data parallel rank with the fewest in-flight requests. @@ -307,6 +315,12 @@ def _send_to_engine(self, identity, payload): return False raise + def _broadcast_to_engines(self, payload): + """Send a deserialized payload to every connected data parallel rank.""" + serialized = msgpack.packb(payload, use_bin_type=True) + for data_parallel_rank_id in list(self.identities_of_data_parallel_ranks): + self._send_to_engine(data_parallel_rank_id, serialized) + def compute_request_hashes(self, prompt): """Compute block hashes for a prompt on CPU. @@ -408,212 +422,33 @@ def start(self): Starts the main event loop for the coordinator. This method runs an infinite loop, continuously listening for incoming - messages on the ZMQ ROUTER socket. It parses the message header to - determine the message type and takes appropriate action, such as - handling new client connections, forwarding requests, broadcasting - control signals, or processing replies from the engines. + messages on the ZMQ ROUTER socket. It reads the message header and + dispatches to the handler registered for it (see handlers.py). + A handler that returns a truthy value stops the loop. """ # Todo [Siddharth]: Make this more robust to handle invalid messages. - known_clients = set() while True: sender_identity, serialized_payload = self.router_socket.recv_multipart() - # Allow for re-registration if connecting to a running coordinator. + # An empty payload is a data parallel rank (re-)registering itself. if serialized_payload == b"": - if sender_identity not in self.identities_of_data_parallel_ranks: - self.identities_of_data_parallel_ranks.append(sender_identity) - self._register_rank_identity(sender_identity) + self._handle_rank_registration(sender_identity) continue deserialized_payload = msgpack.unpackb(serialized_payload, raw=False) header = Headers(deserialized_payload[0]) - if header == Headers.CONNECT: - if sender_identity in known_clients: - logging.info( - f"Client {sender_identity} sent a duplicate connect request. Ignoring .." - ) - continue - - # print(f"New client connected: {sender_identity}") - known_clients.add(sender_identity) - self.router_socket.send_multipart( - [sender_identity, msgpack.packb([Headers.CONNECT_ACK.value], use_bin_type=True)] - ) - - elif header == Headers.SUBMIT_REQUEST: - # ToDo [Siddharth]: We might want to tokenize the prompt on the - # assigned data parallel rank for this process instead - # of the coordinator. - - # Message from a known client - if sender_identity not in known_clients: - logging.info( - f"Received message from unknown client {sender_identity}. Ignoring." - ) - continue - # this is a message from a client. - # route it to a data parallel rank - client_request_id, prompt, sampling_params = deserialized_payload[1:] - # map client request_id to server request_id - # necessary because multiple clients might have the same request_id. - request_id = self.next_request_id - self.next_request_id += 1 - self.request_id_to_client_id[request_id] = sender_identity - self.request_id_to_client_request_id[request_id] = client_request_id - - # Serialize prompt. - if isinstance(prompt, (str, list)): - pass - elif isinstance(prompt, torch.Tensor): - prompt = prompt.tolist() - else: - raise Exception("specialize for <%s> prompt." % type(prompt).__name__) - - payload = msgpack.packb( - [Headers.SUBMIT_REQUEST.value, request_id, prompt, sampling_params], - use_bin_type=True, - ) - - request_hashes = self.compute_request_hashes(prompt) - if ( - self.prefix_caching_coordinator_policy - == PrefixCachingCoordinatorPolicy.FIRST_PREFIX_BLOCK - ): - request_hashes = request_hashes[:1] - - # Account for the fact that some engines may have died. - for _ in range(len(self.identities_of_data_parallel_ranks)): - next_identity = self.get_best_data_parallel_rank(request_hashes) - if self._send_to_engine(next_identity, payload): - break - else: - # If all engines have died, we are in an abnormal state, and must exit cleanly. - logging.error("Coordinator: no reachable engines for request %d", request_id) - del self.request_id_to_client_id[request_id] - del self.request_id_to_client_request_id[request_id] - return - - self.request_id_to_rank[request_id] = next_identity - self._pending_counts[self.identity_to_rank_index[next_identity]] += 1 - if request_hashes: - self._update_rank_hashes(next_identity, request_hashes) - if self.schedule_records is not None: - self.schedule_records.append( - { - "request_id": request_id, - "rank_index": self.identity_to_rank_index[next_identity], - "num_hashes": len(request_hashes), - } - ) - - elif header in ( - Headers.PAUSE, - Headers.UNPAUSE, - Headers.SUSPEND, - Headers.RESUME, - Headers.SET_GENERATION_EPOCH, - Headers.STOP, - ): - # Start by checking the current state against the control signal. - if sender_identity not in known_clients: - logging.warning("Coordinator: ignoring signal from unknown client.") - continue - - if header == Headers.PAUSE: - idem_states = (self.CoordinatorState.PAUSED, self.CoordinatorState.SUSPENDED) - if self.state == self.CoordinatorState.RUNNING: - self.state = self.CoordinatorState.PAUSED - elif self.state in idem_states: - # Already paused/suspended, ignore redundant PAUSE. - continue - else: - logging.warning("Coordinator: ignoring PAUSE in state %s", self.state) - continue - elif header == Headers.UNPAUSE: - if self.state != self.CoordinatorState.PAUSED: - logging.warning("Coordinator: ignoring UNPAUSE in state %s", self.state) - continue - self.state = self.CoordinatorState.RUNNING - elif header == Headers.SUSPEND: - if self.state != self.CoordinatorState.PAUSED: - logging.warning("Coordinator: ignoring SUSPEND in state %s", self.state) - continue - self.state = self.CoordinatorState.SUSPENDED - elif header == Headers.RESUME: - if self.state != self.CoordinatorState.SUSPENDED: - logging.warning("Coordinator: ignoring RESUME in state %s", self.state) - continue - self.state = self.CoordinatorState.PAUSED - elif header == Headers.STOP: - good_states = (self.CoordinatorState.PAUSED, self.CoordinatorState.SUSPENDED) - if self.state not in good_states: - logging.warning("Coordinator: ignoring STOP in state %s", self.state) - continue - self.state = self.CoordinatorState.STOPPING - - # Broadcast the control signal if we're in a good state. - # Forward the full deserialized payload so that data-bearing - # signals (e.g. SET_GENERATION_EPOCH) retain their arguments. - broadcast_payload = msgpack.packb(deserialized_payload, use_bin_type=True) - for data_parallel_rank_id in list(self.identities_of_data_parallel_ranks): - self._send_to_engine(data_parallel_rank_id, broadcast_payload) - - # STOP affects engines; reset coordinator to RUNNING to allow future engines. - if header == Headers.STOP: - self.state = self.CoordinatorState.RUNNING - - elif header in (Headers.START_CUDA_PROFILER, Headers.STOP_CUDA_PROFILER): - # Profiler control: broadcast to every connected DP engine. Not a - # state transition, so no CoordinatorState checks — just forward. - if sender_identity not in known_clients: - logging.warning("Coordinator: ignoring profiler signal from unknown client.") - continue - broadcast_payload = msgpack.packb(deserialized_payload, use_bin_type=True) - for data_parallel_rank_id in list(self.identities_of_data_parallel_ranks): - self._send_to_engine(data_parallel_rank_id, broadcast_payload) - - elif header == Headers.ENGINE_REPLY: - # This is the output of a single engine step on some data parallel rank. - assert sender_identity in self.identities_of_data_parallel_ranks - finished_requests = deserialized_payload[1] - - for finished_request in finished_requests: - self.detokenize(finished_request) - fid = finished_request["request_id"] - client_identity = self.request_id_to_client_id[fid] - client_request_identity = self.request_id_to_client_request_id[fid] - del self.request_id_to_client_id[fid] - del self.request_id_to_client_request_id[fid] - assigned_rank = self.request_id_to_rank.pop(fid, None) - if assigned_rank is not None: - idx = self.identity_to_rank_index.get(assigned_rank) - if idx is not None: - assert self._pending_counts[idx] >= 1 - self._pending_counts[idx] -= 1 - - self.router_socket.send_multipart( - [ - client_identity, - msgpack.packb( - [header.value, client_request_identity, finished_request], - use_bin_type=True, - ), - ] - ) - - elif header == Headers.SHUTDOWN: - if sender_identity not in known_clients: - logging.warning("Coordinator: ignoring signal from unknown client.") - continue + handler = self._handlers.get(header) + if handler is None: + raise UnknownHeaderError(header) + if handler(self, sender_identity, deserialized_payload): break - elif header == Headers.DISCONNECT: - if sender_identity in self.identities_of_data_parallel_ranks: - self._remove_engine(sender_identity) - - else: - raise UnknownHeaderError(header) + def _handle_rank_registration(self, sender_identity): + """Register a data parallel rank that connected to a running coordinator.""" + if sender_identity not in self.identities_of_data_parallel_ranks: + self.identities_of_data_parallel_ranks.append(sender_identity) + self._register_rank_identity(sender_identity) def detokenize(self, finished_request): """ diff --git a/megatron/core/inference/data_parallel_inference_coordinator/handlers.py b/megatron/core/inference/data_parallel_inference_coordinator/handlers.py new file mode 100644 index 00000000000..34932825b34 --- /dev/null +++ b/megatron/core/inference/data_parallel_inference_coordinator/handlers.py @@ -0,0 +1,227 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Message handlers for the data parallel inference coordinator. + +Each handler is a free function decorated with @message_handler, which records +it in the module-level HANDLERS registry keyed by message header. The +coordinator builds its dispatch table from this registry, so a new message type +is supported simply by adding a decorated function here; the coordinator's event +loop never changes. + +Handlers have the signature ``(coordinator, sender_identity, payload) -> bool | None`` +where ``payload`` is the already-deserialized message. Returning a truthy value +signals the coordinator's event loop to stop. +""" + +import logging + +import torch + +from megatron.core.inference.config import PrefixCachingCoordinatorPolicy +from megatron.core.inference.headers import Headers + +from .state import CONTROL_TRANSITIONS, CoordinatorState + +try: + import msgpack +except ImportError: + msgpack = None + + +# Maps a message header value to the function that handles it. Populated by the +# @message_handler decorator at import time. +HANDLERS = {} + + +def message_handler(*headers): + """Register a function as the handler for one or more message headers. + + A new message type is supported by writing a handler function and decorating + it with the header(s) it serves; it is added to HANDLERS, which the + coordinator turns into its dispatch table. The event loop never needs to + change when a header is added. + """ + + def decorator(fn): + for header in headers: + assert header not in HANDLERS, f"duplicate handler for {header}" + HANDLERS[header] = fn + return fn + + return decorator + + +@message_handler(Headers.CONNECT) +def handle_connect(coordinator, sender_identity, payload): + """Handshake with a new client, replying with a CONNECT_ACK.""" + if sender_identity in coordinator.known_clients: + logging.info(f"Client {sender_identity} sent a duplicate connect request. Ignoring ..") + return + + coordinator.known_clients.add(sender_identity) + coordinator.router_socket.send_multipart( + [sender_identity, msgpack.packb([Headers.CONNECT_ACK.value], use_bin_type=True)] + ) + + +@message_handler(Headers.SUBMIT_REQUEST) +def handle_submit_request(coordinator, sender_identity, payload): + """Route a client request to a data parallel rank. + + Returns True (stopping the loop) if no engines are reachable. + """ + # ToDo [Siddharth]: We might want to tokenize the prompt on the + # assigned data parallel rank for this process instead + # of the coordinator. + + # Message from a known client + if sender_identity not in coordinator.known_clients: + logging.info(f"Received message from unknown client {sender_identity}. Ignoring.") + return + # this is a message from a client. + # route it to a data parallel rank + client_request_id, prompt, sampling_params = payload[1:] + # map client request_id to server request_id + # necessary because multiple clients might have the same request_id. + request_id = coordinator.next_request_id + coordinator.next_request_id += 1 + coordinator.request_id_to_client_id[request_id] = sender_identity + coordinator.request_id_to_client_request_id[request_id] = client_request_id + + # Serialize prompt. + if isinstance(prompt, (str, list)): + pass + elif isinstance(prompt, torch.Tensor): + prompt = prompt.tolist() + else: + raise Exception("specialize for <%s> prompt." % type(prompt).__name__) + + engine_payload = msgpack.packb( + [Headers.SUBMIT_REQUEST.value, request_id, prompt, sampling_params], use_bin_type=True + ) + + request_hashes = coordinator.compute_request_hashes(prompt) + if ( + coordinator.prefix_caching_coordinator_policy + == PrefixCachingCoordinatorPolicy.FIRST_PREFIX_BLOCK + ): + request_hashes = request_hashes[:1] + + # Account for the fact that some engines may have died. + for _ in range(len(coordinator.identities_of_data_parallel_ranks)): + next_identity = coordinator.get_best_data_parallel_rank(request_hashes) + if coordinator._send_to_engine(next_identity, engine_payload): + break + else: + # If all engines have died, we are in an abnormal state, and must exit cleanly. + logging.error("Coordinator: no reachable engines for request %d", request_id) + del coordinator.request_id_to_client_id[request_id] + del coordinator.request_id_to_client_request_id[request_id] + return True + + coordinator.request_id_to_rank[request_id] = next_identity + coordinator._pending_counts[coordinator.identity_to_rank_index[next_identity]] += 1 + if request_hashes: + coordinator._update_rank_hashes(next_identity, request_hashes) + if coordinator.schedule_records is not None: + coordinator.schedule_records.append( + { + "request_id": request_id, + "rank_index": coordinator.identity_to_rank_index[next_identity], + "num_hashes": len(request_hashes), + } + ) + + +@message_handler( + Headers.PAUSE, + Headers.UNPAUSE, + Headers.SUSPEND, + Headers.RESUME, + Headers.SET_GENERATION_EPOCH, + Headers.STOP, +) +def handle_control_signal(coordinator, sender_identity, payload): + """Validate a control signal against the transition table and broadcast it.""" + if sender_identity not in coordinator.known_clients: + logging.warning("Coordinator: ignoring signal from unknown client.") + return + + header = Headers(payload[0]) + transition = CONTROL_TRANSITIONS[header] + if coordinator.state not in transition.allowed_from: + # Silently ignore redundant signals; warn on genuinely invalid ones. + if coordinator.state not in transition.idempotent_in: + logging.warning("Coordinator: ignoring %s in state %s", header.name, coordinator.state) + return + if transition.new_state is not None: + coordinator.state = transition.new_state + + # Broadcast the control signal. Forward the full deserialized payload so + # that data-bearing signals (e.g. SET_GENERATION_EPOCH) retain their args. + coordinator._broadcast_to_engines(payload) + + # STOP affects engines; reset coordinator to RUNNING to allow future engines. + if header == Headers.STOP: + coordinator.state = CoordinatorState.RUNNING + + +@message_handler(Headers.START_CUDA_PROFILER, Headers.STOP_CUDA_PROFILER) +def handle_cuda_profiler_signal(coordinator, sender_identity, payload): + """Broadcast a CUDA profiler control signal to every connected DP engine. + + Profiler control is not a coordinator state transition, so there are no + CoordinatorState checks — the signal is simply forwarded to all engines. + """ + if sender_identity not in coordinator.known_clients: + logging.warning("Coordinator: ignoring profiler signal from unknown client.") + return + coordinator._broadcast_to_engines(payload) + + +@message_handler(Headers.ENGINE_REPLY) +def handle_engine_reply(coordinator, sender_identity, payload): + """Route completed requests from an engine back to their originating clients.""" + # This is the output of a single engine step on some data parallel rank. + assert sender_identity in coordinator.identities_of_data_parallel_ranks + finished_requests = payload[1] + + for finished_request in finished_requests: + coordinator.detokenize(finished_request) + fid = finished_request["request_id"] + client_identity = coordinator.request_id_to_client_id[fid] + client_request_identity = coordinator.request_id_to_client_request_id[fid] + del coordinator.request_id_to_client_id[fid] + del coordinator.request_id_to_client_request_id[fid] + assigned_rank = coordinator.request_id_to_rank.pop(fid, None) + if assigned_rank is not None: + idx = coordinator.identity_to_rank_index.get(assigned_rank) + if idx is not None: + assert coordinator._pending_counts[idx] >= 1 + coordinator._pending_counts[idx] -= 1 + + coordinator.router_socket.send_multipart( + [ + client_identity, + msgpack.packb( + [Headers.ENGINE_REPLY.value, client_request_identity, finished_request], + use_bin_type=True, + ), + ] + ) + + +@message_handler(Headers.SHUTDOWN) +def handle_shutdown(coordinator, sender_identity, payload): + """Stop the coordinator event loop on request from a known client.""" + if sender_identity not in coordinator.known_clients: + logging.warning("Coordinator: ignoring signal from unknown client.") + return + return True + + +@message_handler(Headers.DISCONNECT) +def handle_disconnect(coordinator, sender_identity, payload): + """Remove a disconnecting engine from the routing pool.""" + if sender_identity in coordinator.identities_of_data_parallel_ranks: + coordinator._remove_engine(sender_identity) diff --git a/megatron/core/inference/data_parallel_inference_coordinator/state.py b/megatron/core/inference/data_parallel_inference_coordinator/state.py new file mode 100644 index 00000000000..bb6c32104f8 --- /dev/null +++ b/megatron/core/inference/data_parallel_inference_coordinator/state.py @@ -0,0 +1,63 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""State machine definitions for the data parallel inference coordinator.""" + +from dataclasses import dataclass, field +from enum import Enum, auto + +from megatron.core.inference.headers import Headers + + +class CoordinatorState(Enum): + """State machine for the coordinator.""" + + RUNNING = auto() + PAUSED = auto() + SUSPENDED = auto() + STOPPING = auto() + + +_ALL_STATES = frozenset(CoordinatorState) + + +@dataclass(frozen=True) +class ControlTransition: + """A single rule in the control-signal state machine. + + Attributes: + allowed_from: States the signal may be applied from. + new_state: State to move to once applied, or None to leave the state + unchanged (e.g. a pure broadcast such as SET_GENERATION_EPOCH). + idempotent_in: States in which the signal is a silent no-op rather than + a logged rejection (e.g. a redundant PAUSE while already paused). + """ + + allowed_from: frozenset + new_state: CoordinatorState | None + idempotent_in: frozenset = field(default_factory=frozenset) + + +# Control-signal state machine, expressed declaratively and consumed by the +# control-signal handler. +CONTROL_TRANSITIONS = { + Headers.PAUSE: ControlTransition( + allowed_from=frozenset({CoordinatorState.RUNNING}), + new_state=CoordinatorState.PAUSED, + idempotent_in=frozenset({CoordinatorState.PAUSED, CoordinatorState.SUSPENDED}), + ), + Headers.UNPAUSE: ControlTransition( + allowed_from=frozenset({CoordinatorState.PAUSED}), new_state=CoordinatorState.RUNNING + ), + Headers.SUSPEND: ControlTransition( + allowed_from=frozenset({CoordinatorState.PAUSED}), new_state=CoordinatorState.SUSPENDED + ), + Headers.RESUME: ControlTransition( + allowed_from=frozenset({CoordinatorState.SUSPENDED}), new_state=CoordinatorState.PAUSED + ), + Headers.STOP: ControlTransition( + allowed_from=frozenset({CoordinatorState.PAUSED, CoordinatorState.SUSPENDED}), + new_state=CoordinatorState.STOPPING, + ), + # No state change; broadcast in any state so engines stay in sync. + Headers.SET_GENERATION_EPOCH: ControlTransition(allowed_from=_ALL_STATES, new_state=None), +} diff --git a/megatron/core/inference/engines/async_zmq_communicator.py b/megatron/core/inference/engines/async_zmq_communicator.py index aa13f659d40..bba7508e08b 100644 --- a/megatron/core/inference/engines/async_zmq_communicator.py +++ b/megatron/core/inference/engines/async_zmq_communicator.py @@ -41,6 +41,12 @@ def __init__( hostname (str | None): Hostname or IP address to use for ZMQ socket binding. If None, defaults to socket.gethostname(). """ + # Normalize None to the default (world) group. get_rank/get_world_size + # already treat None this way, but get_process_group_ranks below does + # not accept None, so resolve it once here for all three calls. + if process_group is None: + process_group = dist.group.WORLD + self.rank = dist.get_rank(process_group) self.world_size = dist.get_world_size(process_group) self.is_leader = self.rank == 0 diff --git a/megatron/core/inference/headers.py b/megatron/core/inference/headers.py index 107a200818c..4acef82bc1c 100644 --- a/megatron/core/inference/headers.py +++ b/megatron/core/inference/headers.py @@ -1,32 +1,137 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. -from enum import Enum, auto +"""Message headers for inference coordinator/engine/client communication. +Headers are grouped into category IntEnum classes by concern. Each category +occupies a distinct numeric range so that wire values never collide across +categories. Adding a new class of headers means adding a new category enum in +its own range and listing it in HEADER_ENUMS; the existing categories are left +untouched. + +On the wire a header travels as its integer value. decode_header maps an +integer back to the originating category member. +""" + +from enum import IntEnum + + +class Connection(IntEnum): + """Client <-> coordinator handshake.""" + + CONNECT = 0 + CONNECT_ACK = 1 + + +class Request(IntEnum): + """Inference request submission and completion reply.""" + + SUBMIT_REQUEST = 20 + ENGINE_REPLY = 21 + + +class Control(IntEnum): + """Runtime control signals broadcast to engines.""" + + PAUSE = 40 + UNPAUSE = 41 + SUSPEND = 42 + RESUME = 43 + SET_GENERATION_EPOCH = 44 + STOP = 45 + START_CUDA_PROFILER = 46 + STOP_CUDA_PROFILER = 47 + + +class Lifecycle(IntEnum): + """Process lifecycle signals.""" + + DISCONNECT = 60 + SHUTDOWN = 61 + + +class Transport(IntEnum): + """Low-level transport framing.""" + + TP_BROADCAST = 80 -class Headers(Enum): - """ - Enum representing headers used for communication with the inference-coordinator. - """ - CONNECT = auto() - CONNECT_ACK = auto() - SUBMIT_REQUEST = auto() - ENGINE_REPLY = auto() - PAUSE = auto() - UNPAUSE = auto() - SUSPEND = auto() - RESUME = auto() - SET_GENERATION_EPOCH = auto() - STOP = auto() - DISCONNECT = auto() - SHUTDOWN = auto() - TP_BROADCAST = auto() - START_CUDA_PROFILER = auto() - STOP_CUDA_PROFILER = auto() +# All header categories. To add a new class of headers, define a new IntEnum in +# its own (disjoint) numeric range and append it here; nothing else needs to +# change. Ranges are spaced out to leave room for growth within each category. +HEADER_ENUMS = (Connection, Request, Control, Lifecycle, Transport) class UnknownHeaderError(Exception): - """A signal with an unrecognized header was received by the coordinator.""" + """A signal with an unrecognized header was received.""" def __init__(self, header): super().__init__(f"specialize for {header}.") + + +def _build_tables(): + """Index every header by wire value and by name, asserting no collisions.""" + by_value = {} + by_name = {} + for enum_cls in HEADER_ENUMS: + for member in enum_cls: + if member.value in by_value: + raise ValueError( + f"duplicate header wire value {member.value}: " + f"{by_value[member.value]!r} and {member!r}" + ) + if member.name in by_name: + raise ValueError( + f"duplicate header name {member.name!r}: " + f"{by_name[member.name]!r} and {member!r}" + ) + by_value[member.value] = member + by_name[member.name] = member + return by_value, by_name + + +_CODE_TO_MEMBER, _NAME_TO_MEMBER = _build_tables() + + +def decode_header(value): + """Resolve an integer wire value to its category header member. + + Args: + value (int): The integer header value read off the wire. + + Returns: + The corresponding category enum member (e.g. ``Control.PAUSE``). + + Raises: + UnknownHeaderError: if no header is registered for ``value``. + """ + try: + return _CODE_TO_MEMBER[value] + except KeyError: + raise UnknownHeaderError(value) + + +class _Headers: + """Flat, read-only union over every header category. + + Lets callers use a single name without caring which category a header lives + in: attribute access (``Headers.PAUSE``) resolves to the underlying category + member (``Control.PAUSE``), and calling it (``Headers(value)``) decodes a + wire value via decode_header. Because both return the canonical category + members, equality and dict-key lookups against the categories work + unchanged. + """ + + def __call__(self, value): + return decode_header(value) + + def __getattr__(self, name): + try: + return _NAME_TO_MEMBER[name] + except KeyError: + raise AttributeError(f"no header named {name!r}") + + def __iter__(self): + return iter(_NAME_TO_MEMBER.values()) + + +Headers = _Headers() From 407ea4f08d27183269e2993086b53474c3c25f9c Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 16 Jul 2026 08:10:17 -0700 Subject: [PATCH 045/290] Pass device IDs to cleanup barrier (#5702) Signed-off-by: Jingyue Wu --- tests/unit_tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index c207fc2e262..4620c6a57fc 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -1,7 +1,6 @@ # Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import os -from datetime import timedelta from pathlib import Path import pytest @@ -60,7 +59,10 @@ def cleanup(): yield if torch.distributed.is_initialized(): try: - torch.distributed.barrier() + if torch.cuda.is_available(): + torch.distributed.barrier(device_ids=[torch.cuda.current_device()]) + else: + torch.distributed.barrier() except Exception: return torch.distributed.destroy_process_group() From 4abeecce745f2fae236a233c04358b63900b06d1 Mon Sep 17 00:00:00 2001 From: Marks101 <46690260+Marks101@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:35:08 +0200 Subject: [PATCH 046/290] Fix averaging for MoE z-loss metric tracking (#3199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Markus Schnös Co-authored-by: Markus Schnös --- megatron/core/transformer/moe/router.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/moe/router.py b/megatron/core/transformer/moe/router.py index 6273a520588..6c61bb3ed61 100644 --- a/megatron/core/transformer/moe/router.py +++ b/megatron/core/transformer/moe/router.py @@ -693,7 +693,11 @@ def apply_z_loss(self, logits, padding_mask: Optional[torch.Tensor] = None): layer_number = self.layer_number get_moe_metrics_tracker().record( - "z_loss", z_loss / moe_z_loss_coeff, layer_number, num_layers + "z_loss", + z_loss / moe_z_loss_coeff, + layer_number, + num_layers, + avg_group=self.tp_dp_cp_group, ) return logits From 9a67ea0baa6a4407dac2e350f19adb805cb8362a Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Thu, 16 Jul 2026 22:00:13 +0200 Subject: [PATCH 047/290] test(mfsdp): AUT-881 mark test_overlaps_communication_and_compute flaky (#5848) Signed-off-by: svcnemo-autobot --- tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index c26a2ab9130..71f9118c518 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -311,6 +311,8 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): ) +@pytest.mark.flaky +@pytest.mark.flaky_in_dev def test_overlaps_communication_and_compute(distributed_setup): """Forward and backward communication should overlap GEMM compute.""" world_size = distributed_setup.world_size From 740c16e6b80a753bea26232148d9bb2d7f0c827a Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 16 Jul 2026 11:52:48 -0700 Subject: [PATCH 048/290] Inference: Extend default cuda-graph coverage to 512 tokens (#5797) --- megatron/core/inference/config.py | 14 +++++++++++--- .../core/inference/contexts/dynamic_context.py | 9 ++++++--- megatron/training/arguments.py | 7 +++++++ megatron/training/config/inference_config.py | 4 ++++ tests/unit_tests/inference/test_hybrid_moe.py | 9 +++++++-- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index 274a475670b..04af29e4a83 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -217,7 +217,7 @@ class InferenceConfig: Maximum number of cuda graphs to capture. Graph token counts are spaced from 1 up to a per-graph-type budget: - Decode-only graphs are always bounded by `max_requests * (num_speculative_tokens + 1)`. - - Prefill/mixed graphs share that same bound by default, + - Prefill/mixed graphs are bounded by `cuda_graph_max_tokens` by default, or extend up to `max_tokens` when `cuda_graph_all_prefills` is set. Due to rounding, the actual number of cuda graphs may not equal this argument. """ @@ -245,11 +245,19 @@ class InferenceConfig: cuda_graph_all_prefills: bool = False """ Whether prefill/mixed CUDA graphs should span up to `max_tokens`. - When False (default), prefill/mixed graphs are bounded by the same token limit as decode graphs: - `max_requests * (num_speculative_tokens + 1)`. + When False (default), prefill/mixed graphs are bounded by `cuda_graph_max_tokens`. When True, prefill/mixed graph capture is extended to cover the full `max_tokens` budget. """ + cuda_graph_max_tokens: int = 512 + """ + Token ceiling for the largest captured prefill/mixed CUDA graph. + This is a raw token count (not scaled by speculative decoding). The effective ceiling is + clamped to `[max_requests * (num_speculative_tokens + 1), max_tokens]` so it never falls + below the decode bound nor exceeds the token budget. Ignored when `cuda_graph_all_prefills` + is set, which extends capture to the full `max_tokens`. + """ + static_kv_memory_pointers: bool = False """ Whether the KV cache (and Mamba states) will reside at the same memory addresses diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index cffea724884..6ff49388a50 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -662,12 +662,15 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC ) # CUDA graph token budget for prefill/mixed graphs. Decode graphs are always - # capped at max_requests * (num_speculative_tokens + 1) inside the helper; this - # only widens the prefill/mixed range when `cuda_graph_all_prefills` is set. + # capped at max_requests * (num_speculative_tokens + 1) inside the helper. By + # default the prefill/mixed range is bounded by `cuda_graph_max_tokens`, clamped + # to never fall below that decode bound nor exceed `max_tokens`; setting + # `cuda_graph_all_prefills` widens the range to the full `max_tokens`. + decode_bound = self.max_requests * (self.num_speculative_tokens + 1) cuda_graph_max_tokens = ( self.max_tokens if inference_config.cuda_graph_all_prefills - else self.max_requests * (self.num_speculative_tokens + 1) + else min(max(inference_config.cuda_graph_max_tokens, decode_bound), self.max_tokens) ) # CUDA graph config list. diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 2c3cd1cd531..8174fbd5eb3 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1915,6 +1915,13 @@ def _add_inference_args(parser): help='Extend prefill/mixed CUDA graph capture up to `max_tokens`. ' 'By default, all graphs are limited by the decode limit of ' '`max_requests * (num_speculative_tokens + 1)`.') + group.add_argument('--inference-cuda-graph-max-tokens', type=int, default=512, + dest='inference_cuda_graph_max_tokens', + help='Token ceiling for the largest captured prefill/mixed CUDA ' + 'graph (default: 512). Clamped to at least the decode limit ' + '`max_requests * (num_speculative_tokens + 1)` and at most ' + '`max_tokens`. Ignored when --inference-cuda-graph-all-prefills ' + 'is set (which extends capture to the full `max_tokens`).') group.add_argument('--inference-dynamic-batching-unified-memory-level', type=int, default=0, choices=[0, 1], help='Set unified memory usage within the dynamic ' diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index 0e3b960cbe3..96431e23bd5 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -156,6 +156,9 @@ class InferenceSetupConfig: """Extend prefill/mixed CUDA graph capture up to `max_tokens`. By default, all graphs are limited by the decode limit of `max_requests * (num_speculative_tokens + 1)`.""" + inference_cuda_graph_max_tokens: int = 512 + """Token ceiling for the largest captured prefill/mixed CUDA graph (default: 512).""" + # ---------------- Chunked prefill / speculation ---------------- enable_chunked_prefill: bool = False @@ -340,6 +343,7 @@ def to_inference_config( ), use_cuda_graphs_for_non_decode_steps=not self.decode_only_cuda_graphs, cuda_graph_all_prefills=self.inference_cuda_graph_all_prefills, + cuda_graph_max_tokens=self.inference_cuda_graph_max_tokens, static_kv_memory_pointers=static_kv_memory_pointers, max_sequence_length=max_sequence_length, mamba_inference_state_config=mamba_inference_state_config, diff --git a/tests/unit_tests/inference/test_hybrid_moe.py b/tests/unit_tests/inference/test_hybrid_moe.py index 96bb41903e9..a8cc9b743e6 100644 --- a/tests/unit_tests/inference/test_hybrid_moe.py +++ b/tests/unit_tests/inference/test_hybrid_moe.py @@ -164,6 +164,7 @@ def _build_context( use_cuda_graphs_for_non_decode_steps=True, max_requests=None, max_tokens=None, + cuda_graph_max_tokens=512, ): mamba_config = MambaInferenceStateConfig.from_model(model) return DynamicInferenceContext( @@ -178,6 +179,7 @@ def _build_context( use_cuda_graphs_for_non_decode_steps=use_cuda_graphs_for_non_decode_steps, max_requests=max_requests, max_tokens=max_tokens, + cuda_graph_max_tokens=cuda_graph_max_tokens, ), ) @@ -285,7 +287,7 @@ def test_nvls_ep_state_cross_product(self, rank_states): is_dummy = my_state == NONE model = self._build_model() - ctx = self._build_context(model, max_requests=64, max_tokens=512) + ctx = self._build_context(model, max_requests=64, max_tokens=512, cuda_graph_max_tokens=64) # Pre-capture every cuda graph in lockstep across EP ranks (mirrors # DynamicInferenceEngine.create_cuda_graphs in production). Without @@ -428,7 +430,10 @@ def test_nccl_eager_fallback_when_tokens_exceed_capacity(self, peer_state): # exceeded by a prefill-heavy rank. small_max_requests = 16 ctx = self._build_context( - model, use_cuda_graphs_for_non_decode_steps=True, max_requests=small_max_requests + model, + use_cuda_graphs_for_non_decode_steps=True, + max_requests=small_max_requests, + cuda_graph_max_tokens=small_max_requests, ) # Even EP ranks are dummy (no requests). Odd EP ranks get a state From e3fe5508c9a969b32f0b9731f888f828b6eee2f0 Mon Sep 17 00:00:00 2001 From: OlegSudakov Date: Fri, 17 Jul 2026 06:53:11 +0200 Subject: [PATCH 049/290] Fix for sequence-level aux MoE loss being dependent on batch size (#5798) Signed-off-by: Oleg Sudakov Signed-off-by: Oleg Sudakov Co-authored-by: Fei Wu <33940270+YangFei1990@users.noreply.github.com> --- megatron/core/transformer/moe/router.py | 4 +- .../transformer/moe/test_aux_loss.py | 73 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/moe/router.py b/megatron/core/transformer/moe/router.py index 6c61bb3ed61..e4591ce3acf 100644 --- a/megatron/core/transformer/moe/router.py +++ b/megatron/core/transformer/moe/router.py @@ -495,7 +495,9 @@ def _apply_seq_aux_loss( aux_loss, "seq_load_balancing_loss", self.tp_cp_group, - valid_token_count=local_num_tokens, + # local_num_tokens is per-sequence (bsz folded into the expert dim above); + # * bsz recovers the micro-batch total, else per-token-loss scaling keeps a 1/MBS. + valid_token_count=local_num_tokens * bsz, ) return probs diff --git a/tests/unit_tests/transformer/moe/test_aux_loss.py b/tests/unit_tests/transformer/moe/test_aux_loss.py index de4fa906821..c8c7bf0dd0f 100644 --- a/tests/unit_tests/transformer/moe/test_aux_loss.py +++ b/tests/unit_tests/transformer/moe/test_aux_loss.py @@ -383,6 +383,79 @@ def test_seq_aux_loss(self, tp_size, ep_size, cp_size): torch.testing.assert_close(aux_loss, seq_aux_loss) torch.testing.assert_close(grad1, grad2) + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.parametrize("with_padding", [False, True]) + @pytest.mark.parametrize( + "tp_size,ep_size,cp_size", [(8, 1, 1), (4, 2, 1), (1, 1, 8), (2, 1, 4), (2, 2, 2)] + ) + def test_seq_aux_loss_mbs_invariant_per_token_loss( + self, tp_size, ep_size, cp_size, with_padding + ): + """seq_aux_loss gradient must be invariant to MBS under --calculate-per-token-loss. + + The same global batch is processed as N micro-batches of size 1 (MBS=1) and as one + micro-batch of size N (MBS=N). Both cover the same tokens, so the finalize-time + 1/total_tokens normalization is an identical constant and the accumulated + router-weight aux gradients must match. Before the fix (valid_token_count dropped the + bsz factor), the MBS=N gradient is scaled by 1/N and the assertion fails. The padding + case additionally checks the correction uses valid (non-padded) token counts. + """ + Utils.initialize_model_parallel( + tensor_model_parallel_size=tp_size, + expert_tensor_parallel_size=ep_size, + context_parallel_size=cp_size, + ) + model_parallel_cuda_manual_seed(42) + clear_aux_losses_tracker() + + router = self.new_router( + moe_router_load_balancing_type="seq_aux_loss", + moe_aux_loss_coeff=1.0, + moe_router_dtype="fp64", + calculate_per_token_loss=True, + # fp32 weights so the MBS=1 gradient (accumulated over N backward passes) + # is not degraded by bf16 rounding relative to the single MBS=N backward. + params_dtype=torch.float32, + bf16=False, + tensor_model_parallel_size=tp_size, + expert_tensor_parallel_size=ep_size, + context_parallel_size=cp_size, + ).cuda() + + seq_len = 32 + num_seqs = 4 + with get_cuda_rng_tracker().fork(): + hidden_states = torch.randn( + (seq_len, num_seqs, router.config.hidden_size), + device=torch.device("cuda"), + dtype=torch.float32, + ) + padding_mask = None + if with_padding: + # True marks padding tokens (second half of each sequence). + padding_mask = torch.zeros((seq_len, num_seqs), dtype=torch.bool, device="cuda") + padding_mask[seq_len // 2 :, :] = True + + def run(indices): + pmask = None if padding_mask is None else padding_mask[:, indices] + scores, _ = router(hidden_states[:, indices, :].contiguous(), padding_mask=pmask) + scores.backward(torch.zeros_like(scores)) # isolate the aux-loss gradient + clear_aux_losses_tracker() + + # MBS=1: N micro-batches of size 1, accumulating the aux-loss gradient. + router.weight.grad = None + for b in range(num_seqs): + run(slice(b, b + 1)) + grad_mbs1 = router.weight.grad.clone() + + # MBS=N: a single micro-batch of size N. + router.weight.grad = None + run(slice(0, num_seqs)) + grad_mbsN = router.weight.grad.clone() + + torch.testing.assert_close(grad_mbs1, grad_mbsN) + @pytest.mark.internal @pytest.mark.skipif( not torch.cuda.is_available() or not HAVE_ROUTER_FUSION, From 61f31145dfb4cfaa7d285df12aa9abffb22ff485 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 16 Jul 2026 23:25:56 -0700 Subject: [PATCH 050/290] Allow parameterless FSDP root modules (#5711) Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 8 +-- .../distributed/mfsdp_v2/test_fully_shard.py | 52 +++++++++++++++---- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index a1c4999d141..4a63ab8307a 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -152,7 +152,11 @@ def _lazy_init_context(self) -> None: return root_module = cast(nn.Module, self) - context = FsdpContext(device=self._parameter_groups[0].main_weight.device, root_module=self) + first_parameter = next(root_module.parameters(), None) + if first_parameter is None: + raise RuntimeError("FSDP root module requires at least one parameter in its subtree.") + + context = FsdpContext(device=first_parameter.device, root_module=self) # named_modules() yields FsdpModules in registration order, which is the static # forward execution order used to prefetch the next FsdpModule's all-gather. for submodule_name, submodule in root_module.named_modules(): @@ -396,8 +400,6 @@ def visit(submodule: nn.Module, submodule_fqn: str) -> None: visit(child_module, child_fqn) visit(root_module, "") - if not parameters: - raise ValueError("fully_shard requires at least one unowned parameter.") return parameters diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 71f9118c518..a7b9dcd5811 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -403,6 +403,30 @@ def train_one_iteration() -> None: ) +def test_parameterless_parent_with_child_modules_trains(distributed_setup): + """A parent with no unowned parameters should still root trainable child FsdpModules.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + + mesh = init_device_mesh(device.type, (world_size,)) + torch.manual_seed(5678) + model = nn.Sequential(nn.Linear(4, 4, bias=False), nn.Linear(4, 2, bias=False)).to(device) + + fully_shard(model[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model[1], mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + assert model.parameter_groups == () + + optimizer = torch.optim.SGD(model.parameters(), lr=0.05) + x = torch.randn(3, 4, device=device) + + optimizer.zero_grad(set_to_none=True) + loss = model(x).sum() + loss.backward() + optimizer.step() + + def test_frozen_parameter_group_does_not_allocate_main_grad(distributed_setup): """A non-trainable parameter group should not allocate persistent main gradients.""" world_size = distributed_setup.world_size @@ -502,24 +526,30 @@ def test_microbatch_scopes_child_contexts(distributed_setup): def test_cpu_initialized_parameters_shard_to_mesh_device(distributed_setup): - """CPU-initialized parameters should be sharded with their real values.""" + """A CPU model should support sharding a child before moving the full model to CUDA.""" world_size = distributed_setup.world_size device = distributed_setup.device - if world_size < 2: - pytest.skip("This test requires at least 2 ranks.") mesh = init_device_mesh(device.type, (world_size,)) - model = nn.Linear(4, 4, bias=False) + model = nn.Sequential(nn.Linear(4, 4, bias=False), nn.Linear(4, 4, bias=False)) with torch.no_grad(): - model.weight.fill_(3.0) - expected_weight = model.weight.detach().to(device) + model[0].weight.fill_(2.0) + model[1].weight.fill_(3.0) + x = torch.ones(1, 4) + expected_output = model(x).to(device) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + # Shard the second layer's parameters onto the mesh device; the unwrapped + # first layer's parameters remain on CPU until model.to(device) below. + fully_shard(model[1], mesh=mesh, placements=_flat_placements()) - (group,) = model.parameter_groups - full_weight = group.model_weight.allgather(0).get_local_tensor(0) - assert full_weight.device.type == device.type - torch.testing.assert_close(full_weight, expected_weight) + assert model[0].weight.device.type == "cpu" + assert isinstance(model[1].weight, DTensor) + assert model[1].weight.device == device + + model.to(device) + + output = model(x.to(device)) + torch.testing.assert_close(output, expected_output) def test_non_leaf_parameter_view_survives_storage_resize(distributed_setup): From 27e312ca10a2dfdadabfdee4fe39a2172a6656d4 Mon Sep 17 00:00:00 2001 From: "John St. John" Date: Fri, 17 Jul 2026 04:42:23 -0700 Subject: [PATCH 051/290] Fix issue where parameter groups with different min/max LRs get overridden at checkpoint load time (#4705) Signed-off-by: John St John Signed-off-by: John St. John --- megatron/core/optimizer/distrib_optimizer.py | 30 +- megatron/core/optimizer/optimizer.py | 98 +++++-- megatron/training/training.py | 8 +- .../test_param_group_identifier_keys.py | 271 ++++++++++++++++++ 4 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 tests/unit_tests/optimizer/test_param_group_identifier_keys.py diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py index 9e030a6b17f..04a82f0134a 100644 --- a/megatron/core/optimizer/distrib_optimizer.py +++ b/megatron/core/optimizer/distrib_optimizer.py @@ -889,23 +889,31 @@ def load_state_dict(self, state_dict): # contains an integer ordering of parameters within each group, and # the ordering of parameters within its flattened parameter state # list. + + # Pair each current param_group with its saved counterpart by identifier tuple. + # Construction order isn't part of the checkpoint, so we match by a tuple of + # per-group config (``param_group_identifier_keys``) rather than by position. + def make_needed_groups(param_group): needed_groups = [] for key in param_group_identifier_keys: - # NeMo changes these variable names from `lr_mult` and `wd_mult` - # to `pre_lr_mult` and `pre_wd_mult`, so we need to check both. + # NeMo aliases ``lr_mult``/``wd_mult`` as ``pre_lr_mult``/``pre_wd_mult``. if key in param_group: - pass + value = param_group[key] elif f"pre_{key}" in param_group: - key = f"pre_{key}" + value = param_group[f"pre_{key}"] else: - raise ValueError( - f"Key {key} (or pre_{key}) not found in param_group {param_group}." - ) - needed_groups.append(param_group[key]) - needed_groups = tuple(needed_groups) - return needed_groups - + # Treat missing and explicit None identifier values as equivalent. + value = None + needed_groups.append(value) + return tuple(needed_groups) + + # Duplicate identifiers here silently clobber: two saved groups with the same tuple + # collapse to whichever was inserted last, and one current group inherits the wrong + # override state (``max_lr`` etc.). Params are unaffected — they come from the + # inner optimizer below — but the next step runs at the wrong LR / WD. Adding the + # distinguishing field to ``param_group_identifier_keys`` is the fix. See + # ``test_filter_reorder_distinguishes_groups_by_max_lr``. param_groups_map = {} for param_group in state_dict["optimizer"]["param_groups"]: needed_groups = make_needed_groups(param_group) diff --git a/megatron/core/optimizer/optimizer.py b/megatron/core/optimizer/optimizer.py index b40b2cb2dd5..e503a16dde3 100644 --- a/megatron/core/optimizer/optimizer.py +++ b/megatron/core/optimizer/optimizer.py @@ -46,6 +46,7 @@ optim_state_to_sharding_state, ) from ..dist_checkpointing.utils import add_prefix_for_sharding +from ..optimizer_param_scheduler import ParamGroupOverride as _ParamGroupOverride from ..transformer.module import param_is_not_shared from ..utils import log_single_rank from .clip_grads import clip_grad_by_total_norm_fp32, count_zeros_fp32, get_grad_norm_fp32 @@ -94,7 +95,58 @@ def _multi_tensor_copy_this_to_that( that_.copy_(this_) -param_group_identifier_keys = ('wd_mult', 'lr_mult', 'is_expert_parallel', 'is_decoupled_lr') +# Per-group keys used to uniquely identify a param_group during save/load matching. +# Used by ``DistributedOptimizer.load_state_dict`` and +# ``MegatronOptimizer._filter_and_reorder_param_groups`` to map saved param_groups +# onto current param_groups by behavioral equivalence. +# +# This MUST cover every per-group field that influences scheduler or optimizer behavior; +# otherwise two groups that differ only in a missing key (e.g. ``max_lr``) will collide +# in the matching dict and one will silently overwrite the other on load. That's a +# correctness bug: the load returns silently but the LR/WD applied at the next +# optimizer step is wrong, leading to loss explosion on a converged-enough model. +# +# Source of truth for the user-overridable fields is +# :class:`megatron.core.optimizer_param_scheduler.ParamGroupOverride` (the keys the +# scheduler reads from each param_group via ``param_group.get(...)``): +# ``max_lr``, ``min_lr``, ``start_wd``, ``end_wd``, ``wd_mult``, ``optimizer``. +# We pull those keys directly from that TypedDict's annotations so future +# additions to ``ParamGroupOverride`` automatically extend the identifier. +# +# The remaining keys (``lr_mult``, ``is_expert_parallel``, ``is_decoupled_lr``) are +# structural flags set by ``_get_param_groups`` (in this module's ``__init__.py``) +# on every param_group at construction time. They aren't part of ``ParamGroupOverride`` +# (users don't override them directly; they're implied by ``decoupled_lr`` config and +# expert-parallel sharding), so we list them explicitly. +def _param_group_override_keys() -> tuple[str, ...]: + """Return every field declared on ``ParamGroupOverride``. + + For any TypedDict, ``__annotations__.keys() == __required_keys__ | __optional_keys__`` + - the (required, optional) pair is just a *partition* of the declared set + based on the TypedDict's totality choice and any ``Required[]`` / + ``NotRequired[]`` wrappers. We want the whole declared set, regardless of + how it's partitioned, so we read ``__annotations__`` directly. Reading just + one side of the partition would silently miss fields if a future maintainer + flipped totality or introduced wrappers: + + total=False (current): optional={max_lr, min_lr, ...}, required={} + total=True: required={max_lr, min_lr, ...}, optional={} + mixed Required/NotRequired: fields split between the two sides + """ + return tuple(sorted(_ParamGroupOverride.__annotations__.keys())) + + +param_group_identifier_keys = ( + # Per-group user-overridable keys (single source of truth: ParamGroupOverride). + # The scheduler reads ``max_lr``/``min_lr`` in ``get_lr`` and ``start_wd``/``end_wd`` + # in ``get_wd``; ``wd_mult`` is multiplied into ``weight_decay`` in ``step``; + # ``optimizer`` selects per-group optimizer class. + *_param_group_override_keys(), + # Optimizer-side structural flags (not user-overridable via ParamGroupOverride): + 'lr_mult', + 'is_expert_parallel', + 'is_decoupled_lr', +) MTP_GRAD_NORM_GROUP = 'mtp' GRAD_NORM_GROUP_ATTR = 'grad_norm_group' SEPARATE_GRAD_NORM_GROUPS = (MTP_GRAD_NORM_GROUP,) @@ -535,9 +587,10 @@ def restore_from_cpu(self): def _filter_and_reorder_param_groups( current_groups: List[Dict], state_dict_groups: List[Dict] ) -> List[Dict]: - """Filter and reorder state_dict parameter groups to match current optimizer groups. - Keys used for matching align with those from _get_param_groups: - (wd_mult, lr_mult, is_expert_parallel, is_decoupled_lr) + """Pair each current param_group with its saved counterpart by identifier tuple. + + Construction order isn't part of the checkpoint, so we match by a tuple of + per-group config (``param_group_identifier_keys``) rather than by position. Args: current_groups (List[Dict]): Parameter groups from the current optimizer instance. @@ -549,24 +602,29 @@ def _filter_and_reorder_param_groups( Raises: ValueError: If parameter groups in state dict don't match current optimizer. """ - # Define groups order that is needed in the current optimizer (coming from runtime) - needed_groups = [ - # NeMo may have different key for required fields, e.g., "wd_mult" to "pre_wd_mult" - tuple(g[key] if key in g else g[f"pre_{key}"] for key in param_group_identifier_keys) - for g in current_groups - ] - # Keep state_dict param group order since groups are LocalNonpersistentObject - # and their order is determined at runtime, not from the checkpoint. + def _identifier_for(group: dict) -> tuple: + out = [] + for key in param_group_identifier_keys: + # NeMo aliases ``wd_mult``/``lr_mult`` as ``pre_wd_mult``/``pre_lr_mult``. + if key in group: + out.append(group[key]) + elif f"pre_{key}" in group: + out.append(group[f"pre_{key}"]) + else: + # Treat missing and explicit None identifier values as equivalent. + out.append(None) + return tuple(out) + + needed_groups = [_identifier_for(g) for g in current_groups] params_in_state_dict_order = [g['params'] for g in state_dict_groups] - loaded_groups_map = { - tuple( - # NeMo may have different key for required fields, e.g., "wd_mult" to "pre_wd_mult" - group[key] if key in group else group[f"pre_{key}"] - for key in param_group_identifier_keys - ): group - for group in state_dict_groups - } + # Duplicate identifiers here silently clobber: two saved groups with the same tuple + # collapse to whichever was inserted last, and one current group inherits the wrong + # override state (``max_lr`` etc.). Params are unaffected — they come from the + # current optimizer below — but the next step runs at the wrong LR / WD. Adding the + # distinguishing field to ``param_group_identifier_keys`` is the fix. See + # ``test_filter_reorder_distinguishes_groups_by_max_lr``. + loaded_groups_map = {_identifier_for(group): group for group in state_dict_groups} final_groups = [] for key, params in zip(needed_groups, params_in_state_dict_order): diff --git a/megatron/training/training.py b/megatron/training/training.py index 7f91fb195a7..f67397fe889 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -987,7 +987,13 @@ def reorder_inner_param_groups(optimizer_state_dict): if "param_groups" not in inner_optimizer: return param_groups = inner_optimizer["param_groups"] - key_fn = lambda pg: [pg[key] for key in param_group_identifier_keys] + # Treat missing and explicit None identifier values as equivalent. + # Wrap each component so None never compares directly with floats or strings. + def key_fn(pg): + return [ + (value is not None, value) + for value in (pg.get(key) for key in param_group_identifier_keys) + ] param_groups.sort(key=key_fn) inner_optimizer["param_groups"] = param_groups diff --git a/tests/unit_tests/optimizer/test_param_group_identifier_keys.py b/tests/unit_tests/optimizer/test_param_group_identifier_keys.py new file mode 100644 index 00000000000..6c79a54c53c --- /dev/null +++ b/tests/unit_tests/optimizer/test_param_group_identifier_keys.py @@ -0,0 +1,271 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Tests for ``param_group_identifier_keys`` and the param-group save/load matching. + +The identifier tuple is the fingerprint used by +:meth:`MegatronOptimizer._filter_and_reorder_param_groups` and +:meth:`DistributedOptimizer.load_state_dict` to match saved param_groups onto +the current optimizer's param_groups during checkpoint resume. It MUST cover +every per-group field that influences scheduler or optimizer behavior; +otherwise two groups distinguishable only by, say, ``max_lr`` collide in the +matching dict and the second one's config silently overwrites the first's +on load — producing wrong LRs after restart and (on a converged-enough model) +loss explosion at the next optimizer step. + +These tests pin the identifier composition and verify the matching tolerates +keys that aren't present on every group (e.g. ``start_wd``/``end_wd``/ +``optimizer`` from :class:`ParamGroupOverride` are only set on groups that +explicitly override them). +""" + +from megatron.core.optimizer.optimizer import MegatronOptimizer, param_group_identifier_keys +from megatron.core.optimizer_param_scheduler import ParamGroupOverride + + +def _make_pg(**kw) -> dict: + """Build a minimal param_group dict for matcher tests. ``params`` is required.""" + pg = {"params": []} + pg.update(kw) + return pg + + +def test_identifier_keys_cover_all_param_group_override_fields(): + """REGRESSION: every field declared on ``ParamGroupOverride`` must be in + the identifier. If someone adds a new field (per-group user-facing config), + it must also appear in ``param_group_identifier_keys`` — otherwise two + groups distinguishable only by that field will collide on load. + + We use ``__annotations__.keys()`` as the source of truth for "declared + fields". For any TypedDict that equals ``__required_keys__ | __optional_keys__``, + so the test is invariant to the TypedDict's ``total=`` setting or to a + future split between ``Required[]`` / ``NotRequired[]`` wrappers — see + the docstring on ``_param_group_override_keys`` in ``optimizer.py``. + """ + declared = set(ParamGroupOverride.__annotations__.keys()) + missing = declared - set(param_group_identifier_keys) + assert not missing, ( + f"ParamGroupOverride fields not in param_group_identifier_keys: {missing}. " + f"Either add to identifier_keys, or argue why this field should NOT participate " + f"in save/load matching." + ) + + +def test_identifier_keys_invariant_to_totality(): + """The identifier-key derivation must work regardless of whether + ``ParamGroupOverride`` is declared ``total=False`` (current), + ``total=True``, or mixed via ``Required[]`` / ``NotRequired[]``. + + This guards against a future maintainer flipping the totality setting and + silently emptying the identifier (which would re-introduce the LR-restart + bug class). + """ + # Verify every field is captured by __annotations__ (the source we use). + assert set(ParamGroupOverride.__annotations__.keys()) >= { + 'max_lr', + 'min_lr', + 'start_wd', + 'end_wd', + 'wd_mult', + 'optimizer', + }, ( + "ParamGroupOverride lost expected fields. If a field was renamed, update " + "this test AND any consumers of the identifier." + ) + # Verify the identifier-derivation function returns exactly the annotation set. + from megatron.core.optimizer.optimizer import _param_group_override_keys + + assert set(_param_group_override_keys()) == set(ParamGroupOverride.__annotations__.keys()), ( + "_param_group_override_keys() must return ParamGroupOverride.__annotations__ verbatim " + "so the identifier survives totality / Required[] / NotRequired[] changes." + ) + + +def test_identifier_excludes_mutable_per_step_keys(): + """REGRESSION: keys the scheduler / inner optimizer rewrite every step must NOT + appear in the identifier. If they did, the freshly-built optimizer (at step 0) + would have different values than the saved optimizer (at step N) for the same + logical group, and matching across save/load would fail. + + Specifically: + - ``lr`` is rewritten by ``OptimizerParamScheduler.step`` every iter. + - ``weight_decay`` is rewritten by ``OptimizerParamScheduler.step`` every iter. + - ``step`` is incremented by the inner Adam each call. + + None of these are on ``ParamGroupOverride`` today, so the current + ``param_group_identifier_keys`` derivation is safe. This test pins the assumption + so a future maintainer who (e.g.) adds ``lr`` to ``ParamGroupOverride`` for some + new use case will see the test fail immediately rather than silently regressing + save/load matching. + """ + mutating = {"lr", "weight_decay", "step"} + overlap = mutating.intersection(param_group_identifier_keys) + assert not overlap, ( + f"param_group_identifier_keys contains keys that mutate every optimizer step: " + f"{overlap}. These will differ between save (iter N) and the freshly-built " + f"optimizer (iter 0), causing the matcher to fail to pair saved groups with " + f"current groups. Either remove them from the identifier or, if they're " + f"declared on ParamGroupOverride, mask them out in _param_group_override_keys()." + ) + + +def test_identifier_keys_include_structural_flags(): + """``lr_mult`` / ``is_expert_parallel`` / ``is_decoupled_lr`` are set on every + param_group at construction time and must remain in the identifier so saves + from one process layout match loads in another. + """ + for key in ("lr_mult", "is_expert_parallel", "is_decoupled_lr"): + assert key in param_group_identifier_keys, ( + f"{key!r} missing from param_group_identifier_keys; this would let groups " + f"collide across e.g. EP-on/EP-off boundaries on resume." + ) + + +def test_filter_reorder_distinguishes_groups_by_max_lr(): + """REGRESSION: two groups that differ ONLY by max_lr/min_lr must be matched + correctly across save/load. Pre-fix, both would have produced the same legacy + 4-tuple ``(wd_mult, lr_mult, is_expert_parallel, is_decoupled_lr)`` of + ``(1.0, 1.0, False, False)`` and collided in the matching dict — the second + saved group's config (max_lr) silently clobbered the first's at load time, + producing wrong LRs at the next optimizer step. + """ + # Two current groups with same wd/structural flags but different max_lr — + # this is exactly the recipe pattern (trunk-WD vs. projector-WD). + current = [ + _make_pg( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=2e-5, + min_lr=2e-6, + ), + _make_pg( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=5e-4, + min_lr=5e-5, + ), + ] + # Saved groups (deliberately reordered to exercise the reorder logic). + saved = [ + _make_pg( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=5e-4, + min_lr=5e-5, + # Some recognizable extra field to confirm the right saved group was matched. + _tag="from_projector", + ), + _make_pg( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=2e-5, + min_lr=2e-6, + _tag="from_trunk", + ), + ] + + reordered = MegatronOptimizer._filter_and_reorder_param_groups(current, saved) + + assert len(reordered) == 2 + # current[0] has max_lr=2e-5 → must match the saved group with max_lr=2e-5. + assert reordered[0]["max_lr"] == 2e-5 + assert reordered[0]["_tag"] == "from_trunk" + # current[1] has max_lr=5e-4 → must match the saved group with max_lr=5e-4. + assert reordered[1]["max_lr"] == 5e-4 + assert reordered[1]["_tag"] == "from_projector" + + +def test_filter_reorder_tolerates_missing_optional_keys(): + """Some identifier keys (``start_wd`` / ``end_wd`` / ``optimizer``) come from + ``ParamGroupOverride`` and are only present on groups that explicitly + override them. Default groups don't carry these keys at all, so the matcher + must tolerate missing keys (rather than KeyError-ing). Two groups + missing the same set of keys must remain matchable. + """ + # Both groups have only the always-present keys; they should match by tuple + # of values plus the same None placeholder for missing keys. + current = [ + _make_pg( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=1e-3, + min_lr=1e-4, + # NOT setting start_wd, end_wd, optimizer — these are absent. + ) + ] + saved = [ + _make_pg( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=1e-3, + min_lr=1e-4, + _tag="saved_match", + ) + ] + reordered = MegatronOptimizer._filter_and_reorder_param_groups(current, saved) + assert len(reordered) == 1 + assert reordered[0]["_tag"] == "saved_match" + + +def test_filter_reorder_treats_missing_and_none_identifier_values_as_same(): + """A missing identifier key and an explicit ``None`` value use the same convention.""" + common = dict( + wd_mult=1.0, lr_mult=1.0, is_expert_parallel=False, is_decoupled_lr=False, max_lr=1e-3 + ) + current = [_make_pg(**common, min_lr=None)] + saved = [_make_pg(**common, _tag="saved_match")] + + reordered = MegatronOptimizer._filter_and_reorder_param_groups(current, saved) + + assert reordered[0]["_tag"] == "saved_match" + + +def test_filter_reorder_distinguishes_by_optional_override_key(): + """When a group sets a key that another doesn't (e.g. ``start_wd``), the + identifier tuple must reflect that — the two groups must be distinguishable + rather than collapsed into one match. + """ + common = dict( + wd_mult=1.0, + lr_mult=1.0, + is_expert_parallel=False, + is_decoupled_lr=False, + max_lr=1e-3, + min_lr=1e-4, + ) + current = [ + _make_pg(**common, start_wd=0.05), # explicit per-group start_wd + _make_pg(**common), # default start_wd (absent -> None) + ] + saved = [ + _make_pg(**common, _tag="default_wd"), + _make_pg(**common, start_wd=0.05, _tag="explicit_wd"), + ] + reordered = MegatronOptimizer._filter_and_reorder_param_groups(current, saved) + assert reordered[0]["_tag"] == "explicit_wd" + assert reordered[0].get("start_wd") == 0.05 + assert reordered[1]["_tag"] == "default_wd" + assert "start_wd" not in reordered[1] + + +def test_filter_reorder_handles_nemo_pre_prefix(): + """NeMo renames ``lr_mult``/``wd_mult`` to ``pre_lr_mult``/``pre_wd_mult``. + The matcher's per-key fallback must look up ``pre_`` if ```` is + missing — this preserves NeMo-saved checkpoint compatibility. + """ + common = dict(is_expert_parallel=False, is_decoupled_lr=False, max_lr=1e-3, min_lr=1e-4) + # Current uses standard names, saved uses NeMo's pre_-prefixed names. + current = [_make_pg(**common, wd_mult=1.0, lr_mult=1.0)] + saved = [_make_pg(**common, pre_wd_mult=1.0, pre_lr_mult=1.0, _tag="from_nemo")] + reordered = MegatronOptimizer._filter_and_reorder_param_groups(current, saved) + assert reordered[0]["_tag"] == "from_nemo" From acc7e644572b1e503545803b016bd1b8b2dda717 Mon Sep 17 00:00:00 2001 From: Yury Parfenov <4665475+warpuv@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:35:39 +0300 Subject: [PATCH 052/290] fix bug where Gemma4 is not working with recompute_granularity = "full" (#5324) Signed-off-by: Yury Parfenov <4665475+warpuv@users.noreply.github.com> Co-authored-by: Guihong Li --- megatron/core/recompute.py | 26 ++++- .../transformer/test_transformer_block.py | 103 +++++++++++++++++- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/megatron/core/recompute.py b/megatron/core/recompute.py index d852afe5d59..bd0d1bcb3b2 100644 --- a/megatron/core/recompute.py +++ b/megatron/core/recompute.py @@ -12,10 +12,10 @@ from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.transformer_layer import TransformerLayer -te_checkpoint = None - if HAVE_TE: from megatron.core.extensions.transformer_engine import te_checkpoint +else: + te_checkpoint = None def checkpointed_forward( @@ -50,10 +50,27 @@ def checkpointed_forward( extract_layer_indices = set() intermediate_hidden_states: List[Tensor] = [] + # Wrap non-dual RoPE to tuple to unify custom_forward interface. + is_dual_rope = isinstance(rotary_pos_emb, (tuple, list)) + assert not is_dual_rope or len(rotary_pos_emb) == 2, "Dual RoPE input length is not equal to 2" + rotary_pos_emb = rotary_pos_emb if is_dual_rope else (None, rotary_pos_emb) + def custom(start: int, end: int): def custom_forward( - hidden_states, attention_mask, context, context_mask, rotary_pos_emb, padding_mask=None + hidden_states, + attention_mask, + context, + context_mask, + rotary_pos_emb_local, + rotary_pos_emb_global, + padding_mask=None, ): + rotary_pos_emb = ( + (rotary_pos_emb_local, rotary_pos_emb_global) + if is_dual_rope + else rotary_pos_emb_global + ) + for index in range(start, end): # Use self.layers[index] (not self._get_layer) so this # function works for both TransformerBlock and HybridStack. @@ -108,7 +125,8 @@ def custom_forward( def chunk_runner(start: int, end: int, use_checkpoint: bool): nonlocal hidden_states, context cf = custom(start, end) - args = (hidden_states, attention_mask, context, context_mask, rotary_pos_emb, padding_mask) + # Unpack the RoPE tuple as torch cannot save tuples for backward pass. + args = (hidden_states, attention_mask, context, context_mask, *rotary_pos_emb, padding_mask) if use_checkpoint: # Precision-aware activation checkpoint: TE under FP8/FP4, # tensor_parallel under BF16/FP16/FP32. diff --git a/tests/unit_tests/transformer/test_transformer_block.py b/tests/unit_tests/transformer/test_transformer_block.py index 63add511bcd..0f19bc3dc95 100644 --- a/tests/unit_tests/transformer/test_transformer_block.py +++ b/tests/unit_tests/transformer/test_transformer_block.py @@ -14,6 +14,7 @@ from megatron.core.models.gpt.gpt_model import GPTModel from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.attention import SelfAttention from megatron.core.transformer.enums import ModelType from megatron.core.transformer.pipeline_parallel_layer_layout import PipelineParallelLayerLayout from megatron.core.transformer.spec_utils import build_module @@ -77,13 +78,101 @@ def test_gpu_forward_full_checkpoint(self): def test_gpu_forward_full_checkpoint_fp8(self): self._run_full_checkpoint_test(fp8="e4m3") + def test_gpu_forward_full_checkpoint_dual_rope(self): + kv_channels = self.transformer_config.kv_channels + sequence_length = 32 + rotary_pos_emb = ( + torch.ones(sequence_length, 1, 1, kv_channels, device='cuda'), + torch.ones(sequence_length, 1, 1, kv_channels, device='cuda'), + ) + + def modify_arg_and_forward( + target_func, args, kwargs, target_name, target_index, arg_modifier_func + ): + args_list = list(args) + + if target_name in kwargs: + kwargs[target_name] = arg_modifier_func(kwargs[target_name]) + elif len(args_list) > target_index: + args_list[target_index] = arg_modifier_func(args_list[target_index]) + else: + raise RuntimeError( + f"Argument '{target_name}' at index {target_index} was not provided in args or kwargs." + ) + + return target_func(*args_list, **kwargs) + + class MockSelfAttentionWithDualRope(SelfAttention): + def forward(self, *args, **kwargs): + """Switch to either local or global RoPE embedding before forward.""" + + def arg_modifier_func(rotary_pos_emb): + assert isinstance(rotary_pos_emb, (tuple, list)) and len(rotary_pos_emb) == 2 + + if self.dual_rope_kind == "local_and_global": + assert rotary_pos_emb[0] is not None + assert rotary_pos_emb[1] is not None + + if self.layer_number % 2 == 0: + final_rotary_pos_emb = rotary_pos_emb[0] + else: + final_rotary_pos_emb = rotary_pos_emb[1] + elif self.dual_rope_kind == "local_only": + assert rotary_pos_emb[0] is not None + assert rotary_pos_emb[1] is None + + final_rotary_pos_emb = rotary_pos_emb[0] + elif self.dual_rope_kind == "global_only": + assert rotary_pos_emb[0] is None + assert rotary_pos_emb[1] is not None + + final_rotary_pos_emb = rotary_pos_emb[1] + else: + assert False, f"Unknown dual_rope_kind: {self.dual_rope_kind}" + + return final_rotary_pos_emb + + return modify_arg_and_forward( + super().forward, args, kwargs, "rotary_pos_emb", 5, arg_modifier_func + ) + + # Test non-Dual RoPE + self._run_full_checkpoint_test( + fp8=None, seq_len=sequence_length, rotary_pos_emb=rotary_pos_emb[0] + ) + + # Test Dual RoPE + self._run_full_checkpoint_test( + fp8=None, + seq_len=sequence_length, + attn_class=MockSelfAttentionWithDualRope, + rotary_pos_emb=rotary_pos_emb, + dual_rope_kind="local_and_global", + ) + self._run_full_checkpoint_test( + fp8=None, + seq_len=sequence_length, + attn_class=MockSelfAttentionWithDualRope, + rotary_pos_emb=(rotary_pos_emb[0], None), + dual_rope_kind="local_only", + ) + self._run_full_checkpoint_test( + fp8=None, + seq_len=sequence_length, + attn_class=MockSelfAttentionWithDualRope, + rotary_pos_emb=(None, rotary_pos_emb[1]), + dual_rope_kind="global_only", + ) + def test_gpu_forward_selective_checkpoint(self): self._run_selective_checkpoint_test(fp8=None) def test_gpu_forward_selective_checkpoint_fp8(self): self._run_selective_checkpoint_test(fp8="e4m3") - def _run_full_checkpoint_test(self, fp8): + def _run_full_checkpoint_test( + self, fp8, seq_len=None, attn_class=None, rotary_pos_emb=None, dual_rope_kind=None + ): transformer_config = self.transformer_config config = transformer_config config.recompute_granularity = 'full' @@ -93,11 +182,17 @@ def _run_full_checkpoint_test(self, fp8): full_transformer_block = TransformerBlock( config, get_gpt_layer_with_transformer_engine_spec() ) + if attn_class is not None: + for layer in full_transformer_block.layers: + layer.self_attention.__class__ = attn_class + assert not hasattr(layer.self_attention, "dual_rope_kind") + layer.self_attention.dual_rope_kind = dual_rope_kind + assert full_transformer_block.config.recompute_granularity == 'full' assert full_transformer_block.config.recompute_method == 'block' assert full_transformer_block.config.fp8 == fp8 - sequence_length = 32 + sequence_length = 32 if seq_len is None else seq_len micro_batch_size = 2 full_transformer_block.cuda() @@ -108,7 +203,9 @@ def _run_full_checkpoint_test(self, fp8): attention_mask = torch.ones((1, 1, sequence_length, sequence_length), dtype=bool).cuda() hidden_states = full_transformer_block( - hidden_states=hidden_states, attention_mask=attention_mask + hidden_states=hidden_states, + attention_mask=attention_mask, + rotary_pos_emb=rotary_pos_emb, ) assert hidden_states.shape[0] == sequence_length assert hidden_states.shape[1] == micro_batch_size From 4a1f74350e760004a596a9c8bb08fb7667222043 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 17 Jul 2026 11:04:03 -0700 Subject: [PATCH 053/290] Avoid extra MFSDP v2 model-weight sync memcpy (#5834) Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/dbuffer.py | 41 ++++++++++--------- .../experimental/parameter_group.py | 7 ++++ .../distributed/mfsdp_v2/test_dbuffer.py | 23 +++++++++++ 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py index 9b6e6dc44c3..a240b148d62 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py @@ -244,15 +244,24 @@ def distribute_tensors( return buffer def _create_or_validate_out( - self, placements: Iterable[Placement], out: "DBuffer | None" + self, + out: "DBuffer | None", + *, + placements: Iterable[Placement] | None = None, + dtype: torch.dtype | None = None, ) -> "DBuffer": - placements = tuple(placements) + if placements is None: + placements = self.placements + else: + placements = tuple(placements) + if dtype is None: + dtype = self.dtype if out is None: return DBuffer( mesh=self.mesh, placements=placements, tensor_shapes=self.layout.tensor_shapes, - dtype=self.dtype, + dtype=dtype, device=self.device, ) @@ -262,24 +271,18 @@ def _create_or_validate_out( raise ValueError(f"Expected out placements {placements!r}, got {out.placements!r}.") if out.layout != self.layout: raise ValueError(f"Expected out layout {self.layout!r}, got {out.layout!r}.") - if out.dtype != self.dtype: - raise ValueError(f"Expected out dtype {self.dtype}, got {out.dtype}.") + if out.dtype != dtype: + raise ValueError(f"Expected out dtype {dtype}, got {out.dtype}.") if out.device != self.device: raise ValueError(f"Expected out device {self.device}, got {out.device}.") return out - def cast(self, dtype: torch.dtype) -> "DBuffer": + def cast(self, dtype: torch.dtype, *, out: "DBuffer | None" = None) -> "DBuffer": """Return this buffer with the same layout and placements in ``dtype``.""" - if self.dtype == dtype: + if self.dtype == dtype and out is None: return self - destination = DBuffer( - mesh=self.mesh, - placements=self.placements, - tensor_shapes=self.layout.tensor_shapes, - dtype=dtype, - device=self.device, - ) + destination = self._create_or_validate_out(out, dtype=dtype) destination.local_buffer.copy_(self.local_buffer) return destination @@ -304,7 +307,7 @@ def redistribute( if changed_axis is None: if out is None: return self - out = self._create_or_validate_out(new_placements, out) + out = self._create_or_validate_out(out, placements=new_placements) out.local_buffer.copy_(self.local_buffer) return out @@ -334,7 +337,7 @@ def allgather(self, mesh_axis: int, *, out: "DBuffer | None" = None) -> "DBuffer placements = list(self.placements) placements[mesh_axis] = Replicate() _validate_placements(placements) - out = self._create_or_validate_out(placements, out) + out = self._create_or_validate_out(out, placements=placements) dist.all_gather_into_tensor( output_tensor=out.local_buffer, input_tensor=self.local_buffer, @@ -351,7 +354,7 @@ def allreduce(self, mesh_axis: int, *, out: "DBuffer | None" = None) -> "DBuffer placements = list(self.placements) placements[axis] = Replicate() - out = self._create_or_validate_out(placements, out) + out = self._create_or_validate_out(out, placements=placements) out.local_buffer.copy_(self.local_buffer) dist.all_reduce( out.local_buffer, op=partial_placement.reduce_op, group=self.mesh.get_group(axis) @@ -372,7 +375,7 @@ def reduce_scatter( placements = list(self.placements) placements[axis] = new_placement _validate_placements(placements) - out = self._create_or_validate_out(placements, out) + out = self._create_or_validate_out(out, placements=placements) dist.reduce_scatter_tensor( output=out.local_buffer, input=self.local_buffer, @@ -400,7 +403,7 @@ def scatter( self.mesh, placements ) else: - out = self._create_or_validate_out(placements, out) + out = self._create_or_validate_out(out, placements=placements) destination_offset = out.offset destination_numel = out.local_buffer.numel() diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 3e5b7a49392..a710c4f07e1 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -200,6 +200,13 @@ def sync_model_weight_from_main_weight(self) -> None: if self.main_weight is self.model_weight: return + if self.main_weight.placements == self.model_weight.placements: + self.main_weight.cast(self.model_weight.dtype, out=self.model_weight) + return + + # main_weight is typically the higher-precision optimizer dtype, while + # model_weight is the lower-precision compute dtype. Cast before redistributing + # so cross-rank communication moves the smaller compute-dtype payload. self.main_weight.cast(self.model_weight.dtype).redistribute( self.model_weight.placements, out=self.model_weight ) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py b/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py index 8631113d480..1180cd55c80 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py @@ -178,6 +178,29 @@ def test_cast_preserves_layout_and_casts_values(distributed_setup): ) +def test_cast_with_out_reuses_destination_and_casts_values(distributed_setup): + """DBuffer.cast writes casted values into an existing destination buffer.""" + mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) + tensors = _same_tensors_on_all_ranks(distributed_setup.device) + buffer = DBuffer.distribute_tensors(tensors, mesh, [Replicate()]) + destination = DBuffer( + mesh=mesh, + placements=[Replicate()], + tensor_shapes=buffer.layout.tensor_shapes, + dtype=torch.bfloat16, + device=distributed_setup.device, + ) + destination_data_ptr = destination.local_buffer.data_ptr() + + result = buffer.cast(torch.bfloat16, out=destination) + + assert result is destination + assert destination.local_buffer.data_ptr() == destination_data_ptr + _assert_dbuffer_local_tensors_close( + destination, [tensor.to(dtype=torch.bfloat16) for tensor in tensors] + ) + + def test_release_and_reallocate_storage_preserves_buffer_views(distributed_setup): """DBuffer storage can be released and reallocated without replacing existing views.""" mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) From 167d51d8ae4a85a959af6cf4abdc1dbc59dfb48c Mon Sep 17 00:00:00 2001 From: Yan Bai Date: Sat, 18 Jul 2026 04:09:11 +0800 Subject: [PATCH 054/290] [experimental] Add experimental/agent_compose placeholder with preview pointer (#5639) Signed-off-by: Yan Bai --- experimental/agent_compose/README.md | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 experimental/agent_compose/README.md diff --git a/experimental/agent_compose/README.md b/experimental/agent_compose/README.md new file mode 100644 index 00000000000..ca2c8e43e38 --- /dev/null +++ b/experimental/agent_compose/README.md @@ -0,0 +1,45 @@ +# Agent Compose (experimental) + +Agent Compose is an experimental effort to make Megatron-LM development +agentic-native: composing Megatron Core primitives with coding agents, rather +than introducing a new standalone product or training stack. + +This directory is a placeholder that establishes the location and naming for +the upstreamed work. Content will land here incrementally as a series of small, +reviewable PRs. + +## Preview + +The full work-in-progress implementation lives on the `dev` branch under +`experimental/lite/`: + +- https://github.com/NVIDIA/Megatron-LM/tree/dev/experimental/lite + +The preview currently includes: + +- A lightweight runtime API built from small composable primitives. +- Native model implementations with explicit model/runtime protocols. +- Hugging Face safetensors load/export helpers. +- Validation recipes and benchmark examples against Megatron-Core reference + paths (bitwise loss/grad-norm parity on the distributed-optimizer path). +- Skills playbooks that let coding agents extend models and primitives in a + reviewable way. + +## Principles + +- **Compose, don't fork.** Primitives reuse and build from existing Megatron + Core modules wherever appropriate. When a primitive cannot reuse an existing + module and needs a separate implementation, the reason is documented in the + docstring, making gaps explicit and providing input for future Megatron Core + improvements. +- **Reviewable by construction.** Runtime, model, and primitive code are split + into small contracts so agents and humans can make targeted changes without + touching unrelated Megatron subsystems. +- **Core performance.** Changes are validated against Megatron-Core reference + paths for both correctness and speed. + +## Status + +Upstreaming is being scoped: the current work is being evaluated for splitting +into small PRs, after which a timeline will be shared. Until then, please use +the preview branch above. From 53492eb1aac3086516cfaa6aeacf706e98c8ed4e Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 17 Jul 2026 15:31:14 -0700 Subject: [PATCH 055/290] Stabilize mfsdp_v2 overlap test by enlarging the model (#5846) Signed-off-by: Jingyue Wu --- .../distributed/mfsdp_v2/test_fully_shard.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index a7b9dcd5811..6f4a1a36620 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -311,8 +311,6 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): ) -@pytest.mark.flaky -@pytest.mark.flaky_in_dev def test_overlaps_communication_and_compute(distributed_setup): """Forward and backward communication should overlap GEMM compute.""" world_size = distributed_setup.world_size @@ -321,7 +319,14 @@ def test_overlaps_communication_and_compute(distributed_setup): pytest.skip("This test requires at least 2 ranks.") mesh = init_device_mesh(device.type, (world_size,)) - dim = 8192 + # A large hidden size keeps the per-layer GEMMs long enough that the + # collectives reliably overlap them. The overlap count is otherwise + # launch-bound: the host issues kernels with gaps (amplified by CI's + # `coverage run` wrapper), so with short GEMMs a collective can land in a + # gap between GEMMs instead of running alongside one, making the count jitter + # run to run. At dim=16384 the GEMMs dominate that launch jitter and the + # overlap becomes deterministic. (dim=8192 was flaky under coverage.) + dim = 16384 num_children = 4 dtype = torch.bfloat16 model = MultiChildModel(dim=dim, num_children=num_children).to(dtype=dtype) @@ -348,7 +353,15 @@ def train_one_iteration() -> None: # drop the CUDA events. torch.cuda.synchronize(device) - cuda_events = [event for event in prof.events() if event.device_type.name == "CUDA"] + # Keep only real device kernels. NCCL also emits per-collective GPU user + # annotations (e.g. "nccl:_reduce_scatter_base") that the profiler reports as + # CUDA events. Filtering by activity type avoids counting those annotations as + # kernels without relying on their current naming convention. + cuda_events = [ + event + for event in prof.events() + if event.device_type.name == "CUDA" and event.activity_type == "kernel" + ] all_gather_events = [ event for event in cuda_events @@ -367,8 +380,16 @@ def train_one_iteration() -> None: for event in cuda_events if any(token in event.name.lower() for token in ("gemm", "cutlass", "cublas", "nvjet")) ] - assert all_gather_events, [event.name for event in cuda_events] - assert reduce_scatter_events, [event.name for event in cuda_events] + # Each of the num_children children plus the root all-gathers in forward and + # again in backward, and each reduce-scatters once in backward. + assert len(all_gather_events) == 2 * (num_children + 1), ( + f"Expected {2 * (num_children + 1)} all-gather kernels, " + f"got {[event.name for event in all_gather_events]}." + ) + assert len(reduce_scatter_events) == num_children + 1, ( + f"Expected {num_children + 1} reduce-scatter kernels, " + f"got {[event.name for event in reduce_scatter_events]}." + ) assert gemm_events, [event.name for event in cuda_events] all_gather_streams = {event.device_resource_id for event in all_gather_events} @@ -388,16 +409,16 @@ def train_one_iteration() -> None: any(_events_overlap(reduce_scatter_event, gemm_event) for gemm_event in gemm_events) for reduce_scatter_event in reduce_scatter_events ) - # Communication overlaps compute only partially due to SM contention (the - # SM-based NCCL collectives share SMs with the GEMMs) and the count varies - # run to run, so assert only that overlap meaningfully happens rather than the - # theoretical maximum (2*(num_children - 1) / num_children - 1). Symmetric-memory - # collectives (use_symm_mem, ~SM-free) would let these thresholds be tightened. - assert all_gather_overlap_count >= 2, ( + # With dim large enough for the GEMMs to dominate launch jitter (see above), + # the prefetched collectives overlap compute deterministically, so assert the + # theoretical maxima (2*(num_children - 1) all-gathers across forward and + # backward, num_children - 1 reduce-scatters in backward) rather than a loose + # floor. + assert all_gather_overlap_count >= 2 * (num_children - 1), ( f"Expected all-gather to overlap compute, " f"got {all_gather_overlap_count}/{len(all_gather_events)}." ) - assert reduce_scatter_overlap_count >= 1, ( + assert reduce_scatter_overlap_count >= num_children - 1, ( f"Expected reduce-scatter to overlap compute, " f"got {reduce_scatter_overlap_count}/{len(reduce_scatter_events)}." ) From dc57c2b1a851a6a956166d1325fc49030d6ea6ca Mon Sep 17 00:00:00 2001 From: gautham-kollu Date: Fri, 17 Jul 2026 17:38:12 -0700 Subject: [PATCH 056/290] Print important dependencies (#5814) Signed-off-by: Gautham Kollu --- pretrain_gpt.py | 7 +++++++ pretrain_hybrid.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/pretrain_gpt.py b/pretrain_gpt.py index 13c409d8ce6..47c1935eb90 100644 --- a/pretrain_gpt.py +++ b/pretrain_gpt.py @@ -28,6 +28,7 @@ from megatron.core.datasets.blended_megatron_dataset_builder import BlendedMegatronDatasetBuilder from megatron.core.datasets.gpt_dataset import GPTDataset, GPTDatasetConfig, MockGPTDataset from megatron.core.enums import ModelType +from megatron.core.package_info import __version__ as mcore_version from megatron.core.models.gpt import GPTModel from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.parallel_state import ( @@ -46,6 +47,8 @@ get_attr_wrapped_model, get_batch_on_this_cp_rank, get_batch_on_this_tp_rank, + get_te_version, + get_torch_version, ) from megatron.training import ( get_args, @@ -493,6 +496,10 @@ def get_embedding_ranks(pp_ranks: List[int]): # Timestamp right after entering __main__ block (after all imports/library setup) _MAIN_ENTRY_TIME = time.time() + print_rank_0(f'> PyTorch version ................ {get_torch_version()}') + print_rank_0(f'> Megatron-Core version .......... {mcore_version}') + print_rank_0(f'> Transformer Engine version ... {get_te_version()}') + # Register startup timestamps for timing report in pretrain() set_startup_timestamps(program_start=_PROGRAM_START_TIME, main_entry=_MAIN_ENTRY_TIME) diff --git a/pretrain_hybrid.py b/pretrain_hybrid.py index 4298b23b9f0..39bc7f30b57 100644 --- a/pretrain_hybrid.py +++ b/pretrain_hybrid.py @@ -27,6 +27,7 @@ from megatron.core.datasets.blended_megatron_dataset_builder import BlendedMegatronDatasetBuilder from megatron.core.datasets.gpt_dataset import GPTDataset, GPTDatasetConfig, MockGPTDataset from megatron.core.enums import ModelType +from megatron.core.package_info import __version__ as mcore_version from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.parallel_state import ( @@ -44,6 +45,8 @@ get_attr_wrapped_model, get_batch_on_this_cp_rank, get_batch_on_this_tp_rank, + get_te_version, + get_torch_version, ) from megatron.training import ( get_args, @@ -439,6 +442,10 @@ def train_valid_test_datasets_provider(train_val_test_num_samples, vp_stage=None # Timestamp right after entering __main__ block (after all imports/library setup) _MAIN_ENTRY_TIME = time.time() + print_rank_0(f'> PyTorch version ................ {get_torch_version()}') + print_rank_0(f'> Megatron-Core version .......... {mcore_version}') + print_rank_0(f'> Transformer Engine version ... {get_te_version()}') + # Register startup timestamps for timing report in pretrain() set_startup_timestamps(program_start=_PROGRAM_START_TIME, main_entry=_MAIN_ENTRY_TIME) From f258d4fa82de72705f846fb713bbe3b4180f38e8 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 17 Jul 2026 17:47:31 -0700 Subject: [PATCH 057/290] Test zero-CTA copy-engine all-gather (#5858) Signed-off-by: Jingyue Wu Co-authored-by: Claude Opus 4.8 (1M context) --- .../mfsdp_v2/test_symmetric_memory.py | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py index 2d3ecafd048..13dc1744628 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py @@ -4,8 +4,10 @@ import pytest import torch +import torch.distributed as dist from torch import nn -from torch.distributed.device_mesh import init_device_mesh +from torch.autograd import DeviceType +from torch.distributed.device_mesh import DeviceMesh, init_device_mesh from torch.profiler import ProfilerActivity, profile from megatron.core.distributed.fsdp.src.megatron_fsdp import MixedPrecisionPolicy @@ -42,7 +44,7 @@ def _flat_placements() -> Placements: def _kernels(prof: torch.profiler.profile) -> list[str]: - return [event.name for event in prof.events()] + return [event.name for event in prof.events() if event.device_type == DeviceType.CUDA] def _is_symmetric_kernel(kernel: str) -> bool: @@ -146,3 +148,83 @@ def train(use_symm_mem: bool) -> list[torch.Tensor]: "Unexpected NCCL symmetric-memory all-gather kernel count. " f"Observed NCCL kernels: {nccl_kernels_with_symm_mem[:20]}" ) + + +def test_fully_shard_zero_cta_moves_all_gather_to_copy_engine(distributed_setup): + """NCCL's zero-CTA policy runs the all-gather on the copy engine. + + Zero-CTA offloads only pure data movement, so the all-gather emits no ``ncclSymk`` + kernel (it becomes a copy-engine memcpy). The reduce-scatter's reduction cannot run on + the copy engine, so it stays a symmetric-memory kernel -- an SM-launched NVLS multicast + reduce (ncclSymkDevKernel_ReduceScatter_LDMC/LL). + """ + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + + # new_group requires a default process group. Initialize it here so this test works + # in isolation. Do not eagerly initialize it with device_id in the shared fixture: + # that can hang teardown after communicator splits; see + # https://github.com/pytorch/pytorch/issues/190396. + if not dist.is_initialized(): + dist.init_process_group(backend="nccl") + + # Dedicated communicator with NCCL's zero-CTA policy, scoped to this test so the rest + # of the bucket keeps default-CTA symmetric-memory kernels. + zero_cta_options = dist.ProcessGroupNCCL.Options() + zero_cta_options.config.cta_policy = dist.ProcessGroupNCCL.NCCL_CTA_POLICY_ZERO + dp_group = dist.new_group(backend="nccl", pg_options=zero_cta_options) + # NCCL window registration can fail when symmetric-memory rendezvous is the first + # operation on a communicator, so initialize this communicator explicitly. + dist.barrier(group=dp_group, device_ids=[device.index]) + mesh = DeviceMesh.from_group(dp_group, device.type) + + num_training_steps = 5 + model = TinyModel().to(device=device, dtype=torch.bfloat16) + mixed_precision_policy = MixedPrecisionPolicy(main_params_dtype=torch.float32) + fully_shard( + model.fc1, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + use_symm_mem=True, + ) + fully_shard( + model.fc2, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + use_symm_mem=True, + ) + optimizer = torch.optim.SGD(model.parameters(), lr=0.05, foreach=False) + x = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) + target = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) + + with profile(activities=[ProfilerActivity.CUDA]) as prof: + for _ in range(num_training_steps): + optimizer.zero_grad() + torch.nn.functional.mse_loss(model(x), target).backward() + optimizer.step() + torch.cuda.synchronize() + + kernels = _kernels(prof) + nccl_kernels = [kernel for kernel in kernels if "nccl" in kernel.lower()] + # Zero-CTA moves the all-gather to the copy engine: no symmetric-memory all-gather kernel. + assert _count_symmetric_kernels(kernels, "AllGather") == 0, ( + f"Expected no symmetric-memory all-gather kernel under zero-CTA. " + f"Observed NCCL kernels: {nccl_kernels[:20]}" + ) + # The reduce-scatter's reduction cannot run on the copy engine, so it stays a + # symmetric-memory kernel (an SM-launched NVLS multicast reduce): one per sharded + # module (fc1, fc2) per training step. + expected_reduce_scatter_kernel_count = num_training_steps * 2 + assert ( + _count_symmetric_kernels(kernels, "ReduceScatter") == expected_reduce_scatter_kernel_count + ), ( + f"Expected {expected_reduce_scatter_kernel_count} symmetric-memory reduce-scatter " + f"kernels under zero-CTA. Observed NCCL kernels: {nccl_kernels[:20]}" + ) + + # Release the dedicated communicator (leaks only on a test failure above, which is fine). + dist.destroy_process_group(dp_group) From b4ad280d352e7d3d57040f60a16f358da1a75893 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Fri, 17 Jul 2026 20:45:08 -0700 Subject: [PATCH 058/290] Route Lion through DistributedOptimizer and support single-moment checkpointing (#5742) Signed-off-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 --- megatron/core/optimizer/__init__.py | 22 ++++- megatron/core/optimizer/distrib_optimizer.py | 60 +++++++----- .../dist_checkpointing/test_optimizer.py | 96 ++++++++++++++++++- tests/unit_tests/dist_checkpointing/utils.py | 12 ++- tests/unit_tests/test_lion_optimizer.py | 51 ++++++++++ 5 files changed, 211 insertions(+), 30 deletions(-) diff --git a/megatron/core/optimizer/__init__.py b/megatron/core/optimizer/__init__.py index 27b675d1b8d..bfe3d9e3c85 100644 --- a/megatron/core/optimizer/__init__.py +++ b/megatron/core/optimizer/__init__.py @@ -798,7 +798,15 @@ def _get_megatron_emerging_optimizer( ) # Apply optimizer-specific default param overrides (e.g. muon: non-linear -> adam). - config_overrides.update(_EMERGING_OPTIMIZERS[eopt_name].default_param_overrides) + # For Muon-family optimizers, the scalar optimizer that handles non-linear/embedding + # params is configurable via ``config.muon_scalar_optimizer`` (e.g., 'adam' or 'lion'); + # deep-copy the registry defaults before rewriting so we never mutate shared state. + default_param_overrides = copy.deepcopy(_EMERGING_OPTIMIZERS[eopt_name].default_param_overrides) + if eopt_name in ('muon', 'adaptive_muon'): + for override in default_param_overrides.values(): + if override.get('optimizer') in ('adam', 'lion'): + override['optimizer'] = config.muon_scalar_optimizer + config_overrides.update(default_param_overrides) # Build param groups and bucket by (optimizer_name, is_expert_parallel). # Layer-wise distributed optimizer handles expert params internally so we skip that split. @@ -831,7 +839,10 @@ def _get_megatron_emerging_optimizer( "fall back to the legacy LayerWise ping-pong path." ) if use_separate_distributed_optimizer and any( - opt_name not in _EMERGING_OPTIMIZERS + # A separate DistributedOptimizer with byte-level sharding handles any group + # whose optimizer is not the primary emerging optimizer (stored in ``eopt_name``, + # e.g., Muon). This includes scalar optimizers like Adam or Lion. + not (opt_name == eopt_name and opt_name in _EMERGING_OPTIMIZERS) for (opt_name, _), groups in grouped_param_groups.items() if groups ): @@ -870,7 +881,10 @@ def _get_megatron_emerging_optimizer( model_parallel_group = pg_collection.tp_ep_pp if is_expert else pg_collection.mp - if opt_name in _EMERGING_OPTIMIZERS: + # Only the primary emerging optimizer (stored in ``eopt_name``, e.g., Muon) is + # constructed via ``_create_emerging_optimizer``. Scalar optimizers that also appear + # in ``_EMERGING_OPTIMIZERS`` (e.g., Lion) fall through to the standard fallback path. + if opt_name == eopt_name and opt_name in _EMERGING_OPTIMIZERS: optimizer, init_state_fn = _create_emerging_optimizer( config, groups, eopt_name, model_chunks, pg_collection ) @@ -895,7 +909,7 @@ def _get_megatron_emerging_optimizer( fallback_config = copy.copy(config) fallback_config.optimizer = opt_name if use_separate_distributed_optimizer: - # Route non-emerging params through a real DistributedOptimizer + # Route non-emerging params (adam/lion) through a real DistributedOptimizer # (byte-level sharding) instead of stuffing them inside LayerWise. for group in groups: assert not group['is_expert_parallel'], ( diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py index 04a82f0134a..4d17d59e437 100644 --- a/megatron/core/optimizer/distrib_optimizer.py +++ b/megatron/core/optimizer/distrib_optimizer.py @@ -666,9 +666,10 @@ def __init__( assert ( isinstance(optimizer, (Adam, torch.optim.AdamW, HybridDeviceOptimizer)) or optimizer is None + or init_state_fn is not None ), ( - "Only Adam and HybridDeviceOptimizer currently supported, " - "due to checkpointing requirements." + "Only Adam, HybridDeviceOptimizer, and optimizers with an init_state_fn " + "(e.g., Lion) are currently supported, due to checkpointing requirements." ) # when freezing sub-models we have no real optimizer @@ -778,6 +779,27 @@ def get_grad_stats_parallel_group(self) -> torch.distributed.ProcessGroup: """ return getattr(self, 'grad_stats_parallel_group', None) + @property + def optimizer_state_keys(self): + """Return the optimizer's tensor state keys, e.g., ('exp_avg', 'exp_avg_sq') for Adam + or ('exp_avg',) for Lion.""" + _OPTIMIZER_STATE_KEYS = {"lion": ("exp_avg",)} + optimizer_name = self.config.optimizer + # When Muon is the top-level optimizer, the DistributedOptimizer wrapping + # scalar parameters uses muon_scalar_optimizer (e.g., Lion) as the actual + # optimizer, so look up state keys by that name instead. + if optimizer_name == "muon": + optimizer_name = self.config.muon_scalar_optimizer + return _OPTIMIZER_STATE_KEYS.get(optimizer_name, ("exp_avg", "exp_avg_sq")) + + def _get_state_key_dtype(self, key): + """Return the dtype for a given optimizer state key.""" + dtype_map = { + "exp_avg": self.config.exp_avg_dtype, + "exp_avg_sq": self.config.exp_avg_sq_dtype, + } + return dtype_map.get(key, torch.float32) + def state_dict(self): """ The state dict contains all non-DP-rank-dependent (i.e., non-parameter- @@ -957,8 +979,8 @@ def make_needed_groups(param_group): # For precision_aware_optimizer, the empty tensors should also be # initialized with the correct dtype. tensors = { - "exp_avg": init_shard(self.config.exp_avg_dtype), - "exp_avg_sq": init_shard(self.config.exp_avg_sq_dtype), + key: init_shard(self._get_state_key_dtype(key)) + for key in self.optimizer_state_keys } if self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8: if self.config.store_param_remainders and self.config.bf16: @@ -1047,12 +1069,8 @@ def _get_main_param_and_optimizer_states(self, model_param): """Return a dict containing the main param and optimizer states corresponding to the input model_param. - The structure of the returned dict: - tensors = { - "param": torch.Tensor - "exp_avg": torch.Tensor - "exp_avg_sq": torch.Tensor - } + The returned dict always contains "param" and one entry per optimizer state tensor + (e.g., "exp_avg" and "exp_avg_sq" for Adam, or just "exp_avg" for Lion). """ group_index, group_order = self.model_param_group_index_map[model_param] if self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8: @@ -1143,12 +1161,8 @@ def _expand_quantized_param_shard_for_cast( def _set_main_param_and_optimizer_states(self, model_param, tensors): """Set the main param and optimizer states corresponding to the input model_param. - The structure of the input `tensors`: - tensors = { - "param": torch.Tensor - "exp_avg": torch.Tensor - "exp_avg_sq": torch.Tensor - } + The input `tensors` dict contains "param" and one entry per optimizer state tensor + (e.g., "exp_avg" and "exp_avg_sq" for Adam, or just "exp_avg" for Lion). """ group_index, group_order = self.model_param_group_index_map[model_param] if self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8: @@ -1271,7 +1285,7 @@ def get_parameter_state_dp_zero( key: torch.zeros( (buffer_numel_unpadded,), dtype=torch.float32, device="cpu" ) - for key in ("param", "exp_avg", "exp_avg_sq") + for key in ("param",) + self.optimizer_state_keys } world_tensors["numel_unpadded"] = buffer_numel_unpadded @@ -1293,7 +1307,7 @@ def get_parameter_state_dp_zero( local_shards = { key: torch.zeros((gbuf_local_numel,), dtype=torch.float32, device="cpu") - for key in ("param", "exp_avg", "exp_avg_sq") + for key in ("param",) + self.optimizer_state_keys } # Build contiguous DP rank shards (for param + optim states). @@ -1652,8 +1666,8 @@ def sharded_param_state_fully_reshardable( `fully_reshardable` format involves gathering the tensors on DP rank 0 during save. Flat DistOpt buffers are unflattened and reshaped into model param like sizes. This results in a state dict similar to a regular optimizer one, where each - param of shape (X, Y, Z) has corresponding 'param', 'exp_avg' and 'exp_avg_sq' - tensors of shape (X, Y, Z) in the optimizer state dict. + param of shape (X, Y, Z) has corresponding 'param' and optimizer state + tensors (e.g., 'exp_avg', 'exp_avg_sq') of shape (X, Y, Z) in the optimizer state dict. During loading there is no data exchange - each rank requests to load the whole state dict (and flattens and trims the tensors afterwards). It is recommended @@ -2125,7 +2139,7 @@ def load_parameter_state_from_dp_zero_legacy(self, state_dict): t.numel() for t in state_dict[gbuf_idx][torch.float32]["param"] ] assert sum(model_numels) == sum(checkpoint_numels) - for key in ("param", "exp_avg", "exp_avg_sq"): + for key in ("param",) + self.optimizer_state_keys: legacy_world_tensors = self._update_legacy_world_tensors( state_dict[gbuf_idx][torch.float32][key], [ @@ -2246,7 +2260,7 @@ def load_parameter_state_from_dp_zero(self, state_dict, *, update_legacy_format= f"({buffer_numel_unpadded}) and checkpoint ({checkpoint_numel_unpadded})" ) recv_tensors = {} - for key in ("param", "exp_avg", "exp_avg_sq"): + for key in ("param",) + self.optimizer_state_keys: offset_in_world_tensors = 0 for bucket_idx, gbuf_range_map in enumerate(gbuf_range_map_for_all_buckets): # Compute local DP contiguous shard's size. @@ -2459,7 +2473,7 @@ def split_state_dict_if_needed(self, state_dict): # Split the target buffer into two separate buffers. fp8_state_dict, non_fp8_state_dict = {}, {} - for key in ['param', 'exp_avg', 'exp_avg_sq']: + for key in ('param',) + self.optimizer_state_keys: tensor = state_dict[non_fp8_gbuf_idx][non_fp8_param_and_grad_dtype][key] fp8_tensor = torch.empty([fp8_offsets[-1]], dtype=tensor.dtype) non_fp8_tensor = torch.empty([non_fp8_offsets[-1]], dtype=tensor.dtype) diff --git a/tests/unit_tests/dist_checkpointing/test_optimizer.py b/tests/unit_tests/dist_checkpointing/test_optimizer.py index 149323707de..f93e09a43b7 100644 --- a/tests/unit_tests/dist_checkpointing/test_optimizer.py +++ b/tests/unit_tests/dist_checkpointing/test_optimizer.py @@ -24,7 +24,8 @@ get_gpt_layer_with_transformer_engine_spec as gpt_te_spec, ) from megatron.core.models.gpt.gpt_model import GPTModel -from megatron.core.optimizer import ChainedOptimizer +from megatron.core.optimizer import HAVE_EMERGING_OPTIMIZERS, ChainedOptimizer +from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer from megatron.core.tensor_parallel import model_parallel_cuda_manual_seed from megatron.core.transformer import MLATransformerConfig, TransformerConfig from megatron.core.transformer.mlp import apply_swiglu_sharded_factory @@ -875,6 +876,99 @@ def test_model_parallel_dp_group_idx_preservation(self, tp, src_pp, dest_pp): # Check each dst group has at least 1 rank both in src and dest assert same_groups == set(range(num_dest_dp_groups)) + @pytest.mark.skipif( + not HAVE_EMERGING_OPTIMIZERS, reason="emerging_optimizers package not installed" + ) + @pytest.mark.skipif( + not is_torch_min_version("2.6a0"), reason="dp_reshardable requires PyTorch 2.6a0 or later" + ) + @pytest.mark.parametrize('sharding_type', ['dp_reshardable', 'fully_reshardable']) + def test_lion_optimizer_checkpoint_round_trip(self, tmp_path_dist_ckpt, sharding_type): + """Test DistributedOptimizer checkpoint save/load with Lion (single-moment optimizer). + + Lion is used as the scalar optimizer for Muon (muon_scalar_optimizer='lion'), + which is the natural path where Lion ends up inside a DistributedOptimizer. + This exercises the dynamic optimizer_state_keys logic with Lion's single + moment ('exp_avg') instead of Adam's two ('exp_avg', 'exp_avg_sq'). + """ + Utils.initialize_model_parallel(2, 1, order='tp-pp-dp') + + def _get_lion_distopt(optimizer): + """Extract the Lion DistributedOptimizer from a Muon+Lion ChainedOptimizer.""" + assert isinstance(optimizer, ChainedOptimizer) + for child in optimizer.chained_optimizers: + if isinstance(child, DistributedOptimizer): + return child + raise AssertionError("No DistributedOptimizer found in ChainedOptimizer") + + def _seed_random_optimizer_state(distopt, seed): + """Seed non-zero random exp_avg values in the DistOpt's raw optimizer state.""" + torch.manual_seed(seed) + for group in distopt.optimizer.param_groups: + for p in group['params']: + state = distopt.optimizer.state[p] + if 'exp_avg' in state: + state['exp_avg'].copy_(torch.randn_like(p.data)) + + with TempNamedDir( + tmp_path_dist_ckpt / 'test_lion_optimizer_checkpoint', sync=True + ) as ckpt_dir_A: + model_A, optimizer_A = setup_model_and_optimizer( + seed=2, + tp=2, + pp=1, + bf16=True, + dist_opt=True, + optimizer='muon', + muon_scalar_optimizer='lion', + use_param_layout=True, + ) + + lion_distopt_A = _get_lion_distopt(optimizer_A) + assert lion_distopt_A.optimizer_state_keys == ("exp_avg",) + _seed_random_optimizer_state(lion_distopt_A, seed=100) + + metadata = {'distrib_optim_sharding_type': sharding_type} + + model_sharded_sd = model_A[0].sharded_state_dict() + optim_sd = optimizer_A.sharded_state_dict(model_sharded_sd, metadata=metadata) + save(optim_sd, ckpt_dir_A) + + dp_zero_optim_A = lion_distopt_A.get_parameter_state_dp_zero(use_gloo_comm=False) + + model_B, optimizer_B = setup_model_and_optimizer( + seed=3, + tp=2, + pp=1, + bf16=True, + dist_opt=True, + optimizer='muon', + muon_scalar_optimizer='lion', + use_param_layout=True, + ) + + lion_distopt_B = _get_lion_distopt(optimizer_B) + _seed_random_optimizer_state(lion_distopt_B, seed=200) + + # Before loading, state should differ. + dp_zero_optim_B = lion_distopt_B.get_parameter_state_dp_zero(use_gloo_comm=False) + assert not self.check_equal_dp_zero_state(dp_zero_optim_A, dp_zero_optim_B, True) + + model_sharded_sd = model_B[0].sharded_state_dict() + load_sharded_state_dict = optimizer_B.sharded_state_dict( + model_sharded_sd, metadata=metadata, is_loading=True + ) + state_dict = load(load_sharded_state_dict, ckpt_dir_A) + optimizer_B.load_state_dict(state_dict) + + # After loading, state should match. + dp_zero_optim_B = lion_distopt_B.get_parameter_state_dp_zero(use_gloo_comm=False) + assert self.check_equal_dp_zero_state( + dp_zero_optim_A, dp_zero_optim_B, True, raise_if_different=True + ) + + Utils.destroy_model_parallel() + class TestFP32Optimizer: def setup_method(self, method): diff --git a/tests/unit_tests/dist_checkpointing/utils.py b/tests/unit_tests/dist_checkpointing/utils.py index 81851f7f80e..d89abf5d7b4 100644 --- a/tests/unit_tests/dist_checkpointing/utils.py +++ b/tests/unit_tests/dist_checkpointing/utils.py @@ -185,6 +185,7 @@ def setup_model_and_optimizer( dist_opt=True, optimizer='adam', use_param_layout=False, + muon_scalar_optimizer='adam', ): optimizer_type = optimizer use_layer_wise = False @@ -226,10 +227,13 @@ def setup_model_and_optimizer( use_distributed_optimizer=ddp_use_dist_opt, use_layer_wise_distributed_optimizer=use_layer_wise, optimizer=optimizer, + muon_scalar_optimizer=muon_scalar_optimizer, ) if optimizer_type in ('muon', 'dist_muon'): config.lr = 0.0 + elif optimizer_type == 'lion': + config.lr = 1e-4 optimizer = get_megatron_optimizer(config, model) torch.manual_seed(seed + 1) @@ -255,11 +259,15 @@ def _init_states(optimizer): if isinstance(optimizer, ChainedOptimizer): _init_states(optimizer) else: + if hasattr(optimizer, 'optimizer_state_keys'): + state_keys = optimizer.optimizer_state_keys + else: + state_keys = ("exp_avg", "exp_avg_sq") for group in optimizer.optimizer.param_groups: for p in group['params']: if len(optimizer.optimizer.state[p]) == 0: - optimizer.optimizer.state[p]['exp_avg'] = torch.rand_like(p.data) - optimizer.optimizer.state[p]['exp_avg_sq'] = torch.rand_like(p.data) + for key in state_keys: + optimizer.optimizer.state[p][key] = torch.rand_like(p.data) optimizer.reload_model_params() CachedMetadataFileSystemReader.clear_metadata_cache() diff --git a/tests/unit_tests/test_lion_optimizer.py b/tests/unit_tests/test_lion_optimizer.py index b0df91073ed..be36f101bdd 100644 --- a/tests/unit_tests/test_lion_optimizer.py +++ b/tests/unit_tests/test_lion_optimizer.py @@ -19,6 +19,7 @@ _get_megatron_optimizer_based_on_param_groups, _get_param_groups, ) +from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer from megatron.core.optimizer.optimizer import FP32Optimizer requires_emerging_optimizers = pytest.mark.skipif( @@ -253,3 +254,53 @@ def test_megatron_lion_exact_match_with_standalone(self, lr, beta1, beta2, weigh rtol=0, msg=f"Step {step}, state '{key}': optimizer states differ", ) + + +class TestDistributedOptimizerStateKeys: + """Tests for DistributedOptimizer.optimizer_state_keys and _get_state_key_dtype. + + These tests use a mock to avoid needing a full distributed setup. + """ + + def _make_mock_distopt(self, optimizer_name): + """Create a minimal mock with just the config needed for optimizer_state_keys.""" + mock = object.__new__(DistributedOptimizer) + mock.config = OptimizerConfig(optimizer=optimizer_name, lr=1e-4) + return mock + + def test_adam_state_keys(self): + distopt = self._make_mock_distopt("adam") + assert distopt.optimizer_state_keys == ("exp_avg", "exp_avg_sq") + + def test_lion_state_keys(self): + distopt = self._make_mock_distopt("lion") + assert distopt.optimizer_state_keys == ("exp_avg",) + + def test_sgd_state_keys_defaults_to_adam(self): + distopt = self._make_mock_distopt("sgd") + assert distopt.optimizer_state_keys == ("exp_avg", "exp_avg_sq") + + def test_get_state_key_dtype_known_keys(self): + distopt = self._make_mock_distopt("adam") + assert distopt._get_state_key_dtype("exp_avg") == torch.float32 + assert distopt._get_state_key_dtype("exp_avg_sq") == torch.float32 + + def test_get_state_key_dtype_unknown_key(self): + distopt = self._make_mock_distopt("adam") + assert distopt._get_state_key_dtype("unknown_key") == torch.float32 + + def test_get_state_key_dtype_respects_config(self): + mock = object.__new__(DistributedOptimizer) + mock.config = SimpleNamespace(exp_avg_dtype=torch.bfloat16, exp_avg_sq_dtype=torch.float16) + assert mock._get_state_key_dtype("exp_avg") == torch.bfloat16 + assert mock._get_state_key_dtype("exp_avg_sq") == torch.float16 + + def test_muon_with_lion_scalar_optimizer(self): + mock = object.__new__(DistributedOptimizer) + mock.config = OptimizerConfig(optimizer="muon", lr=1e-4, muon_scalar_optimizer="lion") + assert mock.optimizer_state_keys == ("exp_avg",) + + def test_muon_with_adam_scalar_optimizer(self): + mock = object.__new__(DistributedOptimizer) + mock.config = OptimizerConfig(optimizer="muon", lr=1e-4, muon_scalar_optimizer="adam") + assert mock.optimizer_state_keys == ("exp_avg", "exp_avg_sq") From 3219f384517af6bab6251baefbfe31db4d0af45b Mon Sep 17 00:00:00 2001 From: Rui Zhu Date: Sun, 19 Jul 2026 00:16:14 -0700 Subject: [PATCH 059/290] Fix broken remove_sharded_tensors public API and re-enable its unit test (#5759) Signed-off-by: Rui Zhu Co-authored-by: Claude Fable 5 Co-authored-by: Dmytro Pykhtar <37850217+dimapihtar@users.noreply.github.com> --- megatron/core/dist_checkpointing/serialization.py | 4 ++-- megatron/core/dist_checkpointing/strategies/torch.py | 10 +++++----- .../dist_checkpointing/test_serialization.py | 9 +++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index dd85fe178cd..f01c0eb45f6 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. """Entrypoints for saving and loading the distributed checkpoints. @@ -326,7 +326,7 @@ def load_content_metadata( def remove_sharded_tensors(checkpoint_dir: str, key_prefix: str): """determine the appropriate sharding strategy and delegate removal to the sharded strategy""" verify_checkpoint(checkpoint_dir) - TorchDistSaveShardedStrategy.remove_sharded_tensors(checkpoint_dir, key_prefix) + TorchDistLoadShardedStrategy().remove_sharded_tensors(checkpoint_dir, key_prefix) def save( diff --git a/megatron/core/dist_checkpointing/strategies/torch.py b/megatron/core/dist_checkpointing/strategies/torch.py index 8d65e299304..617992986ff 100644 --- a/megatron/core/dist_checkpointing/strategies/torch.py +++ b/megatron/core/dist_checkpointing/strategies/torch.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. """ Strategies using PyTorch distributed.checkpoint as an underlying format. """ import inspect @@ -1024,16 +1024,16 @@ def remove_sharded_tensors(self, checkpoint_dir: str, key_prefix: str): except AttributeError: os.sync() ## move the old metadata - fs_writer.fs.rename(fs_writer.metadata_path, old_path) + fs_writer.fs.rename(metadata_filename, old_path) try: ## rename the new metadata - fs_writer.fs.rename(tmp_path, fs_writer.metadata_path) + fs_writer.fs.rename(tmp_path, metadata_filename) ## finally, remove the files we want to drop for f in files_to_remove: - fs_writer.fs.rm_file(checkpoint_dir / f) + fs_writer.fs.rm_file(Path(checkpoint_dir) / f) except Exception as e: - fs_writer.fs.rename(old_path, fs_writer.metadata_path) + fs_writer.fs.rename(old_path, metadata_filename) raise e else: fs_writer.fs.rm_file(old_path) diff --git a/tests/unit_tests/dist_checkpointing/test_serialization.py b/tests/unit_tests/dist_checkpointing/test_serialization.py index 36de2e3c2c5..6fea505f66b 100644 --- a/tests/unit_tests/dist_checkpointing/test_serialization.py +++ b/tests/unit_tests/dist_checkpointing/test_serialization.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import io import logging @@ -528,8 +528,6 @@ def test_tensor_shape_mismatch(self, tmp_path_dist_ckpt): not is_torch_min_version("2.3.0"), reason="remove_sharded_tensors relies on Torch APIs introduced in v2.3.0", ) - @pytest.mark.flaky - @pytest.mark.flaky_in_dev def test_remove_sharded_tensors(self, tmp_path_dist_ckpt): Utils.initialize_model_parallel(2, 4) @@ -576,7 +574,10 @@ def test_remove_sharded_tensors(self, tmp_path_dist_ckpt): assert len(prefix_files) == 0 new_metadata = fs_reader.read_metadata() - assert set(new_metadata.state_dict_metadata.keys()) == {'keyA'} + assert set(new_metadata.state_dict_metadata.keys()) == { + 'common_state/shard_0_1', + 'keyA', + } Utils.destroy_model_parallel() From adfb8ec7926215bfd0d5120aaf6b080de224d328 Mon Sep 17 00:00:00 2001 From: gautham-kollu Date: Sun, 19 Jul 2026 22:00:49 -0700 Subject: [PATCH 060/290] Make the model larger and higher mb size to make reduce flakiness (#5816) Signed-off-by: Gautham Kollu --- .../golden_values_dev_dgx_gb200.json | 498 +++++++++--------- .../model_config.yaml | 10 +- 2 files changed, 254 insertions(+), 254 deletions(-) diff --git a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/golden_values_dev_dgx_gb200.json index 857dfc6f69e..76474a62ea3 100644 --- a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/golden_values_dev_dgx_gb200.json +++ b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/golden_values_dev_dgx_gb200.json @@ -4,56 +4,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 10.90136, - "2": 10.93243, - "3": 10.64755, - "4": 10.41183, - "5": 10.40045, - "6": 10.36588, - "7": 10.11237, - "8": 9.90152, - "9": 9.96409, - "10": 9.51308, - "11": 10.16314, - "12": 9.86212, - "13": 9.8691, - "14": 9.90016, - "15": 9.50636, - "16": 9.4505, - "17": 9.25987, - "18": 9.30236, - "19": 9.20973, - "20": 8.97225, - "21": 9.00508, - "22": 8.60641, - "23": 9.09405, - "24": 8.68494, - "25": 8.50601, - "26": 8.73822, - "27": 8.82506, - "28": 8.95591, - "29": 8.94408, - "30": 8.49401, - "31": 7.96274, - "32": 8.67873, - "33": 8.74695, - "34": 8.26598, - "35": 8.35396, - "36": 8.27, - "37": 8.507, - "38": 8.2667, - "39": 8.63224, - "40": 8.22542, - "41": 8.27217, - "42": 8.42381, - "43": 8.00559, - "44": 8.11452, - "45": 7.99742, - "46": 8.08286, - "47": 8.38391, - "48": 8.09449, - "49": 7.72776, - "50": 8.19243 + "1": 10.90535, + "2": 10.912, + "3": 10.35928, + "4": 10.0822, + "5": 9.85592, + "6": 9.51097, + "7": 9.37079, + "8": 9.20032, + "9": 9.09294, + "10": 8.9534, + "11": 8.99361, + "12": 8.85188, + "13": 8.84429, + "14": 8.77849, + "15": 8.54032, + "16": 8.69694, + "17": 8.37147, + "18": 8.39638, + "19": 8.27818, + "20": 8.27646, + "21": 8.14342, + "22": 8.13757, + "23": 8.16585, + "24": 8.15514, + "25": 8.19365, + "26": 7.90145, + "27": 7.98979, + "28": 8.05491, + "29": 8.10553, + "30": 7.83891, + "31": 8.0464, + "32": 7.95497, + "33": 7.86316, + "34": 7.87278, + "35": 7.60145, + "36": 7.9792, + "37": 7.77922, + "38": 7.5885, + "39": 7.76987, + "40": 7.57503, + "41": 7.75856, + "42": 7.70609, + "43": 7.76788, + "44": 7.60289, + "45": 7.59598, + "46": 7.6509, + "47": 7.71542, + "48": 7.65185, + "49": 7.61001, + "50": 7.58617 } }, "num-zeros": { @@ -61,56 +61,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 30093584.0, - "2": 30296868.0, - "3": 29948216.0, - "4": 30610452.0, - "5": 30079954.0, - "6": 30406440.0, - "7": 30137332.0, - "8": 30305308.0, - "9": 30210788.0, - "10": 30295208.0, - "11": 29844004.0, - "12": 29800708.0, - "13": 30292576.0, - "14": 29729006.0, - "15": 30191246.0, - "16": 30201442.0, - "17": 30181084.0, - "18": 29934542.0, - "19": 29972104.0, - "20": 30057016.0, - "21": 30106920.0, - "22": 30162128.0, - "23": 29890266.0, - "24": 30139388.0, - "25": 30186512.0, - "26": 29894670.0, - "27": 29809340.0, - "28": 29798496.0, - "29": 29879152.0, - "30": 29980394.0, - "31": 30332976.0, - "32": 29938440.0, - "33": 29910984.0, - "34": 30200552.0, - "35": 30158306.0, - "36": 29940436.0, - "37": 29844712.0, - "38": 30271032.0, - "39": 30169892.0, - "40": 30020722.0, - "41": 30016120.0, - "42": 30027286.0, - "43": 30358212.0, - "44": 30109930.0, - "45": 30023796.0, - "46": 30256184.0, - "47": 29987288.0, - "48": 30298758.0, - "49": 30082702.0, - "50": 30275984.0 + "1": 43613800.0, + "2": 43061692.0, + "3": 43018972.0, + "4": 42752120.0, + "5": 43058592.0, + "6": 43163140.0, + "7": 43319396.0, + "8": 43311548.0, + "9": 43217716.0, + "10": 43253296.0, + "11": 43506680.0, + "12": 43393248.0, + "13": 43101692.0, + "14": 43082760.0, + "15": 43206672.0, + "16": 43007424.0, + "17": 43331104.0, + "18": 43052920.0, + "19": 43409776.0, + "20": 43058788.0, + "21": 43163380.0, + "22": 42934560.0, + "23": 43641040.0, + "24": 43096052.0, + "25": 43026180.0, + "26": 43692032.0, + "27": 43430164.0, + "28": 43021920.0, + "29": 43109872.0, + "30": 43258052.0, + "31": 43137200.0, + "32": 43371032.0, + "33": 43064356.0, + "34": 42892228.0, + "35": 43263720.0, + "36": 43096816.0, + "37": 43344644.0, + "38": 43566448.0, + "39": 43389520.0, + "40": 43401520.0, + "41": 43231292.0, + "42": 43148956.0, + "43": 43157596.0, + "44": 43367328.0, + "45": 43323048.0, + "46": 43013076.0, + "47": 43078092.0, + "48": 43305572.0, + "49": 43418436.0, + "50": 43533312.0 } }, "mem-allocated-bytes": { @@ -118,56 +118,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 628048896.0, - "2": 628050432.0, - "3": 628050432.0, - "4": 628050432.0, - "5": 628050432.0, - "6": 628050432.0, - "7": 628050432.0, - "8": 628050432.0, - "9": 628050432.0, - "10": 628050432.0, - "11": 628050432.0, - "12": 628050432.0, - "13": 628050432.0, - "14": 628050432.0, - "15": 628050432.0, - "16": 628050432.0, - "17": 628050432.0, - "18": 628050432.0, - "19": 628050432.0, - "20": 628050432.0, - "21": 628050432.0, - "22": 628050432.0, - "23": 628050432.0, - "24": 628050432.0, - "25": 628050432.0, - "26": 628050432.0, - "27": 628050432.0, - "28": 628050432.0, - "29": 628050432.0, - "30": 628050432.0, - "31": 628050432.0, - "32": 628050432.0, - "33": 628050432.0, - "34": 628050432.0, - "35": 628050432.0, - "36": 628050432.0, - "37": 628050432.0, - "38": 628050432.0, - "39": 628050432.0, - "40": 628050432.0, - "41": 628050432.0, - "42": 628050432.0, - "43": 628050432.0, - "44": 628050432.0, - "45": 628050432.0, - "46": 628050432.0, - "47": 628050432.0, - "48": 628050432.0, - "49": 628050432.0, - "50": 628050432.0 + "1": 824764928.0, + "2": 824766464.0, + "3": 824766464.0, + "4": 824766464.0, + "5": 824766464.0, + "6": 824766464.0, + "7": 824766464.0, + "8": 824766464.0, + "9": 824766464.0, + "10": 824766464.0, + "11": 824766464.0, + "12": 824766464.0, + "13": 824766464.0, + "14": 824766464.0, + "15": 824766464.0, + "16": 824766464.0, + "17": 824766464.0, + "18": 824766464.0, + "19": 824766464.0, + "20": 824766464.0, + "21": 824766464.0, + "22": 824766464.0, + "23": 824766464.0, + "24": 824766464.0, + "25": 824766464.0, + "26": 824766464.0, + "27": 824766464.0, + "28": 824766464.0, + "29": 824766464.0, + "30": 824766464.0, + "31": 824766464.0, + "32": 824766464.0, + "33": 824766464.0, + "34": 824766464.0, + "35": 824766464.0, + "36": 824766464.0, + "37": 824766464.0, + "38": 824766464.0, + "39": 824766464.0, + "40": 824766464.0, + "41": 824766464.0, + "42": 824766464.0, + "43": 824766464.0, + "44": 824766464.0, + "45": 824766464.0, + "46": 824766464.0, + "47": 824766464.0, + "48": 824766464.0, + "49": 824766464.0, + "50": 824766464.0 } }, "mem-max-allocated-bytes": { @@ -175,56 +175,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 2302537728.0, - "2": 2346255360.0, - "3": 2354982400.0, - "4": 2357787648.0, - "5": 2360281088.0, - "6": 2360281088.0, - "7": 2360281088.0, - "8": 2360281088.0, - "9": 2360281088.0, - "10": 2360281088.0, - "11": 2360281088.0, - "12": 2360281088.0, - "13": 2360281088.0, - "14": 2361527808.0, - "15": 2361527808.0, - "16": 2361527808.0, - "17": 2361527808.0, - "18": 2361527808.0, - "19": 2361527808.0, - "20": 2361527808.0, - "21": 2361527808.0, - "22": 2361527808.0, - "23": 2361527808.0, - "24": 2361527808.0, - "25": 2361527808.0, - "26": 2361527808.0, - "27": 2361527808.0, - "28": 2361527808.0, - "29": 2361527808.0, - "30": 2361527808.0, - "31": 2361527808.0, - "32": 2361527808.0, - "33": 2361527808.0, - "34": 2361527808.0, - "35": 2361527808.0, - "36": 2361527808.0, - "37": 2361527808.0, - "38": 2361527808.0, - "39": 2369631744.0, - "40": 2369631744.0, - "41": 2369631744.0, - "42": 2369631744.0, - "43": 2369631744.0, - "44": 2369631744.0, - "45": 2376800768.0, - "46": 2376800768.0, - "47": 2376800768.0, - "48": 2376800768.0, - "49": 2376800768.0, - "50": 2376800768.0 + "1": 33842712576.0, + "2": 33868759040.0, + "3": 33868759040.0, + "4": 33868759040.0, + "5": 34014625792.0, + "6": 34014625792.0, + "7": 34014625792.0, + "8": 34014625792.0, + "9": 34014625792.0, + "10": 34014625792.0, + "11": 34308540416.0, + "12": 34308540416.0, + "13": 34308540416.0, + "14": 34308540416.0, + "15": 34308540416.0, + "16": 34308540416.0, + "17": 34308540416.0, + "18": 34308540416.0, + "19": 34308540416.0, + "20": 34308540416.0, + "21": 34308540416.0, + "22": 34308540416.0, + "23": 34308540416.0, + "24": 34308540416.0, + "25": 34308540416.0, + "26": 34308540416.0, + "27": 34308540416.0, + "28": 34308540416.0, + "29": 34308540416.0, + "30": 34308540416.0, + "31": 34308540416.0, + "32": 34308540416.0, + "33": 34308540416.0, + "34": 34308540416.0, + "35": 34308540416.0, + "36": 34308540416.0, + "37": 34308540416.0, + "38": 34308540416.0, + "39": 34308540416.0, + "40": 34308540416.0, + "41": 34308540416.0, + "42": 34308540416.0, + "43": 34308540416.0, + "44": 34308540416.0, + "45": 34308540416.0, + "46": 34308540416.0, + "47": 34308540416.0, + "48": 34308540416.0, + "49": 34308540416.0, + "50": 34308540416.0 } }, "iteration-time": { @@ -233,55 +233,55 @@ "step_interval": 1, "values": { "1": "nan", - "2": 12.8321, - "3": 0.12373, - "4": 0.08346, - "5": 0.08416, - "6": 0.07239, - "7": 0.08224, - "8": 0.07207, - "9": 0.08251, - "10": 0.08562, - "11": 0.08731, - "12": 0.08106, - "13": 0.07755, - "14": 0.07683, - "15": 0.08155, - "16": 0.07425, - "17": 0.07476, - "18": 0.07543, - "19": 0.07529, - "20": 0.07404, - "21": 0.07799, - "22": 0.07111, - "23": 0.07327, - "24": 0.07359, - "25": 0.07084, - "26": 0.07257, - "27": 0.07256, - "28": 0.07299, - "29": 0.07575, - "30": 0.07277, - "31": 0.0723, - "32": 0.0756, - "33": 0.07168, - "34": 0.0718, - "35": 0.07182, - "36": 0.07978, - "37": 0.07074, - "38": 0.07522, - "39": 0.07461, - "40": 0.07262, - "41": 0.07271, - "42": 0.07214, - "43": 0.07172, - "44": 0.07212, - "45": 0.07086, - "46": 0.07628, - "47": 0.07302, - "48": 0.07252, - "49": 0.07203, - "50": 0.07695 + "2": 15.6569, + "3": 0.33059, + "4": 0.27341, + "5": 0.28876, + "6": 0.26934, + "7": 0.2514, + "8": 0.25469, + "9": 0.25137, + "10": 0.24816, + "11": 0.24194, + "12": 0.24407, + "13": 0.24915, + "14": 0.24415, + "15": 0.25581, + "16": 0.24996, + "17": 0.25951, + "18": 0.25195, + "19": 0.24463, + "20": 0.24788, + "21": 0.25507, + "22": 0.25148, + "23": 0.24187, + "24": 0.24372, + "25": 0.23858, + "26": 0.24133, + "27": 0.25301, + "28": 0.25271, + "29": 0.25946, + "30": 0.25355, + "31": 0.25977, + "32": 0.24357, + "33": 0.25322, + "34": 0.25063, + "35": 0.23954, + "36": 0.23716, + "37": 0.24081, + "38": 0.24199, + "39": 0.23279, + "40": 0.23709, + "41": 0.23498, + "42": 0.23858, + "43": 0.23672, + "44": 0.24102, + "45": 0.23953, + "46": 0.23516, + "47": 0.23748, + "48": 0.24031, + "49": 0.23794, + "50": 0.23369 } } } \ No newline at end of file diff --git a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml index 7b820614bf8..1e7f15ce500 100644 --- a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml @@ -1,4 +1,4 @@ -# DeepSeek-style proxy: 2 layers — first dense, second MoE with expert parallelism 4. +# DeepSeek-style proxy: 8 layers — 3 dense, Rest MoE with expert parallelism 2. # Used for functional testing of dense+MoE hybrid and EP. ENV_VARS: NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 @@ -42,8 +42,8 @@ MODEL_ARGS: --use-mcore-models: true --sequence-parallel: true --disable-bias-linear: true - --micro-batch-size: 4 - --global-batch-size: 32 + --micro-batch-size: 32 + --global-batch-size: 256 --train-iters: 50 --exit-duration-in-mins: 60 --no-check-for-nan-in-loss-and-grad: true @@ -62,7 +62,7 @@ MODEL_ARGS: # Network: 2 layers — first dense, second MoE - --num-layers: 2 + --num-layers: 8 --hidden-size: 512 --ffn-hidden-size: 2048 --num-attention-heads: 8 @@ -92,7 +92,7 @@ MODEL_ARGS: --adam-beta2: 0.95 # MoE args (DeepSeek-style): 1 dense layer, 1 MoE layer, ep=4 --num-experts: 8 - --moe-layer-freq: ([0]*1+[1]*1) + --moe-layer-freq: ([0]*3+[1]*5) --moe-ffn-hidden-size: 1024 --moe-shared-expert-intermediate-size: 1024 --moe-router-load-balancing-type: seq_aux_loss From 207be1fd1713f95a2b4bbdcd8c2f9f7569ea8ba1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Jul 2026 12:42:54 +0000 Subject: [PATCH 061/290] =?UTF-8?q?chore(beep=20boop=20=F0=9F=A4=96):=20Bu?= =?UTF-8?q?mp=20=20(main)=20(2026-07-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uv.lock | 1694 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 857 insertions(+), 837 deletions(-) diff --git a/uv.lock b/uv.lock index d9b0e8c417c..dbbbab03056 100644 --- a/uv.lock +++ b/uv.lock @@ -75,7 +75,7 @@ wheels = [ [[package]] name = "aiobotocore" -version = "3.7.0" +version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -86,9 +86,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/a7/bc31b7046c610471f0630819ca5d2a57ac4efa8d47135cb53e43f2785390/aiobotocore-3.8.0.tar.gz", hash = "sha256:80a1eb64ea915f3af3c1518669975bae74a17b2f37c14eb0fa2f83b915974670", size = 131368, upload-time = "2026-07-17T03:10:30.258Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl", hash = "sha256:8bc605132cadfe844a3f334635a0a64fa5e360a4a206e915d99d53db5b6deeba", size = 91169, upload-time = "2026-07-17T03:10:28.771Z" }, ] [[package]] @@ -485,16 +485,16 @@ wheels = [ [[package]] name = "botocore" -version = "1.43.0" +version = "1.43.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" }, ] [[package]] @@ -546,132 +546,148 @@ wheels = [ [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -727,71 +743,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, - { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, - { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, - { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, - { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, - { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, - { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, - { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, - { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, - { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, - { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, - { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, - { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, - { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, - { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, - { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, - { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, - { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, - { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, - { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, - { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, - { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, - { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, - { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, - { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, - { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, - { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, - { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, - { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, - { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, - { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, - { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, - { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, - { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, - { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, - { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, - { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [[package]] @@ -912,24 +928,24 @@ wheels = [ [[package]] name = "cuda-tile" -version = "1.4.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/42/93/64ef40d3982dcda7a97ebfa3e3bb9045b573d4eb3877fa5d1fa3cd2541d3/cuda_tile-1.4.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:9e358a85a153820aa0a51d0e09346d884a3c14b88c0313d20d0fb9f53952abae", size = 280953, upload-time = "2026-05-27T17:46:53.03Z" }, - { url = "https://files.pythonhosted.org/packages/d7/9a/7fbdbdb30c375f80818941165adfc4f1dc6cebaf937c6a9081a02d5871f0/cuda_tile-1.4.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:1d9d99b6fa57366af3f8707ac4fd91411275af2ee736996a60620240fcf92070", size = 282503, upload-time = "2026-05-27T17:45:05.543Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bb/4152dc08a8de5bcdc4b9d80b6917216289526f6e786b09ee80d4df27bcfb/cuda_tile-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:616f13cbc7af6caa7b92430b85ba0a429d1f96ca9e7e04a29d89114cfe859663", size = 269813, upload-time = "2026-05-27T17:46:20.583Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ad/42f0655e6aee5c59015634b46d7f13bc22e74af28d10fb2008a062b37349/cuda_tile-1.4.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:fc74185efd81f6153af0a19549d111dec6861ee9b9bc27927a2cef6e19173eb5", size = 280958, upload-time = "2026-05-27T17:46:53.061Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/4770f9e36b8108ce8c9078f71eb21c65e594d79c0770dd38daa045cfbd6c/cuda_tile-1.4.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:45be74f6568c440446f510bc7799b953858e64c6abf26e96f2c9598a79084860", size = 282508, upload-time = "2026-05-27T17:45:18.515Z" }, - { url = "https://files.pythonhosted.org/packages/a1/67/41f1acdf21bf6214a3a1c3b46d39b8eb0f9eba7aecc6b57005db35d56f9a/cuda_tile-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:edd1df4d7955032c7be2a26c6d7e47261415ba7c87587705e0f4f1fd0d61650a", size = 269783, upload-time = "2026-05-27T17:47:16.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c6/46a329f4c56ce54471784366394e235804423df2531307e14112e4636c76/cuda_tile-1.4.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:738593650784ebb3c601486914b563e7569144fe596048766ea9e12280ac3bb9", size = 281208, upload-time = "2026-05-27T17:46:48.325Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fb/bf3849ad68b1858ba50e6992863d266892d7d7db02d11c485c26cd090a1b/cuda_tile-1.4.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:4b1a591c26836a550c2bf87c22d31c4716e5f83d24d255f843d9429625cca973", size = 282630, upload-time = "2026-05-27T17:45:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/61/bb/211c0d5121230ee76cfc1a9ee107ec28aaae9e6ffb43a04aa172d0d4f4dc/cuda_tile-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19e10fe70ba92709b6ca446d1c52a8a346b56f4f8ad7c8941736f60e32f3c87", size = 270644, upload-time = "2026-05-27T17:45:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/f7f1dfa4d1ee7cc5b69e11d756be6ffec1561a5c7e3836fd0f71ca49adcf/cuda_tile-1.4.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:b3cbeffbe0fedac4936edcf00b6ba13ab5ddb74d3b7ce4a287dfc04491b5f6af", size = 283249, upload-time = "2026-05-27T17:46:12.032Z" }, - { url = "https://files.pythonhosted.org/packages/18/c0/fee527a085fca414fc993769912eb8ba2e15ce388f3168b868706e6d4c61/cuda_tile-1.4.0-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:675b2afff62af5d4e72c34bc72d0be27b0933a44933b8a449f590fbded8c1107", size = 284336, upload-time = "2026-05-27T17:44:59.489Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ab/0883194457932150a5ad334d609ac17bd704345974d21c8bae6ea251e7ed/cuda_tile-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f58eac5577ea3ed7c17bfcab015a506fd2cf61f8848407c5b403f1bf46c55ca", size = 275861, upload-time = "2026-05-27T17:46:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6d/cc2fb5a25689a501564a2eced4acf654f307e801a2c1506be97c0d100491/cuda_tile-1.5.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:87652483baa9c81a9a24e4450f016e4ee78fd205d8422dad8996571bd1f2622e", size = 322641, upload-time = "2026-07-08T01:49:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f5/b4ba9d0fc71198d939ebf9a090228179995d8411ee9def8f638a0e3ccdc5/cuda_tile-1.5.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:cef6d30acc37557643ece0de3770fc4c33497c4af40209e424f72fbfcbe6ea5a", size = 324990, upload-time = "2026-07-08T01:49:17.739Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/7a60f317c503580ab7946dbb7fd080438fe953d0ddfdc81904beb9a1fab7/cuda_tile-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:16d97a60ed1d33388abbca85ea08cdae6325cc700476b3135f190d0fb50329f4", size = 304817, upload-time = "2026-07-08T01:49:38.853Z" }, + { url = "https://files.pythonhosted.org/packages/00/46/60aea981ee7cc0b159eb08c42b795e8e95ae8a7fb451e4565cad0f43cca0/cuda_tile-1.5.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:cfa4a5ef920d9c1fee702611b25bde449915f12bf929c1b8a3faee0c9b03f750", size = 322643, upload-time = "2026-07-08T01:49:26.107Z" }, + { url = "https://files.pythonhosted.org/packages/26/d5/ae03d2b70ed8d6c21ca809ddc98227ad07988e7fe67e7e41d888c0b13d32/cuda_tile-1.5.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:e7cb56186b0cc98166b72c7e5a3764c236151aa8d53e0b37a153766b79ea005a", size = 324995, upload-time = "2026-07-08T01:49:19.931Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/e3f6e9aefcc94d548e5d69f01a02ba5da7c5e200702eba3eb7a4f572a883/cuda_tile-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:c400918faa8492d84c4da3da81ce01ed30d81ed780f9ddbc1a9bdd588058b776", size = 304825, upload-time = "2026-07-08T01:49:37.749Z" }, + { url = "https://files.pythonhosted.org/packages/82/f2/861000a1cc2204be90295c980c488670600e89c638138192c61bf79949f1/cuda_tile-1.5.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:b88d7a3ea0cb30a962c0ff9cb01e2b2b87c6ef816fd53dd92ead3bcff3449e37", size = 322775, upload-time = "2026-07-08T01:49:25.897Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/165499cbfb7c1ada110592fa9224e20851125b337bbe2e656b5d56c676f0/cuda_tile-1.5.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:d4054dc12b6d35d69cb07fcc224183b84f3ad1a0e85ec4414cf660144b8467b0", size = 325052, upload-time = "2026-07-08T01:49:20.082Z" }, + { url = "https://files.pythonhosted.org/packages/fd/76/4f0b7bd3ac2d8383b3cc166ce67c528653b51a761ebc0fc6c132e57ef19b/cuda_tile-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c5e0a5af1347b6326fe7a49e44b15ea01ae2d8ed138617c8331b6aba311d16", size = 305719, upload-time = "2026-07-08T01:50:05.793Z" }, + { url = "https://files.pythonhosted.org/packages/2c/53/e5947ebd44774183f72c317907d803648d8cd87e8a571bb0a4e89a578309/cuda_tile-1.5.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:a4ede5529bd5e13318ec9bbf3f79abaf23b6368c58d247eaf2e5eb5ccae3fa73", size = 324979, upload-time = "2026-07-08T01:49:29.759Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b8/6260d8089287dd8e8f5e456a60391537520b20cd90287eacaac4c71fd140/cuda_tile-1.5.0-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:1088a3ebb5622c24ec32034d0d451bab9c1b85ae67fcdc647464951ec1d1b6ad", size = 326849, upload-time = "2026-07-08T01:49:23.211Z" }, + { url = "https://files.pythonhosted.org/packages/df/ea/104cd115a15768ed3e6d58ce658744784e3376dcf9ca5f1402c6ddab61f5/cuda_tile-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70ecc0e4be063317b5216dc620085be8021f78092a81613268129693cf63e179", size = 313357, upload-time = "2026-07-08T01:50:02.713Z" }, ] [[package]] @@ -1058,16 +1074,16 @@ wheels = [ [[package]] name = "docker" -version = "7.1.0" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, ] [[package]] @@ -1145,7 +1161,7 @@ dependencies = [ [[package]] name = "fastapi" -version = "0.139.0" +version = "0.139.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1154,9 +1170,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, ] [[package]] @@ -1176,11 +1192,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.5" +version = "3.31.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/ee/29c668c50888588c432a702f7c2e8ee8a0c9e5286028d91f170308d6b2e9/filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", size = 68927, upload-time = "2026-07-03T03:50:31.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/55/1e19b2b56a24a4b94624f7e819e1bb87fa6c5609dbaf621df3aa6568a761/filelock-3.31.1.tar.gz", hash = "sha256:9e0c4e88ebe90833c1beafd3a547ccbc0bf7f491cd3858c3ec7aed63efe02163", size = 196656, upload-time = "2026-07-20T03:14:32.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/61/df1d9db18f188d0ae648956a1decadc0e3b77d0571474370fd01f28a82b1/filelock-3.31.1-py3-none-any.whl", hash = "sha256:9ea33146c780161bf67cb20c7cb26b651566820d65ad8dfdd79422602a2dcfc0", size = 97189, upload-time = "2026-07-20T03:14:31.307Z" }, ] [[package]] @@ -1239,13 +1255,15 @@ source = { git = "https://github.com/deepseek-ai/FlashMLA?rev=nv_dev#b7643bd5452 [[package]] name = "flashinfer-python" -version = "0.6.14" +version = "0.6.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-tvm-ffi" }, { name = "click" }, + { name = "cuda-python" }, { name = "cuda-tile" }, { name = "einops" }, + { name = "nccl4py" }, { name = "ninja" }, { name = "numpy" }, { name = "nvidia-cudnn-frontend" }, @@ -1257,9 +1275,9 @@ dependencies = [ { name = "torch", marker = "sys_platform == 'never'" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/11/ce2271271bee6990d34ed2d01288e9e92a0ea8ee45fb28de8e746c7da761/flashinfer_python-0.6.14.tar.gz", hash = "sha256:f4da8b5e005601784e85e0dcaa3389f908ee2d32c2560142d67124ab10e4a070", size = 9944949, upload-time = "2026-07-02T00:22:50.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/a5/9d9aa91304fe4c0ee479bd27866dcaa26dc9c9e177d3d24075e34157a5cb/flashinfer_python-0.6.15.tar.gz", hash = "sha256:2a3f1ed47129f9ac9505a26a8f12cadefc0f27d3104fb623ae281032f49eae5f", size = 10286753, upload-time = "2026-07-17T01:22:44.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/8f/b101913cb2b3687654f56681cfe9836d447526be663c149966470ef70531/flashinfer_python-0.6.14-py3-none-any.whl", hash = "sha256:d124369346a3d48eac67e31c42f7a3c813bcc0abc10e2e36db413b7b3dfd97df", size = 14574383, upload-time = "2026-07-02T00:22:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/66/a6/d3a1bf32b97f6c2c32aedb0b49fcd45259d9fb8d1131e86327b5672dd1b0/flashinfer_python-0.6.15-py3-none-any.whl", hash = "sha256:da6c339e14db4831ade0d593324f02907d44bd4b86b640faad4727d9e089477b", size = 14949949, upload-time = "2026-07-17T01:22:41.878Z" }, ] [[package]] @@ -1397,33 +1415,9 @@ http = [ { name = "aiohttp" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, -] - [[package]] name = "google-api-core" -version = "2.31.0" +version = "2.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -1432,22 +1426,22 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/33/00277be1305fd68355d08197f05e22db259c0cff49a10c8590a1869ade9b/google_api_core-2.32.0.tar.gz", hash = "sha256:2b33aad226b19272458c46abfe5c5a38d9531ece0c44502129a1463ce83674ac", size = 177659, upload-time = "2026-07-16T20:36:07.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/5018c5ac1526c98169db98d87a6ff7d5508f5246621c3ee1a046fdd5e0a6/google_api_core-2.32.0-py3-none-any.whl", hash = "sha256:ae1f0d58a6c8869350bf469f8eb3092e7f8c494a942d9525494afb6c162b0904", size = 174198, upload-time = "2026-07-16T20:35:41.865Z" }, ] [[package]] name = "google-auth" -version = "2.55.1" +version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/66/b4ba60005743e01933e22b4f62313e063f7460458b7d8a358427b4930013/google_auth-2.56.0.tar.gz", hash = "sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553", size = 364629, upload-time = "2026-07-13T19:09:57.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7d/cd3e187f14ce832e419e70709bfcc40cb0dc11517d5d03c9d3919bcc3101/google_auth-2.56.0-py3-none-any.whl", hash = "sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0", size = 257976, upload-time = "2026-07-13T19:09:42.685Z" }, ] [[package]] @@ -1473,86 +1467,86 @@ wheels = [ [[package]] name = "grpcio" -version = "1.82.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/19/e29d3979b420b92d516ee97f0bff4fb88cbb3d724791318f50beb55db549/grpcio-1.82.0.tar.gz", hash = "sha256:bfe3247e0bb598585ce26565730970d21bccf3c9dc547dc6703a1a3c7888e8e1", size = 13184479, upload-time = "2026-07-06T04:22:54.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/e6/9ce68bc431224177b6f506cfb4896a742119c51255143069970955b6510b/grpcio-1.82.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:03cce11532da292215cd8e5f444de91982e39dfb41a916e0e0f91a5c128588ea", size = 6144680, upload-time = "2026-07-06T04:21:29.808Z" }, - { url = "https://files.pythonhosted.org/packages/59/93/00ecf28e1be7a70b4b4d148d3a551b6c9a37024a669c3650a2c374c64026/grpcio-1.82.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:01336fe31d1ea5d5154b0d803eb3584e69fb96e0e657acc87bd4d48d6abc9587", size = 11952213, upload-time = "2026-07-06T04:21:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/9be6bf4cddb5b5f39c0748cdf5639525cd4116a157c8f3802c9217e54882/grpcio-1.82.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe56f1bea709ddb2531fbd510a2dd6040e993985507c3b2bf3404507aee989b1", size = 6710770, upload-time = "2026-07-06T04:21:35.382Z" }, - { url = "https://files.pythonhosted.org/packages/00/97/62c0969e993673ebef16d526db2504f38bc4c73cf2593c28746791ab7956/grpcio-1.82.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:77a5d8fe331376c7aaa4e06e903a43bd144de4f98c6e414e13cbc5d64e5aa61e", size = 7450671, upload-time = "2026-07-06T04:21:37.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/1f/d83d9fbaaef2b6ebbf70a6e467000f13f92b4cf0b84c561c162b43128439/grpcio-1.82.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ce80ea66c803398fde9c5b1fd925920c9742da7f10f8b85acac0adac3e4d7e0", size = 6886855, upload-time = "2026-07-06T04:21:40.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/36/5ed2e28b8c5f00599c7d5e94d5f82c32cf73a6fa42d13c2900cb37c861ec/grpcio-1.82.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4665dc8c9ec30acff455e2b15c47b825f18647c555483aa1ccf670adead8adcc", size = 7501322, upload-time = "2026-07-06T04:21:43.78Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/69113709e14e24289940d6aef067b797c73c8c96f580683af7d5f09b22b0/grpcio-1.82.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4405d19ecfc7a8caa9043ff5a651e1871b54fc620f917de5dede7e6fb78e9e14", size = 8536896, upload-time = "2026-07-06T04:21:46.387Z" }, - { url = "https://files.pythonhosted.org/packages/30/f6/08bd6eb89a558f0f59dacaef747afda7c21e2c2ecf138ae2ec533dea8aeb/grpcio-1.82.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e580c19178609799660f52b1e1eb09248969be20dc44caaa4dcaa3e8450dd9", size = 7913890, upload-time = "2026-07-06T04:21:49.733Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a1/04b467e2ffc93f8e50b660bc6f47c3c8e9ba7603104a741e3d5663a261d4/grpcio-1.82.0-cp312-cp312-win32.whl", hash = "sha256:dbbd83dbaa856b387a4eba74ec3add7f594f090d3b4d390d829dd5834816aae4", size = 4240969, upload-time = "2026-07-06T04:21:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/15d3601384d5937e8d9fbe6d8e7b8171008d649744dd7b6c864932937ec9/grpcio-1.82.0-cp312-cp312-win_amd64.whl", hash = "sha256:73176d270699d76a9dc3753f25e01c19fb98f830f826a506e917d83629f1b39b", size = 5001575, upload-time = "2026-07-06T04:21:54.839Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c8/7aace81e7cd12c6ffccf0d47ac389a3f19e5280e9ab04d301d02855ecd25/grpcio-1.82.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:841bcaa24882921d41cd7a82118f9a766edcf8807c2f486e7deeb0ff5ea36181", size = 6146066, upload-time = "2026-07-06T04:21:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/10/17/b448f2265e4927188425c0b09fb943c39b50f7f53fbead644b44ccdf834b/grpcio-1.82.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d4bb7219c39c735e6573ae3c33aeafb2a4e1f1cc93a762f7899c283defaed365", size = 11948617, upload-time = "2026-07-06T04:22:00.066Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a3/fba54e50fffc9f74b1ffd5eb4e47310e6650557ef136a165f1fe7df03a65/grpcio-1.82.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:254230d2cb773a87380858d0fc74653b4761bf2013c83fe1c5d77ed8f241fc9d", size = 6714591, upload-time = "2026-07-06T04:22:04.04Z" }, - { url = "https://files.pythonhosted.org/packages/29/6f/f0ec3f1a61a746dc7037a737ba4cbe60959645311ac8542bb1ad4e72ae8b/grpcio-1.82.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:16d4f215344057eb21038319b05a0c531306230c17b075fa8461a944581006dd", size = 7454986, upload-time = "2026-07-06T04:22:06.776Z" }, - { url = "https://files.pythonhosted.org/packages/b6/11/9d6ce94465d6a7f92c413430b3e77f0879b39427a217ffb3d23585df4bb3/grpcio-1.82.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a7c283b459151a2e84df32b28c31562ab3e7f624bccffce176c5608565b0212f", size = 6888623, upload-time = "2026-07-06T04:22:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/51/1a/b887adb7abb69081c4f24bc66c3612975e87caf5f7c6aa3916bb82d42e5f/grpcio-1.82.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96a3be4d2a482ff004c036f6391f33553b36a7627138d1ca3ecc1e70d55d8af5", size = 7505067, upload-time = "2026-07-06T04:22:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/60/94/90f11c0f227ac653cff769e05c7f279da945e134c9351a1c37d475714686/grpcio-1.82.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bc481e6c7e6c8721797f2ab764092c843fd5cea06d4c7e9f4c3e3b7ecf331eb0", size = 8535382, upload-time = "2026-07-06T04:22:14.453Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3d/de8569386685213135dfbaab58e221add11858485524cf7d5987efc6d70b/grpcio-1.82.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:680f633bc788652222cb90a568ec374edcd91e1fe7715a7c248ece8e1032c2bd", size = 7910708, upload-time = "2026-07-06T04:22:17.994Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/3950b32369763818ae89ef76d52e84bd034d3a0ab46bf315669c913b32e2/grpcio-1.82.0-cp313-cp313-win32.whl", hash = "sha256:640b4715b9c206e5176e46601aa1422c47c779f642a869dfd27062e8fde13dfb", size = 4240351, upload-time = "2026-07-06T04:22:20.626Z" }, - { url = "https://files.pythonhosted.org/packages/a7/5c/c7b9a48efe973883f5707a311f87772a4c307a22ec80d6f3c851846a0a02/grpcio-1.82.0-cp313-cp313-win_amd64.whl", hash = "sha256:8523e81bd23f83e607aed28fbd8244b2be4aeb34e084fcef1bd1867709085c2a", size = 5000985, upload-time = "2026-07-06T04:22:23.365Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/abc5efe11b73eefa0183531032eef2a53f33aeced6484b0d9da559c12ed6/grpcio-1.82.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:136969867c14fcc743fa39b12d7da133cae7da4f8e0e7767b6b8b7e75a8c24fd", size = 6146898, upload-time = "2026-07-06T04:22:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e4/d01ee88ceac221436dce1584ae1f046bd44d0dffc5a92e95aa34857c4516/grpcio-1.82.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a7e246cd216662f513ae79ea3afadde6afe8a659b48a4170bae9a896e69ac254", size = 11954897, upload-time = "2026-07-06T04:22:29.114Z" }, - { url = "https://files.pythonhosted.org/packages/99/b9/2a35540ec08afc08859c35afd1a8a51e2585f39b12cf4eee68dcb1fa973d/grpcio-1.82.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:85c2a4e9e8a30d73d41e547a5101e57a1ed2093ec4b2daf6847c344adba00523", size = 6723102, upload-time = "2026-07-06T04:22:32.146Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ba/af71af59943b5d08879617a1d97495d45c53e43f3c6ef16f820c64e107f9/grpcio-1.82.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a3a7802328e0d20e6ac458ff1974a8096cfaa3ca84dea0903235f641f1d703c1", size = 7454545, upload-time = "2026-07-06T04:22:34.776Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7e/258aae12b68910140725873657469ff166f8968bdb0674e12d6452b1812c/grpcio-1.82.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9331e6b91b26cffd63fb70a545f3c2cac8dc1b434b4436f43915c23e1e22f265", size = 6889585, upload-time = "2026-07-06T04:22:37.411Z" }, - { url = "https://files.pythonhosted.org/packages/5e/04/cfada8930306b9101cfb405b33ecb3b72abce51d725f900f167ffbc3146b/grpcio-1.82.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:670ab93a0059e707de53b9e715304b9f566b6defee80e5490cdd15820ce032f6", size = 7514162, upload-time = "2026-07-06T04:22:40.076Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/955f95989338857a6d73ee838b4d9e8198d50972a70ca83ec22322396f4c/grpcio-1.82.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbfe2e321bb30b84799677d6e8469896e487b494e9e4fbc9b8cc4425fe054d44", size = 8536163, upload-time = "2026-07-06T04:22:42.801Z" }, - { url = "https://files.pythonhosted.org/packages/06/62/826da0b0f01c7ba3f7ea0185968f551290deb28968d98b1edf604c895db8/grpcio-1.82.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2472b72a55da8570e089f1ca22f34a350d86c109be2755f6ee1cca5ebbdd9b54", size = 7912573, upload-time = "2026-07-06T04:22:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/d8824a66ac1ac7fbcdf63806f1a8725d95426654e859afa4606070c78740/grpcio-1.82.0-cp314-cp314-win32.whl", hash = "sha256:c9feb986151c2726789599b3672c1c9faa1d0824da921dc3f33a8cb1f93e2861", size = 4321824, upload-time = "2026-07-06T04:22:48.866Z" }, - { url = "https://files.pythonhosted.org/packages/da/08/02e581cc0bbb4c7b842f85e66a945b46d5bcc5927575c1086edfbc3360e7/grpcio-1.82.0-cp314-cp314-win_amd64.whl", hash = "sha256:b1eeb0480d86965263f8c8ae7ee5360c284d22176a196aab02fce7fba8d89dd8", size = 5141112, upload-time = "2026-07-06T04:22:51.443Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, + { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, + { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, ] [[package]] name = "grpcio-tools" -version = "1.82.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/c2/aa8fa0004d9b0350016464824427f6ee4ddc9b407ccf05fd0044e94a1652/grpcio_tools-1.82.0.tar.gz", hash = "sha256:c9f3dbb88e90d9b05253a24fc11b61e516a7224ccbfc682e0a3dce55cddcab92", size = 6399122, upload-time = "2026-07-06T04:25:19.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/57/783ce4aed7d1e866d355b5fd0cdcda2aa7f9e6cdcf9401655c5861aae599/grpcio_tools-1.82.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8115ec20ac7aaa632d1b9b22ef21666e1e4ce1952765bee71645427ead4b9295", size = 2653283, upload-time = "2026-07-06T04:24:03.947Z" }, - { url = "https://files.pythonhosted.org/packages/79/de/f5d5f00f0f17b91026272d081f9efcb1345de5bbb81fcc120961f3466a4d/grpcio_tools-1.82.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a0b77297911a2517d78645f01a83a2f54ae0b8d1bdde317be87c633b87bb616a", size = 5965956, upload-time = "2026-07-06T04:24:06.352Z" }, - { url = "https://files.pythonhosted.org/packages/80/e1/15cf0a69e73627bb5ef7753e93f3b428c557fd17896fc149ae4ec066e432/grpcio_tools-1.82.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6eaf2fd99ab3a76a45349b6f20a3985c5db5392b61fa7d170f726a67d2804315", size = 2705355, upload-time = "2026-07-06T04:24:08.392Z" }, - { url = "https://files.pythonhosted.org/packages/e0/19/fe0e4a95a864cf0bb9ce6a9f73fdcd86c92d96297661c3235b72ea7f25c4/grpcio_tools-1.82.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8cb17083d96f459640886cfb94235607a7ff9a932e21a169d43a478435841b2", size = 3033410, upload-time = "2026-07-06T04:24:10.458Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e1/13c0f72241e00a804cedd0eb5170675dfe3d74a0fa818db7e72850c9f1dc/grpcio_tools-1.82.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7df5721d390fe1054c94768f1644ed69570adab3c53e4443dd5624053a13a187", size = 2774497, upload-time = "2026-07-06T04:24:12.448Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/d1ab04143c946f84ed49f880dff681f2128629d9ef4ed22820ce55a36d1e/grpcio_tools-1.82.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e062e92633ee1f58b7c25220301271bdd03e2366a7de42471eaba0e18a44ddf", size = 3230021, upload-time = "2026-07-06T04:24:15.145Z" }, - { url = "https://files.pythonhosted.org/packages/40/c4/18b7a3d0b7dbdfaf9e97d192bfdfb8db5d299c0b005258cc091264c686e3/grpcio_tools-1.82.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0b58d67bd2678858d81349ee62e66181d346812e8ec4a36bc4460600d8983ae2", size = 3803154, upload-time = "2026-07-06T04:24:17.974Z" }, - { url = "https://files.pythonhosted.org/packages/26/c6/366ee97da860b759d997a0b66d6cad234d90709b21e83dde9309fccf198a/grpcio_tools-1.82.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ac09089db603752b0c4713fc5a97030dc7f27a896bcbf2751fa6cb411f920ae2", size = 3461810, upload-time = "2026-07-06T04:24:20.35Z" }, - { url = "https://files.pythonhosted.org/packages/4f/86/9868b7660b13a876ef09b96e498873128f68efabc19d118f938bc818273d/grpcio_tools-1.82.0-cp312-cp312-win32.whl", hash = "sha256:7a2c676b3e38536a64c2c5b65c87c9f03528a2011fd99b9a6d82880e6e206647", size = 1022474, upload-time = "2026-07-06T04:24:22.588Z" }, - { url = "https://files.pythonhosted.org/packages/80/74/efecb62c01cb38601da49f7ba012a5f70e22a5bae22b169d4fff47f9ef2c/grpcio_tools-1.82.0-cp312-cp312-win_amd64.whl", hash = "sha256:f24a42601b699ac620836af9b577b16089fd365dcc07637d8b7857510e87835f", size = 1192179, upload-time = "2026-07-06T04:24:24.921Z" }, - { url = "https://files.pythonhosted.org/packages/82/2e/bbd4a5658cc9755ccf2414a7cab87595c2cf73fb51940a3096086c63f8c8/grpcio_tools-1.82.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:678147f2e5c87844365eba22fe39e001b1f83e7822e9d1915fb497c39477d96b", size = 2652841, upload-time = "2026-07-06T04:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/50/f9/16c3267ca997deb9909bf158f1bf38c111ab707c43bcc2fcc1f356dedd5e/grpcio_tools-1.82.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e2c86c8c275861f621673e7510e96e3caed3541d6b95551d958ec119af1cd6d1", size = 5963606, upload-time = "2026-07-06T04:24:30.574Z" }, - { url = "https://files.pythonhosted.org/packages/83/ca/680699f896a2dcda036b61ed367a7331a99954a45fa6330fc1852599079b/grpcio_tools-1.82.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc01c1774c843ffb7da446d5a3f32b857576705d9de3859bf3ef04f6bf1c1389", size = 2705075, upload-time = "2026-07-06T04:24:32.988Z" }, - { url = "https://files.pythonhosted.org/packages/cd/78/0bbfe7ac39b5a1f88cea31c2ee71439bc61430e23b7cf4c0feb3ea41ae6f/grpcio_tools-1.82.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f6654ef32dde459ea2d929fdf83b4f649acde274ba0285b4a953992bdc76520", size = 3033060, upload-time = "2026-07-06T04:24:35.293Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/ffdbe8e4c838bfd815744cc2a990e102280d2a1effcab1a46847a9917a10/grpcio_tools-1.82.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0b15875e3c0daf3208055f0cc2305a32ece32b914c9150ea89751373a072758", size = 2773647, upload-time = "2026-07-06T04:24:37.725Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/3b7c60d1edd2359b3569d44603f5e5dc992f97e0bf5f203ff493f28498a2/grpcio_tools-1.82.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:338a013a7207650eb4bd14fe01b46cd5d982e7aaf28b82d605e3bac252913977", size = 3229768, upload-time = "2026-07-06T04:24:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/a0/b72d8fed90b3670440f1237140cad2ccd30d0c4917cf0d1796b726a3be22/grpcio_tools-1.82.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3c08d5caa1392535b7372b3e079a9e2fedfc2ee20cdcdc079e3ec6ce288040bf", size = 3802526, upload-time = "2026-07-06T04:24:42.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/c3/4f16f8a00f89a4c91122e53d153dcb92a0dacbc7a7a94a132ae4fedafad0/grpcio_tools-1.82.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82905f3f67630e60eb3fb18276407463189a8d75d7ee3b2c8549c915fa380ad", size = 3461030, upload-time = "2026-07-06T04:24:44.786Z" }, - { url = "https://files.pythonhosted.org/packages/11/fa/03b5b678f7a8067acf76316b2553c4513fabd75693ff840f177fb4b81c56/grpcio_tools-1.82.0-cp313-cp313-win32.whl", hash = "sha256:3e31cfeae69a3e37a401cabf299b057711e3ae0872f62d1a9cd53a25d1400ec7", size = 1022122, upload-time = "2026-07-06T04:24:46.933Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ba/c6761e4d7a1e8e79d130c96ba94725d52034a06859c491584d445f06670d/grpcio_tools-1.82.0-cp313-cp313-win_amd64.whl", hash = "sha256:ff5f6edd816358ea3c0e30cb659e99ab21c94ffd3f552690d171299d1691226d", size = 1191848, upload-time = "2026-07-06T04:24:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/10/d1/8741f51993b8cc5ee014275af028ab80d6282f31cf6d2b575dec8af40b45/grpcio_tools-1.82.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:45da33fd983ca940e90d29fe785241846af3f3b42390f498dc733a06930315ef", size = 2652839, upload-time = "2026-07-06T04:24:52.432Z" }, - { url = "https://files.pythonhosted.org/packages/c6/de/6fc34b0c7ab97fd16006239e200b9b5394c5d25b004dd38cba25857193aa/grpcio_tools-1.82.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:4f8815e0f85c6d2024f762f6ddc20e9c78766e8ebeee83a555ebac01b5834722", size = 5963590, upload-time = "2026-07-06T04:24:55.353Z" }, - { url = "https://files.pythonhosted.org/packages/3d/46/2bd2e9be5a98c7d4210be2a63032906f6ee976f1b2882e4e4ecd396331e0/grpcio_tools-1.82.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4910e44273d0af753dc841a6207966fc4f8958d5fb34d236ce308df610a68fd3", size = 2705215, upload-time = "2026-07-06T04:24:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/2a/02/6d9a2f1c950edf420558ee5bc924ee9f8d4e3ac08b0e08b24cdc8be8086d/grpcio_tools-1.82.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8fd0da0dbe28e7193b685f1c53c6c3986e13c5a1e5d69d6b6e21f44d9c30884", size = 3033046, upload-time = "2026-07-06T04:25:00.723Z" }, - { url = "https://files.pythonhosted.org/packages/4c/5d/fadd751b1c1edfebccf9eccc77bf83ab45439979c22fc88c7cba6852dfdb/grpcio_tools-1.82.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0da01ec7b7fc22a506e6c8433bdc983c52c558258157698ef37a2052fff41366", size = 2773830, upload-time = "2026-07-06T04:25:03.338Z" }, - { url = "https://files.pythonhosted.org/packages/be/39/f2beefd17194bc9cb1c8dd6e24db2190aee048927473b31a961bcf064115/grpcio_tools-1.82.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:db3cf189d10f651db693db5354d8c18a41eacd4e2b9b86c9961b3ea0497b5638", size = 3229936, upload-time = "2026-07-06T04:25:05.961Z" }, - { url = "https://files.pythonhosted.org/packages/3b/fc/beba684b43359f8921aa02df10667036ab6484ad189afd2d6a7579c1ee00/grpcio_tools-1.82.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5edd884e4188f75147e8312b1ae1140ff8e2cb8305ddd66fa73312c58d816e74", size = 3802593, upload-time = "2026-07-06T04:25:08.548Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/91c84fddd8adf01c9496b53142165ea446a7feb365dfbb0ccb3189d78095/grpcio_tools-1.82.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:703b9ba228e5c7f639c04f578ca6481605e8ff211373fb3de73852b02c41cac5", size = 3461307, upload-time = "2026-07-06T04:25:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/a7deb6bb20f1ac65904b5191e852b2791c83285b5b2a79ddbcbbb2b6a909/grpcio_tools-1.82.0-cp314-cp314-win32.whl", hash = "sha256:c617df5b6b260299ebae2cbde260361d905d43eee17696468702c4e6ca9d58b6", size = 1045036, upload-time = "2026-07-06T04:25:13.414Z" }, - { url = "https://files.pythonhosted.org/packages/51/df/ddf3dcb800a526676b9b312129ef69325b6cb413cef3d8f3e8f01b056496/grpcio_tools-1.82.0-cp314-cp314-win_amd64.whl", hash = "sha256:061a50146c3682b3fbd92717139786aa78c3eac997b70f9982a3d1fdf31cd273", size = 1224190, upload-time = "2026-07-06T04:25:16.148Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/af008a0df6f9ec85ae136f763aed207e68097c952a17443d2c2af9d60a91/grpcio_tools-1.82.1.tar.gz", hash = "sha256:2bd3176ccdbf7cd1f463eb75b7b83544c7d6429f5ca8a0f7f784b76097dac891", size = 6399590, upload-time = "2026-07-08T12:38:15.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/b8/70021ba4ea39ed54f175ae79ac9c71b3104ba965418b416e85e18b661d3a/grpcio_tools-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:1b1ae735ad45f8a01715b0106020803330a68b20b17dcdf51e8b7266af44a9ac", size = 2653283, upload-time = "2026-07-08T12:37:05.6Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c5/add9b6f3780aaee6c1463e1295494219fbe849119f7a7eb4968bc677a50d/grpcio_tools-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:e6ce264293507e0a0f2facba230646fd185c18cee14a41bab26bca54b29d6a39", size = 5965914, upload-time = "2026-07-08T12:37:07.985Z" }, + { url = "https://files.pythonhosted.org/packages/1c/76/8849d262571edc9343ff5c2186c8afc61e960ad2b67d9ddc154e02953acf/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75856fb0ba6a574e62b02473b10cb2479356b5db07c39ef8209395105608dc5e", size = 2705355, upload-time = "2026-07-08T12:37:10.279Z" }, + { url = "https://files.pythonhosted.org/packages/69/1f/fc34c4af2464584b31110a8b81e48debb28b0204bbdc6bd5fd625d710c23/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1d7d1f0d0c1fea8bbd6002f7a4ad1eb034b9114f4cf64d6a5d6aaa587725ae02", size = 3033411, upload-time = "2026-07-08T12:37:12.414Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/e42981d84b2e7be1563c6d4fc012330ab6cb42c1f053b8ed81e3e9e5254c/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b926e4ba0afb0a69954ef5ffb039b593676edb16d8c750476e65b1de89b535a6", size = 2774501, upload-time = "2026-07-08T12:37:14.401Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/1e99a12feb9599077b591ae9814daf85a97ff28fcd57571f08d42c5efff9/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91bd88cf4bd6129620a0a27d051cb1c7346e3da498251c18d9df3061c852de3b", size = 3230020, upload-time = "2026-07-08T12:37:16.528Z" }, + { url = "https://files.pythonhosted.org/packages/b7/88/b82a5eebdf98208256326dec9ef752a3b52e22c8e8ed722cb96e81c0d520/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0972d57773ab2861d39df5e6d3d9e1a1008d53e74f8a5f84dd2932dd907e0ac1", size = 3803155, upload-time = "2026-07-08T12:37:18.706Z" }, + { url = "https://files.pythonhosted.org/packages/78/a5/ce7c35e47ed87a46a66c76c104c11204d8492600fd8411260cac5d9f6253/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfd5e337fa40885b82c782968a0d67325a5b769c4e2542cc6fa48133bf6dc97f", size = 3461816, upload-time = "2026-07-08T12:37:20.719Z" }, + { url = "https://files.pythonhosted.org/packages/e5/56/b7fae69b9a9b68df4bdaaaa7ec2e836ba29f811eb2c295bd0014933fd719/grpcio_tools-1.82.1-cp312-cp312-win32.whl", hash = "sha256:518f58639014bf1bcecd9055dc63b6f33d70fd8e7621a15ce7c7d628545b4199", size = 1022473, upload-time = "2026-07-08T12:37:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/b9/81/40c863fa3e84f818dae2f6a58c02b7ef81807f65714f5cefdea3596edf2e/grpcio_tools-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:f28239d935da567af046957b245eab4b1c5f694369a00f0ef2a0a90a63a8ea66", size = 1192176, upload-time = "2026-07-08T12:37:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/53/08/934dd729d3046e4ffd40ff897c26b7391b7b73c21b171c4c52edadc2f933/grpcio_tools-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:fe2e289a95ff818da6e0548ba3d9e24433895b535e1e837ed46900624b1e91c7", size = 2652843, upload-time = "2026-07-08T12:37:26.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/1f4a160a486ac9ed3c6b35a04ab9c48ec9b31141c1c0ff7c27373c0a67ea/grpcio_tools-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:216a476aa5444e66007e53ba0a4c7128f9ad01867ec1ffa788b7c72984a546c3", size = 5963549, upload-time = "2026-07-08T12:37:29.165Z" }, + { url = "https://files.pythonhosted.org/packages/74/2b/8a2675dcb2be98b7cacd09367637063c295aa6008c83c962def12cf47f44/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:92d59cc6232859c760646bb353efd83677e931c171d58eaeaa9451ce41706b58", size = 2705081, upload-time = "2026-07-08T12:37:31.235Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1c/ac011ab4110a2bb37e5af9d6d911183d3cec3fdc178f20477c0582b94d04/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:67e2896338b4299c1363d91856f55349fb9a247d7ec420b7ca021ca1362fb7c6", size = 3033064, upload-time = "2026-07-08T12:37:33.652Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7c/b36f97d0457af255ef5b6ef924b7aa6328b706218e312503b4fbe7056e4b/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:452b4880b7f5ca2bbb6fd26e76ac0e10579afa51e589b45ad7562b034f954642", size = 2773651, upload-time = "2026-07-08T12:37:35.789Z" }, + { url = "https://files.pythonhosted.org/packages/8a/23/d084183effc6e4086fc78d318e510a19bbc3d21d85a9b4eb3236f131618e/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57b35422bec9f7b0eda98cfe057b272102d7662884116d335e8fa94fe446bead", size = 3229769, upload-time = "2026-07-08T12:37:37.99Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/f4084327ff4d743e57f58bbc5eedda04885ea4c7749d9ea07a0284c4e338/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8aa752a4ac0620fd2a427b5bccb772c4c6c9bb497834939481f59579835f0a68", size = 3802527, upload-time = "2026-07-08T12:37:40.665Z" }, + { url = "https://files.pythonhosted.org/packages/65/44/4106351449cfe140d6af39668743eb059c525b1b5dfb37cd4376767bd2a7/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:89f9cac1a313c4e72cf83be7e7413f0a34b6c2f0b4e6d8a56288b0bbf4f213ea", size = 3461032, upload-time = "2026-07-08T12:37:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/34/87/60b3be7084be622edff5781d6b82fd24d4bc00f53fe27350824107b3d637/grpcio_tools-1.82.1-cp313-cp313-win32.whl", hash = "sha256:8aa2079a166ef51cecbdfa677ddbfca9d71eb0fcbb3e61dd74c61eb52723d1e9", size = 1022125, upload-time = "2026-07-08T12:37:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6c/d2460754ab3031d82f6dfd5aea0fdf95ba1004fb56a9a302115da8c4b7ea/grpcio_tools-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:4c00edd39d65b4eafc499b934fb7198788750663d7516718a022d0dd80f4d85c", size = 1191848, upload-time = "2026-07-08T12:37:47.795Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8c/5c2130941fd30d59326fab4c2fe8f8e1c954ebf864c9d2a14d767cc07333/grpcio_tools-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:e6499e7009c38e23f4c9ffc64efa46d3a1ac0c3b01b256e77b9816f7078eb4db", size = 2652840, upload-time = "2026-07-08T12:37:50.264Z" }, + { url = "https://files.pythonhosted.org/packages/33/37/9447cada0b29e3423c38905fcc552ccdaddecac96e6a4ab2ba330e7508c9/grpcio_tools-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:35d79c00a4da740abbbf7fbf1f34151fdca9917885a4a4235d426438a7973aed", size = 5963503, upload-time = "2026-07-08T12:37:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/ce/55/4d4ec2e1064abd14264c3beefba9b46f4c13efad85ad0f1f87eb40ddb2a6/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddd9ddbf43d1a4c472874bed2c491a89be9bf36812a56ed8892f609aebd0a844", size = 2705216, upload-time = "2026-07-08T12:37:55.705Z" }, + { url = "https://files.pythonhosted.org/packages/a8/36/ebd5334dccfe8411487c2feac639bdc275ec263bcd3ec5b620d715ce90ea/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4fa16c11e1c9ad3f35f4545445e217a725359ed235db8ff42c3b40b131ec48b2", size = 3033046, upload-time = "2026-07-08T12:37:57.982Z" }, + { url = "https://files.pythonhosted.org/packages/48/06/69255a28fcb9264db954e8ca6a0eefd692f6efc2a9fef1f0920a42267070/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01c4c5333908b2050a14461f5d71170c948628987322b4526e840159b94ffabe", size = 2773832, upload-time = "2026-07-08T12:38:00.428Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/d7895783de780071f90c7ffb36e534fa1468ef2c5a039ee8c2d89478b1b0/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:75eff2a53ec1f00968d7e018de35b997134a4381c5b769713664b2625aac4035", size = 3229939, upload-time = "2026-07-08T12:38:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/86/3f/2a74d4c6396e62332c1d244077775b540e1ad15376cc831f66589f2e0fc3/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bd0352f3b0341afb911c0b9b177b95811e71476b64004b0d38131a4543d42592", size = 3802594, upload-time = "2026-07-08T12:38:05.51Z" }, + { url = "https://files.pythonhosted.org/packages/3b/69/b559cbea6202bca95cec033c4181413e4417759c90480a10cbc7250d4c63/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0a838ae62bfd71ea8cdcd28e3a017206572eb185846e18114c82f8e2c9fb98b2", size = 3461304, upload-time = "2026-07-08T12:38:08.059Z" }, + { url = "https://files.pythonhosted.org/packages/33/32/9e4bdb2e6c62b66e70e5e8d4ec7e542caed57976d7a0b2ef65763901d0ca/grpcio_tools-1.82.1-cp314-cp314-win32.whl", hash = "sha256:335393c9f8d3c0fa6c1b3d168002beabc0cd2d6974d409d216b9d9ebe5b33a5a", size = 1045038, upload-time = "2026-07-08T12:38:10.193Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5b/bea2551e5d79f7486bad32163ea6d6655bd1e8d663cb928f100b901f0dd6/grpcio_tools-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:15c067844adca93ed4661bdfd9b176618ef6b7fd83fb381f2746bd8a1e9f6d98", size = 1224194, upload-time = "2026-07-08T12:38:12.439Z" }, ] [[package]] @@ -1579,7 +1573,7 @@ wheels = [ [[package]] name = "hatchling" -version = "1.30.1" +version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, @@ -1587,41 +1581,33 @@ dependencies = [ { name = "pluggy" }, { name = "trove-classifiers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/4c/8717ccb844b4fa5a5ba6352e97d743ed24e9a22cf90b7c109c17030a46a1/hatchling-1.30.1.tar.gz", hash = "sha256:eee4fd45357f72ebb3d7a42e5d72cfb5e29ed426d79e8836288926c4258d5f2e", size = 56929, upload-time = "2026-06-02T00:09:41.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/e2/dfa73fe78f773018dcaebc6d09b819bc10d328ff5a6b4a66efa1e3d71f52/hatchling-1.31.0.tar.gz", hash = "sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b", size = 57208, upload-time = "2026-07-08T01:48:32.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl", hash = "sha256:161eacafb3c6f91526e92116d21426369f2c36e98c36a864f11a96345ad4ee31", size = 77489, upload-time = "2026-06-02T00:09:40.139Z" }, + { url = "https://files.pythonhosted.org/packages/64/e2/2c0af0a52d16be74a4f194564fcdc417521ed863e9b65e4bc9052dacba6f/hatchling-1.31.0-py3-none-any.whl", hash = "sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544", size = 77747, upload-time = "2026-07-08T01:48:31.024Z" }, ] [[package]] name = "hf-xet" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, - { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, - { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, - { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, - { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, - { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, - { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, - { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, - { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, - { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, - { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, - { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, - { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, - { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, ] [[package]] @@ -1682,7 +1668,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.22.0" +version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1695,9 +1681,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/ea/dc54b4dda5841cb3a7812a178695be776e7c15c597887c2ed892f17d015a/huggingface_hub-1.22.0.tar.gz", hash = "sha256:e2dfe5fe1ec3b87ba2709aa34555b23e3f3f6ad4d7255238e13ddb8348e6bbfa", size = 914232, upload-time = "2026-07-03T09:46:44.685Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/9c/a1a377265abd8b823a2c661c665028ccb6b9fba1ca9d08e52ff679c20ecd/huggingface_hub-1.22.0-py3-none-any.whl", hash = "sha256:b09e19309ae09ee0a71892701c4fe70af39ab4e00817321dc62f2289a977249b", size = 765085, upload-time = "2026-07-03T09:46:42.832Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, ] [[package]] @@ -2697,6 +2683,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, ] +[[package]] +name = "nccl4py" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-core" }, + { name = "cuda-pathfinder" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/8e/ef2b349050c2586d9c78fb2038b05f694bacae7e61c3cf0805a662f82ae7/nccl4py-0.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0b1bab08b374ba21bb36612710866173e703c06dc197ab13b1e093436ac27ce", size = 10898883, upload-time = "2026-06-11T20:38:34.091Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/b956d2d6991d4c152442a56a072eb88c09b6fa6b46fd449ed3f861ff55cc/nccl4py-0.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f96117a0aed13744d2636760962f1cb45be9138846023b69c5a8053e531cc76", size = 11056754, upload-time = "2026-06-11T20:38:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/f8048544ccca9a1ff72b5c602529a67868059d8a65d961197780e30d6c24/nccl4py-0.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50068bb4e6f60dd831b2d394a9789f08a6f4346f0e51a9d717536868a8fdd398", size = 10836501, upload-time = "2026-06-11T20:38:33.237Z" }, + { url = "https://files.pythonhosted.org/packages/20/6b/84a2eb82666136fcbe93461029fec727ddf61cf2b54687190450c58c85c7/nccl4py-0.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd7862777777162f4735b85472951d91b8847cd7010d8f60bfefe58c7285583", size = 10999870, upload-time = "2026-06-11T20:38:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/5f/992ff72ead3c11ba4abc35c34ea67a6eaf6d6fca922d2d992319c7b4666f/nccl4py-0.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13db9f786c7919ed1df7079c03acbe49eb569d5625fd5cec2344075f37b5e140", size = 10828564, upload-time = "2026-06-11T20:38:34.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/03/bf540fddca3c803ff520d3e984e413c2b0fdba824f961771251f6cbf977a/nccl4py-0.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b684a3ac083fd76bf1e57e69e76575502bcd2aface23e2dad7a88461ce8916d", size = 10957154, upload-time = "2026-06-11T20:38:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/96b85a386a6643766f6755f4951aecb603d1d391527f1bb8db3e849f71df/nccl4py-0.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa09f12a93e0eb7b2dbbffbcb0a3daeeb3c211f2433cc6cc896faeec85ea4b2e", size = 10924309, upload-time = "2026-06-11T20:38:41.897Z" }, + { url = "https://files.pythonhosted.org/packages/1d/43/1dbdadddb88e53c08875c7ede540f95ebb96d81bc8661281179a2d033d28/nccl4py-0.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c58bf4db2eb27636587b3ce899fe47d72c16bf1eeb76c5e1c9bb1feaf57048f5", size = 10902042, upload-time = "2026-06-11T20:38:42.887Z" }, +] + [[package]] name = "nemo-run" version = "0.9.0rc0.dev0" @@ -2754,17 +2762,18 @@ wheels = [ [[package]] name = "nltk" -version = "3.9.4" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, + { name = "defusedxml" }, { name = "joblib" }, { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" }, ] [[package]] @@ -3061,7 +3070,7 @@ wheels = [ [[package]] name = "openai" -version = "2.44.0" +version = "2.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3073,9 +3082,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, ] [package.optional-dependencies] @@ -3136,14 +3145,14 @@ wheels = [ [[package]] name = "opentelemetry-proto" -version = "1.43.0" +version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, ] [[package]] @@ -3393,11 +3402,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.10.0" +version = "4.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/cd/4f25b2f95b23f5d2c9c1fe43e49841bff5800562149b2666afc09309aa8f/platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695", size = 31678, upload-time = "2026-07-18T03:53:43.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, + { url = "https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443", size = 22906, upload-time = "2026-07-18T03:53:42.576Z" }, ] [[package]] @@ -3560,29 +3569,29 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.28.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/44/767757fd2cdd4a60d7e4440d9f7b491d6131103d313638d2c03e06c268fb/proto_plus-1.28.1.tar.gz", hash = "sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168", size = 57166, upload-time = "2026-07-08T17:04:02.367Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/2f2b57dbfd145b995a29847a16b0903fce5ef6ad3c7aad740a609c5d3678/proto_plus-1.28.1-py3-none-any.whl", hash = "sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed", size = 50408, upload-time = "2026-07-08T17:03:34.532Z" }, ] [[package]] name = "protobuf" -version = "6.33.6" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] @@ -3639,54 +3648,47 @@ wheels = [ [[package]] name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, + { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/581ccbcdb3d897eb2893328d68db3d52eca373bf2a7e964d0a6276b8e85b/pyarrow-25.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:72132b9a8a0a1840197794d4dea26080069b6b0981c116bc078762dc9691b21b", size = 35878945, upload-time = "2026-07-10T08:28:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/64/d1/ccb01db7329ea0411ef4fbd9b62a04d3268b36777d4e758d5e39b91ddeab/pyarrow-25.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e009ef945e498dca2f050ea10d2e9764cb44017254826fc4574fdb8d2530173b", size = 37630854, upload-time = "2026-07-10T08:28:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/2d81ba89d1e4198d0cb25fe7529de936830fdaec0db926bb52a1ef7080d4/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f57a39dbcb416345401c2e77a4373669b45fd111a1768e6cf267a7a0607ff0ec", size = 46905617, upload-time = "2026-07-10T08:28:29.376Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/0ed312ec800fb536f93783215126cee4b8977dcfeccba6f0f44df0cc87d7/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:447df764beb07c544f0178a5f6b70ef44b9ecf382b3cdfad4c2d7867353c3887", size = 50119765, upload-time = "2026-07-10T08:28:35.826Z" }, + { url = "https://files.pythonhosted.org/packages/ca/88/cab5063ba0c4d46a9f6b4b7eb1c9029dc0302d65cd5ab3510c949a386568/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac5dfeee59f9ceb4d45ba76e83b026c38c24334135bb329d8274baa49cec3c62", size = 50027563, upload-time = "2026-07-10T08:28:43.848Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/4d24f1b7fe2e042dc4ef315ef75e4e702d8e46fe10c37e63caff00502b03/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0f100dacf2c0f400601664a79d1a907ced4740514bb2b00917341038e2ce76f", size = 53162437, upload-time = "2026-07-10T08:28:52.819Z" }, + { url = "https://files.pythonhosted.org/packages/fa/65/da20806de93ca6ee91e72cb6a9b08b3ac890b46efc8d94a7326c651c4c81/pyarrow-25.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:2e093efbecb5317372f819228fa4b4e6157eee48d3f0a7b0303705ebf81a7104", size = 28613262, upload-time = "2026-07-10T08:29:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/9f/c632afb1d3ef4a7814cee236718235f3a47eac46e97eb87df40f550b6b48/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:26be35b80780d2d21f4bae3d568b1666337c3a89722cc1794c956a77017cb24e", size = 36120702, upload-time = "2026-07-10T08:28:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/36/0a/093d53a0e72ad06e45d6443e00651bbc2d21af4211295086cbf4d873d3b9/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:6f4812bfbf11ca7d8faf59eb8fff8bf4dd25ce3a38b62baa010cc17a0926d1b2", size = 37750674, upload-time = "2026-07-10T08:29:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/b37fc31a69cff4bdfb8842683def5612f551b93fff6f44375e4a4a6a5535/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8af8ceedf0c9c160fd2b63440f2d205b9404db85866c1217bfea601de7cfb50", size = 46912304, upload-time = "2026-07-10T08:29:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/35/5cae19ba72493e5598022468b56f6a5571f399f485bf412f157356476caa/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c70a5fd9a82bd1a702fd482bdc62d38dcb672fb2b449b1d7c0d7d1f4be7b7bfe", size = 50073652, upload-time = "2026-07-10T08:29:22.467Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a5/ddd508424bdfd5e6945765e9e2ffc687e2f6115972badc8ecf423076c407/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0490a7f8b38ffe11cc26526b50c65d111cb54ddac3717cec781806793f1244dc", size = 50058654, upload-time = "2026-07-10T08:29:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/324d0db203ff5eebe8694ec2d6ec5a23f9aaa5d02e5b8c692914c518c33c/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e83916bbcf380866b4e14255850b33323ff678dc9758411d0409cdd2523880b0", size = 53140153, upload-time = "2026-07-10T08:29:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8d/d236e9c82fe315f9128885c8be3ec719f41965a1eb6b6f4b42470904cd41/pyarrow-25.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:13240f0d3dc5932ccd0bfa90cd76d835680b9d94a7661c635df4b703d40ce849", size = 28743657, upload-time = "2026-07-10T08:29:42.742Z" }, ] [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -3820,20 +3822,21 @@ wheels = [ [[package]] name = "pydata-sphinx-theme" -version = "0.19.0" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, { name = "babel" }, { name = "beautifulsoup4" }, { name = "docutils" }, + { name = "jinja2" }, { name = "pygments" }, + { name = "requests" }, { name = "sphinx" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/7e/e3defb93f30557ae825a3b3fbc2c6728301e5e9505a85e00d8503345c42c/pydata_sphinx_theme-0.19.0.tar.gz", hash = "sha256:148ba092bd6937b3321385dc482cc69a29ad2ede36547b4f010ade782bc6a062", size = 5004933, upload-time = "2026-06-15T09:31:02.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/8e/add936feaaa9dade7d5b87c6852566d85e518b38ad32224dea32ef958984/pydata_sphinx_theme-0.20.0.tar.gz", hash = "sha256:0da172d41e19a66de875f4002f7054b385372ec65763852193791e658d50bb4a", size = 5004756, upload-time = "2026-07-09T09:09:14.693Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/77/bdb5a4c0e8e33c08f31fd175a5af0bc5e29f1b43ffa9e963f4c4f9bfbc97/pydata_sphinx_theme-0.19.0-py3-none-any.whl", hash = "sha256:5d7dfe3beb0facc88b5d78ff4a4c948f214cc0e03aae27e7fc58286e963b588b", size = 6201132, upload-time = "2026-06-15T09:30:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/80/08/28e2194ed1c3c3a3e86e0600e2dcc7f21dcd670bd31f3efab2e3e2cf0cbd/pydata_sphinx_theme-0.20.0-py3-none-any.whl", hash = "sha256:56744483c9d72c783e075de716ab95d486108b69605df7528078090b73f11f69", size = 6201166, upload-time = "2026-07-09T09:09:12.899Z" }, ] [[package]] @@ -4030,15 +4033,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.4.3" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, ] [[package]] @@ -4237,7 +4240,7 @@ wheels = [ [[package]] name = "ray" -version = "2.56.0" +version = "2.56.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -4250,16 +4253,16 @@ dependencies = [ { name = "requests" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/05/d2/83777f52c10e04654c162bfa093f4e34cf6decf7a0bb4311e2432bfa8ecb/ray-2.56.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:684a427c50745989e92a332343f0812c93b8506f71c768b95b1eefc113492699", size = 66344824, upload-time = "2026-06-29T20:50:58.367Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b2/d610ffe2878dae8ab903cfdded883a054c102b0104670bca7b34b662db51/ray-2.56.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e1fd03c6ecc5fe4c31466569e41ce0a4faf26fb930798c9d1f1eb1f405a687c8", size = 73317367, upload-time = "2026-06-29T20:51:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/39/d1/8d442116f41e6bebec418d89f1556c67c77d593b55cf59e716503e7a4a17/ray-2.56.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:78ef34a71383c1fcf335e531e0e590867857fce9069f06ed351be6ce7a58fc50", size = 74192607, upload-time = "2026-06-29T20:51:09.528Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9e/cac7454325c79eb32f36f233285d9179858c7073bc27ac91afb74854ff12/ray-2.56.0-cp312-cp312-win_amd64.whl", hash = "sha256:a8fc809dab6fc07cf05d45ea93a776c852c990512eb1fac0e3d15819fe6df10a", size = 28370978, upload-time = "2026-06-29T20:51:14.168Z" }, - { url = "https://files.pythonhosted.org/packages/65/86/29c9444c9f11f0bf3d4da75d3dc7a27c0fb86f8604beea6513968dc2df26/ray-2.56.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:992047f50473b5bfea74c8f528f999968e0b4bc735af23ad476a0f4e04741aea", size = 66289930, upload-time = "2026-06-29T20:51:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/59/59/3c4d533a92e99b12b9d2e8690e41d7ace269d77deecb0aef753df7924b9e/ray-2.56.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:a0e9cfe92c88ab74abca23923c15a592f49bc7617ffdda4190daca7785a7c4f6", size = 73252460, upload-time = "2026-06-29T20:51:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/59/ba/285d409253b332af91884daa4b0a3be3ce799682aa2c1e0f8dbbafaf1da4/ray-2.56.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:54d2725f8b65d9615c933fec5ec62e54a67b8f2e14286026fb014606253670ef", size = 74130685, upload-time = "2026-06-29T20:51:31.59Z" }, - { url = "https://files.pythonhosted.org/packages/82/a0/07a00091cf002662946ed2a16fe4b27c80be6fce7fc831e41c7e1471e2f7/ray-2.56.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f38e03b77c53e3d94091aedb84b14efe7ee5b581d85b7d13925066bcd48c44a2", size = 66298834, upload-time = "2026-06-29T20:51:36.82Z" }, - { url = "https://files.pythonhosted.org/packages/30/3c/2819cc8b40bf4034ff871c18d04a0422b7b0a51b32260c6f03a06460aaa5/ray-2.56.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:c3a16d43d75283a3d64fa1d904a3adaf3f526f3f508f447505b8bb8dc70bad6c", size = 73242922, upload-time = "2026-06-29T20:51:42.467Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5b/33ac5616706d1bc0bd40e22baf12acccd687b505f85d107f549355379d6f/ray-2.56.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:73edb6fb5fd05481b1f358ac2e8a4c7f6a031d89f9b823d25a587ddb7529070f", size = 74099117, upload-time = "2026-06-29T20:51:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/50/d9/a17feef16a123f5d32d4c5fa7de853c59ba702f8404cf452a7ce20faca13/ray-2.56.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:44bc0000c5bfad85b2ff6e0ef91e95f901d1a2d2fdd72f94f08a046eb494cd61", size = 66346989, upload-time = "2026-07-17T21:28:39.4Z" }, + { url = "https://files.pythonhosted.org/packages/05/19/c3b1bcccd09decaf2a2e3370041ae67070bb1a8638f2665d36edfbb0261d/ray-2.56.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:8fdd6b096215906cf1f9acdc7898c9d6140606f2d27245778b8385a9f19e6cb0", size = 73319289, upload-time = "2026-07-17T21:28:44.502Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7f/577a61bf2c8eff26e942afe53a6b03f4dbf5b4f233b832c228417d0c954e/ray-2.56.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:e5d3173696831134c76bd09451dfe95c32d72c271253b0d6b09d2df9994aa660", size = 74194147, upload-time = "2026-07-17T21:28:50.567Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/0fd1223597f9ca98ec496ec726043918a5301547789b1be95a635ec82649/ray-2.56.1-cp312-cp312-win_amd64.whl", hash = "sha256:8052573ee5ef8c4fdd7aeb6a257c80542e69c48f3f6d117101f95c970ffdc7e2", size = 28373294, upload-time = "2026-07-17T21:28:55.122Z" }, + { url = "https://files.pythonhosted.org/packages/dd/58/fab4cf69ec9be22210a6634f9310acf0b40c1c5a0f65d380f8cd11aa661e/ray-2.56.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:93dedab658334af81877b6ed840c4e5f85e2e0b1b3641b20eaaee37cad835cc6", size = 66291122, upload-time = "2026-07-17T21:28:59.906Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/622d4f77fc65e9e2ee1a9d2480a1bde4b244edf65eda1f0c488dc8818e57/ray-2.56.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:7fdc47de4e230f0db7c6c668a9e161f864dac548bd32230986e0b0c36e386eb5", size = 73253432, upload-time = "2026-07-17T21:29:05.445Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/649395a3b286c4be86cfe4e8072a746f4d6b6dc68d78396f462d8b94795e/ray-2.56.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:81f2db202cc31bc3f5c4acf9ef154d10f1f39ece602d9d2d3108875c49bf01c3", size = 74131273, upload-time = "2026-07-17T21:29:10.547Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/e921682f4027459f261a8db2a56a25760458e12c95359f1ce56d525a28d3/ray-2.56.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:76f6268a669c1d9910f1d7b73903de3ede9850ed94d3e28318d495559bd37d0e", size = 66300044, upload-time = "2026-07-17T21:29:15.61Z" }, + { url = "https://files.pythonhosted.org/packages/ed/94/066849822fdd1c7a9221f3e2f9130e2a5eda83f47efc685211fb178b5a58/ray-2.56.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:ea372c7f95b14f1f76f0bdd2abc9602c56e88bc30699d2228f78133e7749b2f1", size = 73243912, upload-time = "2026-07-17T21:29:20.98Z" }, + { url = "https://files.pythonhosted.org/packages/63/ac/74597352f729f9f7d998a93c6dd1de5efca3e6a5750f720d04a42f22bb3e/ray-2.56.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:ae91fe578fadea38c13a208a0fec27e1ced238ccdf5fff95ef3e30ac3071dec9", size = 74099698, upload-time = "2026-07-17T21:29:26.649Z" }, ] [package.optional-dependencies] @@ -4296,90 +4299,90 @@ wheels = [ [[package]] name = "regex" -version = "2026.6.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/05/e4f219230e11e774a6c9987d2ab0d0c6b8573e13a17e143d0015bee710ef/regex-2026.6.28.tar.gz", hash = "sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342", size = 416101, upload-time = "2026-06-28T19:56:55.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/21/44aa415873032056c43eac21c67285deb2cf66cddb2a964c3cdc8f803efc/regex-2026.6.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1", size = 490480, upload-time = "2026-06-28T19:54:05.392Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5f/30d4116093c2128099f78b6990dfc1698fdbf3ee528f1e1c647378034c79/regex-2026.6.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9", size = 292137, upload-time = "2026-06-28T19:54:07.088Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/ca20a0e0de49837e6337603a91ab77556aa27033ac5b975615d98698cfb3/regex-2026.6.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab", size = 289623, upload-time = "2026-06-28T19:54:08.762Z" }, - { url = "https://files.pythonhosted.org/packages/50/11/c013422a7e2c59946df8ac93e792a4922c98287f2a2181341603c78a5d98/regex-2026.6.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195", size = 796756, upload-time = "2026-06-28T19:54:10.616Z" }, - { url = "https://files.pythonhosted.org/packages/b0/95/1309645a0e1ee6fb91d954501da57a0b33d50ad2a9acb313702851a7054e/regex-2026.6.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf", size = 865465, upload-time = "2026-06-28T19:54:12.742Z" }, - { url = "https://files.pythonhosted.org/packages/20/06/491802db47c6f5e2904ffa2518ad3ac27fe6bbf5a66d73210a95cc080d47/regex-2026.6.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2", size = 912350, upload-time = "2026-06-28T19:54:14.508Z" }, - { url = "https://files.pythonhosted.org/packages/5e/60/3ba57840bcc7e2367090360de0c15a5ba6ad22be89314251105f2e943f43/regex-2026.6.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38", size = 801261, upload-time = "2026-06-28T19:54:16.432Z" }, - { url = "https://files.pythonhosted.org/packages/eb/27/af1eb74e9a78c782b3e450b611a595e44906da8a5107e1227f4a7fd0480b/regex-2026.6.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3", size = 777072, upload-time = "2026-06-28T19:54:18.128Z" }, - { url = "https://files.pythonhosted.org/packages/20/18/fdd4c883a39e3ed00d669062af1135809bfd3281bf528150849fbd68825b/regex-2026.6.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417", size = 785119, upload-time = "2026-06-28T19:54:20.314Z" }, - { url = "https://files.pythonhosted.org/packages/1c/79/0aabe34b8482dcadf64355f70f96e22eba5ec6c1efb33563f89654f4061c/regex-2026.6.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e", size = 860118, upload-time = "2026-06-28T19:54:22.368Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2c/c973323306a27c9db7d160e9584eb7e0ece2a96224ccb0d39060558b31f9/regex-2026.6.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8", size = 765786, upload-time = "2026-06-28T19:54:24.265Z" }, - { url = "https://files.pythonhosted.org/packages/e3/df/9ca3e378e352242a4cb45573a5e9162c3ee791507702a23966fa559e36b5/regex-2026.6.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a", size = 852120, upload-time = "2026-06-28T19:54:25.972Z" }, - { url = "https://files.pythonhosted.org/packages/a2/3e/3e31e255c4971f53cbce6306b5e3c76cbd3735a54f419bb3b2f194e9f68c/regex-2026.6.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8", size = 789503, upload-time = "2026-06-28T19:54:27.678Z" }, - { url = "https://files.pythonhosted.org/packages/72/01/d36561c21c3033d7eeb31d51b491916817de7861acefccc5fc9db8a5037c/regex-2026.6.28-cp312-cp312-win32.whl", hash = "sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463", size = 267109, upload-time = "2026-06-28T19:54:29.316Z" }, - { url = "https://files.pythonhosted.org/packages/a0/59/bbbb0591f38b18c65977cd65ce64749eba1c1996c99ac04e900fc30c0dcb/regex-2026.6.28-cp312-cp312-win_amd64.whl", hash = "sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84", size = 277711, upload-time = "2026-06-28T19:54:31.143Z" }, - { url = "https://files.pythonhosted.org/packages/86/06/be4f6b337d773ae5739a1bc238f97c16926e72017243735853c030f4c628/regex-2026.6.28-cp312-cp312-win_arm64.whl", hash = "sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6", size = 277022, upload-time = "2026-06-28T19:54:32.97Z" }, - { url = "https://files.pythonhosted.org/packages/b6/53/d5c1b3cc0b5a0c985563ad6fac93d73ff2b300cb84342d89f044625d6bc7/regex-2026.6.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca", size = 490329, upload-time = "2026-06-28T19:54:35.775Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9f/0c3503e819e91ca0e7a901a8e989ebf840ac7c7aea20b1fc7f31b6759f77/regex-2026.6.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c", size = 292039, upload-time = "2026-06-28T19:54:37.977Z" }, - { url = "https://files.pythonhosted.org/packages/bb/7f/cd004e13fcad23b3794a82307dfd222e6365eb7f598bd3caab148a830bff/regex-2026.6.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94", size = 289488, upload-time = "2026-06-28T19:54:39.545Z" }, - { url = "https://files.pythonhosted.org/packages/73/4c/293fb34586fbcdc47eac436069e9c11f71fae5dadfd4889b475d7d2e5f7a/regex-2026.6.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04", size = 796772, upload-time = "2026-06-28T19:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/92/fa/c0cd1a90b7d12d9dc155cfc8bdea8df9720988ea5b07e8fa1eccbd0ab2dd/regex-2026.6.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762", size = 865467, upload-time = "2026-06-28T19:54:43.485Z" }, - { url = "https://files.pythonhosted.org/packages/4e/db/0b479973046d005a1eaea299d5d536aeecb9488a16d9cbb8286338102e2d/regex-2026.6.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba", size = 912345, upload-time = "2026-06-28T19:54:46.091Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5b/d65adfbd02f32212431bca1f06d1e2eb763a20b12978b454bafaf23dacb7/regex-2026.6.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5", size = 801291, upload-time = "2026-06-28T19:54:48.3Z" }, - { url = "https://files.pythonhosted.org/packages/fc/09/2103686defaf9a0a31c1663782359d5b45f42524c64cca681f5481e44a5e/regex-2026.6.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe", size = 777106, upload-time = "2026-06-28T19:54:50.326Z" }, - { url = "https://files.pythonhosted.org/packages/85/5a/b57593c0aa23ed269ec332fbcf07852abcb6b746e811d9464e0d09b4e25f/regex-2026.6.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20", size = 785175, upload-time = "2026-06-28T19:54:52.172Z" }, - { url = "https://files.pythonhosted.org/packages/79/59/c36e756ad29bf14d7b6c6d7138952476b21f6160286cedb98ac13481c993/regex-2026.6.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4", size = 860186, upload-time = "2026-06-28T19:54:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/49808aea0da9649c300139360708fb91b7144be1f962fcebf96755fde948/regex-2026.6.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c", size = 765754, upload-time = "2026-06-28T19:54:56.04Z" }, - { url = "https://files.pythonhosted.org/packages/be/c5/52bbd436cf2200decdf48825fa38363eaaeebb77011ea9928a1ef9e0b9f2/regex-2026.6.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854", size = 852085, upload-time = "2026-06-28T19:54:57.988Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c3/0390b66e3019497143fe768b3ba567b64d8b24f3812d09506deb86f4a0f0/regex-2026.6.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b", size = 789600, upload-time = "2026-06-28T19:54:59.977Z" }, - { url = "https://files.pythonhosted.org/packages/88/fd/ab5b03653a244975069fed93d73f4f5f7484c03a84cedb238292510d7182/regex-2026.6.28-cp313-cp313-win32.whl", hash = "sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5", size = 267088, upload-time = "2026-06-28T19:55:02.159Z" }, - { url = "https://files.pythonhosted.org/packages/68/55/21022f7d3143210ae8d4ff905c45306237b657375cc0b97883f49db3d423/regex-2026.6.28-cp313-cp313-win_amd64.whl", hash = "sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3", size = 277680, upload-time = "2026-06-28T19:55:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/b6/99/7f664804f1aef924542b0b233996b78b3e4d0a52d9951358aac99f129f51/regex-2026.6.28-cp313-cp313-win_arm64.whl", hash = "sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767", size = 277017, upload-time = "2026-06-28T19:55:06.29Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e1/9eb83518e159d719fd681c4932dc2aaff855ce72451e1d05d69466f25a96/regex-2026.6.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c", size = 494195, upload-time = "2026-06-28T19:55:08.292Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e2/e259c5f2f7be269d0e2fb54275c1fa6a13fb47019f389c3f3ae457447825/regex-2026.6.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1", size = 293976, upload-time = "2026-06-28T19:55:10.014Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4e/9bdf444014d22b045d0c82ca114fac7e07a597b5b5331b7c4ce6328426e2/regex-2026.6.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97", size = 292340, upload-time = "2026-06-28T19:55:11.88Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3a/f49b11e59cbfe187ace0053a460bd72a0169b8cd52e7db9421a074ce7a43/regex-2026.6.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d", size = 811704, upload-time = "2026-06-28T19:55:13.612Z" }, - { url = "https://files.pythonhosted.org/packages/2f/fb/ad04c39e149bf8b6cf357df5fff78341733ec366780a00c803a36735818c/regex-2026.6.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475", size = 871157, upload-time = "2026-06-28T19:55:15.797Z" }, - { url = "https://files.pythonhosted.org/packages/7f/64/0e5ba31c11eb8ef7aac19a690c1211fc9aa9990caf09565785ebb0081b9a/regex-2026.6.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb", size = 917287, upload-time = "2026-06-28T19:55:18.692Z" }, - { url = "https://files.pythonhosted.org/packages/11/75/6b78df2b858c2fcbbc4858fdc3f2975cf2703be374b2842db7d2c32591a7/regex-2026.6.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e", size = 816333, upload-time = "2026-06-28T19:55:20.973Z" }, - { url = "https://files.pythonhosted.org/packages/b4/01/ecfe665a3694d5eda9f3ec686c856438ada0943947b6005e90556a1e2cdf/regex-2026.6.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27", size = 785518, upload-time = "2026-06-28T19:55:23.003Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0a/88f9cd88ff1e82881605c4ffd62d77ee67d051232cfe6f8e9a64b86cf0e8/regex-2026.6.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3", size = 801371, upload-time = "2026-06-28T19:55:24.888Z" }, - { url = "https://files.pythonhosted.org/packages/a8/97/601483732f93275482ceb9fed57813dfed7c47d3a019db6ec4a3bb6e23e0/regex-2026.6.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7", size = 866517, upload-time = "2026-06-28T19:55:27.232Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/385c2a0351b994a693453c1d1a6e9af9eb35db3c9460d76b5078acd70c62/regex-2026.6.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb", size = 772834, upload-time = "2026-06-28T19:55:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/bc/bbf4a5b3b29770d7f307d3c28b5b1bca0105b0cb424be0a4eb1339bc92cf/regex-2026.6.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816", size = 856606, upload-time = "2026-06-28T19:55:32.186Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/51d74fff82f682819979249f8d700267108ba5dc4eb284b0e11b9c85e4b3/regex-2026.6.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb", size = 803475, upload-time = "2026-06-28T19:55:34.328Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3e/6be10cefdc813533fe604dbf5d3c77d2638e7ee658b2749ebadc113b6b2e/regex-2026.6.28-cp313-cp313t-win32.whl", hash = "sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d", size = 269126, upload-time = "2026-06-28T19:55:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/3c/3c/32cda905ea1a6eeeb798291c294d8ec66ee0efe0cdba28b061e248b1d396/regex-2026.6.28-cp313-cp313t-win_amd64.whl", hash = "sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab", size = 279961, upload-time = "2026-06-28T19:55:38.456Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b9/69f4e5cd6fbe0bb420cb2dbae441ca118f2495bdda522a74da75aa9829e7/regex-2026.6.28-cp313-cp313t-win_arm64.whl", hash = "sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d", size = 279266, upload-time = "2026-06-28T19:55:40.62Z" }, - { url = "https://files.pythonhosted.org/packages/3b/fb/fad3b810a5bb1e09b9e5d6913fc6ba88cab738fdf283196827a3c59a4c10/regex-2026.6.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c", size = 490407, upload-time = "2026-06-28T19:55:42.724Z" }, - { url = "https://files.pythonhosted.org/packages/d6/52/b8c79d12276d93e90e707e939b396034c04980caf1235312ef790f8e11fc/regex-2026.6.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3", size = 291988, upload-time = "2026-06-28T19:55:44.549Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/6a911f18279daa8d7bb8b20d771ddb6ef31fabd35f5921f9d3ba21640e80/regex-2026.6.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba", size = 289704, upload-time = "2026-06-28T19:55:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/fd/22/ad1955c47c669291a05804d53d7071cc0732dfdf166857be38003cedc2d1/regex-2026.6.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878", size = 797017, upload-time = "2026-06-28T19:55:48.166Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/a83159ff8703ab4d0c2cf99e76ebf289b7b4a501623241d09f88f3614f80/regex-2026.6.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26", size = 866112, upload-time = "2026-06-28T19:55:51.047Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/7bff2d6dbbd77421b3274aa51db1c887381cbc5b6eda93598c3e882ea345/regex-2026.6.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0", size = 911554, upload-time = "2026-06-28T19:55:53.707Z" }, - { url = "https://files.pythonhosted.org/packages/29/44/ae59c3826e7ba492e56795cdf74ea2a7b5b7c5ea116afb79ee4956a5dff1/regex-2026.6.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a", size = 800665, upload-time = "2026-06-28T19:55:55.875Z" }, - { url = "https://files.pythonhosted.org/packages/d6/19/6fd033d2ab00f35d445aaeaf3307c1e721424dcbfd48f6f65c857cb939cf/regex-2026.6.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581", size = 777243, upload-time = "2026-06-28T19:55:57.909Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9d/99730f26df4938049ab1e652ca75e967b4c6739444e18d9707bfdb8af20c/regex-2026.6.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b", size = 785784, upload-time = "2026-06-28T19:56:00.072Z" }, - { url = "https://files.pythonhosted.org/packages/48/49/105cd57162f5fc5c04cc917a1388a060cf8427e5c14353cd9044660fbf4d/regex-2026.6.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc", size = 860914, upload-time = "2026-06-28T19:56:02.017Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a5/788245a95b69018f58bff2f4fd27d007cacaea088cdb390979743f1b2571/regex-2026.6.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9", size = 765915, upload-time = "2026-06-28T19:56:05.021Z" }, - { url = "https://files.pythonhosted.org/packages/ca/01/292065a39a004b05e67a337b18213670a7cb919d6856ac2d7df7f1a10dbb/regex-2026.6.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db", size = 851404, upload-time = "2026-06-28T19:56:07.251Z" }, - { url = "https://files.pythonhosted.org/packages/98/9e/a93d865db0e13483ae1a01d81e2ce16d4a7fe2f9b9fe4aac4cc08590b136/regex-2026.6.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc", size = 789373, upload-time = "2026-06-28T19:56:09.894Z" }, - { url = "https://files.pythonhosted.org/packages/82/0c/38b1685ad4017d78efbc8fa7dbbf96d8113b53750c8aa2d3609defd46605/regex-2026.6.28-cp314-cp314-win32.whl", hash = "sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03", size = 272496, upload-time = "2026-06-28T19:56:11.83Z" }, - { url = "https://files.pythonhosted.org/packages/55/50/e19f261ff9ba9b50722a529e09b1743ecf65eb348be99d0fd2cd7fcede1c/regex-2026.6.28-cp314-cp314-win_amd64.whl", hash = "sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a", size = 280754, upload-time = "2026-06-28T19:56:13.758Z" }, - { url = "https://files.pythonhosted.org/packages/36/b8/c9e68f3a9e33be73f20990b2c065b144ff2d0aa242608a950d8c4f3b56e8/regex-2026.6.28-cp314-cp314-win_arm64.whl", hash = "sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7", size = 280979, upload-time = "2026-06-28T19:56:15.928Z" }, - { url = "https://files.pythonhosted.org/packages/03/e6/21c425a37880c650d007c4171c6a80325446d830d85f5fbf335e7205b1e7/regex-2026.6.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c", size = 494282, upload-time = "2026-06-28T19:56:18.049Z" }, - { url = "https://files.pythonhosted.org/packages/07/50/6647a7ccf5ffff995ba955a0b7d766440f4e58ce1666549c8ee998f2b972/regex-2026.6.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5", size = 293977, upload-time = "2026-06-28T19:56:20.145Z" }, - { url = "https://files.pythonhosted.org/packages/8c/dc/a3e141a4eaf125e50f63105570c01fa477c06ac5259dcfa95e9b90760e84/regex-2026.6.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820", size = 292432, upload-time = "2026-06-28T19:56:22.345Z" }, - { url = "https://files.pythonhosted.org/packages/35/ee/2ac1a6b9f167f8ff69f5a789938cc103b60cff41b24a6990daced8b88e34/regex-2026.6.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2", size = 811877, upload-time = "2026-06-28T19:56:25.056Z" }, - { url = "https://files.pythonhosted.org/packages/df/7b/9a5505ee92180bcae300b1018b9ff3d3c19962436e66f2505f255e9fde35/regex-2026.6.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1", size = 871212, upload-time = "2026-06-28T19:56:27.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/4d/d61a702a9f9d1bd29b22cbef1aed6d477baa961232a7eb4d91b7775b0b3e/regex-2026.6.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f", size = 917507, upload-time = "2026-06-28T19:56:29.762Z" }, - { url = "https://files.pythonhosted.org/packages/d4/60/1308066f5966b65fbb6905b99ba37e9f1cd753dd0ac08485f8257334ee92/regex-2026.6.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546", size = 816389, upload-time = "2026-06-28T19:56:32.043Z" }, - { url = "https://files.pythonhosted.org/packages/bd/5c/57ce2cb8d714ee0b7f11c7ee4cfe2af66df2b90f147feadcb538609a3a02/regex-2026.6.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400", size = 785890, upload-time = "2026-06-28T19:56:34.492Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fd/1d5350d3a8a327bff0fccacb911732baf7b5b6f5529c0e3fa602a23e7dad/regex-2026.6.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387", size = 801451, upload-time = "2026-06-28T19:56:36.749Z" }, - { url = "https://files.pythonhosted.org/packages/f3/79/3c9e4f8a0306e030ad5a43bbbc01625fb28d58a813bc52d42fd1cc63fb2e/regex-2026.6.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06", size = 866504, upload-time = "2026-06-28T19:56:38.994Z" }, - { url = "https://files.pythonhosted.org/packages/65/12/f747de475b54f4709efb24dd0fbc8467c64cec91f5db0d047b079646ee78/regex-2026.6.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19", size = 773047, upload-time = "2026-06-28T19:56:41.061Z" }, - { url = "https://files.pythonhosted.org/packages/58/3c/f02f860e0500c1b2d61a79dec7e214b37fb9656281dcddc92397edf96678/regex-2026.6.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858", size = 856665, upload-time = "2026-06-28T19:56:43.466Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6c/28b3fa222513484be9dee26b7222bda109056c43ea28aa2314262ca48816/regex-2026.6.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34", size = 803573, upload-time = "2026-06-28T19:56:45.791Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/8f86cf1a1fd85c5ab0c503c9fe4607ad4ad48978b2d8b435d94465e134c7/regex-2026.6.28-cp314-cp314t-win32.whl", hash = "sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493", size = 274515, upload-time = "2026-06-28T19:56:47.948Z" }, - { url = "https://files.pythonhosted.org/packages/0f/de/f8613c03b36786ddef2c930d28f9bcae861fcd541cc9203a870956cf1e83/regex-2026.6.28-cp314-cp314t-win_amd64.whl", hash = "sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d", size = 283650, upload-time = "2026-06-28T19:56:50.614Z" }, - { url = "https://files.pythonhosted.org/packages/4d/f3/f5ec86839bbabe33b6dee649b62ff9a445d43de6b0ad780cf6b83c56f61e/regex-2026.6.28-cp314-cp314t-win_arm64.whl", hash = "sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8", size = 283338, upload-time = "2026-06-28T19:56:52.879Z" }, +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, ] [[package]] @@ -4627,72 +4630,67 @@ wheels = [ [[package]] name = "sentencepiece" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" }, - { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, - { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, - { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, - { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, - { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, - { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, - { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, - { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, - { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, - { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, - { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, - { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, - { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" }, - { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" }, - { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" }, - { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" }, - { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" }, - { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" }, - { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" }, - { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" }, - { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" }, - { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" }, - { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" }, - { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cc/33/ea3cb3839607eb175da835244a798f797f478c5ddf0e8ecdf57ea85a4c70/sentencepiece-0.2.2.tar.gz", hash = "sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6", size = 8218435, upload-time = "2026-07-12T08:39:34.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/13/7a562289c8d5b49ebdf3f9c1e8ab67cf14a8743b1d90c8f406bfdec36b72/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b", size = 2188384, upload-time = "2026-07-12T08:38:28.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/d1/912f14fd5eae168aba726ffb6a9a2dc1c71fe7676c53da6f5c442b886d4a/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820", size = 1441553, upload-time = "2026-07-12T08:38:30.552Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/caa9cab5f261a019e2808bc5046152775dc57352ba9cbae7525e9e7a1ed4/sentencepiece-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838", size = 1347176, upload-time = "2026-07-12T08:38:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/cd798935668cff71d309d8ff10385844ecf216b1fe454f1993ed8bf2cb91/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a", size = 1325200, upload-time = "2026-07-12T08:38:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/b3b05095c174d6e80d37d5ddc2f57c2c56237333e7bbd6079cf3243c2a8a/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8", size = 2188346, upload-time = "2026-07-12T08:38:41.089Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/72ebc4acb10a06bcf7503fbc6091c8f5db68300f6aac4356c09e6c76e0e1/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c", size = 1441434, upload-time = "2026-07-12T08:38:42.56Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a", size = 1347267, upload-time = "2026-07-12T08:38:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/32/4f/31c1073314ad94466bca37d29581761d70110237ee3d46b0efece59a8c1e/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0", size = 1324980, upload-time = "2026-07-12T08:38:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/59/b4/a0356fa04d6a14337a6e0e443556785a0422c53ec58baae6b9568120eb0f/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb", size = 1397593, upload-time = "2026-07-12T08:38:48.302Z" }, + { url = "https://files.pythonhosted.org/packages/09/fa/d2d6369257fd2f0de616b1c7110b73fab409ef61b14f1b9e0010ed325914/sentencepiece-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9", size = 1247987, upload-time = "2026-07-12T08:38:50.15Z" }, + { url = "https://files.pythonhosted.org/packages/17/ee/2bb594da6fd95e32f29057f1aa7fa996701b8980090923c2d8711fdc0a24/sentencepiece-0.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91", size = 1187250, upload-time = "2026-07-12T08:38:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/58/9c/dfc82846460e7a712310f5613f23d8b553cabb4e2e648663c11d8382af56/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78", size = 2223080, upload-time = "2026-07-12T08:38:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/3ff12cebe6d31662d9ceeabfb282de20bd0d6098fa282b4a3b8305abc7e8/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563", size = 1458511, upload-time = "2026-07-12T08:38:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/5a/16d51d05360be4cee3ebfe4837c184054c4eed16cabaeb3b039524e9a000/sentencepiece-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5", size = 1361138, upload-time = "2026-07-12T08:38:58.808Z" }, + { url = "https://files.pythonhosted.org/packages/0f/af/c30ee2a9f99d51db9844acaa8fa0b611a97c2fa7116646fa43db3300b187/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d", size = 1328625, upload-time = "2026-07-12T08:39:00.849Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1a/4c6b39d03f5ba8439509adbd5a23c9538088a3cb679e7a47b911e8442bc6/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b", size = 1398595, upload-time = "2026-07-12T08:39:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/9eedddcec1fd57bc70200fa3ebf792d18fa63527a5369581cd416c81f97f/sentencepiece-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53", size = 1259346, upload-time = "2026-07-12T08:39:04.559Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/7e74c8533848866ff560b29f7d8719921b76c4ec7149592d6d28e0deee75/sentencepiece-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd", size = 1196596, upload-time = "2026-07-12T08:39:06.454Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/f5df63edb6bcb46c1343cfa5d9192d73a4eb61af2e800d9402efff387523/sentencepiece-0.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c62bd361cec1f5b556eb8210264ecfff37486cd990c3386cc00310f26c54090a", size = 2190240, upload-time = "2026-07-12T08:39:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/095d183b453b2a2e20b016829029c58eca90adc1c9911113e5d26fff45ed/sentencepiece-0.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:46ba07b543add034de0ff47ac5f907e9a06682f91d85121a972764628933be6b", size = 1442220, upload-time = "2026-07-12T08:39:09.91Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/823954c9c90e74eba09fb96752dc37a5555df00d69866cb9406d1725dc7e/sentencepiece-0.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79bac5a251f23a7341e28fda9ce0d5319edf45328239ce037c0682936f137906", size = 1348056, upload-time = "2026-07-12T08:39:11.744Z" }, + { url = "https://files.pythonhosted.org/packages/10/ca/1b6c251321901cbf8a2d2e48b8b70eb82a449011b766af52a228d0a90b6b/sentencepiece-0.2.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1402d8ee36f0d851cea8eee4dbb85fea14643b7503cf4d00d102eec0fe3ca719", size = 1325463, upload-time = "2026-07-12T08:39:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/24/b3/718847349da7b25c8220ed86d85b89080af94740b2d87a59198104ae5c51/sentencepiece-0.2.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d44b20234905ff022b7d535f79d1f823ad7670c9851cc4f03cdc34787cdb3ab", size = 1398138, upload-time = "2026-07-12T08:39:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/33/fe/4906f12c458274edd96387e4baaad7c6f064a2b7c11a1cc2401c8a7bd483/sentencepiece-0.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:63250cfab8b80a1ef82a614eb2b3cadfec2c405f870cedc139d08e2f063eb708", size = 1356144, upload-time = "2026-07-12T08:39:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/22f89b6542aba400b0007cf0b1697cc3f99be8fb682fdb4c05eec450e33f/sentencepiece-0.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:65d84ec36888de4a848eee5f910e67fbc79b064685ef1e10a502e14520ead9c9", size = 1294351, upload-time = "2026-07-12T08:39:18.967Z" }, + { url = "https://files.pythonhosted.org/packages/84/c4/7afe8c2315b76e46818851a057e50a378a0382aa00b970a1fa444181b6f6/sentencepiece-0.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d254c98ca6387655400b3959c33c83efd807f5edeb608e3aca45800ceaa77151", size = 2223281, upload-time = "2026-07-12T08:39:20.978Z" }, + { url = "https://files.pythonhosted.org/packages/98/42/fb678e472c554ef086be6375d20060ca610a2c4218854d4c091001fc6f91/sentencepiece-0.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3fd9ce2ab4460c713cfdeb4aca693ca6732a11538e05fb332d5af42e3d7fde25", size = 1458779, upload-time = "2026-07-12T08:39:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/78/52/ffe402b13bce1889228a98dc6cd86ae8afac1112362236be3468be784441/sentencepiece-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7fc14c1585139fa6b68775e616a6b90cf622ebf219f9558c0aeaf5d253ee6c9b", size = 1361736, upload-time = "2026-07-12T08:39:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/78/4a/2288f60e7283583ec0a0f16e72f9c8e68557d7e7a4b585d2cda4f9f47e64/sentencepiece-0.2.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df88b0c34f2fa909d322f7b06b1398e1e81af4b2f42a7b8e3556f928b25d1811", size = 1328155, upload-time = "2026-07-12T08:39:26.422Z" }, + { url = "https://files.pythonhosted.org/packages/26/31/5dd6882ebe899f741a5cfe40ff56c6efc06bc26ee287abdb723b671f409c/sentencepiece-0.2.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f5851441ab1ef8634963a5100b733a8bbeefe623e0c5c005b1f1f3880e574cf", size = 1398307, upload-time = "2026-07-12T08:39:28.637Z" }, + { url = "https://files.pythonhosted.org/packages/da/05/7d7780fa63f4b8c1821953b916e25f89ae8f14d4da6ba91e10f6d06dc2b4/sentencepiece-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:046b15ea22d8042e2e173561d464ec3b64a9c2081324df70ebce7bf7ebb3e497", size = 1367133, upload-time = "2026-07-12T08:39:30.546Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/70007fef3f818c688de4a730f98024a671599ab67f20270f8efb03d69dcc/sentencepiece-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fa9f5ef0e2a82233dd0b8b32ea3f5710e0c44afbc07ed3620219f32601e56090", size = 1302760, upload-time = "2026-07-12T08:39:32.457Z" }, ] [[package]] name = "sentry-sdk" -version = "2.64.0" +version = "2.66.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/ff/670abe04c5072719b5060ed93851d0d69525d60f8f2c5810f8becd58f9c1/sentry_sdk-2.66.0.tar.gz", hash = "sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265", size = 935745, upload-time = "2026-07-16T12:42:04.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bb/49b10783f29067da2eec179320617e94faf63196609de47aeab3c26c3325/sentry_sdk-2.66.0-py3-none-any.whl", hash = "sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11", size = 504769, upload-time = "2026-07-16T12:42:02.919Z" }, ] [[package]] name = "setuptools" -version = "81.0.0" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] @@ -4724,23 +4722,14 @@ wheels = [ [[package]] name = "smart-open" -version = "8.0.0" +version = "8.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/3e/79fd5fd2375a8a500b9ec2f6a0762fc1ac33e35582d4a87483a78d19408f/smart_open-8.0.0.tar.gz", hash = "sha256:5a2008d60688bd3b33c52e2ef666d3c60cf956e73e215de8c7b242cf56fdd1b2", size = 61520, upload-time = "2026-06-27T16:28:11.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/53/9c513747547fd595d5c143259129ea8b9c3ea2f6b7bb9dcea2b1966ded3c/smart_open-8.0.1.tar.gz", hash = "sha256:18b1c4496003c6902be17c15f032b5c319f307c89c6ae9e6b028b508bed8b2cf", size = 61882, upload-time = "2026-07-15T13:56:10.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/bd/1c92e69a1daff70118f21e18ef3a100c114f00f08b64a1074484f12d9020/smart_open-8.0.0-py3-none-any.whl", hash = "sha256:ff4f395c9e86f23e27771dc4ba756ad4bd145f181859a782c50d64168485761b", size = 73029, upload-time = "2026-06-27T16:28:10.589Z" }, -] - -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, + { url = "https://files.pythonhosted.org/packages/c3/96/325b8c507ccecc50421fecc0345a502ee6e4a44785af3c4e6ecbadad624a/smart_open-8.0.1-py3-none-any.whl", hash = "sha256:3e97f90e92a952cb57863dfe132082c400a52eeeb27c067692fb51dbcc5b0089", size = 73504, upload-time = "2026-07-15T13:56:09.033Z" }, ] [[package]] @@ -4763,11 +4752,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.4" +version = "2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, ] [[package]] @@ -5097,27 +5086,27 @@ wheels = [ [[package]] name = "tomlkit" -version = "0.15.0" +version = "0.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] [[package]] name = "torch" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "filelock", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "fsspec", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jinja2", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "networkx", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "setuptools", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "sympy", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, { name = "triton", marker = "sys_platform == 'never'" }, - { name = "typing-extensions", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "typing-extensions" }, ] [[package]] @@ -5165,14 +5154,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.68.3" +version = "4.69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, + { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, ] [[package]] @@ -5192,7 +5181,7 @@ dependencies = [ [[package]] name = "transformers" -version = "5.13.0" +version = "5.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -5205,9 +5194,9 @@ dependencies = [ { name = "tqdm" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/e1/720ff7ff666b04279fea5bb7ac3ef8675e98f0ddbc1b8cb8bc9f3889d62e/transformers-5.13.0.tar.gz", hash = "sha256:940c1428e42a4238f9ccf0cd41e63c590701aa63c19fd2ce3d7d602222d68495", size = 9195801, upload-time = "2026-07-03T16:05:39.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/fb/2a2ba88f325e68a921d8b69ff63b477830b2e73ade9a3c8c8cab2f06d741/transformers-5.14.1.tar.gz", hash = "sha256:60d196c27781eacf8637e2b533f517582907ad6f9ae142046d6b69431a5b2173", size = 9295927, upload-time = "2026-07-16T09:41:57.773Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/3a/d99704c5effe10c6339c98cb236259161103e159bb99a78468b6729572ec/transformers-5.13.0-py3-none-any.whl", hash = "sha256:8adbc1d20bd5463cd6876b2eb7cb31971e1065788e7dc6bc12bab597a7c504b7", size = 11503730, upload-time = "2026-07-03T16:05:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", hash = "sha256:9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", size = 11625234, upload-time = "2026-07-16T09:41:54.143Z" }, ] [[package]] @@ -5226,7 +5215,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.8" +version = "0.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -5234,9 +5223,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] [[package]] @@ -5293,20 +5282,20 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.50.1" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/bb/88735238d7ead151c28d5432551170f17746c70c257aa66e8d7e64eca7a3/uvicorn-0.50.1.tar.gz", hash = "sha256:ccb3061887829fd8471cfa6fc65b2594689342ee00792e5d257d34871755b09f", size = 93722, upload-time = "2026-07-06T07:52:25.238Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/59/02c7859295b001e77b8079fc9df9f8161b7c872cbf7ea8dab70cf51d61f1/uvicorn-0.50.1-py3-none-any.whl", hash = "sha256:8139bce59602f55d497c9ed77af3117b0b5fa033e3e887193fa00a5968c38c57", size = 72843, upload-time = "2026-07-06T07:52:23.62Z" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [[package]] name = "virtualenv" -version = "21.5.1" +version = "21.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -5314,18 +5303,17 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, ] [[package]] name = "wandb" -version = "0.28.0" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "gitpython" }, { name = "packaging" }, { name = "platformdirs" }, { name = "protobuf" }, @@ -5335,17 +5323,17 @@ dependencies = [ { name = "sentry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a7/683bfbd6cbade3012bc90d3e9c4cfc72dd62566195bf4c30321946d64b77/wandb-0.28.0.tar.gz", hash = "sha256:b20e5af0fe80e2e2a466b0466a1d60cedcc578dce0f036eca04f4a0adcad95b6", size = 40558332, upload-time = "2026-06-23T00:38:50.115Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/fb/8d3f96a8b143060d6fa145462d0785981373e04694e4152555ccb5d23939/wandb-0.28.1.tar.gz", hash = "sha256:870ccb1a01238b0ac07c6fd96a0810a1f79090aba04ea29f4ee012ac8327705d", size = 40578119, upload-time = "2026-07-16T18:47:05.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/47/1723605f76c5d6446b6d0db65b83eda1599721bc8c1e65bd76cc1682b1a7/wandb-0.28.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:c3dab1205a5aca4abbad1eca08902cdba86add0edfa83d8d61b4429d0e79fa87", size = 24335272, upload-time = "2026-06-23T00:38:26.002Z" }, - { url = "https://files.pythonhosted.org/packages/81/ff/42b539bc75bc48fc86981dccde89327ba9b71504b805b9ba42cba7c26de9/wandb-0.28.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:ae255da18726ee8e731ef82cbc85035b901a28ae14cf91604c361b44b8d44ce0", size = 25557959, upload-time = "2026-06-23T00:38:28.993Z" }, - { url = "https://files.pythonhosted.org/packages/15/55/c3db03d04aeab3726066a418b2ef6a1f8119774ee510f4fbe992f52b7472/wandb-0.28.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6dbcba12ab168aa37561f2f32dcdef8713495fc25fa7d30fdc9bfb37989694dd", size = 24878557, upload-time = "2026-06-23T00:38:31.417Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5d/1385ce3c219cb5bd30d4027687e3f8d25969c7dfd09adad1cbd5080e1a72/wandb-0.28.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:325b2d0bd88be6eda5db10542499bad3710927f2569c81a84dc5eeaffc76825c", size = 26764727, upload-time = "2026-06-23T00:38:33.775Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/23b6c17a6d3d5422b007707961c4496b2f6f892624d2910c9f7742fcc202/wandb-0.28.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8954bc1c62ae43914dce2bebfd1d9957f72350f8fbb78e5cdfe2ca9b6be8a7b8", size = 25051656, upload-time = "2026-06-23T00:38:36.281Z" }, - { url = "https://files.pythonhosted.org/packages/89/67/9be00fb2db2281063af24a148636d2dd363d337317642ab5d8e93572c794/wandb-0.28.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9fec6c908554c2dad33110c1312bc3028cc2e430f0679f16b84f82c8ea801e3b", size = 27074113, upload-time = "2026-06-23T00:38:38.737Z" }, - { url = "https://files.pythonhosted.org/packages/59/b1/f7a96c09cab0c5131b1e6466659b093b401e1653cbe6bb77b462fc1c361d/wandb-0.28.0-py3-none-win32.whl", hash = "sha256:8834ef3a7c8c43b701654162783caa7ad37af48a0ff06fc35d0d65a411f76ccd", size = 24525206, upload-time = "2026-06-23T00:38:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c4/c7bed5e981679c74e9fbb22c03ff31c42e95f266199d03d8d325f4d0e6df/wandb-0.28.0-py3-none-win_amd64.whl", hash = "sha256:ac1f82292e2da4f98297b78c3a46726b3a6c5734ecb75fc39b8db2c8a4989159", size = 24525214, upload-time = "2026-06-23T00:38:44.549Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/b5ce9696c8cb955521a7941fbc443e78b2f504894c6ae1a2d0b1de6e12ae/wandb-0.28.0-py3-none-win_arm64.whl", hash = "sha256:c5b0faf1b84cf79ebabed77538c1940a4c6053e815f767a4004e877a1354bed1", size = 22378208, upload-time = "2026-06-23T00:38:47.148Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/8df50164d07623cfcefec19bbf9327d9be84b637a827cea1f0c06db005fd/wandb-0.28.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:da909a76e65c64c0d93acc485d2a19f66e336f1e3f725f1c98a070883e084943", size = 24277925, upload-time = "2026-07-16T18:46:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/6c3da7e6cb215ad363324db8dc4d83b93626f5e339822b05b1c38a6097fd/wandb-0.28.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:3da3db219c54bfd1082c00e9061c8ea894ba43e42733b5af00bb10c09d7158fe", size = 25480852, upload-time = "2026-07-16T18:46:45.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1a/d15bcfb4417fa69edcaa33db8ea012db733da1057e193b047e3f69fdd671/wandb-0.28.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ae9ae6fb29e2e2b1d097ed8b75c0c0240c778c2a8cad1d996dee870a1e401c2c", size = 24832138, upload-time = "2026-07-16T18:46:47.433Z" }, + { url = "https://files.pythonhosted.org/packages/b3/da/49924c7df2952dfd82c86c3779c339c0c3d6f6439387c03d97d0470c3658/wandb-0.28.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8cfb898b6a6c884d9c9294b02764e88bce65049f027a124d6bee53fe722469b6", size = 26486533, upload-time = "2026-07-16T18:46:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/c0/06b23518e29690784f1b3081e39c7679ca076cb0af094cb9b4bb309150f5/wandb-0.28.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cf2b1533945395e4fdbe6182b272bb0ca8a02c10b3086a395e2d57686ae3ed0d", size = 25022635, upload-time = "2026-07-16T18:46:52.376Z" }, + { url = "https://files.pythonhosted.org/packages/23/30/6de2f7995a8a6eecbd03d24c79a139a734c0168f5520cf4c7ccb43c1dbbc/wandb-0.28.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7233061080507a4b4098bed1ccb381ce6f890c60397cd4153d060285bcb267bd", size = 27008895, upload-time = "2026-07-16T18:46:55.025Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/49deab9447687625371435ca21b6da82f223f1c7d014d77386b9cb91833c/wandb-0.28.1-py3-none-win32.whl", hash = "sha256:4bc461cda3ce23a19d8df5e42981a664d95fa3231efb10fd1e85d9d4824c7d29", size = 24418398, upload-time = "2026-07-16T18:46:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/ed6616b11ea15b8ceabedcaa567286c1c9ec65fa50563230a90bfb627cc5/wandb-0.28.1-py3-none-win_amd64.whl", hash = "sha256:d98a10370162b1e970850237114c56e9c4c58f3cb701e4b8cb38f36f6749fd52", size = 24418404, upload-time = "2026-07-16T18:47:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/75b6827a6665337a715c5347c5edbd84eca660f7a0f48d8d6d24d1f66bee/wandb-0.28.1-py3-none-win_arm64.whl", hash = "sha256:4aa07f13dd3bcac2c0524c8d0f49f76e83ab5c1054fd09f3b1a436cfcde146a6", size = 22299006, upload-time = "2026-07-16T18:47:02.71Z" }, ] [[package]] @@ -5471,47 +5459,79 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, ] [[package]] @@ -5658,197 +5678,197 @@ wheels = [ [[package]] name = "xxhash" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/ed/07e560876a4458987511461187b285071f53cde49dd5b25cd8c51091522b/xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43", size = 86107, upload-time = "2026-06-27T08:17:28.798Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/2e/4b7c3ab28b7a54ac17eae7e02471c49609d6fc5900856a455feeb847a2a3/xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3", size = 34623, upload-time = "2026-06-27T08:13:16.696Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/09eea3e1bba6a59d64599cb8fba39f2a0872d06e85420eae989a4da61a9d/xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602", size = 32318, upload-time = "2026-06-27T08:13:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/688bbae31e4e2d6d6eb92acbd3837c0e44ff8c7d435e6da922844ff6efda/xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341", size = 220461, upload-time = "2026-06-27T08:13:19.311Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/71484ce0dab2fa4a475705d1ebc37a17ff02d40e5df6767b3255cc53120e/xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b", size = 241110, upload-time = "2026-06-27T08:13:20.844Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f9/1ac88f02e7df7898541490260b21f2b7f7bd2b233038a0cbd3a3b1bffdc2/xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31", size = 264779, upload-time = "2026-06-27T08:13:22.485Z" }, - { url = "https://files.pythonhosted.org/packages/25/49/7ea1f128d2fe948ed679020f97a0896cdc6c975da5cc69b53a4a9c4a5def/xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba", size = 242609, upload-time = "2026-06-27T08:13:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/a0/da/7d237278dfa1c48722c31010c84a328a317b8885429c8cb6ae4a8fa3e3db/xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6", size = 473472, upload-time = "2026-06-27T08:13:25.877Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5f/980fda82620a07d80026b4df371cbca12fca0fd94d7087c4ec5d898da76f/xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc", size = 220374, upload-time = "2026-06-27T08:13:27.366Z" }, - { url = "https://files.pythonhosted.org/packages/14/71/efa37bc3e91e1c801972bcef99eab877fcbd17ec10aca16c550ee2951107/xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624", size = 310220, upload-time = "2026-06-27T08:13:28.804Z" }, - { url = "https://files.pythonhosted.org/packages/9d/48/19e40320044dc7051e8446505f18557d5661853b87a8770ad399325bb3c8/xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908", size = 238100, upload-time = "2026-06-27T08:13:30.378Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0d/588499f4d7cd064864ada7adfb9e8785f88a988f1332ed4c1be73d249c15/xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b", size = 268937, upload-time = "2026-06-27T08:13:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/54/18/fb2ad593572a33d1b6864b33047b8ca7269273a3c56107b5fd33e0b9c8fb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62", size = 224910, upload-time = "2026-06-27T08:13:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/63/9e/b880f9ed61b73492e24bb962d76aeb63f18ccb895f0edfb52e20d45ed6f2/xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9", size = 240742, upload-time = "2026-06-27T08:13:35.237Z" }, - { url = "https://files.pythonhosted.org/packages/3f/89/fc682f93e54e486fc338b26a7d6d0d5cb0ab366269273c2608ac62b51afb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f", size = 300527, upload-time = "2026-06-27T08:13:36.997Z" }, - { url = "https://files.pythonhosted.org/packages/80/71/a4b4122afb2d17ad69e0922cfeddb5ad5c25b02f37eed3dd3819d42e5f55/xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675", size = 443195, upload-time = "2026-06-27T08:13:38.719Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e5/ed3930f5dc90f4b1bab5ac3be099e8b2e81c1262d85e4adb5f2758e30d23/xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772", size = 217252, upload-time = "2026-06-27T08:13:41.179Z" }, - { url = "https://files.pythonhosted.org/packages/44/ae/128ea5794387ca54bb4084566db20dbdfc9c21cb17b67d3fcb403927b5ba/xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54", size = 31890, upload-time = "2026-06-27T08:13:42.568Z" }, - { url = "https://files.pythonhosted.org/packages/4f/04/a6c182dc566c88e8d1a497d22cc4ffdcfcc0a9fa80325efa6cd4b9002c54/xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499", size = 32677, upload-time = "2026-06-27T08:13:43.705Z" }, - { url = "https://files.pythonhosted.org/packages/93/b5/aeda4e79f962c8d58ec60cb20a5abfe91c9f7d62e626f69f6659bc0bd0c4/xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3", size = 29155, upload-time = "2026-06-27T08:13:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/ec/1f/96f43c5c7c7c4d44721f8d2e5d74698c667a30283c4b10a7e50a56804ee3/xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143", size = 38508, upload-time = "2026-06-27T08:13:46.152Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d9/7d5d6af4876c6481f2e0acb2dda64dd5209574bf7ba1ad4f6af7a1f8d473/xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e", size = 36542, upload-time = "2026-06-27T08:13:47.497Z" }, - { url = "https://files.pythonhosted.org/packages/32/ff/66fed439d78c5a09a1491a85af29bf8923b516530116731a9ac6b14dee2b/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342", size = 31102, upload-time = "2026-06-27T08:13:48.721Z" }, - { url = "https://files.pythonhosted.org/packages/56/b8/9fae0399281095f8aca1f32b21947b3c3c75ad6021b255c5c6e4b11d3866/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b", size = 32096, upload-time = "2026-06-27T08:13:50.138Z" }, - { url = "https://files.pythonhosted.org/packages/61/a4/e53d162c74a8a2950dc063969914387b0680da4c7c20ad17744ec03a3b0a/xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c", size = 34585, upload-time = "2026-06-27T08:13:51.572Z" }, - { url = "https://files.pythonhosted.org/packages/69/f5/e12397e3f2c4917b6572e103a3277cd27cc56330e304bba61d195d7e5224/xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef", size = 34622, upload-time = "2026-06-27T08:13:52.818Z" }, - { url = "https://files.pythonhosted.org/packages/70/80/c053dc51af5c942229689a0e9cb66fdc999bbd840f645e761f5ab73cbb17/xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc", size = 32320, upload-time = "2026-06-27T08:13:54.04Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/294171b67dfe770e1293edcf2a3f7e41302cdb8aefb258585312191b3ffe/xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c", size = 220532, upload-time = "2026-06-27T08:13:55.448Z" }, - { url = "https://files.pythonhosted.org/packages/80/c3/d141bfdeca785c8c680abf867d4b52a5e64a55d90df242c3141a3e58c4b2/xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59", size = 241215, upload-time = "2026-06-27T08:13:57.047Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/aeaf35143a6f3d44db73298e861405bdd9c9dacaedfc369cb43d9fd65282/xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689", size = 264615, upload-time = "2026-06-27T08:13:58.912Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/f8ca782bb34f99693faab70a7989bcc84f62ffe93c9a4cca464a33507a4b/xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb", size = 242682, upload-time = "2026-06-27T08:14:00.483Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/ddbee4ff1542c2e88e72269a5a6bd18c3f26a80c2514e0918f5d1f3e9ec5/xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12", size = 473551, upload-time = "2026-06-27T08:14:02.17Z" }, - { url = "https://files.pythonhosted.org/packages/25/f5/a680d48dddab37ab2fd9189ca03f775e29e3627122e30790816d7eb365af/xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191", size = 220485, upload-time = "2026-06-27T08:14:03.765Z" }, - { url = "https://files.pythonhosted.org/packages/22/b1/7ac129b74981c07f1ff9c649f204465e86f83f9f29b2ebdc70d91514c365/xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd", size = 310307, upload-time = "2026-06-27T08:14:05.366Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/43e673411249dd63f6cd974523a1b32fad75cf5453e363bc8f44af215fb9/xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd", size = 238164, upload-time = "2026-06-27T08:14:07.149Z" }, - { url = "https://files.pythonhosted.org/packages/e5/95/87f8baf41f63130f3637104b7a610f82b20106332fc6e289c8dbf7955d0e/xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a", size = 269062, upload-time = "2026-06-27T08:14:08.834Z" }, - { url = "https://files.pythonhosted.org/packages/38/c9/3369b497cd1f926b930c52fd2400606f177790d887b49f9e86bddcc24562/xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b", size = 225007, upload-time = "2026-06-27T08:14:10.689Z" }, - { url = "https://files.pythonhosted.org/packages/34/c8/03dceb86a8128858ac105bd6e282d62b3db6fd421a79bd8a9f6b8cdc47a7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a", size = 240815, upload-time = "2026-06-27T08:14:12.195Z" }, - { url = "https://files.pythonhosted.org/packages/47/a5/ebd43eeb1af1dd8f0201943688b20958e99d3f6eb36481fb8c37b55ef139/xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7", size = 300632, upload-time = "2026-06-27T08:14:13.916Z" }, - { url = "https://files.pythonhosted.org/packages/df/24/c873e41a3c00dacc385c8ff08c007723f6a528922c1cea7fd9684e86dae7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d", size = 443293, upload-time = "2026-06-27T08:14:15.446Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1b/c671272fe28f70574e3c574d58465f26460154bcc68876121872afa1c14d/xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e", size = 217327, upload-time = "2026-06-27T08:14:17.28Z" }, - { url = "https://files.pythonhosted.org/packages/57/43/b45a52f795812cb769b6ac159e69b605d18b1c067749e63dcac159e90064/xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5", size = 31898, upload-time = "2026-06-27T08:14:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/a1/42/2bd70e4eec25dc5990652979d708d4d7c999793d7d5af5d0e48ab4374dc1/xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07", size = 32680, upload-time = "2026-06-27T08:14:20.277Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/2fe61edb6144183cf094035a8c5354c65a073127acf6379655ed1e705b70/xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63", size = 29157, upload-time = "2026-06-27T08:14:21.674Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b8/81d17a993b9a4750ba426ce966421681bb4b8e82a460cd346756491b8cc2/xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635", size = 34897, upload-time = "2026-06-27T08:14:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/f5a368e3273440b3ea58fbd3f0b08c19f552b25ca59f43f5732ca96d2126/xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0", size = 32630, upload-time = "2026-06-27T08:14:24.603Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ab/f424359c91c55f564fbbe4e454a126eb522471109f67376f20ad19c5e663/xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a", size = 225874, upload-time = "2026-06-27T08:14:25.992Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c2/434579ef9235123b6c9bfa89c5614e0001e988613b91557b24aa326d9faa/xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0", size = 249705, upload-time = "2026-06-27T08:14:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6c/3c0c917331ca3c71f826cedce2127f230624e2b49b992472dd5e9e72101c/xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7", size = 274716, upload-time = "2026-06-27T08:14:29.495Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f3/a8bb98d3307c67e88be9642dff52854c3de3f488f95989b60ff69c8dcc42/xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5", size = 252019, upload-time = "2026-06-27T08:14:31.247Z" }, - { url = "https://files.pythonhosted.org/packages/f7/73/fab69a2e5b6353dde643209fe9b6adf4fbd64c888e531deffc476bfb2635/xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40", size = 482024, upload-time = "2026-06-27T08:14:32.973Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/ba34099b5278097ec9c68c0b740719813553bfd11ca17e7353de6d2a41e3/xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302", size = 226655, upload-time = "2026-06-27T08:14:34.608Z" }, - { url = "https://files.pythonhosted.org/packages/76/0c/90aba4708a37fe752b324a7cbf10058eaa33e892cdd62751ff17a5137b93/xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251", size = 319583, upload-time = "2026-06-27T08:14:36.419Z" }, - { url = "https://files.pythonhosted.org/packages/38/46/42e349e2d3017b2688f4cb301742c37c438e77963e3fef711edce2fc5c65/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b", size = 246000, upload-time = "2026-06-27T08:14:38.104Z" }, - { url = "https://files.pythonhosted.org/packages/ee/15/741b947ae3c768e82018c46846f8616f6aa9b5042649f318a1a6897defe3/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af", size = 275455, upload-time = "2026-06-27T08:14:39.841Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b4/a9db84c9458fc8f53eaf0051377d1e9eecd9f330fb1225640027417a309d/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53", size = 231209, upload-time = "2026-06-27T08:14:41.543Z" }, - { url = "https://files.pythonhosted.org/packages/20/92/60a868cd34851746d0b0d95dced0f42867c7c00606f6e5dba85b70b232ce/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da", size = 250416, upload-time = "2026-06-27T08:14:43.193Z" }, - { url = "https://files.pythonhosted.org/packages/7a/6a/168ca46a4679c32aae9246caa1fddf35981d6304487e45e992b3d4530324/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d", size = 309764, upload-time = "2026-06-27T08:14:44.79Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/13646b348c07679c818791ab2d35415db5cb20f3bc77daaa255909a401b4/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0", size = 448650, upload-time = "2026-06-27T08:14:46.562Z" }, - { url = "https://files.pythonhosted.org/packages/59/9a/3d244b2acf6bbd86a363817ee09084b4684e8e11840663e19869e9e0d952/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd", size = 223572, upload-time = "2026-06-27T08:14:48.294Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c7/143410d026a6e0d86dc69037ec2a3b8db810a54e7f443b340ac17612be2e/xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a", size = 32301, upload-time = "2026-06-27T08:14:49.687Z" }, - { url = "https://files.pythonhosted.org/packages/6c/db/2240b0638161637b2f310231748a7a6a06c79fb43a3adb34c96f359762bf/xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29", size = 33221, upload-time = "2026-06-27T08:14:51.404Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d8/52038e4fa5baf4f00654a225516168d02908edfec7ca104fbefc58af394f/xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f", size = 29294, upload-time = "2026-06-27T08:14:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ef/a09907aa28bdcdf6810d5c26656b154c60c0f06bb8db8442a1192d9c227a/xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f", size = 38365, upload-time = "2026-06-27T08:14:54.166Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4d/d991ff77bc489c2231025e64e570502156d573c7bff69c917589cc307089/xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4", size = 36477, upload-time = "2026-06-27T08:14:55.427Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0e/553eab001f1e274da73da074968cdc8be8cacfb318937ab9871b8e1909cb/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120", size = 31116, upload-time = "2026-06-27T08:14:56.897Z" }, - { url = "https://files.pythonhosted.org/packages/55/d5/d0f4dbe7b4d9ce0125f16e45ec0be5e04f6a172edb4e2fa551c4f2eb5d7a/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08", size = 32112, upload-time = "2026-06-27T08:14:58.126Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2f/b332c7bede6a676343f2c9c8dea233c8c82753eaeda6f7a2c321d8c58ca3/xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301", size = 34618, upload-time = "2026-06-27T08:14:59.458Z" }, - { url = "https://files.pythonhosted.org/packages/b3/5b/2bf3c9e61c7cf8f53bce937af45e22b72bb1f224d5afb20352beba0d628d/xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4", size = 34739, upload-time = "2026-06-27T08:15:00.863Z" }, - { url = "https://files.pythonhosted.org/packages/64/b6/e88521f5736c181b89bfb7ab756f0ca658a8a1ecece7277b75e167717614/xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f", size = 32332, upload-time = "2026-06-27T08:15:02.383Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a2/fba440739fa5f86d2c28738c202e88d3dd063290c8bbb20e183c5334456a/xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f", size = 220479, upload-time = "2026-06-27T08:15:03.785Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1c/4a1639efec16416695d6c7bc6b224d3f607e0b8cbe2409fa81081a849d1c/xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e", size = 241409, upload-time = "2026-06-27T08:15:05.439Z" }, - { url = "https://files.pythonhosted.org/packages/92/d1/8ce471f8d6752384f972fd5f6363f2e8d8b867a89fbd724c6dbd91d2bb98/xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784", size = 264433, upload-time = "2026-06-27T08:15:07.027Z" }, - { url = "https://files.pythonhosted.org/packages/95/77/400a281683fd39c54e2ac497fa67bdf886baaadb8c0ba58f7e1ea1d7692e/xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d", size = 242835, upload-time = "2026-06-27T08:15:08.703Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a6/edda651cfa0ba8e921791e93468fae655b63894d89730fcbfe46704f0d0a/xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953", size = 473800, upload-time = "2026-06-27T08:15:10.503Z" }, - { url = "https://files.pythonhosted.org/packages/dd/da/50f764ec6a93d3961fce294567e41bfca0e66d168deed354a3dc90ebeba6/xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e", size = 220677, upload-time = "2026-06-27T08:15:12.622Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/9fe4ed5aac6f38629cc83b34f84748b83ad8295a578ec6a49d8bf896cafb/xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765", size = 310385, upload-time = "2026-06-27T08:15:14.384Z" }, - { url = "https://files.pythonhosted.org/packages/83/f5/1147e03c0553ed22bbae9ce47503c37ee0c5f95592aae10f339c25f61de9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b", size = 238330, upload-time = "2026-06-27T08:15:16.201Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/92daf66c1966c84da5c97a06ced1480208d3a3bd465cb0630565ec00d1b9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2", size = 268667, upload-time = "2026-06-27T08:15:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c0/080c1a92972667e183c04b03f33c877f8ec61cfa3570e61731077286648d/xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0", size = 224934, upload-time = "2026-06-27T08:15:19.972Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d5/cbc4e5b2bee10c94cba05b5bb2b8033e7ef44ae742583fdafcd9188e33ed/xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71", size = 240870, upload-time = "2026-06-27T08:15:22.04Z" }, - { url = "https://files.pythonhosted.org/packages/76/f7/09679b00e192b741b65c230440c4f7e6df3251a9ad427a518ddf262ec71a/xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6", size = 300683, upload-time = "2026-06-27T08:15:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1b/f43ec36e8c6a20c77be0bcca23f0b133ed8a0312681500d1676eebd71924/xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b", size = 443407, upload-time = "2026-06-27T08:15:25.504Z" }, - { url = "https://files.pythonhosted.org/packages/45/2e/a3e3a779c5e4789daf975e05cc1c7f11bae724a03855120029d4592c8e63/xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f", size = 217559, upload-time = "2026-06-27T08:15:27.234Z" }, - { url = "https://files.pythonhosted.org/packages/44/da/1c1e078ac290afff304a541a2a60965beb369ad65b4f30ec93ea1e0b7210/xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545", size = 32602, upload-time = "2026-06-27T08:15:28.809Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7a/d455cb83d5e3c94046234294fb5dbbe5da600d1bbdf76b9527756920cce9/xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42", size = 33393, upload-time = "2026-06-27T08:15:30.166Z" }, - { url = "https://files.pythonhosted.org/packages/89/8f/1b14471f617bc96edbb9566099a162d918a981381c398114726cc600b76c/xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d", size = 30007, upload-time = "2026-06-27T08:15:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/51ad2f9f784121c8057ef1ba36362f58d4595cbcad16322941f5b73eb53d/xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f", size = 34957, upload-time = "2026-06-27T08:15:33.292Z" }, - { url = "https://files.pythonhosted.org/packages/1b/14/175c573ae4fac48bf21a82e5b9ceec75d64c520c51ca08de3105de539438/xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410", size = 32635, upload-time = "2026-06-27T08:15:34.766Z" }, - { url = "https://files.pythonhosted.org/packages/96/08/f83efabd350a50c31c851b88891e318a6f07bdbf40a43d0f7bb6cedade7f/xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea", size = 225969, upload-time = "2026-06-27T08:15:36.35Z" }, - { url = "https://files.pythonhosted.org/packages/7c/78/2b6d12da9cf572c84d93b88ecbf9bf6539a7c5219bde128b214396b97c8b/xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37", size = 249851, upload-time = "2026-06-27T08:15:38.087Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0a/755eeb1882634983b24e6375a95ed233228dc48f0ef12655388bf3c7eeaf/xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851", size = 274842, upload-time = "2026-06-27T08:15:39.808Z" }, - { url = "https://files.pythonhosted.org/packages/77/f2/09b1231cad17c314e51664c4a004c919108ec59aba10f9a28fa061e7b8be/xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c", size = 252218, upload-time = "2026-06-27T08:15:42.105Z" }, - { url = "https://files.pythonhosted.org/packages/b2/24/de756d55547953494eb6775aea92e258035647b3ecb8547618cd549001e1/xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e", size = 482135, upload-time = "2026-06-27T08:15:44.476Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/b8147633e32f98ef2b4bb0dfca82f0f63e2b02ff179f20664af64c4216a7/xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8", size = 226776, upload-time = "2026-06-27T08:15:46.597Z" }, - { url = "https://files.pythonhosted.org/packages/29/37/ba051d8f0380d3cf845b23ba058a17d32025846463eb6bf885887fc8effe/xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540", size = 319738, upload-time = "2026-06-27T08:15:48.394Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/36e0a27dd27ffa3f7b521650cbcd52a00fb86b71343ffadb642374e8263c/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218", size = 246136, upload-time = "2026-06-27T08:15:50.981Z" }, - { url = "https://files.pythonhosted.org/packages/fe/73/2663dbf4c09386a9dcc8a94d7a14b4609ed4bad8180ced5b848e60a9b660/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc", size = 275568, upload-time = "2026-06-27T08:15:52.735Z" }, - { url = "https://files.pythonhosted.org/packages/d6/58/f3ce1bc3bb3971191f6521273ddae98d3c610bcefbbed5327c3b3627c12f/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833", size = 231314, upload-time = "2026-06-27T08:15:54.73Z" }, - { url = "https://files.pythonhosted.org/packages/4d/51/835706a36cdc00e5b638fba9b22218b3d40d23a7677c923feca8a3f55b98/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732", size = 250521, upload-time = "2026-06-27T08:15:56.853Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/b0b62caa3caee58ab9de8969f66aef1c3729886f3ff60e173fda3f2762be/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef", size = 309926, upload-time = "2026-06-27T08:15:58.704Z" }, - { url = "https://files.pythonhosted.org/packages/69/c4/60e6d18a0e131c7af622374af9deede15d3c47d8e5e7221933481b57b319/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e", size = 448812, upload-time = "2026-06-27T08:16:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/12/9f/c9627daa052be39a932d0e17c6bf6a9041d2cde3afacbded9196acf70261/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4", size = 223639, upload-time = "2026-06-27T08:16:02.784Z" }, - { url = "https://files.pythonhosted.org/packages/a9/38/92916e008a84c1f1a9aef82e4363cdc478a722ff69e59c6afbf93d3d1fda/xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795", size = 33078, upload-time = "2026-06-27T08:16:04.639Z" }, - { url = "https://files.pythonhosted.org/packages/31/7c/e413bc75121d9628bf023b2ed251411ca3a447cf00cd9aa3438ab17f6c67/xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468", size = 33953, upload-time = "2026-06-27T08:16:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/f6/eb/21a96e218375bd8b6ecd6d07cf60c8ff1a046e93cdedc3cf7bc3309edf7b/xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f", size = 30164, upload-time = "2026-06-27T08:16:08.009Z" }, +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, + { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, + { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, + { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, + { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, ] [[package]] name = "yarl" -version = "1.24.2" +version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] [[package]] From 1c742862114775b3830523c6cd55f3b6e72b68dd Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Mon, 20 Jul 2026 12:13:05 -0400 Subject: [PATCH 062/290] Update active oncall to Phlip79 this week (#5896) Signed-off-by: Philip Petrakian --- .github/oncall_schedule.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/oncall_schedule.json b/.github/oncall_schedule.json index 0d0c4e9f9a4..1b76b86583b 100644 --- a/.github/oncall_schedule.json +++ b/.github/oncall_schedule.json @@ -1,6 +1,6 @@ [ { - "user": "dimapihtar", + "user": "Phlip79", "date": "2026-07-15" }, { From f41ec54958a0e4962c45cefb108a1c60a6a93beb Mon Sep 17 00:00:00 2001 From: Lawrence McAfee <85179052+lmcafee-nvidia@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:19 -0400 Subject: [PATCH 063/290] Overlap async scheduling phases (#5549) Signed-off-by: Lawrence McAfee --- .../advanced/gpt_dynamic_inference.py | 30 +- examples/inference/utils.py | 28 +- megatron/core/inference/config.py | 3 + .../inference/contexts/dynamic_context.py | 145 +++- .../core/inference/engines/dynamic_engine.py | 12 +- .../text_generation_controller.py | 747 ++++++++++++------ megatron/training/arguments.py | 6 +- megatron/training/config/inference_config.py | 5 +- .../contexts/test_dynamic_context.py | 203 ++++- .../inference/engines/test_dynamic_engine.py | 43 + .../test_dynamic_engine_async_sched.py | 4 + .../test_async_sched_output_metrics.py | 42 + .../inference/test_inference_config.py | 10 +- .../test_mtp_cuda_graph_inference.py | 4 +- .../test_text_generation_controller.py | 590 +++++++++++--- 15 files changed, 1434 insertions(+), 438 deletions(-) diff --git a/examples/inference/advanced/gpt_dynamic_inference.py b/examples/inference/advanced/gpt_dynamic_inference.py index 1b526f401cd..83d4f4b2638 100644 --- a/examples/inference/advanced/gpt_dynamic_inference.py +++ b/examples/inference/advanced/gpt_dynamic_inference.py @@ -119,7 +119,7 @@ def _add_request(): nonlocal num_requests_added _request = requests[num_requests_added] engine.add_request(num_requests_added, _request.prompt_text, _request.sampling_params) - _request.time_start = get_curr_time() + _request.time_start = get_curr_time(do_broadcast=False) _request.state = "started" num_requests_added += 1 tbar.update(1) @@ -148,14 +148,14 @@ def _process_step_result(result): step_times["prefill"].append(step_time) # Append output tokens. - output_start = get_curr_time() + output_start = get_curr_time(do_broadcast=False) for finished_request_record in finished_request_records: finished_request = finished_request_record.merge() # Update local request object. request = requests[finished_request.request_id] - request.time_end = get_curr_time() + request.time_end = get_curr_time(do_broadcast=False) request.state = "finished" request.request_id = finished_request.request_id request.events = finished_request.events @@ -185,16 +185,16 @@ def _process_step_result(result): if not finished_request.sampling_params.skip_prompt_log_probs: request.prompt_top_n_logprobs = finished_request.prompt_top_n_logprobs num_requests_finished += 1 - output_times.append(get_curr_time() - output_start) + output_times.append(get_curr_time(do_broadcast=False) - output_start) if batch_ranges is not None: # Batch-drain mode: add all requests in a batch, drain, then next batch. for batch_idx, (batch_start, batch_end) in enumerate(batch_ranges): # Add all requests in current batch. - add_start = get_curr_time() + add_start = get_curr_time(do_broadcast=False) while num_requests_added < batch_end: _add_request() - add_times.append(get_curr_time() - add_start) + add_times.append(get_curr_time(do_broadcast=False) - add_start) # Step until all active requests finish (drain). while engine.has_unfinished_requests(): @@ -212,7 +212,7 @@ def _process_step_result(result): # Original mode: add requests per step based on arrival time or count. while True: # Add requests. - add_start = get_curr_time() + add_start = get_curr_time(do_broadcast=False) if args.incoming_requests_per_step is None: # Add requests with 'earlier' arrival time. while num_requests_added < num_requests_total: @@ -225,7 +225,7 @@ def _process_step_result(result): min(args.incoming_requests_per_step, num_requests_total - num_requests_added) ): _add_request() - add_times.append(get_curr_time() - add_start) + add_times.append(get_curr_time(do_broadcast=False) - add_start) # Step inference engine (i.e., generate a token for each active request). # Before step, we haven't done the scheduling, so we cannot know the is_decode_only @@ -241,7 +241,10 @@ def _process_step_result(result): # Suspend. if attempted_step_count % args.suspend_resume_interval == 0: - print("**** step %d/%d ... suspend." % (engine.context.step_count, attempted_step_count)) + print( + "**** step %d/%d ... suspend." + % (engine.context.step_count, attempted_step_count) + ) engine.suspend() # Resume, 0+ attempted steps later. @@ -251,7 +254,10 @@ def _process_step_result(result): % args.suspend_resume_interval == 0 ): - print("**** step %d/%d ... resume." % (engine.context.step_count, attempted_step_count)) + print( + "**** step %d/%d ... resume." + % (engine.context.step_count, attempted_step_count) + ) engine.resume() # If engine suspended, continue to next iter. @@ -469,7 +475,9 @@ def escape_str(s): # Attach peak memory metrics; the functional test only validates these # if the fields exist in the golden values. json_results.update(peak_mem_stats) - json_results["lifetime_prefill_token_count"] = engine.context.lifetime_prefill_token_count + json_results["lifetime_prefill_token_count"] = ( + engine.context.lifetime_prefill_token_count + ) json_results["async_sched_step_count"] = engine.context.async_sched_step_count json_results["async_sched_compaction_step_count"] = ( engine.context.async_sched_compaction_step_count diff --git a/examples/inference/utils.py b/examples/inference/utils.py index 104c1d4b201..532813ac1da 100644 --- a/examples/inference/utils.py +++ b/examples/inference/utils.py @@ -34,10 +34,24 @@ def get_default_sampling_params(termination_id: int = None): def get_curr_time(do_broadcast: bool = True) -> float: - """Get synchronized time across ranks.""" + """Get the current time, optionally synchronized across distributed ranks. + + Args: + do_broadcast (bool): Whether multi-rank callers require a rank-zero + timestamp broadcast. + + Returns: + float: Current time in seconds. + """ + if ( + not do_broadcast + or not torch.distributed.is_initialized() + or torch.distributed.get_world_size() == 1 + ): + return time.time_ns() / 10**9 + curr_time = torch.cuda.LongTensor([time.time_ns()]) - if torch.distributed.is_initialized() and do_broadcast: - torch.distributed.broadcast(curr_time, src=0) + torch.distributed.broadcast(curr_time, src=0) return curr_time.item() / 10**9 @@ -401,7 +415,9 @@ def dump_inference_results_to_json( lifetime_prefill_token_count (int): Total prefill tokens processed. async_sched_step_count (int): Number of async scheduling decode steps. async_sched_compaction_step_count (int): Number of async scheduling decode - steps where post-forward compaction discarded finished rows. + steps that discarded speculative rows for finished requests. This + includes identity-prefix and all-finished cases that require no GPU + gather. """ if not args.output_path: return @@ -442,9 +458,7 @@ def dump_inference_results_to_json( json_results.update(peak_mem_stats) json_results["lifetime_prefill_token_count"] = lifetime_prefill_token_count json_results["async_sched_step_count"] = async_sched_step_count - json_results["async_sched_compaction_step_count"] = ( - async_sched_compaction_step_count - ) + json_results["async_sched_compaction_step_count"] = async_sched_compaction_step_count print(f' Saving results to {args.output_path}') with open(args.output_path, "w") as fp: diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index 04af29e4a83..62729c3d29f 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -142,6 +142,9 @@ class AsyncScheduleMode(str, Enum): SERIAL = "serial" """Prepare and forward speculatively before resolving the sampled requests.""" + OVERLAP = "overlap" + """Overlap async scheduling prepare/sample and forward/resolve phases.""" + @dataclass class InferenceConfig: diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 6ff49388a50..258917d07ab 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -1284,6 +1284,7 @@ def initialize_all_tensors(self) -> None: device=torch.cuda.current_device(), max_mamba_chunks=self._max_mamba_chunks, ) + self._bookkeeping_h2d_done_event = torch.cuda.Event() # Cache of (input_ids_view, pos_ids_view) keyed by num_tokens. Instead of slicing and # unsqueezing on every new inference step (constructing new TensorImpls at 30-60 us), @@ -2169,7 +2170,9 @@ def initialize_attention_state( *, construct_graph_dimensions: Optional[InferenceBatchDimensions] = None, is_expert_parallel_dummy_cuda_graph_step: bool = False, - ) -> None: + transfer_bookkeeping_to_gpu: bool = True, + record_bookkeeping_done_event: bool = False, + ) -> Optional[torch.cuda.Event]: """Initialize attention state so that every layer can use it. Args: @@ -2177,8 +2180,17 @@ def initialize_attention_state( The graph config to use for constructing the cuda graphs. is_expert_parallel_dummy_cuda_graph_step (bool): Whether this is a dummy expert model parallel step. - Return: - None. + transfer_bookkeeping_to_gpu (bool): Whether to publish the prepared + CPU bookkeeping snapshot to GPU before returning. Legacy + callers publish immediately; async scheduling binds the GPU + views here and publishes their values later. + record_bookkeeping_done_event (bool): Whether to record an event + after the bookkeeping H2D transfer. + + Returns: + Optional[torch.cuda.Event]: Event marking bookkeeping H2D + completion, or `None` when no event was requested or no + transfer was performed. """ # Launch deferred Mamba GPU ops first (state zeroing/restore) so they # overlap with the CPU work below. These are non-blocking GPU kernels. @@ -2423,12 +2435,10 @@ def initialize_attention_state( construct_graph_dimensions is not None or is_expert_parallel_dummy_cuda_graph_step ) - # Run the H2D transfer here so callers that bypass the controller - # (e.g. unit tests that call `model.forward()` directly after - # `initialize_attention_state()`) see populated GPU bookkeeping. The - # text-generation controller still calls `transfer_bookkeeping_to_gpu` - # explicitly; that second call is a cheap idempotent re-copy. - self.transfer_bookkeeping_to_gpu() + # Preserve the existing behavior for callers that do not publish explicitly. + if transfer_bookkeeping_to_gpu: + return self.transfer_bookkeeping_to_gpu(record_done_event=record_bookkeeping_done_event) + return None def _execute_pending_mamba_ops(self) -> None: """Execute Mamba GPU operations deferred from add_request() / update_requests(). @@ -2454,20 +2464,33 @@ def _execute_pending_mamba_ops(self) -> None: self.mamba_ssm_states[:, indices] = 0.0 self._pending_mamba_zeros.clear() - def transfer_bookkeeping_to_gpu(self) -> None: + def transfer_bookkeeping_to_gpu( + self, skip_token_input_ids: bool = False, record_done_event: bool = False + ) -> Optional[torch.cuda.Event]: """Batch transfer CPU bookkeeping state to GPU staging buffers. - Called after initialize_attention_state() and before the forward pass. - The coalesced H2D from the pinned `_cpu_bookkeeping_buf` uses - ``non_blocking=False``: that buffer is re-staged in place on the next - step, so an async copy can race with host writes and corrupt GPU - bookkeeping (see the inline comment at the copy site). + Legacy steps call this from initialize_attention_state(). Async + scheduling instead delays publication until after preparation and the + GPU sample-to-input copy. Legacy transfers block because the pinned CPU + source is re-staged in place. Async scheduling requests an event-tracked + non-blocking copy and synchronizes that event before reusing the source. The bookkeeping fields are backed by one contiguous pinned CPU buffer and one contiguous GPU buffer; a single memcpy covers the whole transfer. Request-level staging slots are refreshed from the persistent CPU tensors immediately before the H2D (GPU reads them at `[:n_active]` while CPU bookkeeping keeps them at `[paused_count:total_count)`). + + Args: + skip_token_input_ids (bool): If true, leave + `gpu_view.token_to_input_ids` unchanged while copying the rest + of the bookkeeping buffer. + record_done_event (bool): Whether to record and return an event after + an asynchronous bookkeeping transfer. + + Returns: + Optional[torch.cuda.Event]: Event marking H2D completion, or `None` + when no event was requested. """ n_active = self.total_request_count - self.paused_request_count active_slice = slice(self.paused_request_count, self.total_request_count) @@ -2510,21 +2533,23 @@ def transfer_bookkeeping_to_gpu(self) -> None: 0 if self._bookkeeping_no_real_work else self.batch_dimensions.token_count ) - # Coalesced H2D: one cudaMemcpyAsync for the entire bookkeeping buffer. + # Coalesced H2D: one copy for the entire bookkeeping buffer. # Copying the whole (max_tokens + max_requests)-sized buffer including # unused slots is cheap (~71 KB total, ~3-5 us on PCIe Gen4) and saves - # 8 redundant launch overheads vs. the prior per-field copies. - # This copy MUST be blocking. `_cpu_bookkeeping_buf` is a pinned host - # buffer that is re-staged in place on the very next step (the staging - # writes above plus `initialize_attention_state()`). A non_blocking copy - # lets the host overwrite those bytes while the async H2D is still in - # flight, so the GPU reads corrupted bookkeeping (token/block indices) - # and dereferences out-of-bounds memory -> async `CUDA error: an illegal - # memory access`. The CUDA-graph warmup loop makes the race fire - # reliably. Blocking costs a per-step host<->device sync, but that is - # negligible relative to the forward pass (benchmarked: no measurable - # generation-throughput difference vs. an async double-buffered copy). - self.gpu_view._buf.copy_(self._cpu_bookkeeping_buf, non_blocking=False) + # redundant launch overheads vs. per-field copies. Async scheduling + # decode steps skip token_to_input_ids here because sampled tokens are + # already GPU-resident and copied directly into the GPU input buffer. + if skip_token_input_ids: + token_to_input_ids_offset = ( + self.token_to_input_ids.numel() * self.token_to_input_ids.element_size() + ) + else: + token_to_input_ids_offset = 0 + # Only event-tracked callers may leave the copy in flight; legacy callers + # block before the pinned CPU source can be re-staged. + self.gpu_view._buf[token_to_input_ids_offset:].copy_( + self._cpu_bookkeeping_buf[token_to_input_ids_offset:], non_blocking=record_done_event + ) # MHA metadata GPU views were already bound to state_data in # initialize_attention_state(); the H2D above populates the underlying @@ -2535,6 +2560,35 @@ def transfer_bookkeeping_to_gpu(self) -> None: self.mamba_metadata.load_from_cpu(self._pending_mamba_transfer) self._pending_mamba_transfer = None + done_event = None + if record_done_event: + done_event = self._bookkeeping_h2d_done_event + done_event.record(torch.cuda.current_stream()) + + return done_event + + def copy_async_sched_sample_to_forward(self, sampled_tokens_cuda: Tensor) -> None: + """Populate GPU input token IDs from sampled CUDA tokens for async scheduled decode. + + Async scheduling keeps sampled tokens GPU-resident for the next decode + forward. CPU bookkeeping is prepared independently and published later; + this direct GPU copy populates the live input-ID view without waiting + for the sample's CPU copy. + + Args: + sampled_tokens_cuda (Tensor): 1D CUDA tensor containing one sampled + token per active decode request. + """ + active_request_count = self.total_request_count - self.paused_request_count + + self.gpu_view.token_to_input_ids[:active_request_count].copy_( + sampled_tokens_cuda, non_blocking=True + ) + if active_request_count < self.padded_active_token_count: + self.gpu_view.token_to_input_ids[ + active_request_count : self.padded_active_token_count + ].zero_() + def reset_tensors(self) -> None: """Fill all bookkeeping tensors with sentinel values.""" @@ -3460,20 +3514,14 @@ def evict_overflow_paused_requests( return evict_request_ids - def prepare_requests(self, new_tokens: Tensor) -> None: + def prepare_requests(self) -> None: """Speculatively prepare active decode requests for the next forward pass. Async scheduling only supports decode-only steps with no pause, evict, or resume lifecycle changes. If preparing the next token would require one of those lifecycle changes, this method raises and the caller should treat async scheduling as unsupported for that workload. - - Args: - new_tokens (Tensor): Newly sampled token for each active request. """ - if new_tokens.is_cuda: - new_tokens = new_tokens.cpu() - active_request_count = self.total_request_count - self.paused_request_count if self.num_speculative_tokens != 0: raise RuntimeError("Async scheduling does not support speculative tokens.") @@ -3481,10 +3529,6 @@ def prepare_requests(self, new_tokens: Tensor) -> None: raise RuntimeError("Async scheduling only supports decode-only steps.") if self.paused_request_count != 0: raise RuntimeError("Async scheduling does not support paused requests.") - if new_tokens.numel() != active_request_count: - raise RuntimeError( - f"Expected {active_request_count} new tokens, got {new_tokens.numel()}." - ) if active_request_count == 0: self.active_token_count = 0 @@ -3518,7 +3562,6 @@ def prepare_requests(self, new_tokens: Tensor) -> None: ) % self.block_size_tokens self.active_token_count = active_request_count - self.token_to_input_ids[:active_request_count] = new_tokens self.token_to_pos_ids[:active_request_count] = self.request_kv_length_offsets[active_slice] self.token_to_request_idx[:active_request_count] = torch.arange( active_request_count, device='cpu' @@ -3531,6 +3574,28 @@ def prepare_requests(self, new_tokens: Tensor) -> None: ) self.token_to_block_idx[:active_request_count] = self.request_last_kv_block_id[active_slice] + def commit_sampled_tokens(self, sampled_tokens_cpu: Tensor) -> None: + """Commit sampled CPU token IDs to the prepared request state. + + This updates the CPU source of truth used by resolution. Async + scheduling has already copied the same samples into the live GPU input + view for the speculative forward. + + Args: + sampled_tokens_cpu (Tensor): Sampled CPU token for each active request. + """ + assert sampled_tokens_cpu.device == torch.device( + 'cpu' + ), "Sampled tokens must be on the CPU before they are committed." + + active_request_count = self.total_request_count - self.paused_request_count + if sampled_tokens_cpu.numel() != active_request_count: + raise RuntimeError( + f"Expected {active_request_count} new tokens, got {sampled_tokens_cpu.numel()}." + ) + + self.token_to_input_ids[:active_request_count] = sampled_tokens_cpu + def resolve_requests(self, active_requests_mask: Tensor) -> Tensor: """Resolve finished requests after an async scheduling forward pass. diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 80cc133c6b5..c0372127e98 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -441,7 +441,7 @@ def create_cuda_graphs(self, reset_context: bool = True): if HAVE_TQDM: tbar = tqdm(tbar, total=len(context.cuda_graph_batch_dimensions_list)) for tbar_idx, cuda_graph_batch_dimension in tbar: - input_ids, position_ids = self.controller._dynamic_step_context_init( + input_ids, position_ids, _ = self.controller._dynamic_step_context_init( construct_graph_dimensions=cuda_graph_batch_dimension ) # Progress. @@ -993,11 +993,11 @@ def get_request(self, request_id: int) -> DynamicInferenceRequest: return self.requests[request_id].record[-1] def _validate_async_sched_support_for_config(self) -> None: - """Validate config-level restrictions for serial async scheduling. + """Validate config-level restrictions for async scheduling. - Raises if the config does not support serial async scheduling. + Raises if the config does not support async scheduling. """ - if self.context.config.async_sched_mode != AsyncScheduleMode.SERIAL: + if self.context.config.async_sched_mode == AsyncScheduleMode.LEGACY: return model_config = self.controller.inference_wrapped_model.model.config @@ -1017,12 +1017,12 @@ def _validate_async_sched_support_for_config(self) -> None: raise ValueError("Async scheduling does not support routing replay.") def _validate_async_sched_support_for_request(self, request: DynamicInferenceRequest) -> None: - """Validate request-level restrictions for serial async scheduling. + """Validate request-level restrictions for async scheduling. Args: request (DynamicInferenceRequest): Request being added to the engine. """ - if self.context.config.async_sched_mode != AsyncScheduleMode.SERIAL: + if self.context.config.async_sched_mode == AsyncScheduleMode.LEGACY: return sampling_params = request.sampling_params diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index d325ee21497..d265ca5fa23 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -74,26 +74,50 @@ @dataclass -class DecodeForwardPrimer: - """Track whether a decode forward is ready to sample.""" +class AsyncScheduleLogitsState: + """Track logits submitted for the next async-scheduling sample. - is_primed: bool = False + When ``is_valid`` is true, ``ready_event`` marks when the logits are + sampleable. The event may represent either forward completion or survivor + compaction completion. + """ + + is_valid: bool = False cuda_graph_request_count: Optional[int] = None + ready_event: Optional[torch.cuda.Event] = None - def mark_primed(self, cuda_graph_request_count: Optional[int]) -> None: - """Record that a decode forward has produced logits ready for sampling. + def set_pending( + self, + cuda_graph_request_count: Optional[int], + ready_event: Optional[torch.cuda.Event] = None, + ) -> None: + """Record logits that become sampleable when their event completes. Args: cuda_graph_request_count (Optional[int]): CUDA graph request count - for the primed forward, or `None` when CUDA graphs were not used. + for the pending logits, or `None` when CUDA graphs were not used. + ready_event (Optional[torch.cuda.Event]): Event marking completion + of the forward or survivor compaction producing the logits. """ - self.is_primed = True + self.is_valid = True self.cuda_graph_request_count = cuda_graph_request_count + self.ready_event = ready_event def clear(self) -> None: - """Clear any primed-forward state.""" - self.is_primed = False + """Clear the pending logits state.""" + self.is_valid = False self.cuda_graph_request_count = None + self.ready_event = None + + +@dataclass +class _AsyncScheduleResolveResult: + """State produced by async scheduling request resolution.""" + + sampled_tokens_cpu: Tensor + active_request_ids: Tensor + finished_request_ids: Tensor + compaction_done_event: Optional[torch.cuda.Event] # pylint: disable=line-too-long @@ -197,14 +221,19 @@ def _init_dynamic_sampling_tensors(self): ) else: self._all_logits_cuda = None - self._decode_forward_primer = DecodeForwardPrimer() - # Speculative path: - # - `self._sampled_tokens_cuda` is pre-allocated by `_init_mtp_sampling_tensors`. - # - The tensor cannot be reused between the Triton kernel and the sampling graph. - # Non-speculative path: - # - `self._sampled_tokens_cuda` is rebound to the output of `sample_kernel`, - # which uses CudaGraphManager syntactic sugar to keep it as a static tensor. - self._sampled_tokens_cuda = None + self._async_sched_logits = AsyncScheduleLogitsState() + # This buffer has a stable address across legacy-prefill, async-decode, + # and MTP routing. Sampling producers must copy into it rather than rebind it. + self._sampled_tokens_cuda = torch.empty(max_requests, dtype=torch.int64, device=device) + self._async_sched_sample_values_cuda = torch.empty( + max_requests, dtype=logits_dtype, device=device + ) + self._async_sched_sampled_tokens_cpu_buffer = torch.empty( + max_requests, dtype=torch.int64, device="cpu", pin_memory=True + ) + self._async_sched_sample_gpu_ready_event = torch.cuda.Event() + self._async_sched_sample_cpu_ready_event = torch.cuda.Event() + self._async_sched_copy_stream = torch.cuda.Stream(device=device) # Sampling backend: provides the sampling kernel. if self._sampling_backend == "flashinfer": @@ -239,7 +268,6 @@ def _init_mtp_sampling_tensors(self): context = self.inference_wrapped_model.inference_context max_requests = context.max_requests device = torch.cuda.current_device() - self._sampled_tokens_cuda = torch.empty(max_requests, dtype=torch.int64, device=device) self._sampled_mtp_tokens_cuda = torch.empty( [self.num_speculative_tokens, max_requests], dtype=torch.int64, device=device ) @@ -261,69 +289,6 @@ def _init_mtp_sampling_tensors(self): [1, max_requests], dtype=torch.int64, device=device ) - def _validate_async_sched_support_for_step(self) -> None: - """Validate controller/context state for async scheduling. - - Raises if the current step does not support async scheduling. - """ - context = self.inference_wrapped_model.inference_context - if not context.config.materialize_only_last_token_logits: - raise RuntimeError("Async scheduling requires materialize_only_last_token_logits=True.") - if self.num_speculative_tokens != 0: - raise RuntimeError("Async scheduling does not support speculative tokens.") - if context.is_hybrid_model: - raise RuntimeError("Async scheduling does not support hybrid/Mamba models.") - if context.enable_prefix_caching: - raise RuntimeError("Async scheduling does not support prefix caching.") - if context.paused_request_count != 0: - raise RuntimeError("Async scheduling does not support paused requests.") - if context.chunked_prefill_request_id != -1: - raise RuntimeError("Async scheduling does not support chunked prefill.") - if self.model_config.expert_model_parallel_size > 1: - raise RuntimeError("Async scheduling does not support expert parallelism.") - if self.model_config.num_moe_experts is not None: - raise RuntimeError("Async scheduling does not support MoE models.") - if self.model_config.moe_enable_routing_replay: - raise RuntimeError("Async scheduling does not support routing replay.") - - active_request_count = context.total_request_count - context.paused_request_count - active_slice = slice(context.paused_request_count, context.total_request_count) - if active_request_count == 0: - return - if not torch.all(context.request_metadata["top_k"][active_slice] == 1): - raise RuntimeError( - "Async scheduling only supports greedy sampling " "(SamplingParams.top_k == 1)." - ) - if not torch.all(context.request_metadata["top_p"][active_slice] == 0.0): - raise RuntimeError( - "Async scheduling only supports greedy sampling " "(SamplingParams.top_p == 0.0)." - ) - if torch.any(context.request_metadata["return_log_probs"][active_slice]): - raise RuntimeError("Async scheduling does not support log probabilities.") - if torch.any(context.request_metadata["top_n_logprobs"][active_slice] > 0): - raise RuntimeError("Async scheduling does not support top-n log probabilities.") - - def _compact_async_sched_logits(self, survivor_idxs: Tensor) -> None: - """Compact cached logits from old active-row order into survivor order. - - Args: - survivor_idxs (Tensor): Active-row indices for requests that remain - active after async scheduling. - """ - if survivor_idxs.numel() == 0: - self._decode_forward_primer.clear() - return - - survivor_idxs_cuda = survivor_idxs.to(self._all_logits_cuda.device) - compacted_logits = self._all_logits_cuda[:, survivor_idxs_cuda, :].contiguous() - if self._enable_cuda_graph: - self._all_logits_cuda[:, : survivor_idxs.numel(), :].copy_(compacted_logits) - else: - self._all_logits_cuda = compacted_logits - self._decode_forward_primer.mark_primed( - self._decode_forward_primer.cuda_graph_request_count - ) - @staticmethod def tokenize_prompt(tokenizer, prompt: str, add_BOS: bool = False) -> List[int]: """Utility to tokenize the input prompts. @@ -623,17 +588,24 @@ def _dynamic_step_context_init( self, construct_graph_dimensions: Optional[InferenceBatchDimensions] = None, is_dummy_forward: bool = False, - ): + transfer_bookkeeping_to_gpu: bool = True, + record_bookkeeping_done_event: bool = False, + ) -> Tuple[Tensor, Tensor, Optional[torch.cuda.Event]]: """Initializes the inference context for dynamic batching. Args: construct_graph_dimensions (Optional[InferenceBatchDimensions]): The graph config to use for constructing the cuda graphs. is_dummy_forward (bool): Whether we are running an expert parallel dummy forward pass + transfer_bookkeeping_to_gpu (bool): Whether to publish the prepared + CPU bookkeeping snapshot to GPU before returning. + record_bookkeeping_done_event (bool): Whether to record an event + after the bookkeeping H2D transfer. - Return: - input_ids (Tensor): The active input IDs. - position_ids (Tensor): The active position IDs. + Returns: + Tuple[Tensor, Tensor, Optional[torch.cuda.Event]]: The active input + IDs, position IDs, and optional bookkeeping H2D completion + event. """ context = self.inference_wrapped_model.inference_context @@ -641,19 +613,16 @@ def _dynamic_step_context_init( unwrapped_model = unwrap_model(self.inference_wrapped_model.model) model_config = get_model_config(unwrapped_model) - # Initialize attention state (100% CPU computation). + # Initialize attention state and optionally publish CPU bookkeeping to GPU. range_push("initialize_attention_state") - context.initialize_attention_state( + bookkeeping_done_event = context.initialize_attention_state( construct_graph_dimensions=construct_graph_dimensions, is_expert_parallel_dummy_cuda_graph_step=is_dummy_forward, + transfer_bookkeeping_to_gpu=transfer_bookkeeping_to_gpu, + record_bookkeeping_done_event=record_bookkeeping_done_event, ) range_pop() - # Single batch CPU-to-GPU transfer of bookkeeping state. - range_push("transfer_bookkeeping_to_gpu") - context.transfer_bookkeeping_to_gpu() - range_pop() - set_moe_metadata_sync(unwrapped_model) # Derive the MTP padded batch size from the existing padded graph dimensions. @@ -701,11 +670,12 @@ def _dynamic_step_context_init( # If we are running a dummy forward step we want to use the token count agreed upon # by all EP ranks rather than the minimum number of tokens. if construct_graph_dimensions is not None and not is_dummy_forward: - return context.current_input_and_position_ids( + input_ids, position_ids = context.current_input_and_position_ids( num_warmup_tokens=construct_graph_dimensions.token_count ) else: - return context.current_input_and_position_ids() + input_ids, position_ids = context.current_input_and_position_ids() + return input_ids, position_ids, bookkeeping_done_event def _dynamic_step_forward_logits(self, input_ids: Tensor, position_ids: Tensor): """Forward step the model to get logits for dynamic batching. @@ -759,42 +729,6 @@ def _dynamic_step_forward_logits(self, input_ids: Tensor, position_ids: Tensor): else: self._all_logits_cuda = logits - def _run_async_sched_prepare(self, new_sample_copy: Tensor) -> Tuple[Tensor, Tensor]: - """Prepare decode requests and GPU-visible forward state for async scheduling. - - Args: - new_sample_copy (Tensor): CPU copy of sampled tokens for active requests. - - Returns: - Tuple[Tensor, Tensor]: Input token IDs and position IDs for the speculative forward. - """ - context = self.inference_wrapped_model.inference_context - context.prepare_requests(new_sample_copy) - return self._dynamic_step_context_init() - - def _run_async_sched_forward(self, input_ids: Tensor, position_ids: Tensor) -> Optional[int]: - """Run one dynamic forward pass and cache logits for async scheduling. - - Args: - input_ids (Tensor): The input token IDs. - position_ids (Tensor): The position IDs. - - Returns: - Optional[int]: CUDA graph request count for the forward pass, or - `None` when CUDA graphs were not used. - """ - context = self.inference_wrapped_model.inference_context - cuda_graph_request_count = ( - context.padded_active_request_count if context.using_cuda_graph_this_step() else None - ) - - range_push("forward_pass") - self._dynamic_step_forward_logits(input_ids, position_ids) - range_pop() - - self._decode_forward_primer.mark_primed(cuda_graph_request_count) - return cuda_graph_request_count - def _rewind_kv_cache(self) -> tuple: """Update the KV cache bookkeeping for speculative decoding. @@ -1194,7 +1128,7 @@ def _dynamic_step_sample_logits(self): if context.config.materialize_only_last_token_logits else context.gpu_view.active_request_last_token_idxs ) - self._sampled_tokens_cuda = self._sampling.sample_kernel( + sampled_tokens_cuda = self._sampling.sample_kernel( self._all_logits_cuda.squeeze(0), n, context, @@ -1202,6 +1136,9 @@ def _dynamic_step_sample_logits(self): eager=not use_graph, cache_key=("sample", n) if use_graph else None, ) + self._sampled_tokens_cuda[:active_request_count].copy_( + sampled_tokens_cuda[:active_request_count] + ) def _dynamic_step_log_probs_bookkeeping(self) -> Tuple[bool, bool]: """Perform bookkeeping necessary to compute log probs for dynamic batching. @@ -1615,7 +1552,7 @@ def dummy_forward(self): context = self.inference_wrapped_model.inference_context # attempt to use cuda-graph if possible - input_ids, position_ids = self._dynamic_step_context_init(is_dummy_forward=True) + input_ids, position_ids, _ = self._dynamic_step_context_init(is_dummy_forward=True) self._dynamic_step_forward_logits(input_ids, position_ids) # Disable MoE padding for MTP computation, unless CUDA graphs @@ -1835,6 +1772,454 @@ def _dynamic_step_context_bookkeeping(self) -> Dict[str, Tensor]: **(update_result or {}), } + # ------------------------------------------------------------------------- + # Begin async scheduling methods + # ------------------------------------------------------------------------- + + def _validate_async_sched_support_for_step(self) -> None: + """Validate controller/context state for async scheduling. + + Raises if the current step does not support async scheduling. + """ + context = self.inference_wrapped_model.inference_context + active_request_count = context.total_request_count - context.paused_request_count + if context.active_token_count == 0 and active_request_count == 0: + return + + if context.paused_request_count != 0: + raise RuntimeError("Async scheduling does not support paused requests.") + if context.chunked_prefill_request_id != -1: + raise RuntimeError("Async scheduling does not support chunked prefill.") + + def _compact_async_sched_logits(self, survivor_idxs: Tensor) -> Optional[torch.cuda.Event]: + """Compact cached logits from old active-row order into survivor order. + + Args: + survivor_idxs (Tensor): Active-row indices for requests that remain + active after async scheduling. + + Returns: + Optional[torch.cuda.Event]: Event marking compaction completion, or + `None` when no GPU compaction was needed. + """ + if survivor_idxs.numel() == 0: + self._async_sched_logits.clear() + return None + + identity_idxs = torch.arange(survivor_idxs.numel(), device=survivor_idxs.device) + if torch.equal(survivor_idxs, identity_idxs): + return None + + survivor_idxs_cuda = survivor_idxs.to(self._all_logits_cuda.device) + compacted_logits = self._all_logits_cuda[:, survivor_idxs_cuda, :].contiguous() + if self._enable_cuda_graph: + self._all_logits_cuda[:, : survivor_idxs.numel(), :].copy_(compacted_logits) + else: + self._all_logits_cuda = compacted_logits + + compaction_done_event = self._record_fresh_async_sched_event(self._all_logits_cuda) + self._async_sched_logits.set_pending( + self._async_sched_logits.cuda_graph_request_count, compaction_done_event + ) + return compaction_done_event + + def _record_fresh_async_sched_event( + self, reference_tensor: Optional[Tensor] = None + ) -> Optional[torch.cuda.Event]: + """Record a fresh event on the current CUDA stream when CUDA work is active. + + Forward and compaction events can remain in the logits state across + controller steps, so each operation owns a fresh event. Transfer events + are reused separately because they are synchronized within each step. + + Args: + reference_tensor (Optional[Tensor]): Tensor used to determine whether + CUDA work is active. + + Returns: + Optional[torch.cuda.Event]: Recorded CUDA event, or `None` when no + CUDA work is active. + """ + if reference_tensor is not None and not reference_tensor.is_cuda: + return None + if not torch.cuda.is_available(): + return None + event = torch.cuda.Event() + event.record() + return event + + @staticmethod + def _synchronize_async_sched_event(event: Optional[torch.cuda.Event]) -> None: + """Block the host until an async-scheduling CUDA event completes. + + Args: + event (Optional[torch.cuda.Event]): CUDA event to synchronize, or + `None` when no CUDA work was recorded. + """ + if event is not None: + event.synchronize() + + def _copy_async_sched_sample_to_cpu( + self, sampled_tokens_gpu: Tensor + ) -> Tuple[Tensor, Optional[torch.cuda.Event]]: + """Start copying sampled tokens to CPU and return a view plus ready event. + + Args: + sampled_tokens_gpu (Tensor): Sampled token IDs for active requests. + + Returns: + Tuple[Tensor, Optional[torch.cuda.Event]]: A transient view into + the reusable pinned CPU sample buffer and its copy-completion + event. The caller must synchronize the event and clone the view + before retaining it beyond this step. + """ + if not sampled_tokens_gpu.is_cuda: + return sampled_tokens_gpu.cpu(), None + + buffer = self._async_sched_sampled_tokens_cpu_buffer + sample_cpu = buffer[: sampled_tokens_gpu.numel()] + with torch.cuda.stream(self._async_sched_copy_stream): + self._async_sched_copy_stream.wait_event(self._async_sched_sample_gpu_ready_event) + sample_cpu.copy_(sampled_tokens_gpu, non_blocking=True) + self._async_sched_sample_cpu_ready_event.record(self._async_sched_copy_stream) + return sample_cpu, self._async_sched_sample_cpu_ready_event + + def _build_async_sched_request_state( + self, sampled_tokens_cpu: Tensor + ) -> Tuple[Tensor, Tensor, Tensor, Tensor]: + """Build request IDs and active/finished row sets after prepare. + + Args: + sampled_tokens_cpu (Tensor): Sampled CPU token IDs for active requests. + + Returns: + Tuple[Tensor, Tensor, Tensor, Tensor]: Active request IDs, finished + request IDs, active-request mask, and survivor row indices. + """ + context = self.inference_wrapped_model.inference_context + active_request_count = context.total_request_count - context.paused_request_count + active_request_slice = slice(context.paused_request_count, context.total_request_count) + active_request_ids = context.request_ids[active_request_slice].long() + + active_sequence_lengths = context.get_active_sequence_lengths() + max_sequence_lengths = context.get_max_sequence_lengths() + active_request_mask = ( + sampled_tokens_cpu != context.request_metadata["termination_id"][active_request_slice] + ).byte() & torch.less(active_sequence_lengths, max_sequence_lengths).byte() + + finished_idxs = ( + torch.nonzero(active_request_mask == 0, as_tuple=True)[0] + context.paused_request_count + ) + finished_request_ids = context.request_ids[finished_idxs].clone() + survivor_idxs = torch.nonzero(active_request_mask == 1, as_tuple=True)[0] + assert sampled_tokens_cpu.numel() == active_request_count + + return active_request_ids, finished_request_ids, active_request_mask, survivor_idxs + + def _run_async_sched_sample(self) -> Tensor: + """Sample active requests and record when their GPU tokens are ready. + + Returns: + Tensor: GPU token samples for the active requests. + """ + context = self.inference_wrapped_model.inference_context + active_request_count = context.total_request_count - context.paused_request_count + + # Sample. + range_push("sampling") + sampled_tokens_gpu = self._sampled_tokens_cuda[:active_request_count] + torch.max( + self._all_logits_cuda.squeeze(0)[:active_request_count], + dim=-1, + out=(self._async_sched_sample_values_cuda[:active_request_count], sampled_tokens_gpu), + ) + if sampled_tokens_gpu.is_cuda: + current_stream = torch.cuda.current_stream(sampled_tokens_gpu.device) + self._async_sched_sample_gpu_ready_event.record(current_stream) + range_pop() + + # Return the sampling result. + return sampled_tokens_gpu + + def _run_async_sched_prepare(self) -> Tuple[Tensor, Tensor]: + """Prepare decode requests and return live GPU forward-input views. + + The returned views have their final shape and stable backing storage, + but their contents are populated later. Sampling updates the input-ID + view, and deferred bookkeeping publication updates the position-ID view. + + Returns: + Tuple[Tensor, Tensor]: Live GPU input-ID and position-ID views for + the speculative forward. + """ + context = self.inference_wrapped_model.inference_context + context.prepare_requests() + input_ids, position_ids, _ = self._dynamic_step_context_init( + transfer_bookkeeping_to_gpu=False + ) + return input_ids, position_ids + + def _run_async_sched_publish_bookkeeping(self) -> Optional[torch.cuda.Event]: + """Publish prepared bookkeeping without overwriting GPU input token IDs. + + Returns: + Optional[torch.cuda.Event]: Event marking bookkeeping H2D completion. + """ + context = self.inference_wrapped_model.inference_context + return context.transfer_bookkeeping_to_gpu( + skip_token_input_ids=True, record_done_event=True + ) + + def _run_async_sched_forward( + self, input_ids_gpu_view: Tensor, position_ids_gpu_view: Tensor + ) -> Optional[torch.cuda.Event]: + """Run one dynamic forward pass and cache logits for async scheduling. + + Args: + input_ids_gpu_view (Tensor): Live GPU view of the input token IDs. + position_ids_gpu_view (Tensor): Live GPU view of the position IDs. + + Returns: + Optional[torch.cuda.Event]: Event marking forward completion, or + `None` when no CUDA work was recorded. + """ + context = self.inference_wrapped_model.inference_context + cuda_graph_request_count = ( + context.padded_active_request_count if context.using_cuda_graph_this_step() else None + ) + + # Forward. + range_push("forward_pass") + self._dynamic_step_forward_logits(input_ids_gpu_view, position_ids_gpu_view) + range_pop() + + # Record forward completion. + forward_done_event = self._record_fresh_async_sched_event(self._all_logits_cuda) + + # Record the logits that this forward will produce. + self._async_sched_logits.set_pending(cuda_graph_request_count, forward_done_event) + + # Return the forward-done event. + return forward_done_event + + def _run_async_sched_forward_primer(self) -> Tuple[bool, Optional[torch.cuda.Event]]: + """Launch the initial forward when no valid logits state exists. + + Returns: + Tuple[bool, Optional[torch.cuda.Event]]: Whether this call launched + the forward primer and its bookkeeping H2D completion event. + """ + if self._async_sched_logits.is_valid: + return False, None + + # Initialize, forward, and record the pending logits state. + with torch.inference_mode(): + input_ids_gpu_view, position_ids_gpu_view, bookkeeping_done_event = ( + self._dynamic_step_context_init(record_bookkeeping_done_event=True) + ) + self._run_async_sched_forward(input_ids_gpu_view, position_ids_gpu_view) + + return True, bookkeeping_done_event + + def _run_async_sched_resolve( + self, + sampled_tokens_cpu_view: Tensor, + forward_done_event: Optional[torch.cuda.Event], + overlap: bool, + ) -> _AsyncScheduleResolveResult: + """Resolve request state and compact speculative forward logits. + + Args: + sampled_tokens_cpu_view (Tensor): Transient view of sampled tokens + in the reusable pinned CPU buffer. + forward_done_event (Optional[torch.cuda.Event]): Event marking + speculative forward completion. + overlap (bool): Whether the speculative forward may still be running. + + Returns: + _AsyncScheduleResolveResult: Sampled tokens, resolved request row + sets, and any logits-compaction completion event. + """ + context = self.inference_wrapped_model.inference_context + + # Clone the transient D2H view before the next step can reuse its buffer. + range_push("active_request_mask") + sampled_tokens_cpu = sampled_tokens_cpu_view.clone() + context.commit_sampled_tokens(sampled_tokens_cpu) + (active_request_ids, finished_request_ids, active_request_mask, survivor_idxs) = ( + self._build_async_sched_request_state(sampled_tokens_cpu) + ) + range_pop() + + # Finish the speculative forward before releasing finished-request resources. + if overlap and survivor_idxs.numel() < active_request_ids.numel(): + self._synchronize_async_sched_event(forward_done_event) + + # Resolve CPU request lifecycle state. + range_push("resolve_requests") + resolved_finished_request_ids = context.resolve_requests(active_request_mask) + range_pop() + + assert torch.equal(finished_request_ids, resolved_finished_request_ids) + + # Compact only when survivor rows moved. + compaction_done_event = self._compact_async_sched_logits(survivor_idxs) + + # Return the resolution result. + return _AsyncScheduleResolveResult( + sampled_tokens_cpu=sampled_tokens_cpu, + active_request_ids=active_request_ids, + finished_request_ids=finished_request_ids, + compaction_done_event=compaction_done_event, + ) + + async def _run_async_sched_step(self, *, overlap: bool) -> Optional[Dict]: + """Run one decode-only step using the async scheduling path. + + The first decode step launches and completes a forward primer so logits + exist. Steady-state overlap follows this schedule:: + + CPU: prepare request state N+1 + compute stream: forward N -> sample N -> copy input N+1 + -> publish metadata N+1 -> forward N+1 + copy stream: wait for sample/input copy -> copy sample N to CPU + CPU: wait for required copies -> resolve N + while forward N+1 continues + + Serial mode uses the same operation order but host-synchronizes at each + boundary. Input and position tensors are live GPU views populated by + stream-ordered copies before forward execution. CPU resolution cannot + mutate bookkeeping until its H2D completes, and finished-request + resources cannot be released until the forward using them completes. + + Args: + overlap (bool): Whether to submit the next forward before waiting + for current-step GPU work. + + Returns: + Optional[Dict]: Step result for sampled and finished requests, or + `None` when no requests are active. + """ + context = self.inference_wrapped_model.inference_context + + # Validate async scheduling support. + self._validate_async_sched_support_for_step() + + # Clear pending logits and stop when there is no active work. + active_request_count = context.total_request_count - context.paused_request_count + if context.active_token_count == 0 and active_request_count == 0: + self._async_sched_logits.clear() + return None + + # ------------------------------------------------------------------------- + # Primer + # ------------------------------------------------------------------------- + # Launch the forward primer if no existing logits state can be reused. + primer_launched, primer_bookkeeping_done_event = self._run_async_sched_forward_primer() + + with torch.inference_mode(): + current_logits_ready_event = self._async_sched_logits.ready_event + cuda_graph_request_count = self._async_sched_logits.cuda_graph_request_count + + # Serial mode waits for logits; overlap only waits for a new primer's H2D source read. + if not overlap: + self._synchronize_async_sched_event(current_logits_ready_event) + elif primer_launched: + self._synchronize_async_sched_event(primer_bookkeeping_done_event) + + # ------------------------------------------------------------------------- + # Prepare + # ------------------------------------------------------------------------- + # Prepare CPU state and live GPU views without publishing bookkeeping yet. + range_push("prepare_requests") + input_ids_gpu_view, position_ids_gpu_view = self._run_async_sched_prepare() + range_pop() + + # ------------------------------------------------------------------------- + # Sample + # ------------------------------------------------------------------------- + # Enqueue sampling behind the current logits-producing work. + sampled_tokens_gpu = self._run_async_sched_sample() + + # Populate the next forward's input-ID view directly from GPU samples. + context.copy_async_sched_sample_to_forward(sampled_tokens_gpu) + + # Start D2H after sampling; it may overlap the GPU input-ID copy. + sampled_tokens_cpu_view, sample_cpu_ready_event = self._copy_async_sched_sample_to_cpu( + sampled_tokens_gpu + ) + + # Serial mode needs the CPU sample before proceeding. + if not overlap: + self._synchronize_async_sched_event(sample_cpu_ready_event) + + # ------------------------------------------------------------------------- + # Forward + # ------------------------------------------------------------------------- + # Publish positions and metadata without overwriting GPU-resident input IDs. + range_push("async_sched_transfer_bookkeeping_to_gpu") + bookkeeping_done_event = self._run_async_sched_publish_bookkeeping() + range_pop() + + # Serial mode completes publication before submitting the forward. + if not overlap: + self._synchronize_async_sched_event(bookkeeping_done_event) + + # The compute stream orders both input updates before forward N+1. + range_push("async_sched_forward_pass") + forward_done_event = self._run_async_sched_forward( + input_ids_gpu_view, position_ids_gpu_view + ) + range_pop() + + # Serial mode completes forward N+1 before resolving N. + if not overlap: + self._synchronize_async_sched_event(forward_done_event) + + # ------------------------------------------------------------------------- + # Resolve + # ------------------------------------------------------------------------- + # Resolution reads the CPU sample and mutates the H2D source buffer. + if overlap: + self._synchronize_async_sched_event(sample_cpu_ready_event) + self._synchronize_async_sched_event(bookkeeping_done_event) + + # Resolve N while forward N+1 continues unless finished resources are needed. + resolve_result = self._run_async_sched_resolve( + sampled_tokens_cpu_view, forward_done_event, overlap + ) + + # Serial mode completes any survivor compaction before returning. + if not overlap: + self._synchronize_async_sched_event(resolve_result.compaction_done_event) + + # Count async steps and steps that logically discarded speculative rows. + context.async_sched_step_count += 1 + if resolve_result.finished_request_ids.numel() > 0: + context.async_sched_compaction_step_count += 1 + + result = { + "active_request_ids": resolve_result.active_request_ids, + "finished_request_ids": resolve_result.finished_request_ids, + "sample": resolve_result.sampled_tokens_cpu, + "finished_routing_block_ids": {}, + "newly_paused_request_ids": None, + "evict_request_ids": None, + "accepted_tokens": None, + "log_probs": None, + "top_n_logprobs": None, + "cuda_graph_request_count": cuda_graph_request_count, + } + + # Yield only after resolution is complete and forward N+1 is already submitted. + await asyncio.sleep(0) + + return result + + # ------------------------------------------------------------------------- + # End async scheduling methods + # ------------------------------------------------------------------------- + async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Optional[Dict]: """Forward step the model and update the inference context. @@ -1851,7 +2236,7 @@ async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Op cuda_graph_request_count (Optional[int]): Size of cuda graph used for this step. """ context = self.inference_wrapped_model.inference_context - self._decode_forward_primer.clear() + self._async_sched_logits.clear() active_request_count = context.total_request_count - context.paused_request_count # No tokens and no active requests? @@ -1859,7 +2244,7 @@ async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Op return None with torch.inference_mode(): - input_ids, position_ids = self._dynamic_step_context_init() + input_ids, position_ids, _ = self._dynamic_step_context_init() cuda_graph_request_count = ( context.padded_active_request_count @@ -1998,93 +2383,6 @@ async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Op ret.update(request_bookkeeping) return ret - async def _run_async_sched_serial_step(self) -> Optional[Dict]: - """Run one decode-only step using serial async scheduling. - - Returns: - Optional[Dict]: Step result for sampled and finished requests, or - `None` when no requests are active. - """ - context = self.inference_wrapped_model.inference_context - active_request_count = context.total_request_count - context.paused_request_count - - if context.active_token_count == 0 and active_request_count == 0: - self._decode_forward_primer.clear() - return None - - self._validate_async_sched_support_for_step() - - with torch.inference_mode(): - if not self._decode_forward_primer.is_primed: - input_ids, position_ids = self._dynamic_step_context_init() - self._run_async_sched_forward(input_ids, position_ids) - - await asyncio.sleep(0) - - with torch.inference_mode(): - active_request_count = context.total_request_count - context.paused_request_count - active_request_slice = slice(context.paused_request_count, context.total_request_count) - active_request_ids = context.request_ids[active_request_slice].long() - - cached_cuda_graph_request_count = self._decode_forward_primer.cuda_graph_request_count - - range_push("sampling") - sampled_tokens_cuda = torch.argmax( - self._all_logits_cuda.squeeze(0)[:active_request_count].float(), dim=-1 - ) - sampled_tokens_cpu = sampled_tokens_cuda.cpu() - range_pop() - - range_push("active_request_mask") - active_sequence_lengths = context.get_active_sequence_lengths() - active_sequence_lengths += 1 - max_sequence_lengths = context.get_max_sequence_lengths() - active_request_mask = ( - sampled_tokens_cpu - != context.request_metadata["termination_id"][active_request_slice] - ).byte() & torch.less(active_sequence_lengths, max_sequence_lengths).byte() - - finished_idxs = ( - torch.nonzero(active_request_mask == 0, as_tuple=True)[0] - + context.paused_request_count - ) - finished_request_ids = context.request_ids[finished_idxs].clone() - survivor_idxs = torch.nonzero(active_request_mask == 1, as_tuple=True)[0] - new_sample_copy = sampled_tokens_cpu.clone() - range_pop() - - range_push("prepare_requests") - input_ids, position_ids = self._run_async_sched_prepare(new_sample_copy) - range_pop() - - range_push("async_sched_forward_pass") - self._run_async_sched_forward(input_ids, position_ids) - range_pop() - - range_push("resolve_requests") - resolved_finished_request_ids = context.resolve_requests(active_request_mask) - range_pop() - - assert torch.equal(finished_request_ids, resolved_finished_request_ids) - self._compact_async_sched_logits(survivor_idxs) - - context.async_sched_step_count += 1 - if survivor_idxs.numel() < active_request_count: - context.async_sched_compaction_step_count += 1 - - return { - "active_request_ids": active_request_ids, - "finished_request_ids": finished_request_ids, - "sample": sampled_tokens_cpu, - "finished_routing_block_ids": {}, - "newly_paused_request_ids": None, - "evict_request_ids": None, - "accepted_tokens": None, - "log_probs": None, - "top_n_logprobs": None, - "cuda_graph_request_count": cached_cuda_graph_request_count, - } - async def async_generate_output_tokens_dynamic_batch( self, skip_bookkeeping: Optional[bool] = False ) -> Optional[Dict]: @@ -2104,8 +2402,11 @@ async def async_generate_output_tokens_dynamic_batch( if mode == AsyncScheduleMode.LEGACY or context.num_prefill_requests != 0: return await self._run_legacy_step(skip_bookkeeping) if mode == AsyncScheduleMode.SERIAL: - assert not skip_bookkeeping, "Serial async scheduling requires request bookkeeping." - return await self._run_async_sched_serial_step() + assert not skip_bookkeeping, "Async scheduling requires request bookkeeping." + return await self._run_async_sched_step(overlap=False) + if mode == AsyncScheduleMode.OVERLAP: + assert not skip_bookkeeping, "Async scheduling requires request bookkeeping." + return await self._run_async_sched_step(overlap=True) raise AssertionError(f"Unexpected async scheduling mode: {mode}") @torch.inference_mode() diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 8174fbd5eb3..3929b69d606 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2000,11 +2000,13 @@ def _add_inference_args(parser): 'is requested but the package is not installed.') group.add_argument('--inference-dynamic-batching-async-sched-mode', type=str, default='legacy', - choices=['legacy', 'serial'], + choices=['legacy', 'serial', 'overlap'], help='Async scheduling mode for dynamic batching. ' '"legacy" (default) preserves the existing resolve-before-prepare ' 'path. "serial" speculatively prepares and forwards decode-only ' - 'steps before resolving finished requests.') + 'steps before resolving finished requests. "overlap" uses the same ' + 'async scheduling path while overlapping prepare/sample and ' + 'forward/resolve phases.') group.add_argument('--inference-dynamic-batching-logprobs-mode', type=str, default='raw_logprobs', choices=['raw_logprobs', 'processed_logprobs'], diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index 96431e23bd5..5144ded2b34 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -136,10 +136,11 @@ class InferenceSetupConfig: """Which sampling kernels to use during inference. Falls back to "torch" with a warning if "flashinfer" is requested but the package is not installed.""" - inference_dynamic_batching_async_sched_mode: Literal["legacy", "serial"] = "legacy" + inference_dynamic_batching_async_sched_mode: Literal["legacy", "serial", "overlap"] = "legacy" """Async scheduling mode for dynamic batching. "legacy" (default) preserves the existing resolve-before-prepare path. "serial" speculatively prepares and forwards decode-only - steps before resolving finished requests.""" + steps before resolving finished requests. "overlap" uses the same async scheduling path while + overlapping prepare/sample and forward/resolve phases.""" inference_dynamic_batching_logprobs_mode: Literal["raw_logprobs", "processed_logprobs"] = ( "raw_logprobs" diff --git a/tests/unit_tests/inference/contexts/test_dynamic_context.py b/tests/unit_tests/inference/contexts/test_dynamic_context.py index 0008ba043b7..8487c779814 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_context.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_context.py @@ -298,6 +298,131 @@ def test_current_input_and_position_ids_view_cache(self): assert torch.equal(refreshed_input_ids.squeeze(0), new_input_ids) assert torch.equal(refreshed_pos_ids.squeeze(0), new_pos_ids) + @pytest.mark.internal + @rounder_override(64) + @pytest.mark.parametrize( + "transfer_bookkeeping,record_done_event,expected_event", + [(False, False, None), (True, False, None), (True, True, "bookkeeping")], + ) + def test_initialize_attention_state_bookkeeping_transfer_event( + self, transfer_bookkeeping, record_done_event, expected_event + ): + dynamic_context = self._get_dynamic_context( + params_dtype=torch.float32, + num_layers=2, + kv_channels=64, + num_attention_heads=8, + max_sequence_length=128, + buffer_size_gb=0.1, + block_size_tokens=128, + max_tokens=None, + ) + dynamic_context.transfer_bookkeeping_to_gpu = mock.Mock( + side_effect=lambda *, record_done_event=False: ( + "bookkeeping" if record_done_event else None + ) + ) + + done_event = dynamic_context.initialize_attention_state( + transfer_bookkeeping_to_gpu=transfer_bookkeeping, + record_bookkeeping_done_event=record_done_event, + ) + + if transfer_bookkeeping: + dynamic_context.transfer_bookkeeping_to_gpu.assert_called_once_with( + record_done_event=record_done_event + ) + else: + dynamic_context.transfer_bookkeeping_to_gpu.assert_not_called() + assert done_event == expected_event + + @pytest.mark.internal + @rounder_override(64) + def test_transfer_bookkeeping_to_gpu_can_skip_input_token_ids(self): + dynamic_context = self._get_dynamic_context( + params_dtype=torch.float32, + num_layers=2, + kv_channels=64, + num_attention_heads=8, + max_sequence_length=128, + buffer_size_gb=0.1, + block_size_tokens=128, + max_tokens=None, + ) + + num_tokens = 4 + dynamic_context.total_request_count = 2 + dynamic_context.paused_request_count = 0 + dynamic_context.padded_active_request_count = 2 + dynamic_context.token_to_input_ids[:num_tokens] = torch.tensor( + [11, 12, 13, 14], dtype=torch.int64 + ) + dynamic_context.token_to_pos_ids[:num_tokens] = torch.tensor( + [21, 22, 23, 24], dtype=torch.int64 + ) + existing_gpu_tokens = torch.tensor( + [91, 92, 93, 94], + dtype=torch.int64, + device=dynamic_context.gpu_view.token_to_input_ids.device, + ) + dynamic_context.gpu_view.token_to_input_ids[:num_tokens] = existing_gpu_tokens + + done_event = dynamic_context.transfer_bookkeeping_to_gpu( + skip_token_input_ids=True, record_done_event=True + ) + done_event.synchronize() + + assert torch.equal( + dynamic_context.gpu_view.token_to_input_ids[:num_tokens], existing_gpu_tokens + ) + assert torch.equal( + dynamic_context.gpu_view.token_to_pos_ids[:num_tokens].cpu(), + torch.tensor([21, 22, 23, 24], dtype=torch.int64), + ) + + dynamic_context.token_to_input_ids[:num_tokens] = torch.tensor( + [31, 32, 33, 34], dtype=torch.int64 + ) + dynamic_context.transfer_bookkeeping_to_gpu() + + assert torch.equal( + dynamic_context.gpu_view.token_to_input_ids[:num_tokens].cpu(), + torch.tensor([31, 32, 33, 34], dtype=torch.int64), + ) + + @pytest.mark.internal + @rounder_override(8) + def test_copy_async_sched_sample_to_forward_populates_active_and_clears_padding(self): + ctx = self._get_dynamic_context( + params_dtype=torch.float32, + num_layers=2, + kv_channels=8, + num_attention_heads=2, + max_sequence_length=32, + buffer_size_gb=0.01, + block_size_tokens=4, + max_tokens=32, + max_requests=8, + ) + + ctx.total_request_count = 3 + ctx.paused_request_count = 0 + ctx.num_prefill_requests = 0 + ctx.active_token_count = 3 + ctx.padded_active_token_count = 8 + device = ctx.gpu_view.token_to_input_ids.device + ctx.gpu_view.token_to_input_ids[:8] = torch.full( + (8,), 777, dtype=torch.int64, device=device + ) + sampled_tokens_cuda = torch.tensor([90, 91, 92], dtype=torch.int64, device=device) + + ctx.copy_async_sched_sample_to_forward(sampled_tokens_cuda) + + assert torch.equal(ctx.gpu_view.token_to_input_ids[:3], sampled_tokens_cuda) + assert torch.equal( + ctx.gpu_view.token_to_input_ids[3:8].cpu(), torch.zeros(5, dtype=torch.int64) + ) + @pytest.mark.internal @rounder_override(64) @pytest.mark.parametrize("is_hybrid_model", [False, True]) @@ -911,64 +1036,90 @@ def _setup_async_sched_decode_rows( @pytest.mark.internal @rounder_override(8) @pytest.mark.parametrize( - "new_tokens, kv_offsets, last_offsets, expected_kv_offsets, expected_last_offsets", + "active_request_count, kv_offsets, last_offsets, expected_kv_offsets, expected_last_offsets", [ - ([], [], [], [], []), - ([90, 91], [3, 5], [1, 2], [4, 6], [2, 3]), - ([90, 91], [3, 5], [3, 1], [4, 6], [0, 2]), + (0, [], [], [], []), + (2, [3, 5], [1, 2], [4, 6], [2, 3]), + (2, [3, 5], [3, 1], [4, 6], [0, 2]), ], ) def test_async_sched_prepare_requests_success( - self, new_tokens, kv_offsets, last_offsets, expected_kv_offsets, expected_last_offsets + self, + active_request_count, + kv_offsets, + last_offsets, + expected_kv_offsets, + expected_last_offsets, ): """Async scheduling prepare advances active decode rows without lifecycle changes.""" ctx = self._get_async_sched_context() self._setup_async_sched_decode_rows( ctx, - active_request_count=len(new_tokens), + active_request_count=active_request_count, kv_offsets=kv_offsets, last_block_offsets=last_offsets, ) - tokens = torch.tensor(new_tokens, dtype=torch.int64) - if new_tokens and torch.cuda.is_available(): - tokens = tokens.cuda() + original_tokens = ctx.token_to_input_ids[:active_request_count].clone() - ctx.prepare_requests(tokens) + ctx.prepare_requests() - assert ctx.active_token_count == len(new_tokens) + assert ctx.active_token_count == active_request_count assert torch.equal( - ctx.request_kv_length_offsets[: len(new_tokens)], + ctx.request_kv_length_offsets[:active_request_count], torch.tensor(expected_kv_offsets, dtype=torch.int32), ) assert torch.equal( - ctx.request_last_kv_block_offset[: len(new_tokens)], + ctx.request_last_kv_block_offset[:active_request_count], torch.tensor(expected_last_offsets, dtype=torch.int32), ) + assert torch.equal(ctx.token_to_input_ids[:active_request_count], original_tokens) assert torch.equal( - ctx.token_to_input_ids[: len(new_tokens)], torch.tensor(new_tokens, dtype=torch.long) - ) - assert torch.equal( - ctx.token_to_pos_ids[: len(new_tokens)], + ctx.token_to_pos_ids[:active_request_count], torch.tensor(expected_kv_offsets, dtype=torch.long), ) if last_offsets and last_offsets[0] == ctx.block_size_tokens - 1: assert ctx.request_kv_block_counts[0] == 2 assert ctx.token_to_block_idx[0] == ctx.request_last_kv_block_id[0] + @pytest.mark.internal + @rounder_override(8) + def test_async_sched_commit_sampled_tokens(self): + """Async scheduling commits sampled CPU tokens after prepare.""" + ctx = self._get_async_sched_context() + self._setup_async_sched_decode_rows(ctx, active_request_count=2, kv_offsets=[3, 5]) + original_tokens = ctx.token_to_input_ids[:2].clone() + + ctx.prepare_requests() + + assert torch.equal(ctx.token_to_input_ids[:2], original_tokens) + assert torch.equal( + ctx.request_kv_length_offsets[:2], torch.tensor([4, 6], dtype=torch.int32) + ) + + sampled_tokens_cpu = torch.tensor([90, 91], dtype=torch.int64) + if torch.cuda.is_available(): + with pytest.raises(AssertionError, match="must be on the CPU"): + ctx.commit_sampled_tokens(sampled_tokens_cpu.cuda()) + ctx.commit_sampled_tokens(sampled_tokens_cpu) + + assert torch.equal(ctx.token_to_input_ids[:2], sampled_tokens_cpu) + + with pytest.raises(RuntimeError, match="Expected 2 new tokens"): + ctx.commit_sampled_tokens(torch.tensor([90], dtype=torch.int64)) + @pytest.mark.internal @rounder_override(8) @pytest.mark.parametrize( - "setup, new_tokens, expected_message", + "setup, expected_message", [ - (lambda ctx: setattr(ctx, "num_speculative_tokens", 1), [90, 91], "speculative"), - (lambda ctx: setattr(ctx, "num_prefill_requests", 1), [90, 91], "decode-only"), - (lambda ctx: setattr(ctx, "paused_request_count", 1), [90, 91], "paused"), - (lambda ctx: None, [90], "Expected 2 new tokens"), - (lambda ctx: None, [90, 91], "pause requests"), - (lambda ctx: None, [90, 91], "evict requests"), + (lambda ctx: setattr(ctx, "num_speculative_tokens", 1), "speculative"), + (lambda ctx: setattr(ctx, "num_prefill_requests", 1), "decode-only"), + (lambda ctx: setattr(ctx, "paused_request_count", 1), "paused"), + (lambda ctx: None, "pause requests"), + (lambda ctx: None, "evict requests"), ], ) - def test_async_sched_prepare_requests_errors(self, setup, new_tokens, expected_message): + def test_async_sched_prepare_requests_errors(self, setup, expected_message): """Async scheduling prepare raises instead of performing lifecycle operations.""" ctx = self._get_async_sched_context() self._setup_async_sched_decode_rows( @@ -983,7 +1134,7 @@ def test_async_sched_prepare_requests_errors(self, setup, new_tokens, expected_m setup(ctx) with pytest.raises(RuntimeError, match=expected_message): - ctx.prepare_requests(torch.tensor(new_tokens, dtype=torch.int64)) + ctx.prepare_requests() @pytest.mark.internal @rounder_override(8) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index c05d61cbc78..2513bd78b6c 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -18,6 +18,7 @@ from megatron.core import parallel_state from megatron.core.inference.config import ( + AsyncScheduleMode, InferenceConfig, KVCacheManagementMode, MambaInferenceStateConfig, @@ -148,6 +149,7 @@ class DynamicEngineTestConfig: num_speculative_tokens: int = 0 position_embedding_type: str = "learned_absolute" sampling_backend: str = 'torch' + async_sched_mode: AsyncScheduleMode = AsyncScheduleMode.LEGACY # Sliding-window attention config. When `window_size` is None, SWA is # disabled and all layers do full causal attention. When set to a # `(left, right)` tuple, layers selected by `window_attn_skip_freq` use a @@ -307,6 +309,7 @@ def _build_inference_context( track_generated_token_events=test_config.track_generated_token_events, num_speculative_tokens=test_config.num_speculative_tokens, sampling_backend=test_config.sampling_backend, + async_sched_mode=test_config.async_sched_mode, ), ) @@ -1088,6 +1091,46 @@ async def test_run_engine(self): engine_task.cancel() + @pytest.mark.internal + @pytest.mark.asyncio + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + async def test_async_sched_run_engine_accepts_request_during_decode(self): + """Verify async decode yields so a new request can enter a running engine.""" + with torch.inference_mode(): + test_config = DynamicEngineTestConfig( + num_requests=2, + min_prompt_length=4, + max_prompt_length=4, + num_tokens_to_generate=16, + async_sched_mode=AsyncScheduleMode.OVERLAP, + ) + env = self._build_test_env(test_config) + long_request, short_request = env.requests + for request in env.requests: + request.sampling_params.top_k = 1 + request.sampling_params.top_p = 0.0 + request.sampling_params.termination_id = -1 + short_request.sampling_params.num_tokens_to_generate = 2 + + engine_task = asyncio.create_task(env.engine.run_engine()) + try: + long_request_future = env.engine._add_request(long_request) + + while len(long_request.generated_tokens) < 2: + await asyncio.sleep(0) + + generated_count_at_submission = len(long_request.generated_tokens) + short_request_future = env.engine._add_request(short_request) + await asyncio.gather(long_request_future, short_request_future) + + assert generated_count_at_submission < 16 + assert len(short_request.generated_tokens) == 2 + finally: + engine_task.cancel() + await engine_task + @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py b/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py index f1ad8a84517..abab280497f 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py @@ -42,7 +42,9 @@ def _make_engine(async_sched_mode=AsyncScheduleMode.SERIAL, **overrides): [ ({"async_sched_mode": AsyncScheduleMode.LEGACY, "num_speculative_tokens": 1}, False), ({}, False), + ({"async_sched_mode": AsyncScheduleMode.OVERLAP}, False), ({"num_speculative_tokens": 1}, True), + ({"async_sched_mode": AsyncScheduleMode.OVERLAP, "num_speculative_tokens": 1}, True), ({"context_is_hybrid_model": True}, True), ({"context_enable_prefix_caching": True}, True), ({"materialize_only_last_token_logits": False}, True), @@ -67,7 +69,9 @@ def test_validate_async_sched_support_for_config(overrides, should_raise): [ (AsyncScheduleMode.LEGACY, SamplingParams(top_k=0, top_p=0.5), False), (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0), False), + (AsyncScheduleMode.OVERLAP, SamplingParams(top_k=1, top_p=0.0), False), (AsyncScheduleMode.SERIAL, SamplingParams(top_k=0, top_p=0.0), True), + (AsyncScheduleMode.OVERLAP, SamplingParams(top_k=0, top_p=0.0), True), (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.5), True), (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0, return_log_probs=True), True), (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0, top_n_logprobs=1), True), diff --git a/tests/unit_tests/inference/test_async_sched_output_metrics.py b/tests/unit_tests/inference/test_async_sched_output_metrics.py index 5c327cfb7f6..6490c9a4fde 100644 --- a/tests/unit_tests/inference/test_async_sched_output_metrics.py +++ b/tests/unit_tests/inference/test_async_sched_output_metrics.py @@ -3,7 +3,11 @@ import json from argparse import Namespace from types import SimpleNamespace +from unittest import mock +import pytest + +from examples.inference import utils as inference_utils from examples.inference.offline_inference import _capture_engine_stats from examples.inference.utils import dump_inference_results_to_json from tests.functional_tests.python_test_utils.test_inference_regular_pipeline import ( @@ -70,3 +74,41 @@ def test_capture_engine_stats_includes_async_sched_counters(): "async_sched_compaction_step_count": 4, "capture_stats": {"graphs": 5}, } + + +@pytest.mark.parametrize( + ("do_broadcast", "distributed_initialized", "world_size"), + [(False, True, 2), (True, False, 2), (True, True, 1)], +) +def test_get_curr_time_avoids_cuda_when_rank_sync_is_unnecessary( + monkeypatch, do_broadcast, distributed_initialized, world_size +): + """Ensure local timing never synchronizes the CUDA compute stream.""" + monkeypatch.setattr(inference_utils.time, "time_ns", lambda: 123_000_000_000) + monkeypatch.setattr( + inference_utils.torch.distributed, "is_initialized", lambda: distributed_initialized + ) + monkeypatch.setattr(inference_utils.torch.distributed, "get_world_size", lambda: world_size) + cuda_long_tensor = mock.Mock() + broadcast = mock.Mock() + monkeypatch.setattr(inference_utils.torch.cuda, "LongTensor", cuda_long_tensor) + monkeypatch.setattr(inference_utils.torch.distributed, "broadcast", broadcast) + + assert inference_utils.get_curr_time(do_broadcast=do_broadcast) == 123.0 + cuda_long_tensor.assert_not_called() + broadcast.assert_not_called() + + +def test_get_curr_time_broadcasts_for_multi_rank_sync(monkeypatch): + """Ensure explicit multi-rank timing still broadcasts a rank-zero timestamp.""" + timestamp = mock.Mock() + timestamp.item.return_value = 123_000_000_000 + monkeypatch.setattr(inference_utils.time, "time_ns", lambda: 123_000_000_000) + monkeypatch.setattr(inference_utils.torch.distributed, "is_initialized", lambda: True) + monkeypatch.setattr(inference_utils.torch.distributed, "get_world_size", lambda: 2) + monkeypatch.setattr(inference_utils.torch.cuda, "LongTensor", lambda _value: timestamp) + broadcast = mock.Mock() + monkeypatch.setattr(inference_utils.torch.distributed, "broadcast", broadcast) + + assert inference_utils.get_curr_time() == 123.0 + broadcast.assert_called_once_with(timestamp, src=0) diff --git a/tests/unit_tests/inference/test_inference_config.py b/tests/unit_tests/inference/test_inference_config.py index d7e13ea3325..e22bc2619f4 100644 --- a/tests/unit_tests/inference/test_inference_config.py +++ b/tests/unit_tests/inference/test_inference_config.py @@ -28,6 +28,8 @@ def test_mutual_exclusivity_with_transformer_config(self): (None, AsyncScheduleMode.LEGACY), ("serial", AsyncScheduleMode.SERIAL), (AsyncScheduleMode.SERIAL, AsyncScheduleMode.SERIAL), + ("overlap", AsyncScheduleMode.OVERLAP), + (AsyncScheduleMode.OVERLAP, AsyncScheduleMode.OVERLAP), ], ) def test_async_sched_mode_default_and_coercion(self, async_sched_mode, expected): @@ -43,8 +45,8 @@ def test_async_sched_mode_rejects_invalid_value(self): def test_async_sched_argparse_plumbing(self): """Ensure the CLI exposes async scheduling mode.""" parser = _add_inference_args(ArgumentParser()) - args = parser.parse_args(["--inference-dynamic-batching-async-sched-mode", "serial"]) - assert args.inference_dynamic_batching_async_sched_mode == "serial" + args = parser.parse_args(["--inference-dynamic-batching-async-sched-mode", "overlap"]) + assert args.inference_dynamic_batching_async_sched_mode == "overlap" def test_inference_setup_config_maps_async_sched_mode(self): """Ensure declarative inference config maps async scheduling mode to runtime config.""" @@ -54,7 +56,7 @@ def test_inference_setup_config_maps_async_sched_mode(self): pg_collection="pg", decoder=SimpleNamespace(layer_type_list=None), ) - setup_config = InferenceSetupConfig(inference_dynamic_batching_async_sched_mode="serial") + setup_config = InferenceSetupConfig(inference_dynamic_batching_async_sched_mode="overlap") inference_config = setup_config.to_inference_config( model=model, @@ -64,4 +66,4 @@ def test_inference_setup_config_maps_async_sched_mode(self): verbose=False, ) - assert inference_config.async_sched_mode == AsyncScheduleMode.SERIAL + assert inference_config.async_sched_mode == AsyncScheduleMode.OVERLAP diff --git a/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py index 7c005586f83..ca604e8d25d 100644 --- a/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py @@ -1297,7 +1297,7 @@ def test_decoder_hidden_states_set_after_forward(self, inference_cuda_graph_scop if inference_cuda_graph_scope != 'block': context.mtp_decoder_hidden_states = None - input_ids, position_ids = ctrl._dynamic_step_context_init() + input_ids, position_ids, _ = ctrl._dynamic_step_context_init() ctrl._dynamic_step_forward_logits(input_ids, position_ids) assert context.mtp_decoder_hidden_states is not None, ( @@ -1461,7 +1461,7 @@ def test_no_spec_decode_leaves_decoder_hidden_states_unset( # run loop. with InferenceMode.active(): for step in range(3): - input_ids, position_ids = ctrl._dynamic_step_context_init() + input_ids, position_ids, _ = ctrl._dynamic_step_context_init() ctrl._dynamic_step_forward_logits(input_ids, position_ids) assert context.mtp_decoder_hidden_states is None, ( diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 7257dec6a64..f0939fa0ad9 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -33,7 +33,7 @@ ) from megatron.core.inference.sampling_params import SamplingParams from megatron.core.inference.text_generation_controllers.text_generation_controller import ( - DecodeForwardPrimer, + AsyncScheduleLogitsState, TextGenerationController, ) from megatron.core.inference.utils import InferenceMode @@ -225,8 +225,12 @@ def _make_async_sched_context(total_request_count=2, paused_request_count=0): return_value=torch.full((metadata_len,), 10, dtype=torch.int32) ), prepare_requests=mock.Mock(), + commit_sampled_tokens=mock.Mock(), resolve_requests=mock.Mock(return_value=torch.empty(0, dtype=torch.int32)), + copy_async_sched_sample_to_forward=mock.Mock(), + transfer_bookkeeping_to_gpu=mock.Mock(return_value="bookkeeping"), using_cuda_graph_this_step=mock.Mock(return_value=False), + max_requests=metadata_len, ) @@ -245,84 +249,93 @@ def _make_async_sched_controller(context=None, model_config=None): controller.model_config = model_config controller.num_speculative_tokens = 0 controller._enable_cuda_graph = False - controller._decode_forward_primer = DecodeForwardPrimer( - is_primed=True, cuda_graph_request_count=None + controller._async_sched_logits = AsyncScheduleLogitsState(is_valid=True) + controller._sampled_tokens_cuda = torch.empty(context.max_requests, dtype=torch.int64) + controller._async_sched_sample_values_cuda = torch.empty( + context.max_requests, dtype=model_config.params_dtype + ) + controller._async_sched_sampled_tokens_cpu_buffer = torch.empty( + context.max_requests, dtype=torch.int64 ) return controller -def _set_nested_attr(obj, attr_path, value): - for attr in attr_path.split(".")[:-1]: - obj = getattr(obj, attr) - setattr(obj, attr_path.split(".")[-1], value) - - @pytest.mark.parametrize("total_request_count", [0, 2]) def test_validate_async_sched_support_for_step_success(total_request_count): context = _make_async_sched_context(total_request_count=total_request_count) controller = _make_async_sched_controller(context) + if total_request_count == 0: + context.config.materialize_only_last_token_logits = False controller._validate_async_sched_support_for_step() -@pytest.mark.parametrize( - "attr_path, value", - [ - ("context.config.materialize_only_last_token_logits", False), - ("controller.num_speculative_tokens", 1), - ("context.is_hybrid_model", True), - ("context.enable_prefix_caching", True), - ("context.paused_request_count", 1), - ("context.chunked_prefill_request_id", 0), - ("model_config.expert_model_parallel_size", 2), - ("model_config.num_moe_experts", 4), - ("model_config.moe_enable_routing_replay", True), - ("context.request_metadata", {"top_k": torch.tensor([1, 0])}), - ("context.request_metadata", {"top_p": torch.tensor([0.0, 0.5])}), - ("context.request_metadata", {"return_log_probs": torch.tensor([False, True])}), - ("context.request_metadata", {"top_n_logprobs": torch.tensor([0, 1])}), - ], -) -def test_validate_async_sched_support_for_step_errors(attr_path, value): +def test_validate_async_sched_support_for_step_ignores_immutable_restrictions(): context = _make_async_sched_context(total_request_count=2) + context.config.materialize_only_last_token_logits = False + context.is_hybrid_model = True + context.enable_prefix_caching = True + context.request_metadata["top_k"] = torch.tensor([0, 0]) + context.request_metadata["top_p"] = torch.tensor([0.5, 0.5]) + context.request_metadata["return_log_probs"] = torch.tensor([True, True]) + context.request_metadata["top_n_logprobs"] = torch.tensor([1, 1]) model_config = SimpleNamespace( params_dtype=torch.float32, - expert_model_parallel_size=1, - num_moe_experts=None, - moe_enable_routing_replay=False, + expert_model_parallel_size=2, + num_moe_experts=4, + moe_enable_routing_replay=True, ) controller = _make_async_sched_controller(context, model_config) - target = SimpleNamespace(context=context, controller=controller, model_config=model_config) - if attr_path == "context.request_metadata": - context.request_metadata.update(value) - else: - _set_nested_attr(target, attr_path, value) + controller.num_speculative_tokens = 1 + + controller._validate_async_sched_support_for_step() + + +@pytest.mark.parametrize("unsupported_case", ["paused_request", "chunked_prefill"]) +def test_validate_async_sched_support_for_step_errors(unsupported_case): + context = _make_async_sched_context(total_request_count=2) + controller = _make_async_sched_controller(context) + if unsupported_case == "paused_request": + context.paused_request_count = 1 + elif unsupported_case == "chunked_prefill": + context.chunked_prefill_request_id = 0 with pytest.raises(RuntimeError, match="Async scheduling"): controller._validate_async_sched_support_for_step() @pytest.mark.parametrize( - "enable_cuda_graph, survivor_idxs", + "enable_cuda_graph, survivor_idxs, expected_compaction", [ - (False, torch.tensor([0, 2], dtype=torch.int64)), - (True, torch.tensor([0, 2], dtype=torch.int64)), - (False, torch.empty(0, dtype=torch.int64)), + (False, torch.tensor([0, 2], dtype=torch.int64), True), + (True, torch.tensor([0, 2], dtype=torch.int64), True), + (False, torch.tensor([0, 1], dtype=torch.int64), False), + (False, torch.empty(0, dtype=torch.int64), False), ], ) -def test_async_sched_logits_compaction(enable_cuda_graph, survivor_idxs): +def test_async_sched_logits_compaction(enable_cuda_graph, survivor_idxs, expected_compaction): controller = _make_async_sched_controller() controller._enable_cuda_graph = enable_cuda_graph - controller._decode_forward_primer = DecodeForwardPrimer( - is_primed=True, cuda_graph_request_count=8 + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=True, cuda_graph_request_count=8, ready_event="forward" ) + controller._record_fresh_async_sched_event = mock.Mock(return_value="compaction") logits = torch.arange(12).reshape(1, 4, 3) controller._all_logits_cuda = logits.clone() - controller._compact_async_sched_logits(survivor_idxs) + compaction_done_event = controller._compact_async_sched_logits(survivor_idxs) if survivor_idxs.numel() == 0: - assert not controller._decode_forward_primer.is_primed + assert not controller._async_sched_logits.is_valid + assert compaction_done_event is None + controller._record_fresh_async_sched_event.assert_not_called() + return + + if not expected_compaction: + assert torch.equal(controller._all_logits_cuda, logits) + assert controller._async_sched_logits.ready_event == "forward" + assert compaction_done_event is None + controller._record_fresh_async_sched_event.assert_not_called() return expected_logits = logits[:, survivor_idxs, :] @@ -333,44 +346,103 @@ def test_async_sched_logits_compaction(enable_cuda_graph, survivor_idxs): assert controller._all_logits_cuda.shape == logits.shape else: assert torch.equal(controller._all_logits_cuda, expected_logits) - assert controller._decode_forward_primer.is_primed - assert controller._decode_forward_primer.cuda_graph_request_count == 8 + assert controller._async_sched_logits.is_valid + assert controller._async_sched_logits.cuda_graph_request_count == 8 + assert controller._async_sched_logits.ready_event == "compaction" + assert compaction_done_event == "compaction" -def test_run_async_sched_prepare_updates_context_before_h2d_init(): +def test_dynamic_step_context_init_returns_bookkeeping_event(): + context = _make_async_sched_context() + input_ids = torch.tensor([[10, 11]]) + position_ids = torch.tensor([[0, 1]]) + context.initialize_attention_state = mock.Mock(return_value="bookkeeping") + context.current_input_and_position_ids = mock.Mock(return_value=(input_ids, position_ids)) + model_config = SimpleNamespace( + params_dtype=torch.float32, + symmetric_ar_type=None, + nccl_all_reduce_for_prefill=False, + moe_pad_experts_for_cuda_graph_inference=False, + transformer_impl="transformer_engine", + ) + controller = _make_async_sched_controller(context, model_config) + + with ( + mock.patch( + "megatron.core.inference.text_generation_controllers." + "text_generation_controller.set_moe_metadata_sync" + ), + mock.patch( + "megatron.core.inference.text_generation_controllers.text_generation_controller.range_push" + ), + mock.patch( + "megatron.core.inference.text_generation_controllers.text_generation_controller.range_pop" + ), + ): + returned_input_ids, returned_position_ids, bookkeeping_done_event = ( + controller._dynamic_step_context_init(record_bookkeeping_done_event=True) + ) + + context.initialize_attention_state.assert_called_once_with( + construct_graph_dimensions=None, + is_expert_parallel_dummy_cuda_graph_step=False, + transfer_bookkeeping_to_gpu=True, + record_bookkeeping_done_event=True, + ) + assert torch.equal(returned_input_ids, input_ids) + assert torch.equal(returned_position_ids, position_ids) + assert bookkeeping_done_event == "bookkeeping" + + +def test_run_async_sched_prepare_stops_before_bookkeeping_h2d(): context = _make_async_sched_context() controller = _make_async_sched_controller(context) input_ids = torch.tensor([[10, 11]]) position_ids = torch.tensor([[0, 1]]) call_order = [] - context.prepare_requests = mock.Mock(side_effect=lambda _: call_order.append("prepare")) + context.prepare_requests = mock.Mock(side_effect=lambda: call_order.append("prepare")) controller._dynamic_step_context_init = mock.Mock( - side_effect=lambda: call_order.append("context_init") or (input_ids, position_ids) + side_effect=lambda *, transfer_bookkeeping_to_gpu=True: call_order.append( + f"context_init:{transfer_bookkeeping_to_gpu}" + ) + or (input_ids, position_ids, None) ) - sample = torch.tensor([3, 4]) - returned_input_ids, returned_position_ids = controller._run_async_sched_prepare(sample) + returned_input_ids, returned_position_ids = controller._run_async_sched_prepare() - context.prepare_requests.assert_called_once_with(sample) + context.prepare_requests.assert_called_once_with() + controller._dynamic_step_context_init.assert_called_once_with(transfer_bookkeeping_to_gpu=False) assert torch.equal(returned_input_ids, input_ids) assert torch.equal(returned_position_ids, position_ids) - assert call_order == ["prepare", "context_init"] + assert call_order == ["prepare", "context_init:False"] + + +def test_run_async_sched_publish_bookkeeping_skips_gpu_input_ids(): + context = _make_async_sched_context() + controller = _make_async_sched_controller(context) + + done_event = controller._run_async_sched_publish_bookkeeping() + + context.transfer_bookkeeping_to_gpu.assert_called_once_with( + skip_token_input_ids=True, record_done_event=True + ) + assert done_event == "bookkeeping" @pytest.mark.parametrize( "using_cuda_graph, expected_cuda_graph_request_count", [(False, None), (True, 8)] ) -def test_run_async_sched_forward_records_primer( +def test_run_async_sched_forward_records_primer_and_returns_event( using_cuda_graph, expected_cuda_graph_request_count ): context = _make_async_sched_context() context.using_cuda_graph_this_step.return_value = using_cuda_graph controller = _make_async_sched_controller(context) - controller._decode_forward_primer = DecodeForwardPrimer( - is_primed=False, cuda_graph_request_count=None - ) + controller._async_sched_logits = AsyncScheduleLogitsState() + controller._all_logits_cuda = torch.empty(0) controller._dynamic_step_forward_logits = mock.Mock() + controller._record_fresh_async_sched_event = mock.Mock(return_value="forward_done") input_ids = torch.tensor([[10, 11]]) position_ids = torch.tensor([[0, 1]]) @@ -384,97 +456,380 @@ def test_run_async_sched_forward_records_primer( "text_generation_controller.range_pop" ), ): - cuda_graph_request_count = controller._run_async_sched_forward(input_ids, position_ids) + forward_done_event = controller._run_async_sched_forward(input_ids, position_ids) controller._dynamic_step_forward_logits.assert_called_once_with(input_ids, position_ids) - assert cuda_graph_request_count == expected_cuda_graph_request_count - assert controller._decode_forward_primer.is_primed + controller._record_fresh_async_sched_event.assert_called_once_with(controller._all_logits_cuda) + assert forward_done_event == "forward_done" + assert controller._async_sched_logits.is_valid assert ( - controller._decode_forward_primer.cuda_graph_request_count - == expected_cuda_graph_request_count + controller._async_sched_logits.cuda_graph_request_count == expected_cuda_graph_request_count ) + assert controller._async_sched_logits.ready_event == "forward_done" + + +@pytest.mark.parametrize("is_valid", [False, True]) +def test_run_async_sched_forward_primer(is_valid): + context = _make_async_sched_context(total_request_count=2) + controller = _make_async_sched_controller(context) + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=is_valid, ready_event="forward" if is_valid else None + ) + input_ids = torch.tensor([[10, 11]]) + position_ids = torch.tensor([[0, 1]]) + controller._dynamic_step_context_init = mock.Mock( + return_value=(input_ids, position_ids, "bookkeeping") + ) + controller._run_async_sched_forward = mock.Mock() + + primer_launched, bookkeeping_done_event = controller._run_async_sched_forward_primer() + + assert primer_launched is (not is_valid) + assert bookkeeping_done_event == (None if is_valid else "bookkeeping") + if is_valid: + controller._dynamic_step_context_init.assert_not_called() + controller._run_async_sched_forward.assert_not_called() + else: + controller._dynamic_step_context_init.assert_called_once_with( + record_bookkeeping_done_event=True + ) + controller._run_async_sched_forward.assert_called_once_with(input_ids, position_ids) -def test_async_sched_serial_step_returns_none_without_active_requests(): +def test_async_sched_step_returns_none_without_active_requests(): context = _make_async_sched_context(total_request_count=0) context.active_token_count = 0 controller = _make_async_sched_controller(context) - controller._decode_forward_primer = DecodeForwardPrimer( - is_primed=True, cuda_graph_request_count=8 + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=True, cuda_graph_request_count=8, ready_event="forward" ) controller._validate_async_sched_support_for_step = mock.Mock() - result = asyncio.run(controller._run_async_sched_serial_step()) + result = asyncio.run(controller._run_async_sched_step(overlap=False)) assert result is None - assert not controller._decode_forward_primer.is_primed - controller._validate_async_sched_support_for_step.assert_not_called() + assert not controller._async_sched_logits.is_valid + assert controller._async_sched_logits.cuda_graph_request_count is None + assert controller._async_sched_logits.ready_event is None + controller._validate_async_sched_support_for_step.assert_called_once_with() + + +def test_run_async_sched_sample_reuses_gpu_buffer(): + context = _make_async_sched_context(total_request_count=3) + controller = _make_async_sched_controller(context) + controller._all_logits_cuda = torch.zeros(1, 3, 5) + expected_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) + for idx, token in enumerate(expected_tokens.tolist()): + controller._all_logits_cuda[0, idx, token] = 10.0 + + sampled_tokens_gpu = controller._run_async_sched_sample() + + assert sampled_tokens_gpu.data_ptr() == controller._sampled_tokens_cuda.data_ptr() + assert torch.equal(sampled_tokens_gpu, expected_tokens) + + +@pytest.mark.internal +def test_run_async_sched_sample_records_gpu_ready_event(): + context = _make_async_sched_context(total_request_count=3) + controller = _make_async_sched_controller(context) + controller._all_logits_cuda = torch.zeros(1, 3, 5, device="cuda") + controller._sampled_tokens_cuda = torch.empty(3, dtype=torch.int64, device="cuda") + controller._async_sched_sample_values_cuda = torch.empty(3, device="cuda") + controller._async_sched_sample_gpu_ready_event = mock.Mock() + + controller._run_async_sched_sample() + + controller._async_sched_sample_gpu_ready_event.record.assert_called_once_with( + torch.cuda.current_stream() + ) + + +@pytest.mark.internal +def test_async_sched_event_records_and_synchronizes_cuda_work(): + controller = _make_async_sched_controller() + + assert controller._record_fresh_async_sched_event(torch.empty(1)) is None + + event = controller._record_fresh_async_sched_event(torch.empty(1, device="cuda")) + controller._synchronize_async_sched_event(event) + controller._synchronize_async_sched_event(None) + + assert isinstance(event, torch.cuda.Event) + + +@pytest.mark.internal +def test_copy_async_sched_sample_to_cpu_uses_reusable_buffer_and_copy_stream(): + controller = _make_async_sched_controller(_make_async_sched_context(total_request_count=3)) + sampled_tokens_gpu = torch.tensor([1, 2, 3], dtype=torch.int64, device="cuda") + controller._async_sched_sampled_tokens_cpu_buffer = torch.empty( + 3, dtype=torch.int64, device="cpu", pin_memory=True + ) + controller._async_sched_sample_gpu_ready_event = torch.cuda.Event() + controller._async_sched_sample_cpu_ready_event = torch.cuda.Event() + controller._async_sched_copy_stream = torch.cuda.Stream() + controller._async_sched_sample_gpu_ready_event.record(torch.cuda.current_stream()) + + sampled_tokens_cpu_view, sample_cpu_ready_event = controller._copy_async_sched_sample_to_cpu( + sampled_tokens_gpu + ) + sample_cpu_ready_event.synchronize() + + assert ( + sampled_tokens_cpu_view.data_ptr() + == controller._async_sched_sampled_tokens_cpu_buffer.data_ptr() + ) + assert torch.equal(sampled_tokens_cpu_view, sampled_tokens_gpu.cpu()) @pytest.mark.parametrize( - "is_primed, termination_ids, expected_finished_ids, expected_compaction_count", - [(True, torch.tensor([99, 99, 99]), [], 0), (False, torch.tensor([99, 2, 99]), [11], 1)], + "overlap, termination_ids, expected_wait, expected_compaction_event", + [ + (True, [99, 99, 99], False, None), + (True, [99, 2, 99], True, "compaction"), + (False, [99, 2, 99], False, "compaction"), + ], ) -def test_async_sched_serial_step( - is_primed, termination_ids, expected_finished_ids, expected_compaction_count +def test_run_async_sched_resolve_waits_only_for_finish_boundary( + overlap, termination_ids, expected_wait, expected_compaction_event ): sample_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) context = _make_async_sched_context(total_request_count=3) - context.request_metadata["termination_id"] = termination_ids - context.resolve_requests = mock.Mock( - return_value=torch.tensor(expected_finished_ids, dtype=torch.int32) - ) + context.request_metadata["termination_id"] = torch.tensor(termination_ids) controller = _make_async_sched_controller(context) - controller._decode_forward_primer = DecodeForwardPrimer( - is_primed=is_primed, cuda_graph_request_count=7 if is_primed else None - ) - controller._validate_async_sched_support_for_step = mock.Mock() - controller._all_logits_cuda = torch.zeros(1, 3, 5) - for idx, token in enumerate(sample_tokens.tolist()): - controller._all_logits_cuda[0, idx, token] = 10.0 + controller._synchronize_async_sched_event = mock.Mock() + + expected_mask = (sample_tokens != context.request_metadata["termination_id"]).byte() + expected_finished_ids = context.request_ids[expected_mask == 0].clone() + context.resolve_requests = mock.Mock(return_value=expected_finished_ids) + + def compact_logits(survivor_idxs): + identity_idxs = torch.arange(survivor_idxs.numel()) + return None if torch.equal(survivor_idxs, identity_idxs) else "compaction" + + controller._compact_async_sched_logits = mock.Mock(side_effect=compact_logits) + result = controller._run_async_sched_resolve(sample_tokens, "forward", overlap) + + assert torch.equal(result.sampled_tokens_cpu, sample_tokens) + assert result.compaction_done_event == expected_compaction_event + if expected_wait: + controller._synchronize_async_sched_event.assert_called_once_with("forward") + else: + controller._synchronize_async_sched_event.assert_not_called() + context.commit_sampled_tokens.assert_called_once() + context.resolve_requests.assert_called_once() + assert torch.equal(context.resolve_requests.call_args.args[0], expected_mask) + + +@pytest.mark.parametrize( + "overlap, has_valid_logits, expected_call_order", + [ + ( + False, + True, + [ + "wait:current", + "prepare", + "sample", + "copy_input", + "copy_sample", + "wait:sample", + "publish", + "wait:bookkeeping", + "forward", + "wait:forward", + "resolve", + "wait:compaction", + "yield", + ], + ), + ( + True, + True, + [ + "prepare", + "sample", + "copy_input", + "copy_sample", + "publish", + "forward", + "wait:sample", + "wait:bookkeeping", + "resolve", + "yield", + ], + ), + ( + True, + False, + [ + "primer", + "wait:primer_bookkeeping", + "prepare", + "sample", + "copy_input", + "copy_sample", + "publish", + "forward", + "wait:sample", + "wait:bookkeeping", + "resolve", + "yield", + ], + ), + ], +) +def test_async_sched_step_order(overlap, has_valid_logits, expected_call_order): + sample_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) + sampled_tokens_cpu = sample_tokens.clone() input_ids = torch.tensor([[101, 102, 103]]) position_ids = torch.tensor([[0, 1, 2]]) + context = _make_async_sched_context(total_request_count=3) + controller = _make_async_sched_controller(context) + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=has_valid_logits, + cuda_graph_request_count=7 if has_valid_logits else None, + ready_event="current" if has_valid_logits else None, + ) + controller._validate_async_sched_support_for_step = mock.Mock() call_order = [] - controller._dynamic_step_context_init = mock.Mock( - side_effect=lambda: call_order.append("context_init") or (input_ids, position_ids) + + def run_primer(): + if not has_valid_logits: + call_order.append("primer") + controller._async_sched_logits.set_pending(7, "current") + return not has_valid_logits, "primer_bookkeeping" if not has_valid_logits else None + + controller._run_async_sched_forward_primer = mock.Mock(side_effect=run_primer) + controller._synchronize_async_sched_event = mock.Mock( + side_effect=lambda event: call_order.append(f"wait:{event}") + ) + controller._run_async_sched_prepare = mock.Mock( + side_effect=lambda: call_order.append("prepare") or (input_ids, position_ids) + ) + controller._run_async_sched_sample = mock.Mock( + side_effect=lambda: call_order.append("sample") or sample_tokens + ) + context.copy_async_sched_sample_to_forward = mock.Mock( + side_effect=lambda _: call_order.append("copy_input") + ) + controller._copy_async_sched_sample_to_cpu = mock.Mock( + side_effect=lambda _: call_order.append("copy_sample") or (sampled_tokens_cpu, "sample") + ) + controller._run_async_sched_publish_bookkeeping = mock.Mock( + side_effect=lambda: call_order.append("publish") or "bookkeeping" + ) + controller._run_async_sched_forward = mock.Mock( + side_effect=lambda *_: call_order.append("forward") or "forward" + ) + controller._run_async_sched_resolve = mock.Mock( + side_effect=lambda *_: call_order.append("resolve") + or SimpleNamespace( + sampled_tokens_cpu=sampled_tokens_cpu, + active_request_ids=context.request_ids.long(), + finished_request_ids=torch.tensor([11], dtype=torch.int32), + compaction_done_event="compaction", + ) ) - def forward_step(forward_input_ids, forward_position_ids): - call_order.append("forward") - assert torch.equal(forward_input_ids, input_ids) - assert torch.equal(forward_position_ids, position_ids) - controller._decode_forward_primer.mark_primed(5) - return 5 + async def yield_to_event_loop(_delay): + call_order.append("yield") - controller._run_async_sched_forward = mock.Mock(side_effect=forward_step) - context.prepare_requests = mock.Mock(side_effect=lambda _: call_order.append("prepare")) + with mock.patch( + "megatron.core.inference.text_generation_controllers." + "text_generation_controller.asyncio.sleep", + side_effect=yield_to_event_loop, + ): + result = asyncio.run(controller._run_async_sched_step(overlap=overlap)) - def compact_logits(survivor_idxs): - assert context.async_sched_step_count == 0 - assert context.async_sched_compaction_step_count == 0 - expected_survivors = torch.tensor( - [idx for idx, token in enumerate(sample_tokens.tolist()) if token != 2], - dtype=torch.int64, - ) - if not expected_finished_ids: - expected_survivors = torch.arange(sample_tokens.numel(), dtype=torch.int64) - assert torch.equal(survivor_idxs, expected_survivors) + assert result["sample"].tolist() == sample_tokens.tolist() + assert result["cuda_graph_request_count"] == 7 + assert context.async_sched_step_count == 1 + assert context.async_sched_compaction_step_count == 1 + assert call_order == expected_call_order - controller._compact_async_sched_logits = mock.Mock(side_effect=compact_logits) - result = asyncio.run(controller._run_async_sched_serial_step()) +@pytest.mark.parametrize( + "termination_ids, expected_mask, expected_finished_ids, expected_compaction_count", + [ + ([99, 99, 99], [1, 1, 1], [], 0), + ([99, 2, 99], [1, 0, 1], [11], 1), + ([99, 99, 3], [1, 1, 0], [12], 1), + ], +) +def test_async_sched_step_wires_sampling_through_resolution( + termination_ids, expected_mask, expected_finished_ids, expected_compaction_count +): + context = _make_async_sched_context(total_request_count=3) + context.request_metadata["termination_id"] = torch.tensor(termination_ids) + context.resolve_requests.side_effect = lambda mask: context.request_ids[mask == 0].clone() + controller = _make_async_sched_controller(context) + controller._all_logits_cuda = torch.zeros(1, 3, 5) + sampled_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) + for row, token in enumerate(sampled_tokens.tolist()): + controller._all_logits_cuda[0, row, token] = 10.0 + + input_ids_gpu_view = torch.empty(3, dtype=torch.int64) + position_ids_gpu_view = torch.empty(3, dtype=torch.int64) + controller._run_async_sched_prepare = mock.Mock( + return_value=(input_ids_gpu_view, position_ids_gpu_view) + ) + controller._run_async_sched_publish_bookkeeping = mock.Mock(return_value=None) + controller._synchronize_async_sched_event = mock.Mock() + controller._record_fresh_async_sched_event = mock.Mock(return_value="compaction") + + def run_forward(*_args): + controller._async_sched_logits.set_pending(None, "forward") + return "forward" + + controller._run_async_sched_forward = mock.Mock(side_effect=run_forward) + result = asyncio.run(controller._run_async_sched_step(overlap=False)) + + assert torch.equal(result["sample"], sampled_tokens) assert result["finished_request_ids"].tolist() == expected_finished_ids - assert result["sample"].tolist() == sample_tokens.tolist() - assert result["cuda_graph_request_count"] == (7 if is_primed else 5) + context.copy_async_sched_sample_to_forward.assert_called_once() + assert torch.equal(context.copy_async_sched_sample_to_forward.call_args.args[0], sampled_tokens) + context.commit_sampled_tokens.assert_called_once() + assert torch.equal(context.commit_sampled_tokens.call_args.args[0], sampled_tokens) + assert context.resolve_requests.call_args.args[0].tolist() == expected_mask assert context.async_sched_step_count == 1 assert context.async_sched_compaction_step_count == expected_compaction_count - context.prepare_requests.assert_called_once() - context.resolve_requests.assert_called_once() - controller._compact_async_sched_logits.assert_called_once() - expected_prefix = [] if is_primed else ["context_init", "forward"] - assert call_order == expected_prefix + ["prepare", "context_init", "forward"] + + +def test_async_sched_step_yields_after_resolution_outside_inference_mode(): + context = _make_async_sched_context(total_request_count=1) + controller = _make_async_sched_controller(context) + sampled_tokens = torch.tensor([1], dtype=torch.int64) + controller._run_async_sched_prepare = mock.Mock( + return_value=(torch.empty(1, dtype=torch.int64), torch.empty(1, dtype=torch.int64)) + ) + controller._run_async_sched_sample = mock.Mock(return_value=sampled_tokens) + controller._copy_async_sched_sample_to_cpu = mock.Mock(return_value=(sampled_tokens, None)) + controller._run_async_sched_publish_bookkeeping = mock.Mock(return_value=None) + controller._run_async_sched_forward = mock.Mock(return_value=None) + controller._run_async_sched_resolve = mock.Mock( + return_value=SimpleNamespace( + sampled_tokens_cpu=sampled_tokens, + active_request_ids=context.request_ids.long(), + finished_request_ids=torch.empty(0, dtype=torch.int32), + compaction_done_event=None, + ) + ) + observed = [] + + async def run_step(): + asyncio.get_running_loop().call_soon( + lambda: observed.append( + (context.async_sched_step_count, torch.is_inference_mode_enabled()) + ) + ) + return await controller._run_async_sched_step(overlap=True) + + result = asyncio.run(run_step()) + + assert result["sample"].tolist() == [1] + assert observed == [(1, False)] @pytest.mark.parametrize( @@ -483,6 +838,8 @@ def compact_logits(survivor_idxs): (AsyncScheduleMode.LEGACY, 0, False, "legacy"), (AsyncScheduleMode.SERIAL, 1, False, "legacy"), (AsyncScheduleMode.SERIAL, 0, False, "async"), + (AsyncScheduleMode.OVERLAP, 1, False, "legacy"), + (AsyncScheduleMode.OVERLAP, 0, False, "overlap"), ], ) def test_async_generate_output_tokens_dynamic_batch_routes( @@ -493,7 +850,9 @@ def test_async_generate_output_tokens_dynamic_batch_routes( context.num_prefill_requests = num_prefill_requests controller = _make_async_sched_controller(context) controller._run_legacy_step = mock.AsyncMock(return_value="legacy") - controller._run_async_sched_serial_step = mock.AsyncMock(return_value="async") + controller._run_async_sched_step = mock.AsyncMock( + side_effect=lambda *, overlap: "overlap" if overlap else "async" + ) result = asyncio.run(controller.async_generate_output_tokens_dynamic_batch(skip_bookkeeping)) @@ -504,6 +863,7 @@ def test_async_generate_output_tokens_dynamic_batch_routes( "mode, expected_message", [ (AsyncScheduleMode.SERIAL, "request bookkeeping"), + (AsyncScheduleMode.OVERLAP, "request bookkeeping"), ("unexpected", "Unexpected async scheduling mode"), ], ) @@ -512,7 +872,7 @@ def test_async_generate_output_tokens_dynamic_batch_assertions(mode, expected_me context.config.async_sched_mode = mode controller = _make_async_sched_controller(context) controller._run_legacy_step = mock.AsyncMock() - controller._run_async_sched_serial_step = mock.AsyncMock() + controller._run_async_sched_step = mock.AsyncMock() with pytest.raises(AssertionError, match=expected_message): asyncio.run(controller.async_generate_output_tokens_dynamic_batch(skip_bookkeeping=True)) From e443bd3adbe94ff754dbed8da48f0a5bec585c03 Mon Sep 17 00:00:00 2001 From: Ajay Date: Mon, 20 Jul 2026 13:02:54 -0700 Subject: [PATCH 064/290] ci: integrate nemo-ci-triage with linear issues management for gitlab failures (#5881) Signed-off-by: Ajay Balasa --- .gitlab-ci.yml | 16 + .gitlab/nemo-ci-triage.yml | 14 + .gitlab/stages/02.test.yml | 3 +- .gitlab/stages/04.functional-tests.yml | 14 +- .gitlab/stages/06.triage.yml | 95 +++++ docker/Dockerfile.linting | 2 +- tests/test_utils/python_scripts/linear_ci.py | 192 +++++++++++ tests/test_utils/python_scripts/notify.py | 92 ++++- tests/test_utils/test_ci_triage.py | 344 ++++++++++++++++++- 9 files changed, 744 insertions(+), 28 deletions(-) create mode 100644 .gitlab/nemo-ci-triage.yml create mode 100644 .gitlab/stages/06.triage.yml create mode 100644 tests/test_utils/python_scripts/linear_ci.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2eb1b43be0c..ae27dc6d2f4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -154,6 +154,7 @@ stages: - integration_tests - functional_tests - publish + - triage default: interruptible: true @@ -268,7 +269,21 @@ variables: - "upgrade-dependencies" description: Type of publish (freeze or final release) + RUN_LINEAR_STATUS: + value: "True" + options: + - "True" + - "False" + description: Reconcile functional-test failures against Linear + RUN_LINEAR_WRITE: + value: "True" + options: + - "True" + - "False" + description: Apply proposed Linear issue opens, updates, and closes + # CI wide variables + NEMO_CI_TRIAGE_CONFIG: .gitlab/nemo-ci-triage.yml CI_MCORE_LTS_IMAGE: ${GITLAB_ENDPOINT}:5005/adlr/megatron-lm/mcore_ci_lts CI_MCORE_DEV_IMAGE: ${GITLAB_ENDPOINT}:5005/adlr/megatron-lm/mcore_ci_dev CI_NEMO_IMAGE: ${GITLAB_ENDPOINT}:5005/adlr/megatron-lm/nemo_ci @@ -282,3 +297,4 @@ include: - .gitlab/stages/03.integration-tests.yml - .gitlab/stages/04.functional-tests.yml - .gitlab/stages/05.publish.yml + - .gitlab/stages/06.triage.yml diff --git a/.gitlab/nemo-ci-triage.yml b/.gitlab/nemo-ci-triage.yml new file mode 100644 index 00000000000..a32617b2ef0 --- /dev/null +++ b/.gitlab/nemo-ci-triage.yml @@ -0,0 +1,14 @@ +# Megatron-LM configuration for nemo-ci-triage. + +gitlab: + project_id: 19378 + repo_name: ADLR/megatron-lm + +modules: + megatron_lm: + build_module: megatron-lm + team_key: MCORE + project_template: "MCore CI Testing" + enable_linear_open: true + enable_linear_modify: true + enable_linear_close: true diff --git a/.gitlab/stages/02.test.yml b/.gitlab/stages/02.test.yml index d81e1be4857..93385bd8e1b 100644 --- a/.gitlab/stages/02.test.yml +++ b/.gitlab/stages/02.test.yml @@ -195,8 +195,9 @@ test:unit_tests_notify: fi - export RO_API_TOKEN=${PROJECT_ACCESS_TOKEN_MCORE} - export GITLAB_ENDPOINT - - export TAG_TEAM=$([[ "$CI_COMMIT_BRANCH" == "main" ]] && echo "1" || "0") + - export TAG_TEAM=$([[ "$CI_COMMIT_BRANCH" == "main" ]] && echo "1" || echo "0") - export TEAM_SLUG=$SLACK_ADMIN + - export PYTHONPATH=$(pwd) - | python tests/test_utils/python_scripts/notify.py \ --pipeline-id "${CI_PIPELINE_ID}" \ diff --git a/.gitlab/stages/04.functional-tests.yml b/.gitlab/stages/04.functional-tests.yml index f83a2de4563..b7eb169acd4 100644 --- a/.gitlab/stages/04.functional-tests.yml +++ b/.gitlab/stages/04.functional-tests.yml @@ -450,6 +450,7 @@ functional:smoke_notify: fi - export RO_API_TOKEN=${PROJECT_ACCESS_TOKEN_MCORE} - export GITLAB_ENDPOINT + - export PYTHONPATH=$(pwd) - | python tests/test_utils/python_scripts/notify.py \ --pipeline-id "${CI_PIPELINE_ID}" \ @@ -494,19 +495,28 @@ functional:x_notify: - export RO_API_TOKEN=${PROJECT_ACCESS_TOKEN_MCORE} - export GITLAB_ENDPOINT - export CONTEXT=$FUNCTIONAL_TEST_SCOPE - - export TAG_TEAM=$([[ "$CI_COMMIT_BRANCH" == "main" ]] && echo "1" || "0") + - export TAG_TEAM=$([[ "$CI_COMMIT_BRANCH" == "main" ]] && echo "1" || echo "0") - export TEAM_SLUG=$SLACK_ADMIN + - export PYTHONPATH=$(pwd) - | python tests/test_utils/python_scripts/notify.py \ --pipeline-id "${CI_PIPELINE_ID}" \ --check-for functional-tests \ --pipeline-context $CONTEXT \ - --pipeline-created-at "${CI_PIPELINE_CREATED_AT}" + --pipeline-created-at "${CI_PIPELINE_CREATED_AT}" \ + --summary-output pipeline_summaries.json \ + --failure-buckets-output failure_buckets.json \ + --slack-output slack_notification.json artifacts: when: always paths: - scripts + - pipeline_summaries.json + - failure_buckets.json + - slack_notification.json + - inference_metrics.json + - agent_formatter_debug.txt rules: - if: ($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main") && $FUNCTIONAL_TEST == "yes" when: always diff --git a/.gitlab/stages/06.triage.yml b/.gitlab/stages/06.triage.yml new file mode 100644 index 00000000000..84996e99114 --- /dev/null +++ b/.gitlab/stages/06.triage.yml @@ -0,0 +1,95 @@ +.linear_reconcile_rules: + rules: + - if: >- + ($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main") && + $FUNCTIONAL_TEST == "yes" && + $RUN_LINEAR_STATUS == "True" + when: always + - when: never + +.linear_triage_job: + stage: triage + image: ${UTILITY_IMAGE}:${CI_PIPELINE_ID} + tags: + - arch/amd64 + - env/prod + - origin/jet-fleet + - owner/jet-core + - purpose/utility + - team/megatron + +triage:linear_reconcile: + extends: [.linear_triage_job, .linear_reconcile_rules] + needs: + - job: functional:x_notify + artifacts: true + script: + - >- + nemo-ci-linear status + --config "${NEMO_CI_TRIAGE_CONFIG}" + --build-module-regex '^megatron-lm$' + --output linear_status_report.json + - >- + nemo-ci-linear reconcile + --failure-buckets failure_buckets.json + --linear-report linear_status_report.json + --pipeline-summaries pipeline_summaries.json + --output linear_action_plan.json + artifacts: + when: always + paths: + - linear_status_report.json + - linear_action_plan.json + - inference_metrics.json + +triage:linear_write: + extends: [.linear_triage_job] + needs: + - job: triage:linear_reconcile + artifacts: true + allow_failure: true + script: + - >- + nemo-ci-linear write + --config "${NEMO_CI_TRIAGE_CONFIG}" + --plan linear_action_plan.json + --output linear_action_plan_post.json + artifacts: + when: always + paths: + - linear_action_plan.json + - linear_action_plan_post.json + - linear_status_report.json + rules: + - if: >- + ($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main") && + $FUNCTIONAL_TEST == "yes" && + $RUN_LINEAR_STATUS == "True" && + $RUN_LINEAR_WRITE == "True" + when: always + - when: never + +triage:slack_linear_followup: + extends: [.linear_triage_job] + needs: + - job: functional:x_notify + artifacts: true + - job: triage:linear_write + artifacts: true + allow_failure: true + script: + - >- + nemo-ci-notify + --pipeline-summary slack_notification.json + --linear-plan linear_action_plan_post.json + --slack-bot-token "${MCORE_SLACK_BOT_TOKEN:-${ALERTMANAGER_TOKEN}}" + --slack-channel-id "${MCORE_SLACK_CHANNEL_ID}" + rules: + # Post the applied Linear actions under the functional-test notification. + - if: >- + ($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main") && + $FUNCTIONAL_TEST == "yes" && + $RUN_LINEAR_STATUS == "True" && + $RUN_LINEAR_WRITE == "True" + when: always + - when: never diff --git a/docker/Dockerfile.linting b/docker/Dockerfile.linting index 15cea7b6b57..afd9260aa95 100644 --- a/docker/Dockerfile.linting +++ b/docker/Dockerfile.linting @@ -24,7 +24,7 @@ RUN --mount=type=secret,id=JET_INDEX_URLS \ # Keep this in the internal-only stage so public CI has no internal service dependency. ARG CI_SERVER_URL -ARG NEMO_CI_TRIAGE_COMMIT=8e65fa4ae20b58578d0e0f20ebea37ee7d92c8ea +ARG NEMO_CI_TRIAGE_COMMIT=5474f95417758c76c75523ae5319727a1e703437 RUN --mount=type=secret,id=NEMO_CI_TRIAGE_TOKEN \ GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.extraHeader \ diff --git a/tests/test_utils/python_scripts/linear_ci.py b/tests/test_utils/python_scripts/linear_ci.py new file mode 100644 index 00000000000..04f96913f5f --- /dev/null +++ b/tests/test_utils/python_scripts/linear_ci.py @@ -0,0 +1,192 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Megatron-LM adapters for nemo-ci-triage's failure-reporting workflow. + +The triage package owns LLM summarization, Linear reconciliation, and Slack +follow-up logic. This module only converts Megatron-LM's direct child-pipeline +jobs into the generic failure records consumed by the package summarizer. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, Callable + +from nemo_ci_triage.agent import summarize_pipeline_failures as summarizer + +LINEAR_MODULE = "megatron_lm" +_FUNCTIONAL_PREFIX = "functional:run_" + + +def _variant_name(pipeline_name: str) -> str: + """Return the stable environment/platform suffix of a functional bridge.""" + return pipeline_name.removeprefix(_FUNCTIONAL_PREFIX).replace("_", "-") + + +def _recipe_name(pipeline_name: str, config_name: str) -> str: + """Disambiguate the same recipe across dev/LTS and GPU child pipelines.""" + return f"{config_name}@{_variant_name(pipeline_name)}" + + +def _job_url(project_url: str, job: dict) -> str: + return job.get("web_url") or f"{project_url}/-/jobs/{job['id']}" + + +def _failure_record(pipeline_name: str, job: dict, report: dict | None, project_url: str) -> dict: + """Return the raw failure shape accepted by the upstream LLM summarizer.""" + return { + "test_name": _recipe_name(pipeline_name, job["config_name"]), + "module": LINEAR_MODULE, + "report": report, + "job_url": _job_url(project_url, job), + "job_error_type": job.get("error_type"), + } + + +def _fallback_summary(failure: dict) -> dict: + """Preserve a failed test when its per-test LLM summary is unavailable.""" + report = failure.get("report") or {} + category = ( + report.get("error_type") + or report.get("category") + or failure.get("job_error_type") + or "Unknown" + ) + subtype = report.get("error_subtype") or failure.get("job_error_type") + subtype = subtype or (f"No structured error report was available for {failure['test_name']}") + summary = subtype if subtype == category else f"{category}: {subtype}" + return { + "test_name": failure["test_name"], + "module": failure["module"], + "category": category, + "summary": summary, + "excerpt": report.get("excerpt"), + "job_url": failure["job_url"], + } + + +def _summarize_failures(raw_failures: list[dict]) -> list[dict]: + """Use upstream LLM summaries, falling back without dropping failures.""" + with_reports = [failure for failure in raw_failures if failure.get("report")] + summarized = summarizer._summarize_failures( + with_reports, summarizer._SUMMARIZER_PROMPT.read_text(encoding="utf-8").strip() + ) + by_job = {(failure["test_name"], failure["job_url"]): failure for failure in summarized} + return [ + by_job.get((failure["test_name"], failure["job_url"]), _fallback_summary(failure)) + for failure in raw_failures + ] + + +def build_pipeline_reports( + pipeline_id: int, + scope: str, + pipeline_jobs: list[tuple[str, int, list[dict]]], + load_error_report: Callable[[int], dict | None], + project_url: str, +) -> tuple[dict, dict]: + """Build the two JSON contracts consumed by nemo-ci-triage reconciliation. + + Each recipe is qualified by its child-pipeline variant. A recipe is only + included in ``passed_tests`` when that exact variant completed successfully; + failed, canceled, and ambiguous allow-failure jobs can therefore never close + a live Linear issue accidentally. + """ + passed: set[str] = set() + unknown: set[str] = set() + raw_failures: list[dict] = [] + failed_jobs = 0 + + for pipeline_name, _, jobs in sorted(pipeline_jobs, key=lambda item: item[0]): + for job in sorted(jobs, key=lambda item: (item["config_name"], item["id"])): + recipe = _recipe_name(pipeline_name, job["config_name"]) + status = job.get("status") + report = None + + if status == "failed" or (status == "success" and job.get("allow_failure")): + report = load_error_report(job["id"]) + + suppressed_failure = bool( + status == "success" and report and report.get("exit_code_training") not in (None, 0) + ) + if status == "failed" or suppressed_failure: + failed_jobs += 1 + raw_failures.append(_failure_record(pipeline_name, job, report, project_url)) + elif status == "success" and (not job.get("allow_failure") or report is not None): + passed.add(recipe) + else: + unknown.add(recipe) + + failed_recipes = {failure["test_name"] for failure in raw_failures} + passed_tests = sorted(passed - failed_recipes - unknown) + + failures = _summarize_failures(raw_failures) + buckets, failed_stage = summarizer._subcategorize(failures) + bucketing_failed = buckets is None + if bucketing_failed: + print( + f"WARNING: LLM categorizer failed at {failed_stage}; " + "Linear reconciliation will skip this report", + file=sys.stderr, + ) + buckets = [] + else: + summarizer._attach_categories(buckets, failures) + + digest = summarizer._digest( + failures, {LINEAR_MODULE: {"passed": len(passed_tests), "failed": failed_jobs}} + ) + + module_stats = { + "passed": len(passed_tests), + "failed": failed_jobs, + "passed_tests": passed_tests, + } + summaries = { + "pipeline_id": pipeline_id, + "scope": scope, + "modules": {LINEAR_MODULE: module_stats}, + "digest": digest, + "failures": failures, + } + failure_buckets = { + "pipeline_id": pipeline_id, + "bucketing_failed": bucketing_failed, + "buckets": summarizer._denormalize_buckets(buckets, failures), + } + return summaries, failure_buckets + + +def fetch_error_report(project: Any, job_id: int) -> dict | None: + """Fetch one child job's structured report, degrading safely if absent.""" + try: + raw = project.jobs.get(job_id, lazy=True).artifact("error_report.json") + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + return json.loads(raw) + except Exception as exc: + print(f"WARNING: job {job_id}: could not read error_report.json: {exc}", file=sys.stderr) + return None + + +def write_pipeline_reports( + pipeline_id: int, + scope: str, + pipeline_jobs: list[tuple[str, int, list[dict]]], + project: Any, + project_url: str, + summaries_path: Path, + buckets_path: Path, +) -> None: + summaries, buckets = build_pipeline_reports( + pipeline_id, + scope, + pipeline_jobs, + lambda job_id: fetch_error_report(project, job_id), + project_url, + ) + summaries_path.write_text(json.dumps(summaries, indent=2) + "\n", encoding="utf-8") + buckets_path.write_text(json.dumps(buckets, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {summaries_path} and {buckets_path}") diff --git a/tests/test_utils/python_scripts/notify.py b/tests/test_utils/python_scripts/notify.py index db875271d89..cbb9bf527f7 100644 --- a/tests/test_utils/python_scripts/notify.py +++ b/tests/test_utils/python_scripts/notify.py @@ -1,19 +1,28 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import json import logging import os +from pathlib import Path +from typing import Any import click import gitlab from nemo_ci_triage.slack_notification import notification +from nemo_ci_triage.slack_notification.utils import repository_settings -PROJECT_ID = int(os.getenv("CI_PROJECT_ID", 19378)) +from tests.test_utils.python_scripts import linear_ci + +TRIAGE_CONFIG = Path(os.getenv("NEMO_CI_TRIAGE_CONFIG", ".gitlab/nemo-ci-triage.yml")) +PROJECT_ID, REPO_NAME = repository_settings(TRIAGE_CONFIG) WEBHOOK_URL = os.getenv("WEBHOOK_URL", "") +SLACK_BOT_TOKEN = os.getenv("MCORE_SLACK_BOT_TOKEN") or os.getenv("ALERTMANAGER_TOKEN", "") +SLACK_CHANNEL_ID = os.getenv("MCORE_SLACK_CHANNEL_ID", "") GITLAB_ENDPOINT = os.getenv("GITLAB_ENDPOINT") if not GITLAB_ENDPOINT: raise ValueError("GITLAB_ENDPOINT is required") SERVER_URL = f"https://{GITLAB_ENDPOINT}" -PROJECT_URL = os.getenv("CI_PROJECT_URL", f"{SERVER_URL}/ADLR/megatron-lm") +PROJECT_URL = os.getenv("CI_PROJECT_URL", f"{SERVER_URL}/{REPO_NAME}") TAG_TEAM = os.getenv("TAG_TEAM", "0") == "1" TEAM_SLUG = os.getenv("TEAM_SLUG", "") @@ -33,6 +42,11 @@ def get_gitlab_handle() -> gitlab.Gitlab: return gitlab.Gitlab(SERVER_URL, private_token=os.getenv("RO_API_TOKEN")) +def get_project() -> Any: + """Return the configured Megatron-LM GitLab project.""" + return get_gitlab_handle().projects.get(PROJECT_ID) + + def _bridge_gpu(bridge_name: str) -> str: for gpu in ("GB200", "H100", "A100"): if gpu.lower() in bridge_name.lower(): @@ -40,9 +54,11 @@ def _bridge_gpu(bridge_name: str) -> str: return "Unknown" -def get_pipeline_jobs(pipeline_id: int, job_prefix: str) -> list[tuple[str, int, list[dict]]]: +def get_pipeline_jobs( + pipeline_id: int, job_prefix: str, project: Any | None = None +) -> list[tuple[str, int, list[dict]]]: """Collect Megatron-LM's direct child pipelines using nemo-ci-triage-2.""" - project = get_gitlab_handle().projects.get(PROJECT_ID) + project = project or get_project() root_pipeline = project.pipelines.get(pipeline_id) pipeline_jobs = [] @@ -62,10 +78,13 @@ def get_pipeline_jobs(pipeline_id: int, job_prefix: str) -> list[tuple[str, int, return pipeline_jobs -def configure_notification_urls() -> None: - """Point nemo-ci-triage-2's notification links at Megatron-LM.""" - notification.JOB_URL_TEMPLATE = f"{PROJECT_URL}/-/jobs/{{}}" - notification.PIPELINE_URL_TEMPLATE = f"{PROJECT_URL}/-/pipelines/{{}}" +def write_slack_context(output: Path | None, thread_timestamp: str | None) -> None: + """Persist the non-secret Slack coordinates needed by a follow-up job.""" + if output is None: + return + context = {"channel_id": SLACK_CHANNEL_ID or None, "thread_timestamp": thread_timestamp} + output.write_text(json.dumps(context, indent=2) + "\n", encoding="utf-8") + logger.info("Wrote Slack thread context to %s", output) @click.command() @@ -77,23 +96,66 @@ def configure_notification_urls() -> None: ) @click.option("--pipeline-context", required=True, type=str) @click.option("--pipeline-created-at", required=True, type=str, expose_value=False) -def main(pipeline_id: int, check_for: str, pipeline_context: str) -> None: - pipeline_jobs = get_pipeline_jobs(pipeline_id, JOB_PREFIXES[check_for]) +@click.option("--summary-output", type=click.Path(path_type=Path), default=None) +@click.option("--failure-buckets-output", type=click.Path(path_type=Path), default=None) +@click.option("--slack-output", type=click.Path(path_type=Path), default=None) +def main( + pipeline_id: int, + check_for: str, + pipeline_context: str, + summary_output: Path | None, + failure_buckets_output: Path | None, + slack_output: Path | None, +) -> None: + if bool(summary_output) != bool(failure_buckets_output): + raise click.UsageError( + "--summary-output and --failure-buckets-output must be provided together" + ) + + project = get_project() + pipeline_jobs = get_pipeline_jobs(pipeline_id, JOB_PREFIXES[check_for], project=project) + + if summary_output: + linear_ci.write_pipeline_reports( + pipeline_id, + pipeline_context, + pipeline_jobs, + project, + PROJECT_URL, + summary_output, + failure_buckets_output, + ) if check_for == "smoke-tests": if all(job["status"] == "success" for _, _, jobs in pipeline_jobs for job in jobs): logger.info("All smoke tests passed, skipping Slack notification") + write_slack_context(slack_output, None) return - if not WEBHOOK_URL: - logger.info("No webhook URL configured, skipping Slack notification") + use_bot = bool(SLACK_BOT_TOKEN and SLACK_CHANNEL_ID) + if bool(SLACK_BOT_TOKEN) != bool(SLACK_CHANNEL_ID): + logger.warning( + "Both MCORE_SLACK_BOT_TOKEN (or ALERTMANAGER_TOKEN) and " + "MCORE_SLACK_CHANNEL_ID are required for threaded Slack replies" + ) + + if not WEBHOOK_URL and not use_bot: + logger.info("No Slack bot or webhook configured, skipping Slack notification") + write_slack_context(slack_output, None) return - configure_notification_urls() slack_mentions = f"{TEAM_SLUG} <@U09TX0DHZ97>" if TAG_TEAM else None - notification.send_slack_notification( - "megatron-lm", pipeline_context, pipeline_jobs, slack_mentions, webhook_url=WEBHOOK_URL + thread_timestamp = notification.send_slack_notification( + "megatron-lm", + pipeline_context, + pipeline_jobs, + slack_mentions, + webhook_url=WEBHOOK_URL or None, + slack_bot_token=SLACK_BOT_TOKEN if use_bot else None, + slack_channel_id=SLACK_CHANNEL_ID if use_bot else None, + config=TRIAGE_CONFIG, ) + write_slack_context(slack_output, thread_timestamp) if __name__ == "__main__": diff --git a/tests/test_utils/test_ci_triage.py b/tests/test_utils/test_ci_triage.py index 74ca1bc354a..2c8ec07b720 100644 --- a/tests/test_utils/test_ci_triage.py +++ b/tests/test_utils/test_ci_triage.py @@ -1,5 +1,7 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -7,12 +9,42 @@ import yaml from click.testing import CliRunner -from tests.test_utils.python_scripts import generate_jet_trigger_job, recipe_parser +from tests.test_utils.python_scripts import generate_jet_trigger_job, linear_ci, recipe_parser + + +def _mock_llm_reporting(monkeypatch): + summarize = Mock( + side_effect=lambda failures, _prompt: [ + linear_ci._fallback_summary(failure) for failure in failures + ] + ) + + def group_failures(failures): + grouped = {} + for failure in failures: + grouped.setdefault((failure["category"], failure["summary"]), []).append( + failure["test_name"] + ) + return ( + [ + {"label": f"test-bucket-{index}", "rationale": summary, "tests": tests} + for index, ((_, summary), tests) in enumerate(grouped.items(), 1) + ], + None, + ) + + subcategorize = Mock(side_effect=group_failures) + digest = Mock(return_value="LLM pipeline digest") + monkeypatch.setattr(linear_ci.summarizer, "_summarize_failures", summarize) + monkeypatch.setattr(linear_ci.summarizer, "_subcategorize", subcategorize) + monkeypatch.setattr(linear_ci.summarizer, "_digest", digest) + return summarize, subcategorize, digest @pytest.fixture -def notify_module(): +def notify_module(monkeypatch): pytest.importorskip("nemo_ci_triage.slack_notification") + monkeypatch.setenv("GITLAB_ENDPOINT", "ci.example.com") from tests.test_utils.python_scripts import notify return notify @@ -87,6 +119,49 @@ def test_error_extraction_is_opt_in_for_generated_jobs( assert job["artifacts"]["paths"] == ["results/"] +def test_notification_rules_use_expected_pipeline_sources(): + unit = yaml.safe_load(Path(".gitlab/stages/02.test.yml").read_text()) + functional = yaml.safe_load(Path(".gitlab/stages/04.functional-tests.yml").read_text()) + triage = yaml.safe_load(Path(".gitlab/stages/06.triage.yml").read_text()) + + unit_conditions = [ + rule["if"] for rule in unit["test:unit_tests_notify"]["rules"] if "if" in rule + ] + assert unit_conditions == [ + '$CI_PIPELINE_SOURCE == "schedule" && ' + '($CI_COMMIT_BRANCH == "ci-unit-test-extended" || ' + '$CI_COMMIT_BRANCH == "ci-dev-unit-test-extended")' + ] + + smoke_condition = functional["functional:smoke_notify"]["rules"][1]["if"] + assert smoke_condition == ( + '$FUNCTIONAL_TEST == "yes" && $FUNCTIONAL_TEST_SCOPE =~ /^(mr|nightly)$/ && ' + '($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main" || ' + '$CI_MERGE_REQUEST_EVENT_TYPE == "merged_result")' + ) + assert functional["functional:x_notify"]["rules"][0]["if"] == ( + '($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main") && ' + '$FUNCTIONAL_TEST == "yes"' + ) + + triage_jobs = (".linear_reconcile_rules", "triage:linear_write", "triage:slack_linear_followup") + for job_name in triage_jobs: + condition = triage[job_name]["rules"][0]["if"] + assert '$FUNCTIONAL_TEST == "yes"' in condition + assert '$CI_PIPELINE_SOURCE == "schedule"' in condition + assert '$CI_COMMIT_BRANCH == "main"' in condition + + +def test_all_generated_test_types_enable_error_extraction(): + unit = Path(".gitlab/stages/02.test.yml").read_text() + integration = Path(".gitlab/stages/03.integration-tests.yml").read_text() + functional = Path(".gitlab/stages/04.functional-tests.yml").read_text() + + assert unit.count('"--enable-error-extraction"') >= 1 + assert integration.count('"--enable-error-extraction"') >= 1 + assert functional.count('"--enable-error-extraction"') >= 2 + + def test_get_pipeline_jobs_uses_triage_collector(monkeypatch, notify_module): notify = notify_module bridge = SimpleNamespace( @@ -110,14 +185,180 @@ def test_get_pipeline_jobs_uses_triage_collector(monkeypatch, notify_module): collector.assert_called_once_with(project, 101) +def test_build_linear_reports_groups_matching_failures(monkeypatch): + summarize, subcategorize, digest = _mock_llm_reporting(monkeypatch) + pipeline_jobs = [ + ( + "functional:run_dev_dgx_h100", + 101, + [ + { + "config_name": "gpt_pass", + "id": 1, + "status": "success", + "allow_failure": False, + "error_type": None, + }, + { + "config_name": "gpt_fail_a", + "id": 2, + "status": "failed", + "allow_failure": False, + "error_type": "CUDA OOM", + }, + ], + ), + ( + "functional:run_lts_dgx_h100", + 102, + [ + { + "config_name": "gpt_fail_b", + "id": 3, + "status": "failed", + "allow_failure": True, + "error_type": None, + } + ], + ), + ] + reports = { + 2: { + "exit_code_training": 1, + "category": "CUDA OOM", + "error_subtype": "torch.OutOfMemoryError", + "excerpt": "CUDA out of memory", + }, + 3: { + "exit_code_training": 1, + "category": "CUDA OOM", + "error_subtype": "torch.OutOfMemoryError", + "excerpt": "CUDA out of memory", + }, + } + + summaries, buckets = linear_ci.build_pipeline_reports( + 123, "nightly", pipeline_jobs, reports.get, "https://ci.example.com/ADLR/megatron-lm" + ) + + stats = summaries["modules"][linear_ci.LINEAR_MODULE] + assert stats == {"passed": 1, "failed": 2, "passed_tests": ["gpt_pass@dev-dgx-h100"]} + assert len(buckets["buckets"]) == 1 + bucket = buckets["buckets"][0] + assert bucket["category"] == "CUDA OOM" + assert bucket["rationale"] == "CUDA OOM: torch.OutOfMemoryError" + assert bucket["tests"] == [ + { + "name": "gpt_fail_a@dev-dgx-h100", + "job_url": "https://ci.example.com/ADLR/megatron-lm/-/jobs/2", + }, + { + "name": "gpt_fail_b@lts-dgx-h100", + "job_url": "https://ci.example.com/ADLR/megatron-lm/-/jobs/3", + }, + ] + summarize.assert_called_once() + subcategorize.assert_called_once() + digest.assert_called_once() + + +def test_allow_failure_without_report_is_not_counted_as_passed(monkeypatch): + _mock_llm_reporting(monkeypatch) + pipeline_jobs = [ + ( + "functional:run_dev_dgx_h100", + 101, + [ + { + "config_name": "ambiguous", + "id": 4, + "status": "success", + "allow_failure": True, + "error_type": None, + } + ], + ) + ] + + summaries, buckets = linear_ci.build_pipeline_reports( + 123, + "nightly", + pipeline_jobs, + lambda _job_id: None, + "https://ci.example.com/ADLR/megatron-lm", + ) + + stats = summaries["modules"][linear_ci.LINEAR_MODULE] + assert stats["passed_tests"] == [] + assert stats["failed"] == 0 + assert buckets["buckets"] == [] + + +def test_failed_job_without_report_still_creates_a_safe_bucket(monkeypatch): + _mock_llm_reporting(monkeypatch) + pipeline_jobs = [ + ( + "functional:run_dev_dgx_h100", + 101, + [ + { + "config_name": "missing_report", + "id": 5, + "status": "failed", + "allow_failure": False, + "error_type": None, + } + ], + ) + ] + + summaries, buckets = linear_ci.build_pipeline_reports( + 123, + "nightly", + pipeline_jobs, + lambda _job_id: None, + "https://ci.example.com/ADLR/megatron-lm", + ) + + assert summaries["modules"][linear_ci.LINEAR_MODULE]["failed"] == 1 + assert buckets["buckets"][0]["tests"][0]["name"] == "missing_report@dev-dgx-h100" + assert "No structured error report" in buckets["buckets"][0]["rationale"] + + +def test_triage_config_selects_megatron_and_enables_write_actions(): + linear_status = pytest.importorskip("nemo_ci_triage.linear.linear_status") + linear_write = pytest.importorskip("nemo_ci_triage.linear.linear_write") + config = Path(".gitlab/nemo-ci-triage.yml") + + assert linear_status.modules_for_regex("^megatron-lm$", config) == [ + ( + linear_ci.LINEAR_MODULE, + { + "build_module": "megatron-lm", + "team_key": "MCORE", + "project_template": "MCore CI Testing", + "enable_linear_open": True, + "enable_linear_modify": True, + "enable_linear_close": True, + }, + ) + ] + assert linear_write.write_gates(config) == { + linear_ci.LINEAR_MODULE: {"open": True, "modify": True, "close": True} + } + + def test_notification_delegates_to_triage_package(monkeypatch, notify_module): notify = notify_module pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [{"status": "failed"}])] sender = Mock() monkeypatch.setattr(notify, "WEBHOOK_URL", "https://slack.invalid/webhook") + monkeypatch.setattr(notify, "SLACK_BOT_TOKEN", "") + monkeypatch.setattr(notify, "SLACK_CHANNEL_ID", "") monkeypatch.setattr(notify, "PROJECT_URL", "https://ci.example.com/ADLR/megatron-lm") - monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args: pipeline_jobs) + monkeypatch.setattr(notify, "get_project", Mock()) + monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args, **_kwargs: pipeline_jobs) monkeypatch.setattr(notify.notification, "send_slack_notification", sender) result = CliRunner().invoke( @@ -135,13 +376,98 @@ def test_notification_delegates_to_triage_package(monkeypatch, notify_module): ) assert result.exit_code == 0, result.output - assert ( - notify.notification.JOB_URL_TEMPLATE == "https://ci.example.com/ADLR/megatron-lm/-/jobs/{}" + sender.assert_called_once_with( + "megatron-lm", + "mr", + pipeline_jobs, + None, + webhook_url="https://slack.invalid/webhook", + slack_bot_token=None, + slack_channel_id=None, + config=notify.TRIAGE_CONFIG, ) - assert ( - notify.notification.PIPELINE_URL_TEMPLATE - == "https://ci.example.com/ADLR/megatron-lm/-/pipelines/{}" + + +def test_notification_records_bot_thread_context(monkeypatch, tmp_path, notify_module): + notify = notify_module + pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [{"status": "failed"}])] + sender = Mock(return_value="1712345678.000100") + slack_output = tmp_path / "slack_notification.json" + + monkeypatch.setattr(notify, "WEBHOOK_URL", "") + monkeypatch.setattr(notify, "SLACK_BOT_TOKEN", "xoxb-test") + monkeypatch.setattr(notify, "SLACK_CHANNEL_ID", "C0123456789") + monkeypatch.setattr(notify, "get_project", Mock()) + monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args, **_kwargs: pipeline_jobs) + monkeypatch.setattr(notify.notification, "send_slack_notification", sender) + + result = CliRunner().invoke( + notify.main, + [ + "--pipeline-id", + "123", + "--check-for", + "functional-tests", + "--pipeline-context", + "mr", + "--pipeline-created-at", + "2026-07-12T00:00:00Z", + "--slack-output", + str(slack_output), + ], ) + + assert result.exit_code == 0, result.output sender.assert_called_once_with( - "megatron-lm", "mr", pipeline_jobs, None, webhook_url="https://slack.invalid/webhook" + "megatron-lm", + "mr", + pipeline_jobs, + None, + webhook_url=None, + slack_bot_token="xoxb-test", + slack_channel_id="C0123456789", + config=notify.TRIAGE_CONFIG, + ) + assert json.loads(slack_output.read_text()) == { + "channel_id": "C0123456789", + "thread_timestamp": "1712345678.000100", + } + + +def test_notification_writes_linear_inputs_without_webhook(monkeypatch, tmp_path, notify_module): + notify = notify_module + project = Mock() + pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [])] + writer = Mock() + + monkeypatch.setattr(notify, "WEBHOOK_URL", "") + monkeypatch.setattr(notify, "SLACK_BOT_TOKEN", "") + monkeypatch.setattr(notify, "SLACK_CHANNEL_ID", "") + monkeypatch.setattr(notify, "get_project", lambda: project) + monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args, **_kwargs: pipeline_jobs) + monkeypatch.setattr(notify.linear_ci, "write_pipeline_reports", writer) + summaries = tmp_path / "pipeline_summaries.json" + buckets = tmp_path / "failure_buckets.json" + + result = CliRunner().invoke( + notify.main, + [ + "--pipeline-id", + "123", + "--check-for", + "functional-tests", + "--pipeline-context", + "nightly", + "--pipeline-created-at", + "2026-07-12T00:00:00Z", + "--summary-output", + str(summaries), + "--failure-buckets-output", + str(buckets), + ], + ) + + assert result.exit_code == 0, result.output + writer.assert_called_once_with( + 123, "nightly", pipeline_jobs, project, notify.PROJECT_URL, summaries, buckets ) From bd0872dda07a38d1f03c54cdac73689229e07101 Mon Sep 17 00:00:00 2001 From: Evgenii Zheltonozhskii Date: Mon, 20 Jul 2026 23:11:48 +0300 Subject: [PATCH 065/290] Reduce boilerplate around MultiStorageClient feature checks (#5269) Signed-off-by: Evgenii Zheltonozhskii Co-authored-by: Maanu Grover Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- megatron/core/datasets/indexed_dataset.py | 36 ++------- megatron/core/dist_checkpointing/core.py | 26 ++----- .../core/dist_checkpointing/serialization.py | 13 +--- .../dist_checkpointing/strategies/common.py | 21 +----- .../core/dist_checkpointing/validation.py | 73 +++++-------------- megatron/core/msc_utils.py | 64 +++++++++++++--- megatron/training/checkpointing.py | 54 +++++--------- megatron/training/config/container.py | 26 ++----- .../training/distillation/utils_logits.py | 8 +- megatron/training/training.py | 4 +- megatron/training/utils/common_utils.py | 6 +- tests/unit_tests/test_msc_utils.py | 40 ++++++++++ .../training/config/test_container_base.py | 4 +- 13 files changed, 168 insertions(+), 207 deletions(-) create mode 100644 tests/unit_tests/test_msc_utils.py diff --git a/megatron/core/datasets/indexed_dataset.py b/megatron/core/datasets/indexed_dataset.py index 76de4cca8d2..ce216c8b855 100644 --- a/megatron/core/datasets/indexed_dataset.py +++ b/megatron/core/datasets/indexed_dataset.py @@ -39,7 +39,7 @@ is_object_storage_path, parse_s3_path, ) -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import MultiStorageClientFeature, maybe_msc from megatron.core.utils import log_single_rank logger = logging.getLogger(__name__) @@ -138,11 +138,7 @@ def __enter__(self) -> "_IndexWriter": Returns: _IndexWriter: The instance """ - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - self.idx_writer = msc.open(self.idx_path, "wb") - else: - self.idx_writer = open(self.idx_path, "wb") + self.idx_writer = maybe_msc.open(self.idx_path, "wb") # fixed, vestigial practice self.idx_writer.write(_INDEX_HEADER) # fixed, vestigial practice @@ -394,11 +390,7 @@ class _MMapBinReader(_BinReader): """ def __init__(self, bin_path: str) -> None: - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - self._bin_file_reader = msc.open(bin_path, mode="rb") - else: - self._bin_file_reader = open(bin_path, mode="rb") + self._bin_file_reader = maybe_msc.open(bin_path, mode="rb") self._bin_buffer_mmap = numpy.memmap(self._bin_file_reader, mode="r", order="C") self._bin_buffer = memoryview(self._bin_buffer_mmap.data) @@ -462,15 +454,9 @@ def read(self, dtype: Type[numpy.number], count: int, offset: int) -> numpy.ndar def _read(): """Helper method to read `count` bytes from self._bin_path at provided offset.""" sequence = numpy.empty(count, dtype=dtype) - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - with msc.open(self._bin_path, mode="rb", buffering=0) as bin_buffer_file: - bin_buffer_file.seek(offset) - bin_buffer_file.readinto(sequence) - else: - with open(self._bin_path, mode="rb", buffering=0) as bin_buffer_file: - bin_buffer_file.seek(offset) - bin_buffer_file.readinto(sequence) + with maybe_msc.open(self._bin_path, mode="rb", buffering=0) as bin_buffer_file: + bin_buffer_file.seek(offset) + bin_buffer_file.readinto(sequence) return sequence sleep_duration = self.sleep_duration_start @@ -948,13 +934,7 @@ class IndexedDatasetBuilder(object): def __init__( self, bin_path: str, dtype: Type[numpy.number] = numpy.int32, multimodal: bool = False ) -> None: - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - self._open = msc.open - else: - self._open = open - - self.data_file = self._open(bin_path, "wb") + self.data_file = maybe_msc.open(bin_path, "wb") self.dtype = dtype self.multimodal = multimodal @@ -1023,7 +1003,7 @@ def add_index(self, path_prefix: str) -> None: gc.collect() # Concatenate data - with self._open(get_bin_path(path_prefix), "rb") as f: + with maybe_msc.open(get_bin_path(path_prefix), "rb") as f: shutil.copyfileobj(f, self.data_file) def finalize(self, idx_path: str) -> None: diff --git a/megatron/core/dist_checkpointing/core.py b/megatron/core/dist_checkpointing/core.py index c601d0f5ce9..cdb244dbb8d 100644 --- a/megatron/core/dist_checkpointing/core.py +++ b/megatron/core/dist_checkpointing/core.py @@ -8,7 +8,7 @@ from dataclasses import asdict, dataclass from typing import Optional -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import maybe_msc CONFIG_FNAME = 'metadata.json' @@ -57,17 +57,10 @@ def maybe_load_config(checkpoint_dir: str) -> Optional[CheckpointingConfig]: """ config_path = os.path.join(checkpoint_dir, CONFIG_FNAME) if checkpoint_dir: - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - if not msc.os.path.exists(config_path): - return None - with msc.open(config_path) as f: - config_dict = json.load(f) - else: - if not os.path.exists(config_path): - return None - with open(config_path) as f: - config_dict = json.load(f) + if not maybe_msc.os.path.exists(config_path): + return None + with maybe_msc.open(config_path) as f: + config_dict = json.load(f) known_fields = {f.name for f in dataclasses.fields(CheckpointingConfig)} return CheckpointingConfig(**{k: v for k, v in config_dict.items() if k in known_fields}) return None @@ -84,10 +77,5 @@ def save_config(config: CheckpointingConfig, checkpoint_dir: str): None """ config_path = os.path.join(checkpoint_dir, CONFIG_FNAME) - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - with msc.open(config_path, 'w') as f: - json.dump(asdict(config), f) - else: - with open(config_path, 'w') as f: - json.dump(asdict(config), f) + with maybe_msc.open(config_path, 'w') as f: + json.dump(asdict(config), f) diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index f01c0eb45f6..a76f21fed8c 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -16,7 +16,7 @@ import torch -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import maybe_msc from . import ShardedTensor from .core import CheckpointingConfig, save_config @@ -180,10 +180,7 @@ def load( def _legacy_common_state_exists(checkpoint_dir: str) -> bool: """Check whether the checkpoint stores common data in a legacy common.pt file.""" path = os.path.join(checkpoint_dir, COMMON_STATE_FNAME) - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - return msc.Path(path).exists() - return os.path.exists(path) + return maybe_msc.Path(path).exists() def load_common_state_dict(checkpoint_dir: Union[str, Path]) -> StateDict: @@ -398,11 +395,7 @@ def save( from .strategies.fully_parallel import FullyParallelSaveStrategyWrapper if torch.distributed.get_rank() == 0: - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - checkpoint_dir_path = msc.Path(str(checkpoint_dir)) - else: - checkpoint_dir_path = Path(checkpoint_dir) + checkpoint_dir_path = maybe_msc.Path(str(checkpoint_dir)) if next(checkpoint_dir_path.iterdir(), None) is not None: # Don't throw exception here since this could cause a cascade of failures diff --git a/megatron/core/dist_checkpointing/strategies/common.py b/megatron/core/dist_checkpointing/strategies/common.py index 1ec3d829275..3e9685e2b9c 100644 --- a/megatron/core/dist_checkpointing/strategies/common.py +++ b/megatron/core/dist_checkpointing/strategies/common.py @@ -4,12 +4,11 @@ import logging import os -from pathlib import Path import torch from megatron.core.dist_checkpointing.mapping import StateDict -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import maybe_msc from ..mapping import CheckpointingException @@ -27,11 +26,7 @@ def save_common(common_state_dict: StateDict, checkpoint_dir: str): if torch.distributed.get_rank() == 0: path = os.path.join(checkpoint_dir, COMMON_STATE_FNAME) - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - msc.torch.save(common_state_dict, path) - else: - torch.save(common_state_dict, path) + maybe_msc.torch.save(common_state_dict, path) def load_common(checkpoint_dir: str): @@ -50,17 +45,9 @@ def load_common(checkpoint_dir: str): load_path = os.path.join(checkpoint_dir, COMMON_STATE_FNAME) try: - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - return msc.torch.load(load_path, map_location='cpu') - else: - return torch.load(load_path, map_location='cpu') + return maybe_msc.torch.load(load_path, map_location='cpu') except FileNotFoundError as e: err_msg = f'Common file {load_path} does not exist' - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - ckpt_files = [f.name for f in msc.Path(checkpoint_dir).iterdir()] - else: - ckpt_files = [f.name for f in Path(checkpoint_dir).iterdir()] + ckpt_files = [f.name for f in maybe_msc.Path(checkpoint_dir).iterdir()] logger.debug(f'{err_msg}. Checkpoint directory content: {ckpt_files}') raise CheckpointingException(err_msg) from e diff --git a/megatron/core/dist_checkpointing/validation.py b/megatron/core/dist_checkpointing/validation.py index b0cbae618a7..17b773ebb81 100644 --- a/megatron/core/dist_checkpointing/validation.py +++ b/megatron/core/dist_checkpointing/validation.py @@ -6,7 +6,6 @@ import os from collections import Counter, defaultdict from enum import Enum -from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union import numpy as np @@ -25,7 +24,7 @@ ShardedStateDict, is_main_replica, ) -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import maybe_msc if TYPE_CHECKING: from megatron.core.dist_checkpointing.serialization import CkptShardedMetadata @@ -207,12 +206,7 @@ def verify_checkpoint(checkpoint_dir: str): Args: checkpoint_dir (str): checkpoint directory """ - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - isdir = msc.os.path.isdir(str(checkpoint_dir), strict=False) - else: - isdir = os.path.isdir(checkpoint_dir) - if not isdir: + if not maybe_msc.path_isdir(str(checkpoint_dir), strict=False): raise CheckpointingException(f'Checkpoint directory {checkpoint_dir} does not exist') if not check_is_distributed_checkpoint(checkpoint_dir): @@ -506,15 +500,9 @@ def _compute_file_hash(file_path: str) -> str: Lowercase hex-encoded SHA-256 digest string. """ h = hashlib.sha256() - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - with msc.open(file_path, 'rb') as f: - for chunk in iter(lambda: f.read(_READ_CHUNK_SIZE), b''): - h.update(chunk) - else: - with open(file_path, 'rb') as f: - for chunk in iter(lambda: f.read(_READ_CHUNK_SIZE), b''): - h.update(chunk) + with maybe_msc.open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(_READ_CHUNK_SIZE), b''): + h.update(chunk) return h.hexdigest() @@ -528,28 +516,16 @@ def save_integrity_manifest(checkpoint_dir: str) -> None: """ manifest: Dict[str, str] = {} - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - ckpt_path = msc.Path(checkpoint_dir) - for entry in sorted(ckpt_path.iterdir(), key=lambda p: str(p)): - if entry.name != INTEGRITY_FNAME: - manifest[entry.name] = _compute_file_hash(str(entry)) - else: - ckpt_path = Path(checkpoint_dir) - for entry in sorted(ckpt_path.iterdir()): - if entry.is_file() and entry.name != INTEGRITY_FNAME: - manifest[entry.name] = _compute_file_hash(str(entry)) + ckpt_path = maybe_msc.Path(checkpoint_dir) + for entry in sorted(ckpt_path.iterdir(), key=lambda p: str(p)): + if entry.is_file() and entry.name != INTEGRITY_FNAME: + manifest[entry.name] = _compute_file_hash(str(entry)) integrity_path = os.path.join(checkpoint_dir, INTEGRITY_FNAME) payload = {'algorithm': _HASH_ALGORITHM, 'files': manifest} - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - with msc.open(integrity_path, 'w') as f: - json.dump(payload, f, indent=2) - else: - with open(integrity_path, 'w') as f: - json.dump(payload, f, indent=2) + with maybe_msc.open(integrity_path, 'w') as f: + json.dump(payload, f, indent=2) logger.info("Saved integrity manifest with %d file(s) to %s", len(manifest), integrity_path) @@ -567,25 +543,14 @@ def _verify_integrity_manifest_impl(checkpoint_dir: str) -> None: """ integrity_path = os.path.join(checkpoint_dir, INTEGRITY_FNAME) - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - if not msc.os.path.exists(integrity_path): - raise CheckpointingException( - f'Integrity manifest not found at {integrity_path}. ' - 'The checkpoint must be saved with integrity verification enabled ' - '(save_integrity=True) before it can be verified on load.' - ) - with msc.open(integrity_path) as f: - manifest_data = json.load(f) - else: - if not os.path.exists(integrity_path): - raise CheckpointingException( - f'Integrity manifest not found at {integrity_path}. ' - 'The checkpoint must be saved with integrity verification enabled ' - '(save_integrity=True) before it can be verified on load.' - ) - with open(integrity_path) as f: - manifest_data = json.load(f) + if not maybe_msc.os.path.exists(integrity_path): + raise CheckpointingException( + f'Integrity manifest not found at {integrity_path}. ' + 'The checkpoint must be saved with integrity verification enabled ' + '(save_integrity=True) before it can be verified on load.' + ) + with maybe_msc.open(integrity_path) as f: + manifest_data = json.load(f) algorithm = manifest_data.get('algorithm', _HASH_ALGORITHM) if algorithm != _HASH_ALGORITHM: diff --git a/megatron/core/msc_utils.py b/megatron/core/msc_utils.py index ce7cb685e25..c7dd7dc07d6 100644 --- a/megatron/core/msc_utils.py +++ b/megatron/core/msc_utils.py @@ -55,13 +55,57 @@ def __setstate__(self, state): MultiStorageClientFeature = _FeatureFlag(default=False) -def open_file(*args, **kwargs): - """Open a file with the appropriate method based on whether MSC is enabled.""" - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - return msc.open(*args, **kwargs) - else: - return open(*args, **kwargs) - - -__all__ = ['MultiStorageClientFeature', 'open_file'] +class MaybeMultiStorageClient: + """ + Helper class to use MultiStorageClient + """ + + def path_isdir(self, path, strict: bool = True): + """ + Check if a path is an existing directory. + :param path: path to check + :param strict: if True, use only committed metadata for MSC + """ + if MultiStorageClientFeature.is_enabled(): + pkg = MultiStorageClientFeature.import_package() + return pkg.os.path.isdir(path, strict=strict) + else: + import os + + return os.path.isdir(path) + + def __getattr__(self, name): + if MultiStorageClientFeature.is_enabled(): + pkg = MultiStorageClientFeature.import_package() + if hasattr(pkg, name): + return getattr(pkg, name) + + if name == "open": + return open + if name == "os": + import os + + return os + if name == "Path": + from pathlib import Path + + return Path + if name == "torch": + import torch + + return torch + raise AttributeError(f"{self.__class__.__name__!s} has no attribute {name!s}") + + def __dir__(self): + attrs = {"open", "os", "Path", "torch"} + if MultiStorageClientFeature.is_enabled(): + try: + pkg = MultiStorageClientFeature.import_package() + attrs.update(dir(pkg)) + except RuntimeError: + pass + return sorted(attrs) + + +maybe_msc = MaybeMultiStorageClient() +__all__ = ['MultiStorageClientFeature', 'maybe_msc'] diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 2350549ebf1..25951c6abf4 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -35,7 +35,7 @@ TorchDistSaveShardedStrategy, get_async_strategy, ) -from megatron.core.msc_utils import MultiStorageClientFeature, open_file +from megatron.core.msc_utils import maybe_msc from megatron.core.num_microbatches_calculator import update_num_microbatches from megatron.core.optimizer import DistributedOptimizer from megatron.core.rerun_state_machine import get_rerun_state_machine @@ -175,23 +175,10 @@ def _compare(arg_name, old_arg_name=None, default=None): _compare('tensor_model_parallel_size') _compare('pipeline_model_parallel_size') - -def isfile(filename) -> bool: - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - return msc.os.path.isfile(filename) - else: - return os.path.isfile(filename) - - def ensure_directory_exists(filename, check_parent=True): """Build filename's path if it does not already exists.""" dirname = os.path.dirname(filename) if check_parent else filename - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - msc.os.makedirs(dirname, exist_ok=True) - else: - os.makedirs(dirname, exist_ok=True) + maybe_msc.os.makedirs(dirname, exist_ok=True) def get_checkpoint_name(checkpoints_path, iteration, release=False, @@ -243,7 +230,7 @@ def get_load_checkpoint_path_by_args(args, load_arg="load"): tracker_filename = 'because load directory is not defined' if load_dir is not None: tracker_filename = get_checkpoint_tracker_filename(load_dir) - if isfile(tracker_filename): + if maybe_msc.os.path.isfile(tracker_filename): iteration, release = read_metadata(tracker_filename) # Allow user to specify the loaded iteration. @@ -272,7 +259,7 @@ def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): pipeline_parallel=False, tensor_rank=0, pipeline_rank=0, expert_parallel=False, expert_rank=0) - if isfile(filename): + if maybe_msc.os.path.isfile(filename): return filename # Look for checkpoint with no pipelining and expert parallelism @@ -280,7 +267,7 @@ def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): pipeline_parallel=False, tensor_rank=0, pipeline_rank=0, expert_parallel=True, expert_rank=0) - if isfile(filename): + if maybe_msc.os.path.isfile(filename): return filename # Look for checkpoint with pipelining and no expert parallelism @@ -288,7 +275,7 @@ def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): pipeline_parallel=True, tensor_rank=0, pipeline_rank=0, expert_parallel=False, expert_rank=0) - if isfile(filename): + if maybe_msc.os.path.isfile(filename): return filename # Look for checkpoint with pipelining and expert parallelism @@ -296,7 +283,7 @@ def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): pipeline_parallel=True, tensor_rank=0, pipeline_rank=0, expert_parallel=True, expert_rank=0) - if isfile(filename): + if maybe_msc.os.path.isfile(filename): return filename # Look for a distributed checkpoint @@ -320,7 +307,7 @@ def checkpoint_exists(checkpoints_path): if checkpoints_path is None: return False path = get_checkpoint_tracker_filename(checkpoints_path) - return isfile(path) + return maybe_msc.os.path.isfile(path) def read_metadata(tracker_filename): @@ -329,7 +316,7 @@ def read_metadata(tracker_filename): iteration = -1 release = False - with open_file(tracker_filename, 'r') as f: + with maybe_msc.open(tracker_filename, 'r') as f: metastring = f.read().strip() try: iteration = int(metastring) @@ -849,10 +836,10 @@ def iter_finalize_fn(): prev_iteration = 0 save_retain_interval = getattr(args, 'save_retain_interval', None) # For backwards compatibility of tests. if save_retain_interval is not None: - if os.path.exists(tracker_filename): # TODO: Make this work with MSC remote paths? - with open_file(tracker_filename, 'r') as f: + if maybe_msc.os.path.exists(tracker_filename): + with maybe_msc.open(tracker_filename, 'r') as f: prev_iteration = int(f.read().strip()) - with open_file(tracker_filename, 'w') as f: + with maybe_msc.open(tracker_filename, 'w') as f: f.write("release" if release else str(iteration)) print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] successfully saved " f"checkpoint from iteration {int(iteration):7d} to {args.save} " @@ -1236,7 +1223,7 @@ def _get_non_persistent_iteration(non_persistent_global_dir, args, checkpointing return -1 elif args.non_persistent_ckpt_type == "global": tracker_filename = get_checkpoint_tracker_filename(non_persistent_global_dir) - if isfile(tracker_filename): + if maybe_msc.os.path.isfile(tracker_filename): iteration, release = read_metadata(tracker_filename) if release: raise RuntimeError('Non-persistent checkpoint can\'t be a release checkpoint') @@ -1358,14 +1345,9 @@ def _load_global_dist_base_checkpoint( def _get_checkpoint_format(checkpoint_name, args): """Get the format of an existing checkpoint.""" - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - checkpoint_dir = msc.Path(checkpoint_name) - is_torch_ckpt = any([f.name.startswith("mp_rank_0") for f in checkpoint_dir.iterdir()]) - is_torch_dcp = checkpoint_dir.joinpath(".metadata").exists() - else: - is_torch_ckpt = any([f.startswith("mp_rank_0") for f in os.listdir(checkpoint_name)]) - is_torch_dcp = os.path.exists(os.path.join(checkpoint_name, ".metadata")) + checkpoint_dir = maybe_msc.Path(checkpoint_name) + is_torch_ckpt = any([f.name.startswith("mp_rank_0") for f in checkpoint_dir.iterdir()]) + is_torch_dcp = checkpoint_dir.joinpath(".metadata").exists() ckpt_format = None if dist_checkpointing.check_is_distributed_checkpoint(checkpoint_name): @@ -1408,7 +1390,7 @@ def _load_base_checkpoint( tracker_filename = 'because load directory is not defined' if load_dir is not None: tracker_filename = get_checkpoint_tracker_filename(load_dir) - if isfile(tracker_filename): + if maybe_msc.os.path.isfile(tracker_filename): iteration, release = read_metadata(tracker_filename) # Allow user to specify the loaded iteration. @@ -2242,7 +2224,7 @@ def load_biencoder_checkpoint(model, only_query_model=False, tracker_filename = get_checkpoint_tracker_filename(load_path) - with open_file(tracker_filename, 'r') as f: + with maybe_msc.open(tracker_filename, 'r') as f: iteration = int(f.read().strip()) checkpoint_name = get_checkpoint_name(load_path, iteration, diff --git a/megatron/training/config/container.py b/megatron/training/config/container.py index 6477290ff70..1fe7f45715f 100644 --- a/megatron/training/config/container.py +++ b/megatron/training/config/container.py @@ -14,7 +14,7 @@ HAVE_YAML = False from megatron.core.distributed.distributed_data_parallel_config import DistributedDataParallelConfig -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import maybe_msc from megatron.core.optimizer import OptimizerConfig from megatron.training.config.common_config import DistributedInitConfig, ProfilingConfig, RNGConfig from megatron.training.config.inference_config import InferenceSetupConfig @@ -108,22 +108,13 @@ def from_yaml(cls: Type[T], yaml_path: str, mode: InstantiationMode = Instantiat from omegaconf import OmegaConf - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - yaml_path_exists = msc.os.path.exists(yaml_path) - else: - yaml_path_exists = os.path.exists(yaml_path) + yaml_path_exists = maybe_msc.os.path.exists(yaml_path) if not yaml_path_exists: raise FileNotFoundError(f"YAML file not found: {yaml_path}") - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - with msc.open(yaml_path, "r") as f: - config_dict = yaml.safe_load(f) - else: - with open(yaml_path, "r") as f: - config_dict = yaml.safe_load(f) + with maybe_msc.open(yaml_path, "r") as f: + config_dict = yaml.safe_load(f) # Convert to OmegaConf first for better compatibility with instantiate conf = OmegaConf.create(config_dict) @@ -218,13 +209,8 @@ def to_yaml(self, yaml_path: str) -> None: config_dict = self.to_dict() with safe_yaml_representers(): - if MultiStorageClientFeature.is_enabled(): - msc = MultiStorageClientFeature.import_package() - with msc.open(yaml_path, "w") as f: - yaml.safe_dump(config_dict, f, default_flow_style=False) - else: - with open(yaml_path, "w") as f: - yaml.safe_dump(config_dict, f, default_flow_style=False) + with maybe_msc.open(yaml_path, "w") as f: + yaml.safe_dump(config_dict, f, default_flow_style=False) def print_yaml(self) -> None: """ diff --git a/megatron/training/distillation/utils_logits.py b/megatron/training/distillation/utils_logits.py index dd22b2b8b14..14953f875f0 100644 --- a/megatron/training/distillation/utils_logits.py +++ b/megatron/training/distillation/utils_logits.py @@ -32,7 +32,7 @@ except ImportError: HAVE_ZSTANDARD = False -from megatron.core.msc_utils import MultiStorageClientFeature +from megatron.core.msc_utils import MultiStorageClientFeature, maybe_msc from megatron.training import get_args from megatron.training.utils import get_blend_and_blend_per_split @@ -98,11 +98,7 @@ def storage_makedirs(path: str, exist_ok: bool = True) -> None: """Create a local or MSC directory/prefix.""" if not path: return - msc = _msc_if_needed(path) - if msc is not None: - msc.os.makedirs(path, exist_ok=exist_ok) - else: - os.makedirs(path, exist_ok=exist_ok) + maybe_msc.os.makedirs(path, exist_ok=exist_ok) def storage_move(src: str, dst: str) -> None: diff --git a/megatron/training/training.py b/megatron/training/training.py index f67397fe889..200d7816dd6 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -60,7 +60,7 @@ from megatron.core.models.gpt.experimental_attention_variant_module_specs import ( is_linear_attention_variant, ) -from megatron.core.msc_utils import MultiStorageClientFeature, open_file +from megatron.core.msc_utils import maybe_msc from megatron.core.num_microbatches_calculator import ( destroy_num_microbatches_calculator, get_current_global_batch_size, @@ -923,7 +923,7 @@ def get_start_time_from_progress_log(): def _get_field(string, type): return type(string.split(': ')[1]) - with open_file(progress_log_filename, 'r') as f: + with maybe_msc.open(progress_log_filename, 'r') as f: for line in f: line = line.strip() line_tokens = line.split('\t') diff --git a/megatron/training/utils/common_utils.py b/megatron/training/utils/common_utils.py index 316bf598fec..27046ab4b31 100644 --- a/megatron/training/utils/common_utils.py +++ b/megatron/training/utils/common_utils.py @@ -12,10 +12,10 @@ import torch +from megatron.core.msc_utils import maybe_msc from megatron.core._rank_utils import safe_get_rank as _safe_get_rank from megatron.core._slurm_utils import resolve_slurm_local_rank from megatron.core.dist_checkpointing.strategies.nvrx import has_nvrx_async_support -from megatron.core.msc_utils import open_file try: from transformer_engine.pytorch.optimizers import multi_tensor_applier, multi_tensor_l2norm @@ -496,14 +496,14 @@ def get_blend_and_blend_per_split(args): if use_data_path: if args.data_args_path is not None: assert args.data_path is None - with open_file(args.data_args_path, 'r') as f: + with maybe_msc.open(args.data_args_path, 'r') as f: blend = get_blend_from_list(f.read().split()) else: assert args.data_path is not None blend = get_blend_from_list(args.data_path) elif use_per_split_data_path: if args.per_split_data_args_path is not None: - with open_file(args.per_split_data_args_path, 'r') as f: + with maybe_msc.open(args.per_split_data_args_path, 'r') as f: per_split_data_args = json.load(f) # Each element in blend_per_split should be a list of files (and optional # weights), so split string if needed. diff --git a/tests/unit_tests/test_msc_utils.py b/tests/unit_tests/test_msc_utils.py new file mode 100644 index 00000000000..e30e74f928e --- /dev/null +++ b/tests/unit_tests/test_msc_utils.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +import builtins +import os +from pathlib import Path + +import pytest + +from megatron.core.msc_utils import maybe_msc + + +def test_open_is_builtin_open(): + assert maybe_msc.open is builtins.open + + +def test_os_is_os_module(): + assert maybe_msc.os is os + + +def test_Path_is_pathlib_Path(): + assert maybe_msc.Path is Path + + +def test_unknown_attribute_raises(): + with pytest.raises(AttributeError): + getattr(maybe_msc, "this_attribute_does_not_exist_12345") + + +def test_path_isdir_delegates_to_os_path_isdir(monkeypatch): + called = {} + + def fake_isdir(p): + called['p'] = p + return True + + # monkeypatch the os.path.isdir used by the fallback path + monkeypatch.setattr(os.path, 'isdir', fake_isdir) + + result = maybe_msc.path_isdir('/tmp/some-path') + assert result is True + assert called['p'] == '/tmp/some-path' diff --git a/tests/unit_tests/training/config/test_container_base.py b/tests/unit_tests/training/config/test_container_base.py index ea03a356cff..b6641665a63 100644 --- a/tests/unit_tests/training/config/test_container_base.py +++ b/tests/unit_tests/training/config/test_container_base.py @@ -203,7 +203,7 @@ def test_from_yaml_file_not_found(self): with pytest.raises(FileNotFoundError, match="YAML file not found"): TestConfigContainer.from_yaml("non_existent_file.yaml") - @patch("megatron.training.config.container.MultiStorageClientFeature.is_enabled") + @patch("megatron.core.msc_utils.MultiStorageClientFeature.is_enabled") @patch("omegaconf.OmegaConf") @patch("builtins.open", new_callable=mock_open) @patch("os.path.exists") @@ -243,7 +243,7 @@ def test_from_yaml_success(self, mock_exists, mock_file, mock_omegaconf, mock_ms assert result.name == "yaml_config" assert result.value == 500 - @patch("megatron.training.config.container.MultiStorageClientFeature.is_enabled") + @patch("megatron.core.msc_utils.MultiStorageClientFeature.is_enabled") @patch("os.path.exists") def test_from_yaml_with_mode(self, mock_exists, mock_msc): """Test from_yaml with different instantiation modes.""" From cfb116f28875cb69868b2c3f509082349443ef91 Mon Sep 17 00:00:00 2001 From: Yongqiang Wang Date: Mon, 20 Jul 2026 14:55:17 -0700 Subject: [PATCH 066/290] Add NeMo waveform audio processor (data-side feature extractor) (#5570) Signed-off-by: Yongqiang Wang Co-authored-by: Claude Opus 4.8 (1M context) --- megatron/core/models/audio/__init__.py | 2 + megatron/core/models/audio/audio_processor.py | 338 +++++++++++++ .../models/test_nemo_audio_processor.py | 455 ++++++++++++++++++ 3 files changed, 795 insertions(+) create mode 100644 megatron/core/models/audio/audio_processor.py create mode 100644 tests/unit_tests/models/test_nemo_audio_processor.py diff --git a/megatron/core/models/audio/__init__.py b/megatron/core/models/audio/__init__.py index f0cd2776ff9..538e117fe98 100644 --- a/megatron/core/models/audio/__init__.py +++ b/megatron/core/models/audio/__init__.py @@ -5,6 +5,7 @@ NemoTransformerAudioTokenEstimator, ceil_div, ) +from .audio_processor import NemoAudioProcessor from .audio_projector import AudioProjection from .nemo_audio_checkpoint import ( CHECKPOINT_NEMO_AUDIO_PREPROCESSOR_CONFIG_NAME, @@ -29,6 +30,7 @@ "CHECKPOINT_NEMO_AUDIO_PREPROCESSOR_CONFIG_NAME", "CHECKPOINT_NEMO_TRANSFORMER_AUDIO_CONFIG_NAME", "NemoAudioFeatureConfig", + "NemoAudioProcessor", "NemoTransformerAudioConfig", "NemoTransformerAudioModel", "NemoTransformerAudioTokenEstimator", diff --git a/megatron/core/models/audio/audio_processor.py b/megatron/core/models/audio/audio_processor.py new file mode 100644 index 00000000000..30ccaabd257 --- /dev/null +++ b/megatron/core/models/audio/audio_processor.py @@ -0,0 +1,338 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-License-Identifier: BSD-3-Clause + +"""Waveform audio processor for the NeMo Transformer audio frontend. + +``NemoAudioProcessor`` is the concrete, data-side feature extractor that the +multimodal data pipeline uses to (a) estimate how many encoder/projector +embeddings an audio clip expands to (for placeholder expansion and packing) and +(b) materialize log-mel features for the audio encoder. It composes the +model-frontend descriptors (``NemoAudioFeatureConfig`` and +``NemoTransformerAudioTokenEstimator``) with the vendored standalone log-mel +preprocessor. + +The data pipeline depends on this only through a small duck-typed interface +(``compute_num_embeddings`` / ``compute_num_frames`` / ``materialize`` plus the +cumulative-prefix ``num_*_from_num_samples`` primitives), so an ``audio_ref`` is +treated structurally — this module has no dependency on the data library's +``AudioRef`` type. +""" + +from __future__ import annotations + +from typing import Any + +import torch +import torch.nn.functional as F + +from megatron.core.models.audio.audio_feature_config import ( + NemoAudioFeatureConfig, + NemoTransformerAudioTokenEstimator, +) + +_AUDIO_DURATION_MISMATCH_TOLERANCE_SECONDS = 0.5 + + +def _load_waveform_from_spec(audio_spec: dict[str, Any]) -> tuple[torch.Tensor, int | None]: + kind = audio_spec.get("kind") + if kind == "avdecoder": + return _decode_avdecoder( + audio_spec["decoder"], + audio_spec.get("source_name", ""), + sample_rate=audio_spec.get("sample_rate") or audio_spec.get("sampling_rate"), + ) + raise ValueError(f"Unsupported audio kind {kind!r}") + + +def _resolve_lazy_media(media: Any) -> Any: + if hasattr(media, "get") and not hasattr(media, "get_audio"): + media = media.get() + if isinstance(media, (list, tuple)): + if not media: + raise ValueError("Lazy audio media resolved to an empty sequence.") + media = media[0] + return media + + +def _audio_clip_to_float32(clip: torch.Tensor) -> torch.Tensor: + if not torch.is_tensor(clip): + clip = torch.as_tensor(clip) + if clip.ndim == 1: + clip = clip.unsqueeze(0) + elif clip.ndim != 2: + raise ValueError(f"Unsupported decoded audio clip shape {tuple(clip.shape)}.") + + if clip.dtype.is_floating_point: + return clip.to(torch.float32).contiguous() + + if clip.dtype == torch.uint8: + return ((clip.to(torch.float32) - 128.0) / 128.0).contiguous() + + if clip.dtype in (torch.int8, torch.int16, torch.int32, torch.int64): + scale = float(torch.iinfo(clip.dtype).max) + return (clip.to(torch.float32) / scale).contiguous() + + raise ValueError(f"Unsupported decoded audio dtype {clip.dtype}.") + + +def _decoder_sample_rate(decoder: Any, sample_rate: int | None) -> int | None: + if sample_rate is not None: + return int(sample_rate) + + if hasattr(decoder, "get_audio_samples_per_second"): + return int(decoder.get_audio_samples_per_second()) + + if hasattr(decoder, "get_metadata"): + metadata = decoder.get_metadata( + get_video=False, + get_video_duration=False, + get_video_frame_count=False, + get_video_frame_size=False, + get_audio=True, + get_audio_duration=False, + ) + audio_sample_rate = getattr(metadata, "audio_sample_rate", None) + if audio_sample_rate is not None: + return int(audio_sample_rate) + + return None + + +def _decode_avdecoder( + decoder: Any, source_name: str, *, sample_rate: int | None = None +) -> tuple[torch.Tensor, int | None]: + decoder = _resolve_lazy_media(decoder) + if not hasattr(decoder, "get_audio"): + raise ValueError( + f"Expected AVDecoder-like audio media for {source_name}, " + f"got {type(decoder).__name__}." + ) + + av_data = decoder.get_audio() + clips = getattr(av_data, "audio_clips", None) + if not clips: + raise ValueError(f"Decoded audio {source_name!r} did not contain audio clips.") + + waveform = torch.cat([_audio_clip_to_float32(clip) for clip in clips], dim=-1) + return waveform.contiguous(), _decoder_sample_rate(decoder, sample_rate) + + +def _resolve_sample_rate(audio_ref: Any, decoded_sample_rate: int | None) -> int | None: + sample_rate = audio_ref.sample_rate + if sample_rate is None: + sample_rate = decoded_sample_rate + if sample_rate is None and isinstance(audio_ref.data, dict): + sample_rate = audio_ref.data.get("sample_rate") or audio_ref.data.get("sampling_rate") + if sample_rate is None: + return None + return int(sample_rate) + + +def _audio_num_sample_tolerance(audio_ref: Any, decoded_sample_rate: int | None) -> int: + sample_rate = _resolve_sample_rate(audio_ref, decoded_sample_rate) + if sample_rate is None: + return 0 + return int(_AUDIO_DURATION_MISMATCH_TOLERANCE_SECONDS * sample_rate + 0.999999) + + +def _normalize_mono_waveform(audio_ref: Any) -> tuple[torch.Tensor, int | None]: + data = audio_ref.data + decoded_sample_rate = None + if torch.is_tensor(data): + waveform = data + elif isinstance(data, dict): + waveform, decoded_sample_rate = _load_waveform_from_spec(data) + else: + raise ValueError( + "AudioRef.data must be a raw float32 waveform tensor or a supported lazy " + "audio spec for the Megatron multimodal audio path." + ) + if waveform.dtype != torch.float32: + raise ValueError(f"Expected raw float32 waveform tensor, got {waveform.dtype}.") + + if waveform.ndim == 1: + pass + elif waveform.ndim == 2: + waveform = waveform.mean(dim=0) + else: + raise ValueError( + f"Unsupported waveform shape {tuple(waveform.shape)}. Expected [T] or [C, T]." + ) + + # First reconcile the decoded waveform to the full source length (num_samples + # always counts the un-sliced source), then crop to slice_range if set. + if audio_ref.num_samples is not None: + num_samples = int(audio_ref.num_samples) + available_num_samples = int(waveform.shape[0]) + diff = num_samples - available_num_samples + tolerance = _audio_num_sample_tolerance(audio_ref, decoded_sample_rate) + if diff > tolerance: + raise ValueError( + f"AudioRef.num_samples={num_samples} exceeds waveform length " + f"{available_num_samples} by {diff} samples, which is greater than " + f"the allowed tolerance {tolerance}." + ) + if diff > 0: + waveform = F.pad(waveform, (0, diff)) + elif diff < 0: + waveform = waveform[:num_samples] + + if audio_ref.slice_range is not None: + start, end = int(audio_ref.slice_range[0]), int(audio_ref.slice_range[1]) + if start < 0 or end < start: + raise ValueError( + f"AudioRef.slice_range must satisfy 0 <= start <= end, got {(start, end)}" + ) + waveform = waveform[start:end] + + return waveform.contiguous(), decoded_sample_rate + + +def _infer_num_samples(audio_ref: Any) -> int: + # slice_range, when set, defines the effective length of this ref. + if audio_ref.slice_range is not None: + start, end = int(audio_ref.slice_range[0]), int(audio_ref.slice_range[1]) + return max(0, end - start) + if audio_ref.num_samples is not None: + return int(audio_ref.num_samples) + + data = audio_ref.data + if torch.is_tensor(data): + waveform = data + elif isinstance(data, dict): + waveform, _ = _load_waveform_from_spec(data) + else: + raise ValueError( + "AudioRef.data must be a raw float32 waveform tensor or a supported lazy " + "audio spec for the Megatron multimodal audio path." + ) + if waveform.dtype != torch.float32: + raise ValueError(f"Expected raw float32 waveform tensor, got {waveform.dtype}.") + + if waveform.ndim == 1: + available_num_samples = int(waveform.shape[0]) + elif waveform.ndim == 2: + available_num_samples = int(waveform.shape[-1]) + else: + raise ValueError( + f"Unsupported waveform shape {tuple(waveform.shape)}. Expected [T] or [C, T]." + ) + + return available_num_samples + + +class NemoAudioProcessor: + """Waveform audio processor with a NeMo log-mel frontend.""" + + def __init__( + self, + *, + token_estimator: NemoTransformerAudioTokenEstimator, + feature_config: NemoAudioFeatureConfig | None = None, + ) -> None: + # Lazy import keeps construction cheap and avoids importing the heavier + # preprocessor module until a processor is actually built. The vendored + # ``AudioToMelSpectrogramPreprocessor`` is a stdlib+PyTorch port of NeMo's + # preprocessor; ``.eval()`` disables training-time dither and narrowband + # augmentation (typical for a feature extractor inside the multimodal + # pipeline; flip back via ``.train()`` if needed). + from megatron.core.models.audio.nemo_audio_preprocessing import ( + AudioToMelSpectrogramPreprocessor, + ) + + self.token_estimator = token_estimator + self.feature_config = feature_config or NemoAudioFeatureConfig() + self._preprocessor = AudioToMelSpectrogramPreprocessor( + **self.feature_config.to_nemo_kwargs() + ).eval() + # The vendored standalone AudioToMelSpectrogramPreprocessor exposes + # win/hop lengths directly (no ``featurizer`` indirection). + self._hop_length = int(self._preprocessor.hop_length) + self._n_mels = int(self.feature_config.features) + self._sample_rate = int(self.feature_config.sample_rate) + + @property + def input_feature_dim(self) -> int: + """Number of mel feature bins produced per frame (the encoder input dim).""" + return self._n_mels + + @property + def sample_rate(self) -> int: + """Expected input waveform sample rate, in Hz.""" + return self._sample_rate + + def _validate_sample_rate(self, audio_ref: Any, decoded_sample_rate: int | None = None) -> None: + sample_rate = audio_ref.sample_rate + if sample_rate is None: + sample_rate = decoded_sample_rate + if sample_rate is not None and int(sample_rate) != self.sample_rate: + raise ValueError( + f"Expected audio sample rate {self.sample_rate}, got {sample_rate}. " + "Resample raw waveforms to the encoder sample rate before packing." + ) + + def _compute_num_frames_from_num_samples(self, num_samples: int) -> int: + if num_samples < 0: + raise ValueError(f"num_samples must be >= 0, got {num_samples}") + if num_samples == 0: + return 0 + return int(num_samples // self._hop_length) + + def compute_num_frames(self, audio_ref: Any) -> int: + """Number of feature frames the clip described by ``audio_ref`` expands to.""" + self._validate_sample_rate(audio_ref) + return self._compute_num_frames_from_num_samples(_infer_num_samples(audio_ref)) + + def num_frames_from_num_samples(self, num_samples: int) -> int: + """Pure frame-count math for an audio prefix of ``num_samples`` samples. + + Slice math: + ``frames_in([s, e)) = num_frames_from_num_samples(e) - num_frames_from_num_samples(s)``. + """ + return self._compute_num_frames_from_num_samples(num_samples) + + def num_embeddings_from_num_samples(self, num_samples: int) -> int: + """Pure embedding-count math for an audio prefix of ``num_samples`` samples. + + Slice math: ``embeds_in([s, e))`` = + ``num_embeddings_from_num_samples(e) - num_embeddings_from_num_samples(s)``. + """ + return self.token_estimator.estimate_from_num_frames( + self._compute_num_frames_from_num_samples(num_samples) + ) + + def compute_num_embeddings(self, audio_ref: Any) -> int: + """Number of encoder/projector embeddings the clip in ``audio_ref`` expands to.""" + self._validate_sample_rate(audio_ref) + return self.token_estimator.estimate_from_num_frames(self.compute_num_frames(audio_ref)) + + def materialize(self, audio_ref: Any) -> tuple[torch.Tensor, int]: + """Decode ``audio_ref`` and return its ``(T, n_mels)`` log-mel features and frame count.""" + waveform, decoded_sample_rate = _normalize_mono_waveform(audio_ref) + self._validate_sample_rate(audio_ref, decoded_sample_rate) + + num_samples = waveform.shape[0] + num_frames = self._compute_num_frames_from_num_samples(num_samples) + if num_frames == 0: + return ( + torch.empty( + (0, self.input_feature_dim), dtype=torch.float32, device=waveform.device + ), + 0, + ) + + batched = waveform.unsqueeze(0) + lengths = torch.tensor([num_samples], dtype=torch.long, device=waveform.device) + mels, out_lengths = self._preprocessor(batched, lengths) + + # mels: (1, n_mels, T_frames) -> (T_frames, n_mels), trimmed to valid frames. + valid_frames = int(out_lengths[0].item()) + if valid_frames == 0: + return ( + torch.empty( + (0, self.input_feature_dim), dtype=torch.float32, device=waveform.device + ), + 0, + ) + log_mel = mels[0, :, :valid_frames].transpose(0, 1).contiguous() + return log_mel.to(torch.float32), valid_frames diff --git a/tests/unit_tests/models/test_nemo_audio_processor.py b/tests/unit_tests/models/test_nemo_audio_processor.py new file mode 100644 index 00000000000..8e97e0b8bab --- /dev/null +++ b/tests/unit_tests/models/test_nemo_audio_processor.py @@ -0,0 +1,455 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the data-side NeMo audio processor. + +Covers the cumulative-prefix slice primitives +(``num_frames_from_num_samples`` / ``num_embeddings_from_num_samples``), +``slice_range`` waveform cropping, and log-mel materialization. ``audio_ref`` is +duck-typed (a tiny ``SimpleNamespace`` stand-in) — the processor never imports +the data library's ``AudioRef`` type. +""" + +from types import SimpleNamespace + +import pytest + +torch = pytest.importorskip("torch") + +from megatron.core.models.audio import audio_processor +from megatron.core.models.audio.audio_feature_config import ( + NemoAudioFeatureConfig, + NemoTransformerAudioTokenEstimator, +) +from megatron.core.models.audio.audio_processor import NemoAudioProcessor + + +def _audio_ref(**kwargs): + fields = dict( + data=None, + sample_rate=None, + num_samples=None, + slice_range=None, + num_frames=None, + feature_dim=None, + ) + fields.update(kwargs) + return SimpleNamespace(**fields) + + +def _processor(): + return NemoAudioProcessor( + token_estimator=NemoTransformerAudioTokenEstimator( + encoder_time_stride=4, stack_factor=2, pre_encode="conv" + ), + feature_config=NemoAudioFeatureConfig( + sample_rate=16000, window_stride=0.01, n_window_stride=None, dither=0.0 + ), + ) + + +# --------------------------------------------------------------------------- +# Slice primitives +# --------------------------------------------------------------------------- + + +def test_num_frames_from_num_samples_zero(): + assert _processor().num_frames_from_num_samples(0) == 0 + + +def test_num_frames_from_num_samples_one_second(): + # 16000 samples @ hop=160. + assert _processor().num_frames_from_num_samples(16000) == 100 + + +def test_num_embeddings_from_num_samples_one_second(): + # 100 frames -> floor(100 / 4) encoder steps -> ceil(25 / 2) embeddings. + assert _processor().num_embeddings_from_num_samples(16000) == 13 + + +def test_num_embeddings_from_num_samples_zero(): + assert _processor().num_embeddings_from_num_samples(0) == 0 + + +@pytest.mark.parametrize( + "boundaries", + [ + [0, 8000, 16000], # 0.5s, 1.0s halves + [0, 3200, 12800, 28800, 64000, 100000, 160000], # arbitrary 10s split + ], +) +def test_slice_contributions_sum_to_full_embedding_count(boundaries): + """Cumulative-prefix invariant: contributions of disjoint slices sum to the + unsliced total. This is the property that justifies the primitives.""" + p = _processor() + total = p.num_embeddings_from_num_samples(boundaries[-1]) + cum = sum( + p.num_embeddings_from_num_samples(e) - p.num_embeddings_from_num_samples(s) + for s, e in zip(boundaries[:-1], boundaries[1:]) + ) + assert cum == total + + +@pytest.mark.parametrize( + "boundaries", [[0, 8000, 16000], [0, 3200, 12800, 28800, 64000, 100000, 160000]] +) +def test_slice_contributions_sum_to_full_frame_count(boundaries): + p = _processor() + total = p.num_frames_from_num_samples(boundaries[-1]) + cum = sum( + p.num_frames_from_num_samples(e) - p.num_frames_from_num_samples(s) + for s, e in zip(boundaries[:-1], boundaries[1:]) + ) + assert cum == total + + +def test_independent_slice_lengths_drift_from_total(): + """The naive ``f(end - start)`` approach is provably WRONG: ceil divisions + mean per-slice-length counts do not sum to the unsliced total. Use the + subtraction (cumulative-prefix) contract, not addition.""" + p = _processor() + assert p.num_embeddings_from_num_samples(16000) == 13 + assert p.num_embeddings_from_num_samples(8000) == 6 + # 6 + 6 != 13. + assert p.num_embeddings_from_num_samples(8000) + p.num_embeddings_from_num_samples(8000) != 13 + + +# --------------------------------------------------------------------------- +# Waveform normalization / slicing +# --------------------------------------------------------------------------- + + +def test_normalize_waveform_crops_to_slice_range(monkeypatch): + waveform = torch.arange(20, dtype=torch.float32) + + def fake_load_waveform(audio_spec): + del audio_spec + return waveform, 16000 + + monkeypatch.setattr(audio_processor, "_load_waveform_from_spec", fake_load_waveform) + # num_samples is the full source length; slice_range carries the crop window. + audio = _audio_ref( + data={"kind": "avdecoder"}, sample_rate=16000, num_samples=20, slice_range=(5, 9) + ) + + cropped, decoded_sample_rate = audio_processor._normalize_mono_waveform(audio) + + assert decoded_sample_rate == 16000 + assert cropped.tolist() == [5.0, 6.0, 7.0, 8.0] + + +def test_infer_num_samples_uses_slice_range_length(): + # slice_range defines the effective length even though num_samples is the full source. + audio = _audio_ref(data={"kind": "avdecoder"}, num_samples=20, slice_range=(5, 9)) + assert audio_processor._infer_num_samples(audio) == 4 + + +# --------------------------------------------------------------------------- +# Materialization +# --------------------------------------------------------------------------- + + +def test_materialize_returns_time_major_log_mel(): + p = _processor() + waveform = torch.zeros(16000, dtype=torch.float32) + audio = _audio_ref(data=waveform, sample_rate=16000, num_samples=16000) + + log_mel, valid_frames = p.materialize(audio) + + assert valid_frames == 100 + assert log_mel.shape == (100, p.input_feature_dim) + assert log_mel.dtype == torch.float32 + + +def test_materialize_empty_waveform_yields_no_frames(): + p = _processor() + audio = _audio_ref(data=torch.zeros(0, dtype=torch.float32), sample_rate=16000, num_samples=0) + log_mel, valid_frames = p.materialize(audio) + assert valid_frames == 0 + assert log_mel.shape == (0, p.input_feature_dim) + + +# --------------------------------------------------------------------------- +# Public methods / properties +# --------------------------------------------------------------------------- + + +def test_processor_properties(): + p = _processor() + assert p.sample_rate == 16000 + assert p.input_feature_dim == p._n_mels + + +def test_compute_num_frames_and_embeddings_from_waveform_ref(): + p = _processor() + audio = _audio_ref(data=torch.zeros(16000, dtype=torch.float32), sample_rate=16000) + # 16000 samples @ hop=160 -> 100 frames -> 13 embeddings (see slice-primitive tests). + assert p.compute_num_frames(audio) == 100 + assert p.compute_num_embeddings(audio) == 13 + + +def test_validate_sample_rate_mismatch_raises(): + p = _processor() + audio = _audio_ref(data=torch.zeros(16000, dtype=torch.float32), sample_rate=8000) + with pytest.raises(ValueError, match="Expected audio sample rate 16000"): + p.compute_num_frames(audio) + + +# --------------------------------------------------------------------------- +# Lazy AV-decoder decode chain (duck-typed decoder fakes) +# --------------------------------------------------------------------------- + + +class _FakeAVData: + def __init__(self, clips): + self.audio_clips = clips + + +class _FakeDecoder: + """Minimal AVDecoder-like stand-in exposing get_audio + sample-rate probes.""" + + def __init__(self, clips, samples_per_second=None): + self._clips = clips + self._samples_per_second = samples_per_second + + def get_audio(self): + return _FakeAVData(self._clips) + + def get_audio_samples_per_second(self): + return self._samples_per_second + + +def test_audio_clip_to_float32_1d_float_is_unsqueezed(): + out = audio_processor._audio_clip_to_float32(torch.tensor([0.1, 0.2], dtype=torch.float32)) + assert out.shape == (1, 2) + assert out.dtype == torch.float32 + + +def test_audio_clip_to_float32_from_python_list(): + out = audio_processor._audio_clip_to_float32([0.0, 1.0, -1.0]) + assert out.shape == (1, 3) + assert out.dtype == torch.float32 + + +def test_audio_clip_to_float32_uint8_is_centered_and_scaled(): + out = audio_processor._audio_clip_to_float32(torch.tensor([0, 128, 255], dtype=torch.uint8)) + assert torch.allclose(out[0], torch.tensor([-1.0, 0.0, (255 - 128) / 128.0])) + + +def test_audio_clip_to_float32_int16_is_scaled_by_max(): + out = audio_processor._audio_clip_to_float32(torch.tensor([0, 32767], dtype=torch.int16)) + assert torch.allclose(out[0], torch.tensor([0.0, 1.0])) + + +def test_audio_clip_to_float32_rejects_bad_ndim(): + with pytest.raises(ValueError, match="Unsupported decoded audio clip shape"): + audio_processor._audio_clip_to_float32(torch.zeros(2, 2, 2)) + + +def test_audio_clip_to_float32_rejects_bad_dtype(): + with pytest.raises(ValueError, match="Unsupported decoded audio dtype"): + audio_processor._audio_clip_to_float32(torch.tensor([True, False])) + + +def test_decoder_sample_rate_prefers_explicit(): + assert audio_processor._decoder_sample_rate(object(), 22050) == 22050 + + +def test_decoder_sample_rate_from_samples_per_second(): + dec = _FakeDecoder(clips=[], samples_per_second=16000) + assert audio_processor._decoder_sample_rate(dec, None) == 16000 + + +def test_decoder_sample_rate_from_metadata(): + class _MetaDecoder: + def get_metadata(self, **kwargs): + del kwargs + return SimpleNamespace(audio_sample_rate=8000) + + assert audio_processor._decoder_sample_rate(_MetaDecoder(), None) == 8000 + + +def test_decoder_sample_rate_none_when_unavailable(): + assert audio_processor._decoder_sample_rate(object(), None) is None + + +def test_resolve_lazy_media_unwraps_get_and_sequence(): + dec = _FakeDecoder(clips=[]) + lazy = SimpleNamespace(get=lambda: [dec]) + assert audio_processor._resolve_lazy_media(lazy) is dec + + +def test_resolve_lazy_media_empty_sequence_raises(): + with pytest.raises(ValueError, match="empty sequence"): + audio_processor._resolve_lazy_media([]) + + +def test_decode_avdecoder_concatenates_clips(): + dec = _FakeDecoder( + clips=[ + torch.tensor([0.0, 1.0], dtype=torch.float32), + torch.tensor([2.0], dtype=torch.float32), + ], + samples_per_second=16000, + ) + waveform, sr = audio_processor._decode_avdecoder(dec, "") + assert sr == 16000 + assert waveform.shape == (1, 3) + assert waveform[0].tolist() == [0.0, 1.0, 2.0] + + +def test_decode_avdecoder_rejects_non_decoder(): + with pytest.raises(ValueError, match="Expected AVDecoder-like"): + audio_processor._decode_avdecoder(object(), "") + + +def test_decode_avdecoder_rejects_missing_clips(): + with pytest.raises(ValueError, match="did not contain audio clips"): + audio_processor._decode_avdecoder(_FakeDecoder(clips=[]), "") + + +def test_load_waveform_from_spec_dispatches_avdecoder(): + dec = _FakeDecoder(clips=[torch.tensor([0.5], dtype=torch.float32)], samples_per_second=16000) + waveform, sr = audio_processor._load_waveform_from_spec( + {"kind": "avdecoder", "decoder": dec, "sample_rate": 16000} + ) + assert sr == 16000 + assert waveform.shape == (1, 1) + + +def test_load_waveform_from_spec_rejects_unknown_kind(): + with pytest.raises(ValueError, match="Unsupported audio kind"): + audio_processor._load_waveform_from_spec({"kind": "wav"}) + + +# --------------------------------------------------------------------------- +# Sample-rate / tolerance resolution +# --------------------------------------------------------------------------- + + +def test_resolve_sample_rate_prefers_audio_ref(): + audio = _audio_ref(sample_rate=16000, data={}) + assert audio_processor._resolve_sample_rate(audio, 8000) == 16000 + + +def test_resolve_sample_rate_falls_back_to_decoded(): + audio = _audio_ref(sample_rate=None, data={}) + assert audio_processor._resolve_sample_rate(audio, 8000) == 8000 + + +def test_resolve_sample_rate_falls_back_to_data_dict(): + audio = _audio_ref(sample_rate=None, data={"sampling_rate": 22050}) + assert audio_processor._resolve_sample_rate(audio, None) == 22050 + + +def test_resolve_sample_rate_none_when_unknown(): + audio = _audio_ref(sample_rate=None, data=torch.zeros(1)) + assert audio_processor._resolve_sample_rate(audio, None) is None + + +def test_audio_num_sample_tolerance(): + audio = _audio_ref(sample_rate=16000, data={}) + # ceil(0.5 * 16000) = 8000 samples of allowed drift. + assert audio_processor._audio_num_sample_tolerance(audio, None) == 8000 + + +def test_audio_num_sample_tolerance_zero_without_sample_rate(): + audio = _audio_ref(sample_rate=None, data=torch.zeros(1)) + assert audio_processor._audio_num_sample_tolerance(audio, None) == 0 + + +# --------------------------------------------------------------------------- +# Waveform normalization branches +# --------------------------------------------------------------------------- + + +def test_normalize_stereo_is_averaged_to_mono(): + data = torch.tensor([[0.0, 2.0], [2.0, 4.0]], dtype=torch.float32) # [C=2, T=2] + audio = _audio_ref(data=data, sample_rate=16000) + waveform, _ = audio_processor._normalize_mono_waveform(audio) + assert waveform.tolist() == [1.0, 3.0] + + +def test_normalize_pads_up_to_num_samples_within_tolerance(): + audio = _audio_ref(data=torch.ones(10, dtype=torch.float32), sample_rate=16000, num_samples=13) + waveform, _ = audio_processor._normalize_mono_waveform(audio) + assert waveform.shape[0] == 13 + assert waveform[10:].tolist() == [0.0, 0.0, 0.0] + + +def test_normalize_crops_down_to_num_samples(): + audio = _audio_ref(data=torch.arange(10, dtype=torch.float32), sample_rate=16000, num_samples=4) + waveform, _ = audio_processor._normalize_mono_waveform(audio) + assert waveform.tolist() == [0.0, 1.0, 2.0, 3.0] + + +def test_normalize_rejects_num_samples_beyond_tolerance(): + audio = _audio_ref( + data=torch.ones(10, dtype=torch.float32), sample_rate=16000, num_samples=9000 + ) + with pytest.raises(ValueError, match="exceeds waveform length"): + audio_processor._normalize_mono_waveform(audio) + + +def test_normalize_rejects_non_float32(): + audio = _audio_ref(data=torch.zeros(10, dtype=torch.float64), sample_rate=16000) + with pytest.raises(ValueError, match="Expected raw float32 waveform"): + audio_processor._normalize_mono_waveform(audio) + + +def test_normalize_rejects_bad_ndim(): + audio = _audio_ref(data=torch.zeros(2, 2, 2, dtype=torch.float32), sample_rate=16000) + with pytest.raises(ValueError, match="Unsupported waveform shape"): + audio_processor._normalize_mono_waveform(audio) + + +def test_normalize_rejects_unsupported_data_type(): + audio = _audio_ref(data="not-a-waveform", sample_rate=16000) + with pytest.raises(ValueError, match="must be a raw float32 waveform"): + audio_processor._normalize_mono_waveform(audio) + + +def test_normalize_rejects_bad_slice_range(): + audio = _audio_ref( + data=torch.ones(10, dtype=torch.float32), sample_rate=16000, slice_range=(5, 2) + ) + with pytest.raises(ValueError, match="slice_range must satisfy"): + audio_processor._normalize_mono_waveform(audio) + + +# --------------------------------------------------------------------------- +# _infer_num_samples branches +# --------------------------------------------------------------------------- + + +def test_infer_num_samples_from_num_samples_field(): + audio = _audio_ref(num_samples=1234, data=torch.zeros(1, dtype=torch.float32)) + assert audio_processor._infer_num_samples(audio) == 1234 + + +def test_infer_num_samples_from_1d_tensor(): + audio = _audio_ref(data=torch.zeros(500, dtype=torch.float32)) + assert audio_processor._infer_num_samples(audio) == 500 + + +def test_infer_num_samples_from_2d_tensor_uses_time_dim(): + audio = _audio_ref(data=torch.zeros(2, 640, dtype=torch.float32)) + assert audio_processor._infer_num_samples(audio) == 640 + + +def test_infer_num_samples_from_spec(): + dec = _FakeDecoder(clips=[torch.zeros(320, dtype=torch.float32)], samples_per_second=16000) + audio = _audio_ref(data={"kind": "avdecoder", "decoder": dec}) + assert audio_processor._infer_num_samples(audio) == 320 + + +def test_infer_num_samples_rejects_unsupported_data(): + audio = _audio_ref(data=42) + with pytest.raises(ValueError, match="must be a raw float32 waveform"): + audio_processor._infer_num_samples(audio) + + +def test_infer_num_samples_rejects_non_float32(): + audio = _audio_ref(data=torch.zeros(10, dtype=torch.int32)) + with pytest.raises(ValueError, match="Expected raw float32 waveform"): + audio_processor._infer_num_samples(audio) From 5a88c57bdcd67dd4262a4caf89144696f5878fa1 Mon Sep 17 00:00:00 2001 From: "achyuthan.s" <113010327+Achyuthan-S@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:23:01 +0400 Subject: [PATCH 067/290] Support HSDP deferred DP-outer gradient reduction (#5743) Signed-off-by: Achyuthan Sivasankar Signed-off-by: svcnvidia-nemo-ci Co-authored-by: svcnvidia-nemo-ci --- .../src/megatron_fsdp/experimental/dbuffer.py | 34 +++- .../src/megatron_fsdp/experimental/module.py | 2 +- .../experimental/parameter_group.py | 52 +++-- .../distributed/mfsdp_v2/test_fully_shard.py | 190 ++++++++++++++++-- 4 files changed, 241 insertions(+), 37 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py index a240b148d62..8381f9a3a5c 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py @@ -292,8 +292,9 @@ def redistribute( """Redistribute this buffer to ``new_placements``. This dispatcher supports the one-axis transitions: - Flat -> Replicate, Partial -> Replicate, Partial -> Flat, and - Replicate -> Flat. Other placement changes are intentionally unsupported. + Flat -> Replicate, Partial -> Replicate, Partial -> Flat, + Replicate -> Flat, and Replicate -> Partial. Other placement changes are + intentionally unsupported. """ new_placements = tuple(new_placements) if len(new_placements) != self.mesh.ndim: @@ -322,6 +323,23 @@ def redistribute( return self.reduce_scatter(axis, new_placement, out=out) if isinstance(old_placement, Replicate) and isinstance(new_placement, Flat): return self.scatter(axis, new_placement, out=out) + if isinstance(old_placement, Replicate) and isinstance(new_placement, Partial): + # Replicate and Partial share the same local layout, so relabel the + # buffer without communication. Value-preserving for AVG only -- the + # mean of identical per-rank locals is that value; SUM would need a + # 1/axis_size rescale, which no caller needs. + if new_placement.reduce_op != dist.ReduceOp.AVG: + raise NotImplementedError( + "Replicate -> Partial redistribute supports AVG only, got " + f"{new_placement.reduce_op!r}." + ) + if out is not None: + raise NotImplementedError( + "Replicate -> Partial redistribute does not support an out buffer." + ) + return DBuffer.from_local( + self.local_buffer, self.mesh, new_placements, self.layout.tensor_shapes + ) raise NotImplementedError( "Unsupported DBuffer placement transition on axis " f"{axis}: {old_placement!r} -> {new_placement!r}." @@ -452,7 +470,17 @@ def get_dtensor(self, index: int) -> DTensor: elif isinstance(placement, Flat): torch_placements.append(dist_tensor.Shard(0)) elif isinstance(placement, Partial): - raise ValueError("Partial DBuffer placements cannot be represented as DTensor.") + # main_grad backs .grad while it rests DP-outer-Partial between + # microbatches, so a Partial placement must round-trip to a DTensor. + if placement.reduce_op == dist.ReduceOp.AVG: + reduce_op = "avg" + elif placement.reduce_op == dist.ReduceOp.SUM: + reduce_op = "sum" + else: + raise ValueError( + f"Unsupported Partial reduce op for DTensor: {placement.reduce_op!r}." + ) + torch_placements.append(dist_tensor.Partial(reduce_op)) else: raise TypeError(f"Unsupported placement for DTensor conversion: {placement!r}.") diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 4a63ab8307a..125f8982744 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -341,7 +341,7 @@ def _reduce_gradient_groups(self) -> None: reduce_scatter_stream.wait_stream(current_stream) with torch.cuda.stream(reduce_scatter_stream): - group.reduce_partial_gradients(partial_grad) + group.reduce_partial_gradients(partial_grad, self.context.is_last_microbatch) @property def parameter_groups(self) -> tuple[FsdpParameterGroup, ...]: diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index a710c4f07e1..6ea39031e60 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -150,13 +150,9 @@ def __init__( "main_grad is built from main_weight tensor shapes on the same mesh, " "and DBuffer layouts are deterministic from those shapes and mesh size." ) - if self.main_grad.placements != self.main_weight.placements: - raise ValueError( - "FSDP temporarily requires main_grad and main_weight to have the same " - "placements until HSDP/HFSDP support is implemented. " - f"Got main_grad placements {self.main_grad.placements} and " - f"main_weight placements {self.main_weight.placements}." - ) + # main_grad rests here (DP-outer-Partial for HSDP) between microbatches and + # is finalized to main_weight's placements after the last microbatch. + self._accumulation_placements = main_grad_placements sharded_parameters: list[nn.Parameter] = [] unsharded_parameters: list[nn.Parameter] = [] main_grad_dtype = self.main_grad.dtype if self.main_grad is not None else None @@ -251,6 +247,12 @@ def release_unsharded_storage(self) -> None: # so keep the shared storage-release path. self._unsharded_model_weight.release_storage() + def _install_sharded_grads(self) -> None: + """Point each sharded parameter's grad at main_grad's current DTensor view.""" + assert self.main_grad is not None + for index, sharded_parameter in enumerate(self.sharded_parameters): + sharded_parameter.grad = self.main_grad.get_dtensor(index) + def allocate_partial_grad_buffer(self) -> DBuffer: """Allocate the unreduced reduce-scatter input buffer.""" assert self.main_grad is not None @@ -279,8 +281,18 @@ def copy_gradients_to_partial_buffer(self, partial_grad: DBuffer) -> None: partial_grad.get_local_tensor(index).copy_(parameter.grad) parameter.grad = None - def reduce_partial_gradients(self, partial_grad: DBuffer) -> None: - """Reduce a packed partial gradient buffer into sharded parameter gradients.""" + def reduce_partial_gradients( + self, partial_grad: DBuffer, is_last_microbatch: bool = True + ) -> None: + """Reduce a packed partial gradient buffer into sharded parameter gradients. + + For HSDP main_grad rests DP-outer-Partial (Partial where main_weight is + Replicate) between microbatches, accumulating each backward through the + standard zero_grad contract; the last microbatch reduces the DP-outer axes, + finalizing main_grad to main_weight's placements so ``.grad`` is the fully + reduced gradient before ``optimizer.step()``. With every axis Flat (plain + DP) main_grad already rests finalized. + """ assert self.main_grad is not None def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: @@ -295,10 +307,20 @@ def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: raise RuntimeError("FSDP sharded gradients must be either all set or all None.") return has_any_grad - # zero_grad(set_to_none=True) clears sharded parameter grads, so the next + # zero_grad(set_to_none=True) clears sharded parameter grads, so this # backward can reduce directly into main_grad. zero_grad(set_to_none=False) # leaves sharded grads installed, so this backward accumulates into main_grad. has_sharded_grads = has_grad(self.sharded_parameters) + + # A non-accumulation main_grad means the previous step finalized it; this + # only happens on the first microbatch. Redistribute it back to the + # DP-outer-Partial accumulation placement -- a metadata relabel for HSDP, + # and a fresh reduce-scattered buffer for HFSDP in the future. + if self.main_grad.placements != self._accumulation_placements: + self.main_grad = self.main_grad.redistribute(self._accumulation_placements) + if has_sharded_grads: + self._install_sharded_grads() + can_reduce_into_main_grad = ( not has_sharded_grads and partial_grad.dtype == self.main_grad.dtype ) @@ -321,10 +343,14 @@ def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: self.main_grad.local_buffer.add_(reduced_grad.local_buffer) else: self.main_grad.local_buffer.copy_(reduced_grad.local_buffer) - if not has_sharded_grads: - for index, parameter in enumerate(self.sharded_parameters): - parameter.grad = self.main_grad.get_dtensor(index) + self._install_sharded_grads() + + if is_last_microbatch: + # Finalize the deferred DP-outer reduction (all-reduce for HSDP, + # reduce-scatter for HFSDP) and install the sharded parameter gradients. + self.main_grad = self.main_grad.redistribute(self.main_weight.placements) + self._install_sharded_grads() def _get_parameter_owner(module: nn.Module, name: str) -> tuple[nn.Module, str]: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 6f4a1a36620..2804a462c82 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -6,6 +6,7 @@ import pytest import torch +import torch.distributed as dist from torch import nn from torch.distributed.device_mesh import init_device_mesh from torch.distributed.tensor import DTensor @@ -13,7 +14,9 @@ from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( Flat, + Partial, Placements, + Replicate, fully_shard, microbatch, ) @@ -100,6 +103,18 @@ def _flat_placements() -> Placements: return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) +def _hsdp_placements() -> Placements: + """HSDP: params/optimizer replicated across DP-outer (axis 0), sharded within + DP-inner (axis 1). main_grad rests [Partial, Flat] between microbatches and is + all-reduced to [Replicate, Flat] on the last microbatch.""" + return Placements( + dp_axes=[0, 1], + parameter=[Replicate(), Flat()], + gradient=[Partial(dist.ReduceOp.AVG), Flat()], + optimizer=[Replicate(), Flat()], + ) + + def _mb(num_bytes: int) -> str: return f"{num_bytes / 1024**2:.2f} MB" @@ -111,6 +126,17 @@ def _events_overlap(first, second) -> bool: ) +def _nccl_events(cuda_events, *name_fragments): + """CUDA NCCL events whose name contains any of ``name_fragments`` (case-insensitive).""" + return [ + event + for event in cuda_events + if "nccl" in event.name.lower() + and event.activity_type == "kernel" + and any(fragment in event.name.lower() for fragment in name_fragments) + ] + + @pytest.mark.parametrize("num_microbatches", [1, 3]) def test_fully_shard_losses_match_baseline(distributed_setup, num_microbatches): """Minimal per-module FSDP training should match single-rank SGD.""" @@ -168,6 +194,147 @@ def train(model, optimizer, log_prefix) -> list[torch.Tensor]: ) +@pytest.mark.parametrize("set_to_none", [True, False]) +@pytest.mark.parametrize("num_microbatches", [1, 3]) +def test_hsdp_losses_match_baseline(distributed_setup, num_microbatches, set_to_none): + """HSDP (DP-outer replicated, DP-inner sharded) training should match single-rank SGD. + + Gradients reduce-scatter within DP-inner every backward and accumulate into + main_grad; the DP-outer all-reduce runs only on the last microbatch, scoped + via ``microbatch(...)``. Every rank sees identical data, so the averaged + gradient equals the single-rank gradient and losses must match. Both + ``zero_grad`` modes are covered: ``set_to_none=True`` overwrites main_grad, + ``set_to_none=False`` accumulates into a zeroed main_grad. + """ + rank = distributed_setup.rank + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 4 or world_size % 2 != 0: + pytest.skip("This test requires an even number of at least 4 ranks for a 2-D DP mesh.") + + outer_size = 2 + inner_size = world_size // outer_size + mesh = init_device_mesh( + device.type, (outer_size, inner_size), mesh_dim_names=("dp_outer", "dp_inner") + ) + torch.manual_seed(1234) + dim = 8 + baseline = MultiChildModel(dim=dim, num_children=2).to(device) + model = MultiChildModel(dim=dim, num_children=2).to(device) + model.load_state_dict(baseline.state_dict()) + + # Shard the child layers, then the model, so the children share a root context + # and reduce through the overlap path instead of as independent roots. + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=_hsdp_placements()) + fully_shard(model, mesh=mesh, placements=_hsdp_placements()) + baseline_optimizer = torch.optim.SGD(baseline.parameters(), lr=0.05) + optimizer = torch.optim.SGD(model.parameters(), lr=0.05) + + micro_batch_size = 2 + x = torch.randn(num_microbatches, micro_batch_size, dim, device=device) + target = torch.randn(num_microbatches, micro_batch_size, dim, device=device) + microbatches = tuple(zip(x.unbind(), target.unbind())) + + def train(model, optimizer, log_prefix) -> list[torch.Tensor]: + losses = [] + for step in range(5): + optimizer.zero_grad(set_to_none=set_to_none) + + for microbatch_index, (microbatch_x, microbatch_target) in enumerate(microbatches): + is_last = microbatch_index == num_microbatches - 1 + with microbatch(model, is_last=is_last): + loss = torch.nn.functional.mse_loss(model(microbatch_x), microbatch_target) + (loss / num_microbatches).backward() + losses.append(loss.detach()) + logger.debug( + "%s train parity: rank=%s, step=%s, microbatch=%s, loss=%s", + log_prefix, + rank, + step, + microbatch_index, + loss, + ) + + optimizer.step() + return losses + + baseline_losses = train(baseline, baseline_optimizer, "Baseline") + sharded_losses = train(model, optimizer, "HSDP") + + torch.testing.assert_close( + torch.stack(sharded_losses), + torch.stack(baseline_losses), + msg="HSDP losses did not match baseline losses.", + ) + + +def test_hsdp_defers_dp_outer_allreduce_to_last_microbatch(distributed_setup): + """HSDP reduce-scatters DP-inner every microbatch but all-reduces DP-outer once. + + ``fully_shard(model)`` makes the child units share a root context so their + reductions run through the overlap path rather than as independent roots. + Counting NCCL events over a multi-microbatch step, the DP-inner reduce-scatter + fires once per microbatch per group while the DP-outer all-reduce that + finalizes main_grad fires only on the last microbatch, so the reduce-scatter + count is exactly ``num_microbatches`` times the all-reduce count. This asserts + on event counts only, not numerics. + """ + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 4 or world_size % 2 != 0: + pytest.skip("This test requires an even number of at least 4 ranks for a 2-D DP mesh.") + + outer_size = 2 + inner_size = world_size // outer_size + mesh = init_device_mesh( + device.type, (outer_size, inner_size), mesh_dim_names=("dp_outer", "dp_inner") + ) + torch.manual_seed(1234) + dim = 8 + num_children = 2 + model = MultiChildModel(dim=dim, num_children=num_children).to(device) + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=_hsdp_placements()) + fully_shard(model, mesh=mesh, placements=_hsdp_placements()) + optimizer = torch.optim.SGD(model.parameters(), lr=0.05) + + num_microbatches = 3 + micro_batch_size = 2 + x = torch.randn(num_microbatches, micro_batch_size, dim, device=device) + target = torch.randn(num_microbatches, micro_batch_size, dim, device=device) + microbatches = tuple(zip(x.unbind(), target.unbind())) + + def train_one_step() -> None: + optimizer.zero_grad(set_to_none=True) + for microbatch_index, (microbatch_x, microbatch_target) in enumerate(microbatches): + is_last = microbatch_index == num_microbatches - 1 + with microbatch(model, is_last=is_last): + loss = torch.nn.functional.mse_loss(model(microbatch_x), microbatch_target) + (loss / num_microbatches).backward() + optimizer.step() + + train_one_step() + torch.cuda.synchronize(device) + + with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: + train_one_step() + torch.cuda.synchronize(device) + + cuda_events = [event for event in prof.events() if event.device_type.name == "CUDA"] + reduce_scatter_events = _nccl_events(cuda_events, "reducescatter", "reduce_scatter") + all_reduce_events = _nccl_events(cuda_events, "allreduce") + # One DP-outer all-reduce per parameter group -- each child layer plus the + # root unit's bias -- fired only on the last microbatch. Plain DP fires none. + assert len(all_reduce_events) == num_children + 1, [event.name for event in cuda_events] + # DP-inner reduce-scatter runs every microbatch; the DP-outer all-reduce runs + # only on the last, so the counts differ by exactly the microbatch factor. + assert len(reduce_scatter_events) == len(all_reduce_events) * num_microbatches, ( + f"Expected reduce-scatter ({len(reduce_scatter_events)}) to be {num_microbatches}x " + f"the DP-outer all-reduce count ({len(all_reduce_events)})." + ) + + def test_nested_fully_shard_excludes_child_owned_parameters(distributed_setup): """An outer FsdpModule owns direct parameters but not nested child FsdpModule parameters.""" world_size = distributed_setup.world_size @@ -353,26 +520,9 @@ def train_one_iteration() -> None: # drop the CUDA events. torch.cuda.synchronize(device) - # Keep only real device kernels. NCCL also emits per-collective GPU user - # annotations (e.g. "nccl:_reduce_scatter_base") that the profiler reports as - # CUDA events. Filtering by activity type avoids counting those annotations as - # kernels without relying on their current naming convention. - cuda_events = [ - event - for event in prof.events() - if event.device_type.name == "CUDA" and event.activity_type == "kernel" - ] - all_gather_events = [ - event - for event in cuda_events - if "nccl" in event.name.lower() and "allgather" in event.name.lower() - ] - reduce_scatter_events = [ - event - for event in cuda_events - if "nccl" in event.name.lower() - and ("reducescatter" in event.name.lower() or "reduce_scatter" in event.name.lower()) - ] + cuda_events = [event for event in prof.events() if event.device_type.name == "CUDA"] + all_gather_events = _nccl_events(cuda_events, "allgather") + reduce_scatter_events = _nccl_events(cuda_events, "reducescatter", "reduce_scatter") # GEMM device-kernel names vary across CUDA/cuBLAS versions and GPU archs # (e.g. "*gemm*", "cutlass*", "cublas*", and cuBLASLt's Hopper "nvjet_sm90_*"). gemm_events = [ From 337c061c9c9a1d290ec13a66e4cbd1fc082f3dc4 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Mon, 20 Jul 2026 16:02:46 -0700 Subject: [PATCH 068/290] Add fully_shard_optimizer for mixed-precision FSDP (#5411) Signed-off-by: Jingyue Wu --- .../megatron_fsdp/experimental/__init__.py | 2 + .../megatron_fsdp/experimental/optimizer.py | 108 ++++++++++++++++++ .../distributed/mfsdp_v2/test_fully_shard.py | 38 +++++- .../distributed/mfsdp_v2/test_optimizer.py | 99 ++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py create mode 100644 tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py index bc9118598d1..bae27be831c 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py @@ -16,6 +16,7 @@ from .dbuffer import DBuffer from .fully_shard import fully_shard, microbatch +from .optimizer import fully_shard_optimizer from .placement import Flat, Partial, Placement, Placements, Replicate __all__ = [ @@ -26,5 +27,6 @@ "Placements", "Replicate", "fully_shard", + "fully_shard_optimizer", "microbatch", ] diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py new file mode 100644 index 00000000000..3f7745da9ff --- /dev/null +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026, 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. + +"""Optimizer adapter for the minimal Megatron-FSDP path.""" + +from typing import Any, NamedTuple + +import torch +from torch import nn + +from .parameter_group import contained_in_parameter_group + + +def fully_shard_optimizer(optimizer: torch.optim.Optimizer) -> None: + """Attach FSDP-aware step hooks to an optimizer instance. + + The adapted optimizer preserves its existing parameter groups and only adds + temporary gradient casting around optimizer steps for FSDP sharded + parameters whose data dtype differs from their grad dtype. + + Alternatives considered: + - Monkey-patching optimizer methods directly on the instance. This is + more invasive and harder to compose than hooks. + - Generating an FSDP-specific subclass per ``torch.optim.Optimizer``. + This adds extra class-generation machinery, but would let us + instrument ``zero_grad`` and ``__init__`` as well as ``step`` if needed. + - Casting from ``main_grad.dtype`` to ``main_weight.dtype`` after the + last microbatch and casting back before the first microbatch. This + should be done from a root post-backward callback if needed later, so + users do not need to call ``fully_shard_optimizer`` on an existing + ``torch.optim.Optimizer``. + - Letting the user set ``main_weight`` and ``main_grad`` to the same + dtype. This is enough for an FSDP2 drop-in replacement path and lets + optimizers stay unaware of FSDP precision handling. + + Args: + optimizer: Optimizer instance to adapt in place. + """ + + class CastedGrad(NamedTuple): + """Original grad tensor temporarily replaced during an optimizer step.""" + + parameter: nn.Parameter + original_grad: torch.Tensor + + def set_grad(parameter: nn.Parameter, grad: torch.Tensor) -> None: + """Install a grad with matching grad_dtype on a sharded parameter.""" + # Clear the existing grad before switching grad_dtype; the sharded + # parameter cannot advertise a new grad dtype while the old grad + # object with the previous dtype is still attached. + parameter.grad = None + parameter.grad_dtype = grad.dtype + parameter.grad = grad + + casted_grads: list[CastedGrad] = [] + + def step_pre_hook( + hooked_optimizer: torch.optim.Optimizer, args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> None: + closure = kwargs.get("closure") + if closure is None and len(args) > 1: + closure = args[1] + if closure is not None: + # Step hooks run outside the base optimizer step, but closures run inside it. + # We need to cast grads after the closure materializes them and before the + # optimizer consumes them, which this hook-only adapter cannot intercept. + raise NotImplementedError( + "fully_shard_optimizer does not support optimizer.step closures." + ) + assert not casted_grads + for group in hooked_optimizer.param_groups: + for parameter in group["params"]: + if not isinstance(parameter, nn.Parameter): + raise TypeError( + "fully_shard_optimizer expected optimizer param groups to contain " + f"nn.Parameter values, got {type(parameter)!r}." + ) + if not contained_in_parameter_group(parameter): + continue + if parameter.grad is None: + continue + if parameter.grad.dtype == parameter.dtype: + continue + + casted_grads.append(CastedGrad(parameter, parameter.grad)) + set_grad(parameter, parameter.grad.to(dtype=parameter.dtype)) + + def step_post_hook( + hooked_optimizer: torch.optim.Optimizer, args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> None: + del hooked_optimizer, args, kwargs + for parameter, original_grad in casted_grads: + set_grad(parameter, original_grad) + casted_grads.clear() + + optimizer.register_step_pre_hook(step_pre_hook) + optimizer.register_step_post_hook(step_post_hook) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 2804a462c82..5485a1df954 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -18,6 +18,7 @@ Placements, Replicate, fully_shard, + fully_shard_optimizer, microbatch, ) from megatron.core.distributed.fsdp.src.megatron_fsdp.mixed_precision import MixedPrecisionPolicy @@ -138,7 +139,7 @@ def _nccl_events(cuda_events, *name_fragments): @pytest.mark.parametrize("num_microbatches", [1, 3]) -def test_fully_shard_losses_match_baseline(distributed_setup, num_microbatches): +def test_fully_shard_sgd_losses_match_baseline(distributed_setup, num_microbatches): """Minimal per-module FSDP training should match single-rank SGD.""" rank = distributed_setup.rank world_size = distributed_setup.world_size @@ -678,6 +679,41 @@ def train_iteration() -> torch.Tensor: torch.testing.assert_close(second_loss, first_loss) +def test_fully_shard_adam_mixed_precision_losses_match_baseline(distributed_setup): + """Mixed-precision FSDP Adam should track an unsharded Adam baseline.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + mesh = init_device_mesh(device.type, (world_size,)) + torch.manual_seed(2026) + baseline = TinyModel().to(device=device, dtype=torch.bfloat16) + model = TinyModel().to(device=device, dtype=torch.bfloat16) + model.load_state_dict(baseline.state_dict()) + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + + baseline_optimizer = torch.optim.Adam(baseline.parameters(), lr=0.01) + optimizer = torch.optim.Adam(model.parameters(), lr=0.01) + fully_shard_optimizer(optimizer) + + x = torch.randn(3, 8, device=device, dtype=torch.bfloat16) + target = torch.randn(3, 4, device=device, dtype=torch.bfloat16) + + for _ in range(3): + baseline_optimizer.zero_grad() + optimizer.zero_grad() + + baseline_loss = torch.nn.functional.mse_loss(baseline(x).float(), target.float()) + loss = torch.nn.functional.mse_loss(model(x).float(), target.float()) + torch.testing.assert_close(loss, baseline_loss, rtol=0, atol=3e-3) + + baseline_loss.backward() + loss.backward() + baseline_optimizer.step() + optimizer.step() + + def test_microbatch_scopes_child_contexts(distributed_setup): """microbatch() should scope FSDP child contexts under an unwrapped parent.""" world_size = distributed_setup.world_size diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py b/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py new file mode 100644 index 00000000000..3f3b636dd19 --- /dev/null +++ b/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py @@ -0,0 +1,99 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Unit tests for Megatron-FSDP optimizer behavior.""" + +import pytest +import torch +from torch import nn +from torch.distributed.device_mesh import init_device_mesh +from transformer_engine.pytorch.optimizers import FusedAdam + +from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( + Flat, + Placements, + fully_shard, +) +from megatron.core.distributed.fsdp.src.megatron_fsdp.mixed_precision import MixedPrecisionPolicy + + +class TinyModel(nn.Module): + """Small model with two separately shardable units.""" + + def __init__(self) -> None: + super().__init__() + self.fc1 = nn.Linear(8, 16) + self.relu = nn.ReLU() + self.fc2 = nn.Linear(16, 4) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Run the tiny model.""" + return self.fc2(self.relu(self.fc1(x))) + + +def _flat_placements() -> Placements: + return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) + + +def test_adam_without_adapter_raises_precision_error(distributed_setup): + """Raw Adam should fail on mixed-precision FSDP parameters without the adapter.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + mesh = init_device_mesh(device.type, (world_size,)) + torch.manual_seed(2026) + model = TinyModel().to(device=device, dtype=torch.bfloat16) + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + optimizer = torch.optim.Adam(model.parameters(), lr=0.01) + + x = torch.randn(6, 8, device=device, dtype=torch.bfloat16) + optimizer.zero_grad(set_to_none=True) + loss = model(x).sum() + loss.backward() + + with pytest.raises(RuntimeError, match="dtype"): + optimizer.step() + + +def test_fused_adam_without_adapter_accepts_mismatched_grads(distributed_setup): + """TE FusedAdam should handle mixed-precision FSDP grads without the adapter.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + + mesh = init_device_mesh(device.type, (world_size,)) + torch.manual_seed(2026) + model = TinyModel().to(device=device, dtype=torch.bfloat16) + # These are the defaults, but spell them out so the test clearly exercises + # mismatched parameter and gradient precision. + mixed_precision_policy = MixedPrecisionPolicy( + main_params_dtype=torch.float32, main_grads_dtype=torch.bfloat16 + ) + fully_shard( + model.fc1, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + ) + fully_shard( + model.fc2, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + ) + optimizer = FusedAdam(model.parameters(), lr=0.01) + + x = torch.randn(6, 8, device=device, dtype=torch.bfloat16) + optimizer.zero_grad(set_to_none=True) + loss = model(x).sum() + loss.backward() + + for parameter in model.parameters(): + assert parameter.grad is not None + assert parameter.dtype != parameter.grad.dtype + + params_before_step = [parameter.detach().clone() for parameter in model.parameters()] + optimizer.step() + + assert any( + not torch.equal(parameter_before, parameter.detach()) + for parameter_before, parameter in zip(params_before_step, model.parameters()) + ) From 7012adb81765d91d91e404eb480ef614e6423027 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Mon, 20 Jul 2026 22:34:39 -0400 Subject: [PATCH 069/290] Stabilize perf warmup (#5913) Signed-off-by: Philip Petrakian --- .../client/static_benchmark.py | 51 +++++++-- .../shell_test_utils/run_perf_test.sh | 5 + .../gpt/gpt_583m_perf/baseline_values.json | 52 +++++---- tests/unit_tests/test_static_benchmark.py | 106 ++++++++++++++++++ 4 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 tests/unit_tests/test_static_benchmark.py diff --git a/tests/performance_tests/client/static_benchmark.py b/tests/performance_tests/client/static_benchmark.py index 10c945c8afc..1c2d64e9110 100644 --- a/tests/performance_tests/client/static_benchmark.py +++ b/tests/performance_tests/client/static_benchmark.py @@ -2,8 +2,9 @@ """Static throughput/latency benchmark against an OpenAI-compatible completions server. Fires --batch-size requests simultaneously via asyncio.gather, waits for all to -finish, and reports throughput, latency (avg/p50/p99), and TPOT. Iterates over -warmup + timed batches and emits a JSON results file consumable by +finish, and reports throughput, latency (avg/p50/p99), and TPOT. Warmup batches +are widened to cover every data-parallel worker when needed. Timed batches keep +the requested batch size and emit a JSON results file consumable by compare_to_baseline.py. Hits the server's POST /v1/completions endpoint directly via aiohttp — no @@ -92,10 +93,15 @@ async def _run_batch( url: str, prompts: list[str], iter_start_index: int, + request_count: int | None = None, ) -> tuple[list[int], list[int], list[float], float]: - """Fire batch_size requests in parallel. Cycles through `prompts` deterministically - starting at `iter_start_index` so each timed iteration sees the same prompt - distribution (reduces run-to-run variance for gsm8k mode).""" + """Fire requests in parallel, defaulting to the measured batch size. + + Cycles through `prompts` deterministically starting at `iter_start_index` + so each timed iteration sees the same prompt distribution (reduces + run-to-run variance for gsm8k mode). + """ + request_count = args.batch_size if request_count is None else request_count t0 = time.perf_counter() results = await asyncio.gather( *[ @@ -107,7 +113,7 @@ async def _run_batch( args.num_output_tokens, args.temperature, ) - for i in range(args.batch_size) + for i in range(request_count) ] ) wall = time.perf_counter() - t0 @@ -122,8 +128,14 @@ def _percentile(sorted_values: list[float], pct: float) -> float: return sorted_values[idx] +def _get_warmup_batch_size(batch_size: int, data_parallel_size: int) -> int: + """Keep batch-shape warmup while issuing enough requests to cover DP workers.""" + return max(batch_size, data_parallel_size) + + async def main(args: argparse.Namespace) -> dict: url = f"{args.server_url.rstrip('/')}/completions" + warmup_batch_size = _get_warmup_batch_size(args.batch_size, args.data_parallel_size) if args.dataset == "gsm8k": prompts = _load_gsm8k_prompts() @@ -140,26 +152,34 @@ async def main(args: argparse.Namespace) -> dict: print(f"Dataset : {prompt_source}") print(f"Output tokens : {args.num_output_tokens}") print(f"Warmup iters : {args.num_warmup_iters}") + print(f"Warmup batch : {warmup_batch_size}") print(f"Timed iters : {args.num_iters}", flush=True) connector = aiohttp.TCPConnector(limit=0) async with aiohttp.ClientSession(connector=connector) as session: - cursor = 0 + warmup_cursor = 0 for i in range(args.num_warmup_iters): - print(f"\nWarmup {i + 1}/{args.num_warmup_iters}...", flush=True) - await _run_batch(session, args, url, prompts, cursor) - cursor += args.batch_size + print( + f"\nWarmup {i + 1}/{args.num_warmup_iters} (batch={warmup_batch_size})...", + flush=True, + ) + await _run_batch( + session, args, url, prompts, warmup_cursor, request_count=warmup_batch_size + ) + warmup_cursor += warmup_batch_size all_wall: list[float] = [] all_output_tokens: list[int] = [] all_input_tokens: list[int] = [] all_latencies: list[float] = [] + # Keep the timed prompt sequence stable when widening warmup batches. + timed_cursor = args.num_warmup_iters * args.batch_size for i in range(args.num_iters): input_counts, output_counts, latencies, wall = await _run_batch( - session, args, url, prompts, cursor + session, args, url, prompts, timed_cursor ) - cursor += args.batch_size + timed_cursor += args.batch_size total_out = sum(output_counts) all_wall.append(wall) all_output_tokens.append(total_out) @@ -226,6 +246,13 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--num-output-tokens", type=int, default=128) parser.add_argument("--temperature", type=float, default=0.0) parser.add_argument("--num-warmup-iters", type=int, default=2) + parser.add_argument( + "--data-parallel-size", + type=int, + default=1, + help="Number of coordinator-addressable data-parallel workers. Warmup batches " + "use at least this many concurrent requests; timed batch size is unchanged.", + ) parser.add_argument("--num-iters", type=int, default=5) parser.add_argument( "--output-json", diff --git a/tests/performance_tests/shell_test_utils/run_perf_test.sh b/tests/performance_tests/shell_test_utils/run_perf_test.sh index b8effb030b5..f39196cda98 100755 --- a/tests/performance_tests/shell_test_utils/run_perf_test.sh +++ b/tests/performance_tests/shell_test_utils/run_perf_test.sh @@ -95,6 +95,9 @@ mapfile -t BATCH_SIZES < <("$YQ" '.BATCH_SIZES[]' "$CONFIG_PATH") # and MoE-with-DP=1 picks up EP correctly. GROUP_SIZE=$((DP > EP ? DP : EP)) WORLD_SIZE=$((TP * PP * GROUP_SIZE)) +# The inference coordinator uses the dense-model DP group. EP-only configs +# therefore expose GROUP_SIZE workers even when the YAML DP value is one. +COORDINATOR_WORKERS=$GROUP_SIZE ARGS_FILE="$PERF_DIR/server/model_args/${MODEL}.args" if [[ ! -f "$ARGS_FILE" ]]; then echo "[run_perf_test] error: model args file $ARGS_FILE not found" >&2 @@ -102,6 +105,7 @@ if [[ ! -f "$ARGS_FILE" ]]; then fi echo "[run_perf_test] MODEL=$MODEL TP=$TP PP=$PP DP=$DP EP=$EP world_size=$WORLD_SIZE dataset=$DATASET" +echo "[run_perf_test] coordinator workers: $COORDINATOR_WORKERS" echo "[run_perf_test] ISL=$NUM_INPUT_TOKENS OSL=$NUM_OUTPUT_TOKENS" echo "[run_perf_test] batch sizes: ${BATCH_SIZES[*]}" @@ -257,6 +261,7 @@ for BS in "${BATCH_SIZES[@]}"; do --num-input-tokens "$NUM_INPUT_TOKENS" \ --num-output-tokens "$NUM_OUTPUT_TOKENS" \ --num-warmup-iters "$NUM_WARMUP_ITERS" \ + --data-parallel-size "$COORDINATOR_WORKERS" \ --num-iters "$NUM_TIMED_ITERS" \ --output-json "$RESULTS_JSON" \ 2>&1 | tee -a "$RESULTS_ROOT/benchmark.log" diff --git a/tests/performance_tests/test_cases/gpt/gpt_583m_perf/baseline_values.json b/tests/performance_tests/test_cases/gpt/gpt_583m_perf/baseline_values.json index 5c83450644f..be494f2fcf7 100644 --- a/tests/performance_tests/test_cases/gpt/gpt_583m_perf/baseline_values.json +++ b/tests/performance_tests/test_cases/gpt/gpt_583m_perf/baseline_values.json @@ -2,47 +2,51 @@ "h100": { "batch_1": { "batch_size": 1, - "num_input_tokens": 512, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, "num_output_tokens": 128, "num_iters": 5, - "throughput_tok_per_sec": 22.08203581766323, - "avg_latency_ms": 5796.511190757155, - "p50_latency_ms": 5842.958671972156, - "p99_latency_ms": 5919.582245871425, - "tpot_ms_per_tok": 45.28567964734975 + "throughput_tok_per_sec": 44.399582392645556, + "avg_latency_ms": 2882.8655768185854, + "p50_latency_ms": 2883.104130625725, + "p99_latency_ms": 2886.1329462379217, + "tpot_ms_per_tok": 22.522734361700714 }, "batch_8": { "batch_size": 8, - "num_input_tokens": 512, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, "num_output_tokens": 128, "num_iters": 5, - "throughput_tok_per_sec": 357.49352020243185, - "avg_latency_ms": 2808.0778209026903, - "p50_latency_ms": 2813.233459368348, - "p99_latency_ms": 2896.668652072549, - "tpot_ms_per_tok": 22.378027986269444 + "throughput_tok_per_sec": 343.99702071382734, + "avg_latency_ms": 2918.7685920856893, + "p50_latency_ms": 2911.766432225704, + "p99_latency_ms": 3012.841146439314, + "tpot_ms_per_tok": 23.25601536722388 }, "batch_32": { "batch_size": 32, - "num_input_tokens": 512, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, "num_output_tokens": 128, "num_iters": 5, - "throughput_tok_per_sec": 1432.3391490750541, - "avg_latency_ms": 2812.452972715255, - "p50_latency_ms": 2819.5638693869114, - "p99_latency_ms": 2865.5092362314463, - "tpot_ms_per_tok": 22.341077544842847 + "throughput_tok_per_sec": 1378.968944067799, + "avg_latency_ms": 2920.1371596893296, + "p50_latency_ms": 2913.53677585721, + "p99_latency_ms": 2975.6032899022102, + "tpot_ms_per_tok": 23.2057437824551 }, "batch_128": { "batch_size": 128, - "num_input_tokens": 512, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, "num_output_tokens": 128, "num_iters": 5, - "throughput_tok_per_sec": 5643.249306634135, - "avg_latency_ms": 2839.432980850688, - "p50_latency_ms": 2846.7628210783005, - "p99_latency_ms": 2900.5435090512037, - "tpot_ms_per_tok": 22.681967966491356 + "throughput_tok_per_sec": 5414.296145922958, + "avg_latency_ms": 2964.334140153369, + "p50_latency_ms": 2969.226948916912, + "p99_latency_ms": 3022.6969085633755, + "tpot_ms_per_tok": 23.641115400823765 } } } diff --git a/tests/unit_tests/test_static_benchmark.py b/tests/unit_tests/test_static_benchmark.py new file mode 100644 index 00000000000..a0f3f0fc7e7 --- /dev/null +++ b/tests/unit_tests/test_static_benchmark.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import argparse +import sys +from unittest import mock + +import pytest + +from tests.performance_tests.client import static_benchmark + + +@pytest.mark.parametrize( + ("batch_size", "data_parallel_size", "expected"), + [(1, 8, 8), (8, 8, 8), (32, 8, 32), (1, 4, 4), (128, 4, 128), (1, 1, 1)], +) +def test_get_warmup_batch_size(batch_size, data_parallel_size, expected): + assert static_benchmark._get_warmup_batch_size(batch_size, data_parallel_size) == expected + + +def test_parse_args_preserves_single_worker_default(monkeypatch): + monkeypatch.setattr(sys, "argv", ["static_benchmark.py"]) + + assert static_benchmark.parse_args().data_parallel_size == 1 + + +@pytest.mark.asyncio +async def test_run_batch_request_count_override(monkeypatch): + single_request = mock.AsyncMock(return_value=(512, 128, 0.1)) + monkeypatch.setattr(static_benchmark, "_single_request", single_request) + args = argparse.Namespace( + batch_size=1, model="gpt_583m", num_output_tokens=128, temperature=0.0 + ) + + inputs, outputs, latencies, _ = await static_benchmark._run_batch( + mock.sentinel.session, + args, + "http://localhost:5000/v1/completions", + ["prompt 0", "prompt 1"], + iter_start_index=1, + request_count=8, + ) + + assert single_request.await_count == 8 + assert [call.args[3] for call in single_request.await_args_list] == [ + "prompt 1", + "prompt 0", + "prompt 1", + "prompt 0", + "prompt 1", + "prompt 0", + "prompt 1", + "prompt 0", + ] + assert inputs == [512] * 8 + assert outputs == [128] * 8 + assert latencies == [0.1] * 8 + + single_request.reset_mock() + await static_benchmark._run_batch( + mock.sentinel.session, + args, + "http://localhost:5000/v1/completions", + ["prompt"], + iter_start_index=0, + ) + single_request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_main_widens_only_warmup_batches_and_preserves_timed_prompts(monkeypatch): + calls = [] + + async def fake_run_batch(session, args, url, prompts, iter_start_index, request_count=None): + count = args.batch_size if request_count is None else request_count + calls.append((iter_start_index, count)) + return [512] * count, [128] * count, [0.1] * count, 1.0 + + class FakeClientSession: + async def __aenter__(self): + return mock.sentinel.session + + async def __aexit__(self, exc_type, exc_value, traceback): + return False + + monkeypatch.setattr(static_benchmark, "_run_batch", fake_run_batch) + monkeypatch.setattr(static_benchmark.aiohttp, "TCPConnector", mock.Mock()) + monkeypatch.setattr( + static_benchmark.aiohttp, "ClientSession", mock.Mock(return_value=FakeClientSession()) + ) + args = argparse.Namespace( + server_url="http://localhost:5000/v1", + model="gpt_583m", + batch_size=1, + data_parallel_size=8, + dataset="synthetic", + num_input_tokens=512, + num_output_tokens=128, + temperature=0.0, + num_warmup_iters=2, + num_iters=2, + ) + + summary = await static_benchmark.main(args) + + assert calls == [(0, 8), (8, 8), (2, 1), (3, 1)] + assert summary["batch_size"] == 1 From 12c05a2d89799da77a8532ee1bc4b3c46e9cf426 Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene <34819528+tdene@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:45:11 -0500 Subject: [PATCH 070/290] Add compatibility between training CGs and CP>1 (#5894) Signed-off-by: Teodor-Dumitru Ene --- .../core/extensions/transformer_engine.py | 3 + megatron/core/packed_seq_params.py | 1 + .../transformer/test_cuda_graphs.py | 108 ++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index ac7d5c1da9b..3a8d7f23d09 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -1754,6 +1754,9 @@ def __init__( self.kept_packed_seq_params.discard("seq_idx") self.kept_packed_seq_params.discard("tokens_per_sample") + if get_te_version() < PkgVersion("2.2.0"): + self.kept_packed_seq_params.discard("pad_between_seqs") + if config.qk_clip or config.log_max_attention_logit: # qk-clip is only supported in TE 2.9.0 and later assert is_te_min_version("2.9.0"), "qk-clip is only supported in TE 2.9.0 and later" diff --git a/megatron/core/packed_seq_params.py b/megatron/core/packed_seq_params.py index bd598bb557a..1c26af56244 100644 --- a/megatron/core/packed_seq_params.py +++ b/megatron/core/packed_seq_params.py @@ -25,6 +25,7 @@ class PackedSeqParams: total_tokens: int = None seq_idx: Tensor = None tokens_per_sample: int = None + pad_between_seqs: bool = None def __post_init__(self): """Pre-compute seq_idx for Mamba mixer CUDA graph compatibility. diff --git a/tests/unit_tests/transformer/test_cuda_graphs.py b/tests/unit_tests/transformer/test_cuda_graphs.py index 52edbf9a264..ef556b29f42 100644 --- a/tests/unit_tests/transformer/test_cuda_graphs.py +++ b/tests/unit_tests/transformer/test_cuda_graphs.py @@ -23,6 +23,7 @@ destroy_num_microbatches_calculator, init_num_microbatches_calculator, ) +from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.pipeline_parallel.schedules import set_current_microbatch from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.random import ( @@ -34,6 +35,7 @@ CudaGraphManager, TECudaGraphHelper, _CudagraphGlobalRecord, + create_cudagraphs, ) from megatron.core.transformer.enums import CudaGraphModule, CudaGraphScope, InferenceCudaGraphScope from megatron.core.transformer.mlp import MLPSubmodules @@ -444,6 +446,112 @@ def test_gpu_cudagraph(self): ) +@pytest.mark.skipif( + not (HAVE_TE and is_te_min_version("1.5.0")), + reason="use_te_rng_tracker requires TransformerEngine version >= 1.5", +) +class TestPackedSeqCudagraphs: + """Training CUDA graphs over thd input with padding between sequences. + + The padded cu_seqlens describe a slot layout that differs from the actual lengths, + and pad_between_seqs is set explicitly so TE does spend a GPU sync inferring it. + cp_size == 2 additionally captures TE's ring-P2P context-parallel attention inside the graphs. + """ + + SEQ_LENGTHS = [7, 5] + SLOT_STARTS = [0, 8, 16] # slot layout aligned to 2 * cp_size for every cp_size tested + BIN_SIZE = 32 + + def teardown_method(self, method): + Utils.destroy_model_parallel() + _CudagraphGlobalRecord.cudagraph_created = False + _CudagraphGlobalRecord.cudagraph_record = [] + CudaGraphManager.global_mempool = None + + def _build_packed_seq_params(self, device): + # Actual boundaries: each sequence's real tokens inside its slot; the trailing bin + # padding [SLOT_STARTS[-1], BIN_SIZE) forms a ghost slot of pad tokens. + boundaries = [0] + for length in self.SEQ_LENGTHS: + boundaries.append(boundaries[-1] + length) + boundaries.append(boundaries[-1] + self.BIN_SIZE - self.SLOT_STARTS[-1]) + cu_seqlens = torch.tensor(boundaries, dtype=torch.int32, device=device) + cu_seqlens_padded = torch.tensor( + self.SLOT_STARTS + [self.BIN_SIZE], dtype=torch.int32, device=device + ) + return PackedSeqParams( + qkv_format='thd', + cu_seqlens_q=cu_seqlens, + cu_seqlens_kv=cu_seqlens, + cu_seqlens_q_padded=cu_seqlens_padded, + cu_seqlens_kv_padded=cu_seqlens_padded, + max_seqlen_q=self.BIN_SIZE, + max_seqlen_kv=self.BIN_SIZE, + pad_between_seqs=True, + ) + + @pytest.mark.parametrize("cp_size", [1, 2]) + def test_thd_capture_with_pad_between_seqs(self, cp_size): + initialize_rng_tracker(use_te_rng_tracker=True, force_reset=True) + Utils.initialize_model_parallel(context_parallel_size=cp_size) + model_parallel_cuda_manual_seed(123) + + config = TransformerConfig( + num_layers=2, + hidden_size=64, + num_attention_heads=4, + context_parallel_size=cp_size, + bf16=True, + params_dtype=torch.bfloat16, + attention_dropout=0.0, + hidden_dropout=0.0, + cuda_graph_impl="local", + use_cpu_initialization=True, + ) + block = TransformerBlock(config, get_gpt_layer_with_transformer_engine_spec()).cuda() + block.train() + # CUDA-graphed backward assumes DDP-style grad accumulation buffers. + for param in block.parameters(): + param.main_grad = torch.zeros_like(param) + + packed_seq_params = self._build_packed_seq_params(torch.device('cuda')) + # Each CP rank holds its 1/cp_size share of the bin's tokens. + hidden_states = torch.randn( + (self.BIN_SIZE // cp_size, 1, config.hidden_size), + dtype=torch.bfloat16, + device='cuda', + requires_grad=True, + ) + + eager_out = block( + hidden_states=hidden_states, attention_mask=None, packed_seq_params=packed_seq_params + ) + eager_out.sum().backward() + + # This is the primary function under test. + create_cudagraphs() + + for layer in block.layers: + runners = layer.cudagraph_manager.cudagraph_runners + assert len(runners) == 1 + assert runners[0].fwd_graph is not None + + graphed_out = block( + hidden_states=hidden_states, attention_mask=None, packed_seq_params=packed_seq_params + ) + assert torch.allclose(graphed_out.float(), eager_out.float(), rtol=1e-2, atol=1e-2) + graphed_out.sum().backward() + + # Destroy captured graphs deterministically before parallel-state teardown. + for layer in block.layers: + for runner in layer.cudagraph_manager.cudagraph_runners: + if hasattr(runner, "fwd_graph"): + del runner.fwd_graph + if hasattr(runner, "bwd_graph"): + del runner.bwd_graph + torch.cuda.synchronize() + + @pytest.mark.skipif( not (HAVE_TE and is_te_min_version("1.5.0")), reason="use_te_rng_tracker requires TransformerEngine version >= 1.5", From 368fa88e382b274c8fc12af851331cc1d30d69cc Mon Sep 17 00:00:00 2001 From: Ali Arda Eker Date: Tue, 21 Jul 2026 00:30:00 -0700 Subject: [PATCH 071/290] Log app_finish_time and app_train_loop_finish_time on early-exit path (#5864) Signed-off-by: Ali Arda Eker --- megatron/training/training.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/megatron/training/training.py b/megatron/training/training.py index 200d7816dd6..0e4c1ea6a23 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -4001,6 +4001,13 @@ def trace_handler(p): if should_exit: break + # Early-exit paths (exit-duration / exit-interval / signal handler) sys.exit() + # below before the normal-path logging, so record the train-loop finish time here. + if should_exit: + one_logger and one_logger.log_metrics( + {'app_train_loop_finish_time': one_logger_utils.get_timestamp_in_ms()} + ) + # Destroy CUDA Graphs. if args.cuda_graph_impl == "transformer_engine" and cuda_graph_helper.graphs_created(): cuda_graph_helper.delete_cuda_graphs() @@ -4048,6 +4055,9 @@ def trace_handler(p): for buf in model_module.buffers + model_module.expert_parallel_buffers: if getattr(buf, 'nccl_mem_pool', None) is not None: nccl_allocator.deregister_mem_pool(buf.nccl_mem_pool, buf.data_parallel_group) + one_logger and one_logger.log_metrics( + {'app_finish_time': one_logger_utils.get_timestamp_in_ms()} + ) wandb_writer = get_wandb_writer() if wandb_writer: wandb_writer.finish() From 58bf14e9e68915a5e0c7d70451620e6e713c07d5 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 21 Jul 2026 06:59:30 -0700 Subject: [PATCH 072/290] Test mFSDP v2 overlap with default and symmetric memory (#5859) Signed-off-by: Jingyue Wu Signed-off-by: svcnvidia-nemo-ci Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: svcnvidia-nemo-ci --- .../distributed/mfsdp_v2/profiler_utils.py | 55 +++++++ .../distributed/mfsdp_v2/test_fully_shard.py | 151 ++++++++++++------ 2 files changed, 154 insertions(+), 52 deletions(-) create mode 100644 tests/unit_tests/distributed/mfsdp_v2/profiler_utils.py diff --git a/tests/unit_tests/distributed/mfsdp_v2/profiler_utils.py b/tests/unit_tests/distributed/mfsdp_v2/profiler_utils.py new file mode 100644 index 00000000000..2b3e09d9ad6 --- /dev/null +++ b/tests/unit_tests/distributed/mfsdp_v2/profiler_utils.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Helpers for parsing ``torch.profiler`` events in the mfsdp_v2 tests.""" + +from torch.autograd import DeviceType +from torch.autograd.profiler_util import FunctionEvent +from torch.profiler import profile as TorchProfiler + + +def events_overlap(first: FunctionEvent, second: FunctionEvent) -> bool: + return ( + first.time_range.start < second.time_range.end + and second.time_range.start < first.time_range.end + ) + + +def collect_linked_kernels( + prof: TorchProfiler, cpu_event_name_substring: str +) -> list[FunctionEvent]: + """Collect device kernel events linked to matching CPU op instances. + + Device events are attributed by their launching CPU op rather than searched by their + own name: device-side names vary across GPU architectures and kernel libraries -- for + example a matmul kernel is named ``nvjet_``/``cutlass_``/``cublas_``... while its + CPU op is simply ``aten::mm``. + + Zero-CTA all-gather copy-engine memcpys are not kernels and are intentionally not + returned. + """ + # A correlation id is shared by a device event and the leaf runtime op that issued it, + # not the enclosing matched op, so walk cpu_parent up from each correlated leaf. Id 0 + # is the "no device correlation" sentinel and is skipped. + events = prof.events() + matching_correlations: set[int] = set() + for event in events: + if event.device_type != DeviceType.CPU or not event.linked_correlation_id: + continue + node = event + while node is not None: + if cpu_event_name_substring in node.name: + matching_correlations.add(event.linked_correlation_id) + break + node = node.cpu_parent + + linked_kernels: list[FunctionEvent] = [] + for event in events: + if event.device_type != DeviceType.CUDA: + continue + if event.activity_type != "kernel": + continue + if event.linked_correlation_id not in matching_correlations: + continue + linked_kernels.append(event) + + return linked_kernels diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 5485a1df954..2d3cc2a677a 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -8,7 +8,7 @@ import torch import torch.distributed as dist from torch import nn -from torch.distributed.device_mesh import init_device_mesh +from torch.distributed.device_mesh import DeviceMesh, init_device_mesh from torch.distributed.tensor import DTensor from torch.profiler import ProfilerActivity, profile @@ -22,6 +22,10 @@ microbatch, ) from megatron.core.distributed.fsdp.src.megatron_fsdp.mixed_precision import MixedPrecisionPolicy +from tests.unit_tests.distributed.mfsdp_v2.profiler_utils import ( + collect_linked_kernels, + events_overlap, +) logger = logging.getLogger(__name__) @@ -120,11 +124,11 @@ def _mb(num_bytes: int) -> str: return f"{num_bytes / 1024**2:.2f} MB" -def _events_overlap(first, second) -> bool: - return ( - first.time_range.start < second.time_range.end - and second.time_range.start < first.time_range.end - ) +# CPU ops that a device event chains up to via cpu_parent, used to attribute the device +# work to its enclosing collective or matmul operation. +_ALL_GATHER_OP_NAME_SUBSTRING = "allgather" +_REDUCE_SCATTER_OP_NAME_SUBSTRING = "reduce_scatter" +_GEMM_OP_NAME_SUBSTRING = "aten::mm" def _nccl_events(cuda_events, *name_fragments): @@ -479,14 +483,14 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): ) -def test_overlaps_communication_and_compute(distributed_setup): +@pytest.mark.parametrize("use_symm_mem", [False, True], ids=["default", "symmetric_memory"]) +def test_overlaps_communication_and_compute(distributed_setup, use_symm_mem): """Forward and backward communication should overlap GEMM compute.""" world_size = distributed_setup.world_size device = distributed_setup.device if world_size < 2: pytest.skip("This test requires at least 2 ranks.") - mesh = init_device_mesh(device.type, (world_size,)) # A large hidden size keeps the per-layer GEMMs long enough that the # collectives reliably overlap them. The overlap count is otherwise # launch-bound: the host issues kernels with gaps (amplified by CI's @@ -497,12 +501,51 @@ def test_overlaps_communication_and_compute(distributed_setup): dim = 16384 num_children = 4 dtype = torch.bfloat16 + + # new_group requires a default process group. Initialize it here so this test works + # in isolation. Do not eagerly initialize it with device_id in the shared fixture: + # that can hang teardown after communicator splits; see + # https://github.com/pytorch/pytorch/issues/190396. + if not dist.is_initialized(): + dist.init_process_group(backend="nccl") + + if use_symm_mem: + # Dedicated communicator with NCCL's zero-CTA policy. cta_policy is a + # per-communicator property, so scoping it to this group leaves the rest of the + # bucket on default-CTA symmetric-memory kernels (test_symmetric_memory.py asserts + # ncclSymk all-gather kernel counts, which zero-CTA would turn into copy-engine + # memcpys). This 1-D group models the DP (FSDP) sub-mesh that mfsdp is handed in + # production: with EP/TP the full device mesh is multi-dimensional, but mfsdp + # requires an all-FSDP mesh (see experimental/module.py) and never sees the TP/EP + # axes, so only the DP communicator needs the zero-CTA policy. + zero_cta_options = dist.ProcessGroupNCCL.Options() + zero_cta_options.config.cta_policy = dist.ProcessGroupNCCL.NCCL_CTA_POLICY_ZERO + dp_group = dist.new_group(backend="nccl", pg_options=zero_cta_options) + # NCCL window registration can fail when symmetric-memory rendezvous is the first + # operation on a communicator, so initialize this communicator explicitly. + dist.barrier(group=dp_group, device_ids=[device.index]) + else: + dp_group = dist.new_group(backend="nccl") + + mesh = DeviceMesh.from_group(dp_group, device.type) model = MultiChildModel(dim=dim, num_children=num_children).to(dtype=dtype) placements = _flat_placements() policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) - fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard( + layer, + mesh=mesh, + placements=placements, + mixed_precision_policy=policy, + use_symm_mem=use_symm_mem, + ) + fully_shard( + model, + mesh=mesh, + placements=placements, + mixed_precision_policy=policy, + use_symm_mem=use_symm_mem, + ) x = torch.randn(4096, dim, device=device, dtype=dtype, requires_grad=True) @@ -521,59 +564,63 @@ def train_one_iteration() -> None: # drop the CUDA events. torch.cuda.synchronize(device) - cuda_events = [event for event in prof.events() if event.device_type.name == "CUDA"] - all_gather_events = _nccl_events(cuda_events, "allgather") - reduce_scatter_events = _nccl_events(cuda_events, "reducescatter", "reduce_scatter") - # GEMM device-kernel names vary across CUDA/cuBLAS versions and GPU archs - # (e.g. "*gemm*", "cutlass*", "cublas*", and cuBLASLt's Hopper "nvjet_sm90_*"). - gemm_events = [ - event - for event in cuda_events - if any(token in event.name.lower() for token in ("gemm", "cutlass", "cublas", "nvjet")) - ] - # Each of the num_children children plus the root all-gathers in forward and - # again in backward, and each reduce-scatters once in backward. - assert len(all_gather_events) == 2 * (num_children + 1), ( - f"Expected {2 * (num_children + 1)} all-gather kernels, " - f"got {[event.name for event in all_gather_events]}." + gemm_kernels = collect_linked_kernels(prof, _GEMM_OP_NAME_SUBSTRING) + # Each child Linear runs one forward and two backward matmuls. aten::mm may also + # launch auxiliary kernels, so check only the matmul lower bound. + assert len(gemm_kernels) >= 3 * num_children, ( + f"Expected at least {3 * num_children} kernels linked to GEMMs, got " + f"{len(gemm_kernels)}: " + f"{[event.name for event in gemm_kernels]}" + ) + + allgather_kernels = collect_linked_kernels(prof, _ALL_GATHER_OP_NAME_SUBSTRING) + reduce_scatter_kernels = collect_linked_kernels(prof, _REDUCE_SCATTER_OP_NAME_SUBSTRING) + # The num_children child layers plus the root are each a sharded module; each does a + # forward and a backward all-gather and one reduce-scatter. Zero-CTA moves the + # all-gather to copy-engine memcpys, so it should not emit all-gather kernels. + num_sharded_modules = num_children + 1 + expected_allgather_kernel_count = 0 if use_symm_mem else 2 * num_sharded_modules + assert len(allgather_kernels) == expected_allgather_kernel_count, ( + f"Expected {expected_allgather_kernel_count} all-gather kernels, got " + f"{len(allgather_kernels)}: {[event.name for event in allgather_kernels]}" ) - assert len(reduce_scatter_events) == num_children + 1, ( - f"Expected {num_children + 1} reduce-scatter kernels, " - f"got {[event.name for event in reduce_scatter_events]}." + assert len(reduce_scatter_kernels) == num_sharded_modules, ( + f"Expected {num_sharded_modules} reduce-scatter kernels, got " + f"{len(reduce_scatter_kernels)}: {[event.name for event in reduce_scatter_kernels]}" ) - assert gemm_events, [event.name for event in cuda_events] - all_gather_streams = {event.device_resource_id for event in all_gather_events} - reduce_scatter_streams = {event.device_resource_id for event in reduce_scatter_events} - gemm_streams = {event.device_resource_id for event in gemm_events} - assert len(all_gather_streams) == 1 + allgather_streams = {event.device_resource_id for event in allgather_kernels} + reduce_scatter_streams = {event.device_resource_id for event in reduce_scatter_kernels} + gemm_streams = {event.device_resource_id for event in gemm_kernels} + if allgather_kernels: + assert len(allgather_streams) == 1 assert len(reduce_scatter_streams) == 1 - assert all_gather_streams.isdisjoint(reduce_scatter_streams) - assert all_gather_streams.isdisjoint(gemm_streams) + assert allgather_streams.isdisjoint(reduce_scatter_streams) + assert allgather_streams.isdisjoint(gemm_streams) assert reduce_scatter_streams.isdisjoint(gemm_streams) - all_gather_overlap_count = sum( - any(_events_overlap(all_gather_event, gemm_event) for gemm_event in gemm_events) - for all_gather_event in all_gather_events + allgather_overlap_count = sum( + any(events_overlap(event, gemm) for gemm in gemm_kernels) for event in allgather_kernels ) reduce_scatter_overlap_count = sum( - any(_events_overlap(reduce_scatter_event, gemm_event) for gemm_event in gemm_events) - for reduce_scatter_event in reduce_scatter_events + any(events_overlap(event, gemm) for gemm in gemm_kernels) + for event in reduce_scatter_kernels ) - # With dim large enough for the GEMMs to dominate launch jitter (see above), - # the prefetched collectives overlap compute deterministically, so assert the - # theoretical maxima (2*(num_children - 1) all-gathers across forward and - # backward, num_children - 1 reduce-scatters in backward) rather than a loose - # floor. - assert all_gather_overlap_count >= 2 * (num_children - 1), ( - f"Expected all-gather to overlap compute, " - f"got {all_gather_overlap_count}/{len(all_gather_events)}." - ) - assert reduce_scatter_overlap_count >= num_children - 1, ( - f"Expected reduce-scatter to overlap compute, " - f"got {reduce_scatter_overlap_count}/{len(reduce_scatter_events)}." + expected_allgather_overlap = 2 * (num_children - 1) + expected_reduce_scatter_overlap = num_children - 1 + if not use_symm_mem: + assert allgather_overlap_count >= expected_allgather_overlap, ( + f"Expected at least {expected_allgather_overlap} all-gathers to " + f"overlap compute, got {allgather_overlap_count}/{len(allgather_kernels)}." + ) + assert reduce_scatter_overlap_count >= expected_reduce_scatter_overlap, ( + f"Expected at least {expected_reduce_scatter_overlap} reduce-scatters to overlap " + f"compute, got {reduce_scatter_overlap_count}/{len(reduce_scatter_kernels)}." ) + # Release the dedicated communicator so it does not leak into the shared session. + dist.destroy_process_group(dp_group) + def test_parameterless_parent_with_child_modules_trains(distributed_setup): """A parent with no unowned parameters should still root trainable child FsdpModules.""" From ddaa315fef1d597b2783f16ada4e9d88c381d191 Mon Sep 17 00:00:00 2001 From: Charlie Truong Date: Tue, 21 Jul 2026 23:14:59 -0500 Subject: [PATCH 073/290] fix: Harden Claude GitHub workflows (#5408) Signed-off-by: Charlie Truong --- .github/workflows/claude-complexity-label.yml | 69 +++++--- .github/workflows/claude-copy-to-main.yml | 158 ++++++++++++++---- .github/workflows/claude_review.yml | 2 - .../workflows/nightly-sync-main-to-dev.yml | 6 +- 4 files changed, 175 insertions(+), 60 deletions(-) diff --git a/.github/workflows/claude-complexity-label.yml b/.github/workflows/claude-complexity-label.yml index 541cdb4e539..f44e958ad2d 100644 --- a/.github/workflows/claude-complexity-label.yml +++ b/.github/workflows/claude-complexity-label.yml @@ -5,16 +5,16 @@ on: types: [ready_for_review] jobs: - label-complexity: - name: Label PR Complexity + analyze_complexity: + name: Analyze PR Complexity runs-on: ubuntu-latest permissions: contents: read - pull-requests: write - issues: write - id-token: write + pull-requests: read + issues: read + outputs: + label_json: ${{ steps.analyze.outputs.structured_output }} env: - GH_TOKEN: ${{ secrets.PAT }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} steps: @@ -24,6 +24,7 @@ jobs: fetch-depth: 0 - name: Run Claude Complexity Analysis + id: analyze uses: anthropics/claude-code-action@v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} @@ -31,12 +32,12 @@ jobs: DISABLE_PROMPT_CACHING: "1" with: anthropic_api_key: ${{ secrets.NVIDIA_INFERENCE_KEY }} - github_token: ${{ secrets.PAT }} + github_token: ${{ github.token }} prompt: | REPO: ${{ env.REPO }} PR NUMBER: ${{ env.PR_NUMBER }} - You are a PR complexity analyzer. Your job is to analyze the diff of this PR and apply exactly one complexity label. + You are a PR complexity analyzer. Your job is to analyze the diff of this PR and return exactly one complexity label. STEPS: 1. Get the PR diff by running: gh pr diff $PR_NUMBER --repo $REPO @@ -47,19 +48,45 @@ jobs: 3. Compute "real code line changes" using this formula: real_code_line_changes = (number of real code lines changed) + (number of test lines changed / 10) Count both added and removed lines. Do not count unchanged context lines. Do not count comments or docstrings. - 4. Remove any previously applied complexity or docs-only labels: - gh pr edit $PR_NUMBER --repo $REPO --remove-label "complexity: low,complexity: medium,complexity: high,docs-only" - 5. Apply exactly ONE label using the gh CLI: - - If there are ZERO real code lines and ZERO test lines (only docs-only changes), apply label "docs-only": - gh pr edit $PR_NUMBER --repo $REPO --add-label "docs-only" - - If real_code_line_changes < 100, apply label "complexity: low": - gh pr edit $PR_NUMBER --repo $REPO --add-label "complexity: low" - - If real_code_line_changes >= 100 and < 500, apply label "complexity: medium": - gh pr edit $PR_NUMBER --repo $REPO --add-label "complexity: medium" - - If real_code_line_changes >= 500, apply label "complexity: high": - gh pr edit $PR_NUMBER --repo $REPO --add-label "complexity: high" + 4. Return exactly ONE label: + - If there are ZERO real code lines and ZERO test lines (only docs-only changes), return "docs-only". + - If real_code_line_changes < 100, return "complexity: low". + - If real_code_line_changes >= 100 and < 500, return "complexity: medium". + - If real_code_line_changes >= 500, return "complexity: high". - Do NOT post any comments on the PR. Only apply the label. + Do NOT post comments, edit the PR, modify labels, or run any write operation. claude_args: | - --allowedTools "Bash(gh pr diff:*),Bash(gh pr edit:*),Bash(gh pr view:*)" + --allowedTools "Bash(gh pr diff:*),Bash(gh pr view:*)" --model "${{ vars.CLAUDE_MODEL }}" + --json-schema '{"type":"object","properties":{"label":{"type":"string","enum":["docs-only","complexity: low","complexity: medium","complexity: high"]}},"required":["label"],"additionalProperties":false}' + + apply-complexity-label: + name: Apply PR Complexity Label + runs-on: ubuntu-latest + needs: analyze_complexity + permissions: + pull-requests: write + issues: write + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + LABEL_JSON: ${{ needs.analyze_complexity.outputs.label_json }} + steps: + - name: Apply validated complexity label + run: | + set -euo pipefail + + label=$(echo "$LABEL_JSON" | jq -r '.label // empty') + case "$label" in + "docs-only"|"complexity: low"|"complexity: medium"|"complexity: high") + ;; + *) + echo "::error::Claude returned invalid complexity label: $label" + exit 1 + ;; + esac + + gh pr edit "$PR_NUMBER" --repo "$REPO" \ + --remove-label "complexity: low,complexity: medium,complexity: high,docs-only" || true + gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$label" diff --git a/.github/workflows/claude-copy-to-main.yml b/.github/workflows/claude-copy-to-main.yml index 24659574b77..14d18e2c08a 100644 --- a/.github/workflows/claude-copy-to-main.yml +++ b/.github/workflows/claude-copy-to-main.yml @@ -5,8 +5,8 @@ on: types: [created] jobs: - copy-to-main: - name: Copy PR to Main + authorize_copy: + name: Authorize Copy to Main if: | github.event_name == 'issue_comment' && github.event.issue.pull_request && @@ -14,14 +14,14 @@ jobs: contains(github.event.comment.body, '/claude copy') runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write issues: write - id-token: write + pull-requests: read env: GH_TOKEN: ${{ secrets.PAT }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number }} + outputs: + base_ref: ${{ steps.pr-info.outputs.base_ref }} steps: - name: Check commenter has write access env: @@ -34,10 +34,12 @@ jobs: fi - name: Check PR is merged and targets non-main + id: pr-info run: | PR_JSON=$(gh pr view $PR_NUMBER --repo $REPO --json baseRefName,mergedAt) PR_BASE=$(echo "$PR_JSON" | jq -r .baseRefName) PR_MERGED=$(echo "$PR_JSON" | jq -r .mergedAt) + echo "base_ref=$PR_BASE" >> "$GITHUB_OUTPUT" if [ "$PR_BASE" = "main" ]; then gh pr comment $PR_NUMBER --repo $REPO --body "❌ This PR already targets \`main\`. The Claude copy command only works on PRs targeting non-main branches." @@ -49,16 +51,34 @@ jobs: exit 1 fi + prepare_copy: + name: Prepare Copy Patch + runs-on: ubuntu-latest + needs: authorize_copy + permissions: + contents: read + pull-requests: read + issues: read + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COPY_BRANCH: copy-pr-${{ github.event.issue.number }}-to-main + steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - token: ${{ secrets.PAT }} - name: Fetch PR head ref from fork run: | git fetch origin pull/$PR_NUMBER/head:pr-$PR_NUMBER-head + - name: Configure Git + run: | + git config user.name "svcnvidia-nemo-ci" + git config user.email "svcnvidia-nemo-ci@nvidia.com" + - name: Run Claude Copy to Main uses: anthropics/claude-code-action@v1 env: @@ -68,12 +88,14 @@ jobs: with: anthropic_api_key: ${{ secrets.NVIDIA_INFERENCE_KEY }} trigger_phrase: "/claude copy" - github_token: ${{ secrets.PAT }} + github_token: ${{ github.token }} prompt: | REPO: ${{ env.REPO }} PR NUMBER: ${{ env.PR_NUMBER }} + SOURCE BASE REF: ${{ needs.authorize_copy.outputs.base_ref }} + COPY BRANCH: ${{ env.COPY_BRANCH }} - You are a PR copy assistant. Your job is to apply the final changes from a merged PR onto a new branch based on `main` and create a new PR targeting `main`. + You are a PR copy assistant. Your job is to apply the final changes from a merged PR onto a local branch based on `main`. The PR's commits originated from a fork and have been fetched locally as the branch: pr-${PR_NUMBER}-head @@ -81,19 +103,15 @@ jobs: 1. Get the PR details (title, body, and base branch): gh pr view $PR_NUMBER --repo $REPO --json title,body,baseRefName - 2. Configure git for committing (use the svcnvidia-nemo-ci service account since secrets.PAT belongs to it): - git config user.name "svcnvidia-nemo-ci" - git config user.email "svcnvidia-nemo-ci@nvidia.com" - - 3. Create a new branch from `main`: + 2. Create a new local branch from `main`: git checkout main git pull origin main - git checkout -b copy-pr-${PR_NUMBER}-to-main + git checkout -b $COPY_BRANCH - 4. Generate a patch of the PR's final changes and apply it: + 3. Generate a patch of the PR's final changes and apply it: MERGE_BASE=$(git merge-base origin/ pr-${PR_NUMBER}-head) git diff $MERGE_BASE pr-${PR_NUMBER}-head | git apply --3way - (Replace with the actual base branch name from step 1.) + (Replace with SOURCE BASE REF unless step 1 shows a different base branch.) If the apply fails due to merge conflicts: a. Identify conflicted files: git diff --name-only --diff-filter=U @@ -103,25 +121,101 @@ jobs: without overriding what is already on main. d. Stage the resolved files: git add - 5. Commit the changes: + 4. Commit the changes locally: git add -A - git commit -m "Copy PR #${PR_NUMBER} to main" - - 6. Push the new branch: - git push origin copy-pr-${PR_NUMBER}-to-main - - 7. Create a new PR targeting `main`: - gh pr create --repo $REPO \ - --base main \ - --head copy-pr-${PR_NUMBER}-to-main \ - --title "[Copy to main] " \ - --body "🤖 **This PR was auto-generated by Claude** via the Claude copy workflow.\n\nCherry-picked from #${PR_NUMBER}.\n\n---\n\n" - - 8. Comment on the original PR with a link to the newly created PR. + git commit -s -m "Copy PR #${PR_NUMBER} to main" IMPORTANT: + - Do NOT push. + - Do NOT create a pull request. + - Do NOT comment on the original PR. + - Do NOT use gh for any operation except reading PR metadata. - When resolving merge conflicts, favor `main` over the non-main branch. Do not override changes already on main. - - Do NOT force push. claude_args: | - --allowedTools "Bash(git:*),Bash(gh:*),Read,Edit" + --allowedTools "Bash(git:*),Bash(gh pr view:*),Read,Edit" --model "${{ vars.CLAUDE_MODEL }}" + + - name: Export copy patch + run: | + set -euo pipefail + + git status --short + test "$(git rev-parse --abbrev-ref HEAD)" = "$COPY_BRANCH" + test -z "$(git status --porcelain)" + test "$(git rev-list --count origin/main..HEAD)" -gt 0 + + git diff --binary origin/main..HEAD > "$RUNNER_TEMP/copy-pr.patch" + test -s "$RUNNER_TEMP/copy-pr.patch" + + - name: Upload copy patch + uses: actions/upload-artifact@v4 + with: + name: copy-pr-${{ github.event.issue.number }}-patch + path: ${{ runner.temp }}/copy-pr.patch + if-no-files-found: error + retention-days: 1 + + publish_copy: + name: Publish Copy PR + runs-on: ubuntu-latest + needs: [authorize_copy, prepare_copy] + permissions: + contents: write + pull-requests: write + issues: write + env: + GH_TOKEN: ${{ secrets.PAT }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COPY_BRANCH: copy-pr-${{ github.event.issue.number }}-to-main + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.PAT }} + + - name: Download copy patch + uses: actions/download-artifact@v4 + with: + name: copy-pr-${{ github.event.issue.number }}-patch + path: ${{ runner.temp }} + + - name: Create branch, commit, and PR + run: | + set -euo pipefail + + git config user.name "svcnvidia-nemo-ci" + git config user.email "svcnvidia-nemo-ci@nvidia.com" + + git fetch origin main + git checkout -b "$COPY_BRANCH" origin/main + git apply --3way "$RUNNER_TEMP/copy-pr.patch" + git diff --check + git add -A + git commit -s -m "Copy PR #${PR_NUMBER} to main" + git push origin "$COPY_BRANCH" + + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json title,body) + ORIGINAL_TITLE=$(echo "$PR_JSON" | jq -r '.title') + echo "$PR_JSON" | jq -r '.body // ""' > "$RUNNER_TEMP/original-pr-body.md" + { + echo "🤖 **This PR was auto-generated by Claude** via the Claude copy workflow." + echo + echo "Cherry-picked from #${PR_NUMBER}." + echo + echo "---" + echo + cat "$RUNNER_TEMP/original-pr-body.md" + } > "$RUNNER_TEMP/copy-pr-body.md" + + NEW_PR_URL=$(gh pr create \ + --repo "$REPO" \ + --base main \ + --head "$COPY_BRANCH" \ + --title "[Copy to main] $ORIGINAL_TITLE" \ + --body-file "$RUNNER_TEMP/copy-pr-body.md") + + gh pr comment "$PR_NUMBER" \ + --repo "$REPO" \ + --body "✅ Created copy-to-main PR: $NEW_PR_URL" diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml index 98fe4eac964..7b1387f29df 100644 --- a/.github/workflows/claude_review.yml +++ b/.github/workflows/claude_review.yml @@ -52,7 +52,6 @@ jobs: with: anthropic_api_key: ${{ secrets.NVIDIA_INFERENCE_KEY }} trigger_phrase: "/claude review" - show_full_output: true claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Read" --model "${{ vars.CLAUDE_MODEL }}" @@ -155,7 +154,6 @@ jobs: with: anthropic_api_key: ${{ secrets.NVIDIA_INFERENCE_KEY }} trigger_phrase: "/claude strict-review" - show_full_output: true claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Bash(git diff:*),Bash(git show:*),Bash(git log:*),Read" --model "${{ vars.CLAUDE_MODEL }}" diff --git a/.github/workflows/nightly-sync-main-to-dev.yml b/.github/workflows/nightly-sync-main-to-dev.yml index 07490d9bade..bda7146d4be 100644 --- a/.github/workflows/nightly-sync-main-to-dev.yml +++ b/.github/workflows/nightly-sync-main-to-dev.yml @@ -27,10 +27,7 @@ concurrency: cancel-in-progress: false permissions: - contents: write - pull-requests: write - issues: write - id-token: write + contents: read jobs: # Re-dispatch scheduled runs as workflow_dispatch via a PAT so the heavy @@ -301,7 +298,6 @@ jobs: `Nemo_CICD_Test`, `copyright-check`, `pre-flight`, wheel builds, etc. — is NOT exempt and must reach a terminal green conclusion. - show_full_output: true claude_args: | --allowedTools "Bash,Read,Edit,Write,Grep,Glob,Agent" --model "${{ vars.CLAUDE_MODEL }}" From a046281960c13d034267cf15cc3b689048a1be45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 09:49:27 +0000 Subject: [PATCH 074/290] chore: rotate oncall schedule --- .github/oncall_schedule.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/oncall_schedule.json b/.github/oncall_schedule.json index 1b76b86583b..4320b7b407d 100644 --- a/.github/oncall_schedule.json +++ b/.github/oncall_schedule.json @@ -1,8 +1,4 @@ [ - { - "user": "Phlip79", - "date": "2026-07-15" - }, { "user": "guihong-nv", "date": "2026-07-22" @@ -46,5 +42,9 @@ { "user": "dimapihtar", "date": "2026-09-30" + }, + { + "user": "guihong-nv", + "date": "2026-10-07" } ] From d207685b46718e0cafe617306aec8f442c59e0a0 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 22 Jul 2026 05:42:16 -0700 Subject: [PATCH 075/290] Reuse profiler helpers in mFSDP v2 symmetric memory tests (#5873) Signed-off-by: Jingyue Wu --- .../distributed/mfsdp_v2/test_fully_shard.py | 47 ++++------ .../mfsdp_v2/test_symmetric_memory.py | 90 ++++++++++--------- 2 files changed, 66 insertions(+), 71 deletions(-) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 2d3cc2a677a..081cc5c260f 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -128,20 +128,10 @@ def _mb(num_bytes: int) -> str: # work to its enclosing collective or matmul operation. _ALL_GATHER_OP_NAME_SUBSTRING = "allgather" _REDUCE_SCATTER_OP_NAME_SUBSTRING = "reduce_scatter" +_ALLREDUCE_OP_NAME_SUBSTRING = "allreduce" _GEMM_OP_NAME_SUBSTRING = "aten::mm" -def _nccl_events(cuda_events, *name_fragments): - """CUDA NCCL events whose name contains any of ``name_fragments`` (case-insensitive).""" - return [ - event - for event in cuda_events - if "nccl" in event.name.lower() - and event.activity_type == "kernel" - and any(fragment in event.name.lower() for fragment in name_fragments) - ] - - @pytest.mark.parametrize("num_microbatches", [1, 3]) def test_fully_shard_sgd_losses_match_baseline(distributed_setup, num_microbatches): """Minimal per-module FSDP training should match single-rank SGD.""" @@ -279,11 +269,11 @@ def test_hsdp_defers_dp_outer_allreduce_to_last_microbatch(distributed_setup): ``fully_shard(model)`` makes the child units share a root context so their reductions run through the overlap path rather than as independent roots. - Counting NCCL events over a multi-microbatch step, the DP-inner reduce-scatter + Counting linked NCCL kernels over a multi-microbatch step, the DP-inner reduce-scatter fires once per microbatch per group while the DP-outer all-reduce that finalizes main_grad fires only on the last microbatch, so the reduce-scatter count is exactly ``num_microbatches`` times the all-reduce count. This asserts - on event counts only, not numerics. + on kernel counts only, not numerics. """ world_size = distributed_setup.world_size device = distributed_setup.device @@ -326,17 +316,16 @@ def train_one_step() -> None: train_one_step() torch.cuda.synchronize(device) - cuda_events = [event for event in prof.events() if event.device_type.name == "CUDA"] - reduce_scatter_events = _nccl_events(cuda_events, "reducescatter", "reduce_scatter") - all_reduce_events = _nccl_events(cuda_events, "allreduce") + reduce_scatter_kernels = collect_linked_kernels(prof, _REDUCE_SCATTER_OP_NAME_SUBSTRING) + allreduce_kernels = collect_linked_kernels(prof, _ALLREDUCE_OP_NAME_SUBSTRING) # One DP-outer all-reduce per parameter group -- each child layer plus the # root unit's bias -- fired only on the last microbatch. Plain DP fires none. - assert len(all_reduce_events) == num_children + 1, [event.name for event in cuda_events] + assert len(allreduce_kernels) == num_children + 1, [event.name for event in prof.events()] # DP-inner reduce-scatter runs every microbatch; the DP-outer all-reduce runs # only on the last, so the counts differ by exactly the microbatch factor. - assert len(reduce_scatter_events) == len(all_reduce_events) * num_microbatches, ( - f"Expected reduce-scatter ({len(reduce_scatter_events)}) to be {num_microbatches}x " - f"the DP-outer all-reduce count ({len(all_reduce_events)})." + assert len(reduce_scatter_kernels) == len(allreduce_kernels) * num_microbatches, ( + f"Expected reduce-scatter ({len(reduce_scatter_kernels)}) to be {num_microbatches}x " + f"the DP-outer all-reduce count ({len(allreduce_kernels)})." ) @@ -570,7 +559,7 @@ def train_one_iteration() -> None: assert len(gemm_kernels) >= 3 * num_children, ( f"Expected at least {3 * num_children} kernels linked to GEMMs, got " f"{len(gemm_kernels)}: " - f"{[event.name for event in gemm_kernels]}" + f"{[kernel.name for kernel in gemm_kernels]}" ) allgather_kernels = collect_linked_kernels(prof, _ALL_GATHER_OP_NAME_SUBSTRING) @@ -582,16 +571,16 @@ def train_one_iteration() -> None: expected_allgather_kernel_count = 0 if use_symm_mem else 2 * num_sharded_modules assert len(allgather_kernels) == expected_allgather_kernel_count, ( f"Expected {expected_allgather_kernel_count} all-gather kernels, got " - f"{len(allgather_kernels)}: {[event.name for event in allgather_kernels]}" + f"{len(allgather_kernels)}: {[kernel.name for kernel in allgather_kernels]}" ) assert len(reduce_scatter_kernels) == num_sharded_modules, ( f"Expected {num_sharded_modules} reduce-scatter kernels, got " - f"{len(reduce_scatter_kernels)}: {[event.name for event in reduce_scatter_kernels]}" + f"{len(reduce_scatter_kernels)}: {[kernel.name for kernel in reduce_scatter_kernels]}" ) - allgather_streams = {event.device_resource_id for event in allgather_kernels} - reduce_scatter_streams = {event.device_resource_id for event in reduce_scatter_kernels} - gemm_streams = {event.device_resource_id for event in gemm_kernels} + allgather_streams = {kernel.device_resource_id for kernel in allgather_kernels} + reduce_scatter_streams = {kernel.device_resource_id for kernel in reduce_scatter_kernels} + gemm_streams = {kernel.device_resource_id for kernel in gemm_kernels} if allgather_kernels: assert len(allgather_streams) == 1 assert len(reduce_scatter_streams) == 1 @@ -600,11 +589,11 @@ def train_one_iteration() -> None: assert reduce_scatter_streams.isdisjoint(gemm_streams) allgather_overlap_count = sum( - any(events_overlap(event, gemm) for gemm in gemm_kernels) for event in allgather_kernels + any(events_overlap(kernel, gemm) for gemm in gemm_kernels) for kernel in allgather_kernels ) reduce_scatter_overlap_count = sum( - any(events_overlap(event, gemm) for gemm in gemm_kernels) - for event in reduce_scatter_kernels + any(events_overlap(kernel, gemm) for gemm in gemm_kernels) + for kernel in reduce_scatter_kernels ) expected_allgather_overlap = 2 * (num_children - 1) expected_reduce_scatter_overlap = num_children - 1 diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py index 13dc1744628..87207548140 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py @@ -6,7 +6,6 @@ import torch import torch.distributed as dist from torch import nn -from torch.autograd import DeviceType from torch.distributed.device_mesh import DeviceMesh, init_device_mesh from torch.profiler import ProfilerActivity, profile @@ -16,6 +15,7 @@ Placements, fully_shard, ) +from tests.unit_tests.distributed.mfsdp_v2.profiler_utils import collect_linked_kernels # Each sharded Linear's collective must be large enough that NCCL selects its # symmetric-memory (ncclSymk*) kernels over ring. Sub-KB collectives fall back to @@ -23,6 +23,8 @@ # symmetric-kernel assertions below fail; 1024-wide layers (a few-MiB bf16 weight) # reliably engage the symmetric kernels. _HIDDEN = 1024 +_ALL_GATHER_OP_NAME_SUBSTRING = "allgather" +_REDUCE_SCATTER_OP_NAME_SUBSTRING = "reduce_scatter" class TinyModel(nn.Module): @@ -43,18 +45,6 @@ def _flat_placements() -> Placements: return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) -def _kernels(prof: torch.profiler.profile) -> list[str]: - return [event.name for event in prof.events() if event.device_type == DeviceType.CUDA] - - -def _is_symmetric_kernel(kernel: str) -> bool: - return "ncclSymk" in kernel - - -def _count_symmetric_kernels(kernels: list[str], subname: str) -> int: - return sum(1 for kernel in kernels if _is_symmetric_kernel(kernel) and subname in kernel) - - @pytest.mark.parametrize("num_microbatches", [1, 3]) def test_fully_shard_symmetric_memory_matches_default_and_profiles_nccl( distributed_setup, num_microbatches @@ -108,11 +98,11 @@ def train(use_symm_mem: bool) -> list[torch.Tensor]: return losses - with profile(activities=[ProfilerActivity.CUDA]) as prof_without_symm_mem: + with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof_without_symm_mem: losses_without_symm_mem = train(use_symm_mem=False) torch.cuda.synchronize() - with profile(activities=[ProfilerActivity.CUDA]) as prof_with_symm_mem: + with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof_with_symm_mem: losses_with_symm_mem = train(use_symm_mem=True) torch.cuda.synchronize() @@ -122,31 +112,44 @@ def train(use_symm_mem: bool) -> list[torch.Tensor]: msg="Symmetric-memory FSDP losses did not match default FSDP losses.", ) - kernels_without_symm_mem = _kernels(prof_without_symm_mem) - assert _count_symmetric_kernels(kernels_without_symm_mem, "AllGather") == 0 - assert _count_symmetric_kernels(kernels_without_symm_mem, "ReduceScatter") == 0 + allgather_kernels_without_symm_mem = collect_linked_kernels( + prof_without_symm_mem, _ALL_GATHER_OP_NAME_SUBSTRING + ) + reduce_scatter_kernels_without_symm_mem = collect_linked_kernels( + prof_without_symm_mem, _REDUCE_SCATTER_OP_NAME_SUBSTRING + ) + assert all("ncclSymk" not in kernel.name for kernel in allgather_kernels_without_symm_mem) + assert all("ncclSymk" not in kernel.name for kernel in reduce_scatter_kernels_without_symm_mem) - kernels_with_symm_mem = _kernels(prof_with_symm_mem) + allgather_kernels_with_symm_mem = collect_linked_kernels( + prof_with_symm_mem, _ALL_GATHER_OP_NAME_SUBSTRING + ) + reduce_scatter_kernels_with_symm_mem = collect_linked_kernels( + prof_with_symm_mem, _REDUCE_SCATTER_OP_NAME_SUBSTRING + ) # 2 sharded modules (fc1, fc2), one reduce-scatter each per microbatch step. expected_reduce_scatter_kernel_count = num_training_steps * num_microbatches * 2 - nccl_kernels_with_symm_mem = [ - kernel for kernel in kernels_with_symm_mem if "nccl" in kernel.lower() - ] - assert ( - _count_symmetric_kernels(kernels_with_symm_mem, "ReduceScatter") - == expected_reduce_scatter_kernel_count - ), ( + assert len(reduce_scatter_kernels_with_symm_mem) == expected_reduce_scatter_kernel_count, ( "Unexpected NCCL symmetric-memory reduce-scatter kernel count. " - f"Observed NCCL kernels: {nccl_kernels_with_symm_mem[:20]}" + f"Observed reduce-scatter kernels: " + f"{[kernel.name for kernel in reduce_scatter_kernels_with_symm_mem[:20]]}" + ) + assert all("ncclSymk" in kernel.name for kernel in reduce_scatter_kernels_with_symm_mem), ( + "Expected all symmetric-memory reduce-scatter kernels to be ncclSymk kernels. " + f"Observed reduce-scatter kernels: " + f"{[kernel.name for kernel in reduce_scatter_kernels_with_symm_mem[:20]]}" ) - expected_all_gather_kernel_count = 2 * expected_reduce_scatter_kernel_count - assert ( - _count_symmetric_kernels(kernels_with_symm_mem, "AllGather") - == expected_all_gather_kernel_count - ), ( + expected_allgather_kernel_count = 2 * expected_reduce_scatter_kernel_count + assert len(allgather_kernels_with_symm_mem) == expected_allgather_kernel_count, ( "Unexpected NCCL symmetric-memory all-gather kernel count. " - f"Observed NCCL kernels: {nccl_kernels_with_symm_mem[:20]}" + f"Observed all-gather kernels: " + f"{[kernel.name for kernel in allgather_kernels_with_symm_mem[:20]]}" + ) + assert all("ncclSymk" in kernel.name for kernel in allgather_kernels_with_symm_mem), ( + "Expected all symmetric-memory all-gather kernels to be ncclSymk kernels. " + f"Observed all-gather kernels: " + f"{[kernel.name for kernel in allgather_kernels_with_symm_mem[:20]]}" ) @@ -201,29 +204,32 @@ def test_fully_shard_zero_cta_moves_all_gather_to_copy_engine(distributed_setup) x = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) target = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) - with profile(activities=[ProfilerActivity.CUDA]) as prof: + with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for _ in range(num_training_steps): optimizer.zero_grad() torch.nn.functional.mse_loss(model(x), target).backward() optimizer.step() torch.cuda.synchronize() - kernels = _kernels(prof) - nccl_kernels = [kernel for kernel in kernels if "nccl" in kernel.lower()] + allgather_kernels = collect_linked_kernels(prof, _ALL_GATHER_OP_NAME_SUBSTRING) + reduce_scatter_kernels = collect_linked_kernels(prof, _REDUCE_SCATTER_OP_NAME_SUBSTRING) # Zero-CTA moves the all-gather to the copy engine: no symmetric-memory all-gather kernel. - assert _count_symmetric_kernels(kernels, "AllGather") == 0, ( + assert not allgather_kernels, ( f"Expected no symmetric-memory all-gather kernel under zero-CTA. " - f"Observed NCCL kernels: {nccl_kernels[:20]}" + f"Observed all-gather kernels: {[kernel.name for kernel in allgather_kernels[:20]]}" ) # The reduce-scatter's reduction cannot run on the copy engine, so it stays a # symmetric-memory kernel (an SM-launched NVLS multicast reduce): one per sharded # module (fc1, fc2) per training step. expected_reduce_scatter_kernel_count = num_training_steps * 2 - assert ( - _count_symmetric_kernels(kernels, "ReduceScatter") == expected_reduce_scatter_kernel_count - ), ( + assert len(reduce_scatter_kernels) == expected_reduce_scatter_kernel_count, ( f"Expected {expected_reduce_scatter_kernel_count} symmetric-memory reduce-scatter " - f"kernels under zero-CTA. Observed NCCL kernels: {nccl_kernels[:20]}" + f"kernels under zero-CTA. Observed reduce-scatter kernels: " + f"{[kernel.name for kernel in reduce_scatter_kernels[:20]]}" + ) + assert all("ncclSymk" in kernel.name for kernel in reduce_scatter_kernels), ( + "Expected all zero-CTA reduce-scatter kernels to be ncclSymk kernels. " + f"Observed reduce-scatter kernels: {[kernel.name for kernel in reduce_scatter_kernels[:20]]}" ) # Release the dedicated communicator (leaks only on a test failure above, which is fine). From cc5b09239c3f39c93f290f10be099d687bce75eb Mon Sep 17 00:00:00 2001 From: Yashaswi Karnati <144376261+yashaswikarnati@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:34:21 -0700 Subject: [PATCH 076/290] Reduce MimoOptimizer update-success across the world for cross-grid consensus (#5331) Signed-off-by: ykarnati Co-authored-by: Claude Opus 4.8 --- megatron/core/models/mimo/optimizer.py | 5 ++++ .../mimo/test_mimo_optimizer_consensus.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/unit_tests/models/mimo/test_mimo_optimizer_consensus.py diff --git a/megatron/core/models/mimo/optimizer.py b/megatron/core/models/mimo/optimizer.py index 71500b5fcb6..821c3b93065 100644 --- a/megatron/core/models/mimo/optimizer.py +++ b/megatron/core/models/mimo/optimizer.py @@ -103,6 +103,11 @@ def step(self) -> Tuple[bool, Optional[float], Optional[int]]: num_zeros = self.count_zeros() if self.config.log_num_zeros_in_grad else None success = self.step_with_ready_grads() + # Reduce update success across the world (MIN) so disjoint-grid ranks agree. + success_tensor = torch.tensor([1 if success else 0], dtype=torch.int, device="cuda") + torch.distributed.all_reduce(success_tensor, op=torch.distributed.ReduceOp.MIN) + success = bool(success_tensor.item()) + return success, grad_norm, num_zeros @torch.no_grad() diff --git a/tests/unit_tests/models/mimo/test_mimo_optimizer_consensus.py b/tests/unit_tests/models/mimo/test_mimo_optimizer_consensus.py new file mode 100644 index 00000000000..1bba477ea7f --- /dev/null +++ b/tests/unit_tests/models/mimo/test_mimo_optimizer_consensus.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Distributed test for MimoOptimizer cross-grid step-success consensus.""" + +import pytest +import torch + +from megatron.core.models.mimo.optimizer import MimoOptimizer +from megatron.core.optimizer.optimizer_config import OptimizerConfig +from tests.unit_tests.test_utilities import Utils + + +@pytest.mark.skipif(torch.cuda.device_count() < 2, reason="Requires >= 2 ranks.") +def test_step_success_is_world_min(): + """One rank's failed update must propagate to every rank via the MIN reduction.""" + Utils.initialize_distributed() + try: + opt = MimoOptimizer(module_infos={}, config=OptimizerConfig(log_num_zeros_in_grad=False)) + last_rank = torch.distributed.get_world_size() - 1 + opt.step_with_ready_grads = lambda: torch.distributed.get_rank() != last_rank + success, _, _ = opt.step() + assert success is False + finally: + Utils.destroy_model_parallel() From 96d41593057c027615fbd769f4ce435a6fbfd251 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Wed, 22 Jul 2026 15:55:34 -0400 Subject: [PATCH 077/290] Refresh BERT H100 golden values (#5953) Signed-off-by: Philip Petrakian --- .../golden_values_dev_dgx_h100.json | 324 +++++++------- .../golden_values_dev_dgx_h100.json | 410 +++++++++--------- 2 files changed, 367 insertions(+), 367 deletions(-) diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/golden_values_dev_dgx_h100.json index 1d75976567a..7a2b6597c36 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/golden_values_dev_dgx_h100.json @@ -26,34 +26,34 @@ "20": 10.47714, "21": 10.45276, "22": 10.39141, - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "23": 10.3972, + "24": 10.35475, + "25": 10.35246, + "26": 10.35041, + "27": 10.31147, + "28": 10.32877, + "29": 10.30861, + "30": 10.14449, + "31": 10.10687, + "32": 10.07856, + "33": 10.10424, + "34": 10.03458, + "35": 10.02764, + "36": 10.01018, + "37": 10.00276, + "38": 9.96156, + "39": 9.89009, + "40": 9.85306, + "41": 9.78426, + "42": 9.71982, + "43": 9.68658, + "44": 9.65517, + "45": 9.6502, + "46": 9.57537, + "47": 9.59269, + "48": 9.58288, + "49": 9.52859, + "50": 9.49558 } }, "num-zeros": { @@ -83,34 +83,34 @@ "20": 2266.0, "21": 2428.0, "22": 2319.0, - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "23": 2420.0, + "24": 2343.0, + "25": 2235.0, + "26": 2722.0, + "27": 2402.0, + "28": 2568.0, + "29": 1915.0, + "30": 2132.0, + "31": 2699.0, + "32": 2340.0, + "33": 2667.0, + "34": 2775.0, + "35": 2753.0, + "36": 1838.0, + "37": 2756.0, + "38": 2479.0, + "39": 2316.0, + "40": 3061.0, + "41": 3153.0, + "42": 3112.0, + "43": 2808.0, + "44": 3013.0, + "45": 3282.0, + "46": 3037.0, + "47": 3164.0, + "48": 3314.0, + "49": 2706.0, + "50": 2787.0 } }, "mem-allocated-bytes": { @@ -140,34 +140,34 @@ "20": 3433473024.0, "21": 3433473024.0, "22": 3433473024.0, - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "23": 3433473024.0, + "24": 3433473024.0, + "25": 3433473024.0, + "26": 3433473024.0, + "27": 3433473024.0, + "28": 3433473024.0, + "29": 3433473024.0, + "30": 3433473024.0, + "31": 3433473024.0, + "32": 3433473024.0, + "33": 3433473024.0, + "34": 3433473024.0, + "35": 3433473024.0, + "36": 3433473024.0, + "37": 3433473024.0, + "38": 3433473024.0, + "39": 3433473024.0, + "40": 3433473024.0, + "41": 3433473024.0, + "42": 3433473024.0, + "43": 3433473024.0, + "44": 3433473024.0, + "45": 3433473024.0, + "46": 3433473024.0, + "47": 3433473024.0, + "48": 3433473024.0, + "49": 3433473024.0, + "50": 3433473024.0 } }, "mem-max-allocated-bytes": { @@ -197,34 +197,34 @@ "20": 5707393024.0, "21": 5707393024.0, "22": 5707393024.0, - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "23": 5707393024.0, + "24": 5707393024.0, + "25": 5707393024.0, + "26": 5707393024.0, + "27": 5707393024.0, + "28": 5707393024.0, + "29": 5707393024.0, + "30": 5707393024.0, + "31": 5707393024.0, + "32": 5707393024.0, + "33": 5707393024.0, + "34": 5707393024.0, + "35": 5707393024.0, + "36": 5707393024.0, + "37": 5707393024.0, + "38": 5707393024.0, + "39": 5707393024.0, + "40": 5707393024.0, + "41": 5707393024.0, + "42": 5707393024.0, + "43": 5707393024.0, + "44": 5707393024.0, + "45": 5707393024.0, + "46": 5707393024.0, + "47": 5707393024.0, + "48": 5707393024.0, + "49": 5707393024.0, + "50": 5707393024.0 } }, "iteration-time": { @@ -233,55 +233,55 @@ "step_interval": 1, "values": { "1": "nan", - "2": 5.74712, - "3": 0.49778, - "4": 0.45748, - "5": 0.45659, - "6": 0.45981, - "7": 0.46548, - "8": 0.46542, - "9": 0.46526, - "10": 0.46413, - "11": 0.45692, - "12": 0.46222, - "13": 0.46736, - "14": 0.46657, - "15": 0.46742, - "16": 0.46727, - "17": 0.4733, - "18": 0.469, - "19": 0.45727, - "20": 0.47259, - "21": 0.46632, - "22": 0.46891, - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "2": 6.38114, + "3": 0.48561, + "4": 0.89062, + "5": 0.43795, + "6": 0.43936, + "7": 1.41561, + "8": 0.87474, + "9": 0.87862, + "10": 0.91586, + "11": 0.44504, + "12": 0.44695, + "13": 0.45378, + "14": 0.45293, + "15": 0.4528, + "16": 0.43931, + "17": 0.43805, + "18": 0.93878, + "19": 0.44812, + "20": 0.44479, + "21": 0.91621, + "22": 0.44682, + "23": 0.44976, + "24": 0.44655, + "25": 0.43297, + "26": 0.43824, + "27": 0.44048, + "28": 1.4425, + "29": 0.44398, + "30": 0.44292, + "31": 0.45131, + "32": 0.45161, + "33": 0.45562, + "34": 0.45364, + "35": 0.43687, + "36": 0.43542, + "37": 0.43901, + "38": 0.4423, + "39": 0.44083, + "40": 0.44685, + "41": 0.46676, + "42": 0.49012, + "43": 0.45932, + "44": 0.46833, + "45": 0.43681, + "46": 0.48333, + "47": 0.43935, + "48": 0.43647, + "49": 0.4394, + "50": 0.48458 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/golden_values_dev_dgx_h100.json index 651f9de50bb..d820568f1c8 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/golden_values_dev_dgx_h100.json @@ -17,43 +17,43 @@ "11": 10.49164, "12": 10.47821, "13": 10.47598, - "14": "nan", - "15": "nan", - "16": "nan", - "17": "nan", - "18": "nan", - "19": "nan", - "20": "nan", - "21": "nan", - "22": "nan", - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "14": 10.48189, + "15": 10.48217, + "16": 10.46292, + "17": 10.45829, + "18": 10.45957, + "19": 10.43356, + "20": 10.45335, + "21": 10.42765, + "22": 10.37288, + "23": 10.3837, + "24": 10.34039, + "25": 10.30874, + "26": 10.32676, + "27": 10.3348, + "28": 10.31238, + "29": 10.20958, + "30": 10.10211, + "31": 10.07247, + "32": 10.04225, + "33": 10.04856, + "34": 9.96979, + "35": 9.96036, + "36": 9.94987, + "37": 9.93538, + "38": 9.91494, + "39": 9.81544, + "40": 9.7735, + "41": 9.73656, + "42": 9.68286, + "43": 9.66796, + "44": 9.64166, + "45": 9.64023, + "46": 9.56948, + "47": 9.60362, + "48": 9.59334, + "49": 9.54843, + "50": 9.50472 } }, "num-zeros": { @@ -74,43 +74,43 @@ "11": 2246.0, "12": 1932.0, "13": 2162.0, - "14": "nan", - "15": "nan", - "16": "nan", - "17": "nan", - "18": "nan", - "19": "nan", - "20": "nan", - "21": "nan", - "22": "nan", - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "14": 2390.0, + "15": 2034.0, + "16": 2039.0, + "17": 2152.0, + "18": 2153.0, + "19": 2104.0, + "20": 2360.0, + "21": 2119.0, + "22": 2155.0, + "23": 2343.0, + "24": 2293.0, + "25": 2243.0, + "26": 2619.0, + "27": 2396.0, + "28": 2465.0, + "29": 1927.0, + "30": 2561.0, + "31": 2128.0, + "32": 2812.0, + "33": 2801.0, + "34": 2333.0, + "35": 2276.0, + "36": 1626.0, + "37": 2759.0, + "38": 2349.0, + "39": 2650.0, + "40": 1821.0, + "41": 1530.0, + "42": 1737.0, + "43": 1415.0, + "44": 2609.0, + "45": 3604.0, + "46": 3307.0, + "47": 2903.0, + "48": 1855.0, + "49": 3165.0, + "50": 3023.0 } }, "mem-allocated-bytes": { @@ -131,43 +131,43 @@ "11": 2090120192.0, "12": 2090120192.0, "13": 2090120192.0, - "14": "nan", - "15": "nan", - "16": "nan", - "17": "nan", - "18": "nan", - "19": "nan", - "20": "nan", - "21": "nan", - "22": "nan", - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "14": 2090120192.0, + "15": 2090120192.0, + "16": 2090120192.0, + "17": 2090120192.0, + "18": 2090120192.0, + "19": 2090120192.0, + "20": 2090120192.0, + "21": 2090120192.0, + "22": 2090120192.0, + "23": 2090120192.0, + "24": 2090120192.0, + "25": 2090120192.0, + "26": 2090120192.0, + "27": 2090120192.0, + "28": 2090120192.0, + "29": 2090120192.0, + "30": 2090120192.0, + "31": 2090120192.0, + "32": 2090120192.0, + "33": 2090120192.0, + "34": 2090120192.0, + "35": 2090120192.0, + "36": 2090120192.0, + "37": 2090120192.0, + "38": 2090120192.0, + "39": 2090120192.0, + "40": 2090120192.0, + "41": 2090120192.0, + "42": 2090120192.0, + "43": 2090120192.0, + "44": 2090120192.0, + "45": 2090120192.0, + "46": 2090120192.0, + "47": 2090120192.0, + "48": 2090120192.0, + "49": 2090120192.0, + "50": 2090120192.0 } }, "mem-max-allocated-bytes": { @@ -175,56 +175,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 4420291584.0, + "1": 4420548096.0, "2": 5293134848.0, "3": 5293134848.0, "4": 5293134848.0, "5": 5293134848.0, "6": 5293134848.0, "7": 5293134848.0, - "8": 5293134848.0, - "9": 5293917696.0, - "10": 5293917696.0, - "11": 5293917696.0, - "12": 5293917696.0, - "13": 5293917696.0, - "14": "nan", - "15": "nan", - "16": "nan", - "17": "nan", - "18": "nan", - "19": "nan", - "20": "nan", - "21": "nan", - "22": "nan", - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "8": 5293919744.0, + "9": 5293919744.0, + "10": 5293919744.0, + "11": 5293919744.0, + "12": 5293919744.0, + "13": 5293919744.0, + "14": 5293919744.0, + "15": 5293919744.0, + "16": 5293919744.0, + "17": 5293919744.0, + "18": 5293919744.0, + "19": 5293919744.0, + "20": 5293919744.0, + "21": 5293919744.0, + "22": 5293919744.0, + "23": 5293919744.0, + "24": 5293919744.0, + "25": 5293919744.0, + "26": 5293919744.0, + "27": 5293919744.0, + "28": 5293919744.0, + "29": 5293919744.0, + "30": 5293919744.0, + "31": 5293919744.0, + "32": 5293919744.0, + "33": 5293919744.0, + "34": 5293919744.0, + "35": 5293919744.0, + "36": 5293919744.0, + "37": 5293919744.0, + "38": 5293919744.0, + "39": 5293919744.0, + "40": 5293919744.0, + "41": 5293919744.0, + "42": 5293919744.0, + "43": 5293919744.0, + "44": 5293919744.0, + "45": 5293919744.0, + "46": 5293919744.0, + "47": 5293919744.0, + "48": 5293919744.0, + "49": 5293919744.0, + "50": 5293919744.0 } }, "iteration-time": { @@ -233,55 +233,55 @@ "step_interval": 1, "values": { "1": "nan", - "2": 8.61722, - "3": 0.57542, - "4": 0.56623, - "5": 0.5541, - "6": 0.56688, - "7": 0.54082, - "8": 0.54548, - "9": 0.55074, - "10": 0.55601, - "11": 0.55416, - "12": 0.5472, - "13": 1.6631, - "14": "nan", - "15": "nan", - "16": "nan", - "17": "nan", - "18": "nan", - "19": "nan", - "20": "nan", - "21": "nan", - "22": "nan", - "23": "nan", - "24": "nan", - "25": "nan", - "26": "nan", - "27": "nan", - "28": "nan", - "29": "nan", - "30": "nan", - "31": "nan", - "32": "nan", - "33": "nan", - "34": "nan", - "35": "nan", - "36": "nan", - "37": "nan", - "38": "nan", - "39": "nan", - "40": "nan", - "41": "nan", - "42": "nan", - "43": "nan", - "44": "nan", - "45": "nan", - "46": "nan", - "47": "nan", - "48": "nan", - "49": "nan", - "50": "nan" + "2": 9.61344, + "3": 1.04625, + "4": 2.21477, + "5": 2.14621, + "6": 2.08582, + "7": 1.14391, + "8": 1.65552, + "9": 1.48661, + "10": 1.9757, + "11": 0.54115, + "12": 1.89313, + "13": 0.5415, + "14": 0.54171, + "15": 0.57114, + "16": 0.55481, + "17": 0.52196, + "18": 0.52373, + "19": 0.54214, + "20": 0.52465, + "21": 0.52922, + "22": 0.54449, + "23": 0.52283, + "24": 0.54444, + "25": 0.55507, + "26": 0.53438, + "27": 0.54903, + "28": 1.81392, + "29": 0.52179, + "30": 0.53052, + "31": 0.53473, + "32": 0.55591, + "33": 0.55978, + "34": 0.52699, + "35": 0.53671, + "36": 1.04034, + "37": 0.56419, + "38": 0.56171, + "39": 0.53162, + "40": 0.54177, + "41": 0.54959, + "42": 0.54247, + "43": 0.54086, + "44": 0.54122, + "45": 0.54784, + "46": 0.55077, + "47": 0.54358, + "48": 0.55001, + "49": 0.53455, + "50": 0.52111 } } -} \ No newline at end of file +} From 890247c0944d6747b20953acf328f878f646ee1d Mon Sep 17 00:00:00 2001 From: wdykas <73254672+wdykas@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:55:56 -0400 Subject: [PATCH 078/290] fix(resharding): stabilize NVSHMEM refit copy service (#5915) Signed-off-by: William Dykas Signed-off-by: William Dykas Co-authored-by: William Dykas Co-authored-by: William Dykas --- .../core/gpu_resource_manager.py | 9 +-- .../core/pipeline_executor.py | 57 ++++++++++++++++--- .../nvshmem_copy_service/service.py | 36 +++++++++++- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/megatron/core/resharding/nvshmem_copy_service/core/gpu_resource_manager.py b/megatron/core/resharding/nvshmem_copy_service/core/gpu_resource_manager.py index 0a8c94afe65..f10bcfd5680 100644 --- a/megatron/core/resharding/nvshmem_copy_service/core/gpu_resource_manager.py +++ b/megatron/core/resharding/nvshmem_copy_service/core/gpu_resource_manager.py @@ -109,17 +109,18 @@ def init(self, group=None) -> None: "Could not determine nvidia-nvshmem-cu12 package version for NVSHMEM safety check." ) - # Recommend a conservative CTA limit for stability when team counts grow. + # This path can hang during initialization when the CTA limit is higher. max_ctas = os.environ.get("NVSHMEM_MAX_CTAS") if max_ctas != "2": - logger.warning( - "Recommended NVSHMEM_MAX_CTAS=2 for this path. Current value is %r.", max_ctas + raise RuntimeError( + "NVSHMEM_MAX_CTAS must be set to '2' for the NVSHMEM copy service; " + f"got {max_ctas!r}." ) # torch.distributed must be initialized before calling this if not dist.is_initialized(): raise RuntimeError( - "torch.distributed must be initialized before " "GPUResourceManager.init()" + "torch.distributed must be initialized before GPUResourceManager.init()" ) # Get current CUDA device (already set by caller based on LOCAL_RANK) diff --git a/megatron/core/resharding/nvshmem_copy_service/core/pipeline_executor.py b/megatron/core/resharding/nvshmem_copy_service/core/pipeline_executor.py index b4e183b886c..7bee9724413 100644 --- a/megatron/core/resharding/nvshmem_copy_service/core/pipeline_executor.py +++ b/megatron/core/resharding/nvshmem_copy_service/core/pipeline_executor.py @@ -7,6 +7,7 @@ and proper stream synchronization. """ +import time from typing import Dict, List, Optional from ..compat import ensure_nvshmem_compat @@ -117,6 +118,17 @@ def execute_pipeline( """ PELogger.info(f"Executing pipeline: {num_iterations} iterations") + pipeline_start = time.perf_counter() + wait_unpack_seconds = 0.0 + put_submit_seconds = 0.0 + quiet_submit_seconds = 0.0 + barrier_submit_seconds = 0.0 + barrier_host_sync_seconds = 0.0 + wait_pack_seconds = 0.0 + final_unpack_seconds = 0.0 + slowest_barrier_sync_iteration = -1 + slowest_barrier_sync_seconds = 0.0 + # Priming: Pack iteration 0 (async, no CPU sync needed — # step 3 uses GPU-level event wait for pack→put ordering) if num_iterations > 0 and iter_schedules[0]["send"]: @@ -156,7 +168,7 @@ def execute_pipeline( next_batch = iter_schedules[i + 1]["send"] assert next_batch is not None PELogger.debug( - f" Pack next (iter {i+1}): {len(next_batch.tasks)} tasks " + f" Pack next (iter {i + 1}): {len(next_batch.tasks)} tasks " f"→ PE {next_batch.dest_pe}" ) self._launch_pack(i + 1, next_batch) @@ -168,7 +180,7 @@ def execute_pipeline( prior_batch = iter_schedules[i - 1]["recv"] assert prior_batch is not None PELogger.debug( - f" Unpack prior (iter {i-1}): {prior_batch.total_size} bytes " + f" Unpack prior (iter {i - 1}): {prior_batch.total_size} bytes " f"← PE {prior_batch.src_pe}" ) # GPU-level event wait: ensures send_stream's barrier_all from @@ -191,39 +203,50 @@ def execute_pipeline( # the NIC's DMA engine. self.torch_send_stream_wrapper.wait_event(self.pack_events[slot]) + put_start = time.perf_counter() nvshmem.core.put( self.buffer_manager.recv_slots[slot][0:transfer_size], self.buffer_manager.send_slots[slot][0:transfer_size], batch.dest_pe, stream=self.send_stream, ) + put_submit_seconds += time.perf_counter() - put_start nvtx_range_pop("Step 3: Send Current") # Step 4a: Wait for prior unpack to complete BEFORE the barrier. nvtx_range_push("Step 4a: Wait Unpack") if has_prior_recv: + wait_start = time.perf_counter() self.unpack_events[(i - 1) % 2].synchronize() + wait_unpack_seconds += time.perf_counter() - wait_start nvtx_range_pop("Step 4a: Wait Unpack") # Ensure all NVSHMEM operations on send_stream complete (stream-ordered) + quiet_start = time.perf_counter() nvshmem.core.quiet(stream=self.send_stream) + quiet_submit_seconds += time.perf_counter() - quiet_start - # Step 4b: Global barrier + CPU sync + record event + # Step 4b: Global barrier + CPU sync + record event. nvtx_range_push("Step 4b: Barrier") + barrier_start = time.perf_counter() nvshmem.core.barrier_all(stream=self.send_stream) - # CPU-sync the send_stream to ensure barrier_all has actually - # completed (not just submitted). Without this, the barrier_event - # can fire before RDMA data from the remote PE is visible, because - # stream-ordered operations are only guaranteed to be submitted, - # not completed, when the event is recorded. + barrier_submit_seconds += time.perf_counter() - barrier_start + sync_start = time.perf_counter() self.torch_send_stream_wrapper.synchronize() + sync_seconds = time.perf_counter() - sync_start + barrier_host_sync_seconds += sync_seconds + if sync_seconds > slowest_barrier_sync_seconds: + slowest_barrier_sync_iteration = i + slowest_barrier_sync_seconds = sync_seconds self.barrier_events[slot].record(stream=self.torch_send_stream_wrapper) nvtx_range_pop("Step 4b: Barrier") # Step 5: Wait for async pack to complete (double-buffer safety) nvtx_range_push("Step 5: Wait Pack") if has_next_send: + wait_start = time.perf_counter() self.pack_events[(i + 1) % 2].synchronize() + wait_pack_seconds += time.perf_counter() - wait_start nvtx_range_pop("Step 5: Wait Pack") nvtx_range_pop(nvtx_iter_msg) @@ -231,7 +254,7 @@ def execute_pipeline( # Final unpack for last iteration if num_iterations > 0 and iter_schedules[num_iterations - 1]["recv"]: nvtx_range_push("Final Unpack") - PELogger.debug(f"Final unpack: iteration {num_iterations-1}") + PELogger.debug(f"Final unpack: iteration {num_iterations - 1}") last_recv = iter_schedules[num_iterations - 1]["recv"] assert last_recv is not None # GPU-level event wait for NVSHMEM RDMA data visibility @@ -239,9 +262,25 @@ def execute_pipeline( self.barrier_events[(num_iterations - 1) % 2] ) self._launch_unpack(num_iterations - 1, last_recv) + wait_start = time.perf_counter() self.unpack_events[(num_iterations - 1) % 2].synchronize() + final_unpack_seconds += time.perf_counter() - wait_start nvtx_range_pop("Final Unpack") + pipeline_seconds = time.perf_counter() - pipeline_start + PELogger.info( + "Pipeline timing: " + f"total={pipeline_seconds:.6f}s " + f"wait_unpack={wait_unpack_seconds:.6f}s " + f"put_submit={put_submit_seconds:.6f}s " + f"quiet_submit={quiet_submit_seconds:.6f}s " + f"barrier_submit={barrier_submit_seconds:.6f}s " + f"barrier_host_sync={barrier_host_sync_seconds:.6f}s " + f"wait_pack={wait_pack_seconds:.6f}s " + f"final_unpack={final_unpack_seconds:.6f}s " + f"slowest_barrier_sync_iteration={slowest_barrier_sync_iteration} " + f"slowest_barrier_sync={slowest_barrier_sync_seconds:.6f}s" + ) PELogger.info(f"Pipeline complete: {num_iterations} iterations") def _launch_pack(self, iteration: int, batch: ScheduledBatch) -> None: diff --git a/megatron/core/resharding/nvshmem_copy_service/service.py b/megatron/core/resharding/nvshmem_copy_service/service.py index 332fe892c4e..4cf5151fc5f 100644 --- a/megatron/core/resharding/nvshmem_copy_service/service.py +++ b/megatron/core/resharding/nvshmem_copy_service/service.py @@ -8,6 +8,7 @@ GPU resource management, and pipelined execution. """ +import time from typing import Dict, List, Optional, Tuple from .compat import ensure_nvshmem_compat @@ -102,8 +103,12 @@ def init(self, log_level: str = "INFO") -> None: "nvshmem.core is not available. Please install nvshmem to use NVSHMEMCopyService." ) + init_start = time.perf_counter() + # Initialize GPU resources (NVSHMEM, device, streams) + phase_start = time.perf_counter() self.gpu_resources.init(group=self._group) + gpu_resources_seconds = time.perf_counter() - phase_start # Initialize logger after PE ID is known PELogger.init(self.my_pe, level=log_level) @@ -113,10 +118,13 @@ def init(self, log_level: str = "INFO") -> None: # buffer_manager.allocate() calls bytetensor() which is a collective operation # Without this barrier, early PEs call bytetensor() while late PEs # are still in init() -> deadlock + phase_start = time.perf_counter() nvshmem.core.barrier_all(stream=self.gpu_resources.send_stream) self.gpu_resources.send_stream.sync() # Ensure barrier completes on CPU + initial_barrier_seconds = time.perf_counter() - phase_start # Allocate double-buffered send/recv slots + phase_start = time.perf_counter() self.buffer_manager.allocate() # The .zero_() calls inside allocate() go to the default CUDA stream. # Sync it now so the zeros are fully committed before any NVShmem @@ -124,6 +132,7 @@ def init(self, log_level: str = "INFO") -> None: # Without this, a still-running zero() can race with the first # nvshmem.core.put() and overwrite received data. torch.cuda.synchronize() + buffer_allocation_seconds = time.perf_counter() - phase_start # Barrier to ensure all PEs complete buffer allocation before proceeding nvshmem.core.barrier_all(stream=self.gpu_resources.send_stream) @@ -131,7 +140,9 @@ def init(self, log_level: str = "INFO") -> None: PELogger.debug("Allocated double-buffered send/recv slots") # Load CUDA kernels + phase_start = time.perf_counter() self.kernel_launcher.load_kernels() + kernel_load_seconds = time.perf_counter() - phase_start PELogger.debug("Loaded CUDA kernels") # Cache CuPy stream wrappers for efficient kernel launching @@ -165,6 +176,14 @@ def init(self, log_level: str = "INFO") -> None: self.gpu_resources.unpack_stream.sync() self.gpu_resources.copy_stream.sync() + PELogger.info( + "Initialization timing: " + f"total={time.perf_counter() - init_start:.6f}s " + f"gpu_resources={gpu_resources_seconds:.6f}s " + f"initial_barrier={initial_barrier_seconds:.6f}s " + f"buffer_allocation={buffer_allocation_seconds:.6f}s " + f"kernel_load={kernel_load_seconds:.6f}s" + ) PELogger.info("Initialization complete") def register_send( @@ -224,6 +243,7 @@ def schedule(self) -> None: if not self.initialized: raise RuntimeError("RemoteCopyService not initialized") + schedule_start = time.perf_counter() PELogger.info( f"Starting schedule: {len(self.send_requests)} send requests, " f"{len(self.receive_requests)} receive requests" @@ -277,7 +297,10 @@ def schedule(self) -> None: ) self.pipeline_executor.set_events(self.pack_events, self.unpack_events, self.barrier_events) - PELogger.info(f"Schedule complete: {self.num_iterations} iterations ready") + PELogger.info( + f"Schedule complete: {self.num_iterations} iterations ready " + f"in {time.perf_counter() - schedule_start:.6f}s" + ) def run(self) -> None: """ @@ -295,6 +318,7 @@ def run(self) -> None: if self.iter_schedules is None: raise RuntimeError("Must call schedule() before run()") + run_start = time.perf_counter() PELogger.info(f"Starting execution: {self.num_iterations} iterations") # Start timing @@ -302,12 +326,16 @@ def run(self) -> None: # Global barrier before execution PELogger.debug("Barrier: Synchronizing all PEs before execution") + phase_start = time.perf_counter() nvshmem.core.barrier_all(stream=self.gpu_resources.send_stream) self.gpu_resources.send_stream.sync() + initial_barrier_seconds = time.perf_counter() - phase_start # Execute pipelined communication nvtx_range_push("execute_pipeline") + phase_start = time.perf_counter() self.pipeline_executor.execute_pipeline(self.iter_schedules, self.num_iterations) + pipeline_seconds = time.perf_counter() - phase_start nvtx_range_pop("execute_pipeline") # Global barrier after execution @@ -319,6 +347,12 @@ def run(self) -> None: # End timing range nvtx_range_pop("RemoteCopyService.run_total") + PELogger.info( + "Execution timing: " + f"total={time.perf_counter() - run_start:.6f}s " + f"initial_barrier={initial_barrier_seconds:.6f}s " + f"pipeline={pipeline_seconds:.6f}s" + ) def clear_requests(self) -> None: """ From e4ea85970b2699b8506631d45acd6d1579f155f0 Mon Sep 17 00:00:00 2001 From: Zhongbo Zhu <42691305+zhongbozhu@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:43:16 -0700 Subject: [PATCH 079/290] [Main] Numerical fix for FC2 expert bias scales when using `use_transformer_engine_op_fuser` (#5850) Signed-off-by: zhongboz Signed-off-by: Zhongbo Zhu --- megatron/core/transformer/moe/experts.py | 15 +- .../transformer/moe/test_grouped_mlp.py | 130 +++++++++++++++++- 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index 6f71c83465f..8ee1b4654ad 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -347,6 +347,11 @@ def _is_fused_impl_supported(self) -> bool: return False if not isinstance(self.linear_fc2, te.pytorch.GroupedLinear): return False + if ( + self.linear_fc2.use_bias + and "scale_bias" not in inspect.signature(GroupedLinear.__init__).parameters + ): + return False # Older TE op-fuser versions cannot scale FC2 bias by router probabilities # Check activation: SwiGLU, quick GEGLU, or weighted squared ReLU. # Use config.activation_func instead of self.activation_func because when @@ -500,6 +505,7 @@ def _make_fused_ops(self) -> torch.nn.Module: ops.append(op) # FC2 + fc2_bias_kwargs = {"scale_bias": True} if self.linear_fc2.use_bias else {} op = te.pytorch.ops.GroupedLinear( self.linear_fc2.num_gemms, self.linear_fc2.in_features, @@ -511,6 +517,8 @@ def _make_fused_ops(self) -> torch.nn.Module: single_grouped_weight=fc2_single_grouped_weight, single_grouped_bias=fc2_single_grouped_bias, delay_wgrad_compute=fc2_delay_wgrad_compute, + # Preserve p * (FC2(x) + bias) after the scaled activation moves p before FC2. + **fc2_bias_kwargs, ) # Copy the weights from GroupedLinear module to GroupedLinear op. @@ -622,11 +630,16 @@ def _fused_forward( ) with stash_context: # Call fused impl + fc2_extra_inputs = ( + (tokens_per_expert, permuted_probs) + if self.linear_fc2.use_bias + else (tokens_per_expert,) + ) output = ops( permuted_local_hidden_states, tokens_per_expert, # FC1 permuted_probs, # Scaled activation - tokens_per_expert, # FC2 + *fc2_extra_inputs, # FC2 splits and, for bias, its per-token scale ) output = fused_group_mlp_manager.group_offload( output, forced_released_tensors=forced_released_tensors diff --git a/tests/unit_tests/transformer/moe/test_grouped_mlp.py b/tests/unit_tests/transformer/moe/test_grouped_mlp.py index f6cef08b1b6..2188e1d1946 100644 --- a/tests/unit_tests/transformer/moe/test_grouped_mlp.py +++ b/tests/unit_tests/transformer/moe/test_grouped_mlp.py @@ -62,6 +62,7 @@ def __init__( single_grouped_weight, single_grouped_bias=False, delay_wgrad_compute=False, + scale_bias=False, ): super().__init__() self.num_gemms = num_gemms @@ -74,6 +75,7 @@ def __init__( self.single_grouped_weight = single_grouped_weight self.single_grouped_bias = single_grouped_bias self.delay_wgrad_compute = delay_wgrad_compute + self.scale_bias = scale_bias def need_backward_dw(self): return False @@ -144,17 +146,20 @@ def register_forward_pre_hook(self, hook): assert ops[0].weight1 is module.linear_fc1.weight1 assert ops[0].bias0 is module.linear_fc1.bias0 assert ops[0].bias1 is module.linear_fc1.bias1 + assert ops[0].scale_bias is False assert ops[1].glu_interleave_size == 16 assert ops[2].device == "meta" assert ops[2].weight is module.linear_fc2.weight + assert ops[2].scale_bias is False assert hasattr(ops, "forward_pre_hook") -def test_fused_forward_caches_ops_and_forwards_expected_arguments(): +@pytest.mark.parametrize("fc2_bias", [False, True], ids=["no_fc2_bias", "fc2_bias"]) +def test_fused_forward_caches_ops_and_forwards_expected_arguments(fc2_bias): class FakeFusedOps: - def __call__(self, hidden_states, fc1_tokens, probs, fc2_tokens): - self.args = (hidden_states, fc1_tokens, probs, fc2_tokens) - return hidden_states + 1 + def __call__(self, *args): + self.args = args + return args[0] + 1 module = TEGroupedMLP.__new__(TEGroupedMLP) # `_fused_forward` calls `skip_routed_expert_padding(config)` (added by PR 4071), which @@ -169,6 +174,7 @@ def __call__(self, hidden_states, fc1_tokens, probs, fc2_tokens): moe_paged_stash=False, ) module._fused_ops = None + module.linear_fc2 = SimpleNamespace(use_bias=fc2_bias) fused_ops = FakeFusedOps() module._make_fused_ops = lambda: fused_ops hidden_states = torch.zeros(2, 4) @@ -183,6 +189,10 @@ def __call__(self, hidden_states, fc1_tokens, probs, fc2_tokens): assert fused_ops.args[1] is tokens_per_expert assert fused_ops.args[2] is probs assert fused_ops.args[3] is tokens_per_expert + if fc2_bias: + assert fused_ops.args[4] is probs + else: + assert len(fused_ops.args) == 4 def test_apply_bias_returns_input_unchanged_when_bias_is_none(): @@ -268,6 +278,7 @@ def __init__( single_grouped_weight, single_grouped_bias=False, delay_wgrad_compute=False, + scale_bias=False, ): super().__init__() self.num_gemms = num_gemms @@ -280,6 +291,7 @@ def __init__( self.single_grouped_weight = single_grouped_weight self.single_grouped_bias = single_grouped_bias self.delay_wgrad_compute = delay_wgrad_compute + self.scale_bias = scale_bias def need_backward_dw(self): return False @@ -352,6 +364,7 @@ def register_forward_pre_hook(self, hook): assert ops[2].weight1 is module.linear_fc2.weight1 assert ops[2].bias0 is module.linear_fc2.bias0 assert ops[2].bias1 is module.linear_fc2.bias1 + assert ops[2].scale_bias is True def _make_fake_te_namespace(): @@ -371,6 +384,7 @@ def __init__( single_grouped_weight, single_grouped_bias=False, delay_wgrad_compute=False, + scale_bias=False, ): super().__init__() self.num_gemms = num_gemms @@ -383,6 +397,7 @@ def __init__( self.single_grouped_weight = single_grouped_weight self.single_grouped_bias = single_grouped_bias self.delay_wgrad_compute = delay_wgrad_compute + self.scale_bias = scale_bias def need_backward_dw(self): return False @@ -591,6 +606,28 @@ def test_is_fused_impl_supported_uses_config_activation_for_swiglu(monkeypatch): assert module._is_fused_impl_supported() is True +def test_is_fused_impl_supported_requires_scaled_fc2_bias(monkeypatch): + fake_te, FakeGroupedLinear = _make_fake_te_namespace() + + class FakeGroupedLinearWithoutScaleBias(FakeGroupedLinear): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + fake_te.pytorch.GroupedLinear = FakeGroupedLinearWithoutScaleBias + fake_te.pytorch.ops.GroupedLinear = FakeGroupedLinearWithoutScaleBias + monkeypatch.setattr(experts_module, "te", fake_te) + monkeypatch.setattr(experts_module, "HAVE_TE", True) + monkeypatch.setattr(experts_module, "is_te_min_version", lambda _: True) + _install_fake_te_ops_modules(monkeypatch, fake_te) + + module = _make_fused_impl_support_module( + FakeGroupedLinearWithoutScaleBias, activation_func=F.silu, gated_linear_unit=True + ) + module.linear_fc2.use_bias = True + + assert module._is_fused_impl_supported() is False + + @pytest.mark.parametrize( ("use_fused_weighted_squared_relu", "gated_linear_unit", "expected"), [(True, False, True), (False, False, False), (True, True, False)], @@ -996,6 +1033,91 @@ def test_gpu_make_fused_ops_constructs_with_real_te(self): experts.linear_fc2, f"weight{idx}" ) + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.internal + def test_gpu_fused_path_scales_fc2_bias(self): + """FC2 bias and its gradients must use the per-token router probability.""" + try: + from transformer_engine.pytorch.ops import GroupedLinear + except ImportError: + pytest.skip("TE op fuser API not available") + import inspect + + if "scale_bias" not in inspect.signature(GroupedLinear.__init__).parameters: + pytest.skip("Installed TE op fuser GroupedLinear lacks `scale_bias` support") + + Utils.destroy_model_parallel() + Utils.initialize_model_parallel(1, 1) + + tf_config = TransformerConfig( + num_layers=1, + hidden_size=self.hidden_size, + num_attention_heads=4, + num_moe_experts=self.num_experts, + use_cpu_initialization=False, + add_bias_linear=True, + gated_linear_unit=True, + activation_func=F.silu, + bias_activation_fusion=False, + bias_dropout_fusion=False, + bf16=True, + params_dtype=torch.bfloat16, + moe_router_load_balancing_type="sinkhorn", + moe_router_topk=1, + moe_grouped_gemm=True, + use_transformer_engine_op_fuser=True, + ) + _set_random_seed(seed_=123, data_parallel_random_init=False) + submodules = get_submodules( + get_gpt_layer_with_transformer_engine_submodules( + self.num_experts, moe_grouped_gemm=True + ).mlp + ) + layer = MoELayer(tf_config, submodules) + layer = Float16Module(layer.config, layer).module + layer.cuda() + experts = layer.experts + assert isinstance(experts, TEGroupedMLP) + + with torch.no_grad(): + for linear in (experts.linear_fc1, experts.linear_fc2): + for expert_idx in range(self.num_experts): + getattr(linear, f"weight{expert_idx}").zero_() + getattr(linear, f"bias{expert_idx}").zero_() + experts.linear_fc2.bias0.fill_(2.0) + experts.linear_fc2.bias1.fill_(4.0) + + hidden_states = torch.zeros( + 3, self.hidden_size, dtype=torch.bfloat16, device="cuda", requires_grad=True + ) + tokens_per_expert = torch.tensor([2, 1], dtype=torch.int32, device="cuda") + probs = torch.tensor( + [0.25, 0.5, 0.125], dtype=torch.bfloat16, device="cuda", requires_grad=True + ) + + output, _ = experts(hidden_states, tokens_per_expert, probs) + expected_output = torch.cat( + ( + probs[:2, None] * torch.full_like(output[:2], 2.0), + probs[2:, None] * torch.full_like(output[2:], 4.0), + ) + ) + torch.testing.assert_close(output, expected_output) + + output.sum().backward() + expected_prob_grad = ( + torch.tensor([2.0, 2.0, 4.0], dtype=torch.bfloat16, device="cuda") * self.hidden_size + ) + torch.testing.assert_close(probs.grad, expected_prob_grad) + torch.testing.assert_close( + experts.linear_fc2.bias0.grad, + torch.ones_like(experts.linear_fc2.bias0) * probs[:2].detach().sum(), + ) + torch.testing.assert_close( + experts.linear_fc2.bias1.grad, + torch.ones_like(experts.linear_fc2.bias1) * probs[2:].detach().sum(), + ) + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") @pytest.mark.internal def test_gpu_fused_path_loss_decreases(self): From b842f59f77f3fbe3c5868ba4abc544705485c156 Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 22 Jul 2026 13:45:32 -0700 Subject: [PATCH 080/290] ci: Integrates the latest config-driven nemo-ci-triage Slack and Linear workflow (#5957) Signed-off-by: Ajay Balasa --- .gitlab/nemo-ci-triage.yml | 2 + .gitlab/stages/04.functional-tests.yml | 36 ---- .gitlab/stages/06.triage.yml | 15 ++ docker/Dockerfile.linting | 2 +- .../python_scripts/launch_jet_workload.py | 13 +- tests/test_utils/python_scripts/linear_ci.py | 9 +- tests/test_utils/python_scripts/notify.py | 4 +- tests/test_utils/test_ci_triage.py | 190 ++++++++++++++++-- 8 files changed, 205 insertions(+), 66 deletions(-) diff --git a/.gitlab/nemo-ci-triage.yml b/.gitlab/nemo-ci-triage.yml index a32617b2ef0..4922d63dac1 100644 --- a/.gitlab/nemo-ci-triage.yml +++ b/.gitlab/nemo-ci-triage.yml @@ -7,6 +7,8 @@ gitlab: modules: megatron_lm: build_module: megatron-lm + channel_id_env: MCORE_SLACK_CHANNEL_ID + reconcile_proposal: true team_key: MCORE project_template: "MCore CI Testing" enable_linear_open: true diff --git a/.gitlab/stages/04.functional-tests.yml b/.gitlab/stages/04.functional-tests.yml index b7eb169acd4..7a7cc5363bd 100644 --- a/.gitlab/stages/04.functional-tests.yml +++ b/.gitlab/stages/04.functional-tests.yml @@ -428,42 +428,6 @@ functional:run_nemo: allow_failure: true - when: never -functional:smoke_notify: - extends: [.functional_tests_rules] - image: ${UTILITY_IMAGE}:${CI_PIPELINE_ID} - needs: - - functional:smoke-h100 - - functional:smoke-gb200 - tags: - - arch/amd64 - - env/prod - - origin/jet-fleet - - owner/jet-core - - purpose/utility - - team/megatron - script: - - | - if [[ "$CI_COMMIT_BRANCH" == *dev* ]]; then - export WEBHOOK_URL=${MCORE_NOTIFICATION_HOOK_DEV} - else - export WEBHOOK_URL=${MCORE_NOTIFICATION_HOOK} - fi - - export RO_API_TOKEN=${PROJECT_ACCESS_TOKEN_MCORE} - - export GITLAB_ENDPOINT - - export PYTHONPATH=$(pwd) - - | - python tests/test_utils/python_scripts/notify.py \ - --pipeline-id "${CI_PIPELINE_ID}" \ - --check-for smoke-tests \ - --pipeline-context "smoke-${FUNCTIONAL_TEST_SCOPE}" \ - --pipeline-created-at "${CI_PIPELINE_CREATED_AT}" - rules: - - if: $BUILD == "no" - when: never - - if: $FUNCTIONAL_TEST == "yes" && $FUNCTIONAL_TEST_SCOPE =~ /^(mr|nightly)$/ && ($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_EVENT_TYPE == "merged_result") - when: always - - when: never - functional:x_notify: extends: [.functional_tests_rules] image: ${UTILITY_IMAGE}:${CI_PIPELINE_ID} diff --git a/.gitlab/stages/06.triage.yml b/.gitlab/stages/06.triage.yml index 84996e99114..b80010540ae 100644 --- a/.gitlab/stages/06.triage.yml +++ b/.gitlab/stages/06.triage.yml @@ -84,6 +84,21 @@ triage:slack_linear_followup: --linear-plan linear_action_plan_post.json --slack-bot-token "${MCORE_SLACK_BOT_TOKEN:-${ALERTMANAGER_TOKEN}}" --slack-channel-id "${MCORE_SLACK_CHANNEL_ID}" + - | + THREAD_TIMESTAMP="$(python -c 'import json; print(json.load(open("slack_notification.json")).get("thread_timestamp") or "")')" + if [[ -z "${THREAD_TIMESTAMP}" ]]; then + echo "No Slack thread timestamp; skipping detailed triage follow-ups." + else + nemo-ci-notify \ + --config "${NEMO_CI_TRIAGE_CONFIG}" \ + --module megatron_lm \ + --only-followup \ + --thread-ts "${THREAD_TIMESTAMP}" \ + --failure-buckets failure_buckets.json \ + --linear-report linear_status_report.json \ + --action-plan linear_action_plan_post.json \ + --slack-bot-token "${MCORE_SLACK_BOT_TOKEN:-${ALERTMANAGER_TOKEN}}" + fi rules: # Post the applied Linear actions under the functional-test notification. - if: >- diff --git a/docker/Dockerfile.linting b/docker/Dockerfile.linting index afd9260aa95..737b8cefac8 100644 --- a/docker/Dockerfile.linting +++ b/docker/Dockerfile.linting @@ -24,7 +24,7 @@ RUN --mount=type=secret,id=JET_INDEX_URLS \ # Keep this in the internal-only stage so public CI has no internal service dependency. ARG CI_SERVER_URL -ARG NEMO_CI_TRIAGE_COMMIT=5474f95417758c76c75523ae5319727a1e703437 +ARG NEMO_CI_TRIAGE_COMMIT=6e24e567ae2855e8acad5e2f780c7c26c195ab46 RUN --mount=type=secret,id=NEMO_CI_TRIAGE_TOKEN \ GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.extraHeader \ diff --git a/tests/test_utils/python_scripts/launch_jet_workload.py b/tests/test_utils/python_scripts/launch_jet_workload.py index ff79f88bc1f..543db4f904c 100644 --- a/tests/test_utils/python_scripts/launch_jet_workload.py +++ b/tests/test_utils/python_scripts/launch_jet_workload.py @@ -665,12 +665,13 @@ def main( n_iteration += 1 - send_slack_alert( - test_case=test_case, - context="max attempts exhausted", - n_iteration=n_iteration, - n_attempts=n_attempts, - ) + if test_type == "release": + send_slack_alert( + test_case=test_case, + context="max attempts exhausted", + n_iteration=n_iteration, + n_attempts=n_attempts, + ) telemetrics_and_exit( success=False, test_case=test_case, diff --git a/tests/test_utils/python_scripts/linear_ci.py b/tests/test_utils/python_scripts/linear_ci.py index 04f96913f5f..9bd9549b164 100644 --- a/tests/test_utils/python_scripts/linear_ci.py +++ b/tests/test_utils/python_scripts/linear_ci.py @@ -18,11 +18,18 @@ LINEAR_MODULE = "megatron_lm" _FUNCTIONAL_PREFIX = "functional:run_" +_SMOKE_PREFIX = "functional:smoke-" def _variant_name(pipeline_name: str) -> str: """Return the stable environment/platform suffix of a functional bridge.""" - return pipeline_name.removeprefix(_FUNCTIONAL_PREFIX).replace("_", "-") + if pipeline_name.startswith(_FUNCTIONAL_PREFIX): + variant = pipeline_name.removeprefix(_FUNCTIONAL_PREFIX) + elif pipeline_name.startswith(_SMOKE_PREFIX): + variant = f"smoke-{pipeline_name.removeprefix(_SMOKE_PREFIX)}" + else: + variant = pipeline_name + return variant.replace("_", "-") def _recipe_name(pipeline_name: str, config_name: str) -> str: diff --git a/tests/test_utils/python_scripts/notify.py b/tests/test_utils/python_scripts/notify.py index cbb9bf527f7..71852bc617b 100644 --- a/tests/test_utils/python_scripts/notify.py +++ b/tests/test_utils/python_scripts/notify.py @@ -29,7 +29,7 @@ JOB_PREFIXES = { "unit-tests": "test:unit_tests", "integration-tests": "integration:run_", - "functional-tests": "functional:run_", + "functional-tests": ("functional:run_", "functional:smoke-"), "smoke-tests": "functional:smoke-", } @@ -55,7 +55,7 @@ def _bridge_gpu(bridge_name: str) -> str: def get_pipeline_jobs( - pipeline_id: int, job_prefix: str, project: Any | None = None + pipeline_id: int, job_prefix: str | tuple[str, ...], project: Any | None = None ) -> list[tuple[str, int, list[dict]]]: """Collect Megatron-LM's direct child pipelines using nemo-ci-triage-2.""" project = project or get_project() diff --git a/tests/test_utils/test_ci_triage.py b/tests/test_utils/test_ci_triage.py index 2c8ec07b720..95469b3ccd9 100644 --- a/tests/test_utils/test_ci_triage.py +++ b/tests/test_utils/test_ci_triage.py @@ -133,12 +133,7 @@ def test_notification_rules_use_expected_pipeline_sources(): '$CI_COMMIT_BRANCH == "ci-dev-unit-test-extended")' ] - smoke_condition = functional["functional:smoke_notify"]["rules"][1]["if"] - assert smoke_condition == ( - '$FUNCTIONAL_TEST == "yes" && $FUNCTIONAL_TEST_SCOPE =~ /^(mr|nightly)$/ && ' - '($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main" || ' - '$CI_MERGE_REQUEST_EVENT_TYPE == "merged_result")' - ) + assert "functional:smoke_notify" not in functional assert functional["functional:x_notify"]["rules"][0]["if"] == ( '($CI_PIPELINE_SOURCE == "schedule" || $CI_COMMIT_BRANCH == "main") && ' '$FUNCTIONAL_TEST == "yes"' @@ -162,27 +157,92 @@ def test_all_generated_test_types_enable_error_extraction(): assert functional.count('"--enable-error-extraction"') >= 2 +@pytest.mark.parametrize(("test_type", "expected_alerts"), [("regular", 0), ("release", 1)]) +def test_retry_exhaustion_per_test_alert_is_release_only( + monkeypatch, tmp_path, test_type, expected_alerts +): + pytest.importorskip("jetclient") + from tests.test_utils.python_scripts import launch_jet_workload + + base_path = tmp_path / "tests" / "test_utils" / "python_scripts" + model_config = ( + tmp_path + / "tests" + / "functional_tests" + / "test_cases" + / "model" + / "case" + / "model_config.yaml" + ) + base_path.mkdir(parents=True) + model_config.parent.mkdir(parents=True) + model_config.write_text(f"TEST_TYPE: {test_type}\n") + + job = Mock() + job.name = "basic-job" + pipeline = Mock() + pipeline.get_jobs.return_value = [job] + launch = Mock(return_value=pipeline) + alert = Mock() + telemetry = Mock() + monkeypatch.setattr(launch_jet_workload, "BASE_PATH", base_path) + monkeypatch.setattr(launch_jet_workload, "launch_and_wait_for_completion", launch) + monkeypatch.setattr(launch_jet_workload, "download_job_assets", Mock(return_value=None)) + monkeypatch.setattr(launch_jet_workload, "send_slack_alert", alert) + monkeypatch.setattr(launch_jet_workload, "telemetrics_and_exit", telemetry) + + launch_jet_workload.main.callback( + model="model", + test_case="case", + environment="dev", + n_repeat=1, + time_limit=1, + scope="mr", + account="mcore", + partition=None, + cluster="cluster", + platform="platform", + container_tag="tag", + record_checkpoints="false", + run_name="run", + wandb_experiment="experiment", + ) + + assert launch.call_count == 9 + assert alert.call_count == expected_alerts + telemetry.assert_called_once() + + def test_get_pipeline_jobs_uses_triage_collector(monkeypatch, notify_module): notify = notify_module - bridge = SimpleNamespace( - name="functional:run_dev_dgx_h100", attributes={"downstream_pipeline": {"id": 101}} - ) + bridges = [ + SimpleNamespace( + name="functional:run_dev_dgx_h100", attributes={"downstream_pipeline": {"id": 101}} + ), + SimpleNamespace( + name="functional:smoke-gb200", attributes={"downstream_pipeline": {"id": 102}} + ), + ] root_pipeline = Mock() - root_pipeline.bridges.list.return_value = [bridge] + root_pipeline.bridges.list.return_value = bridges project = Mock() project.pipelines.get.return_value = root_pipeline handle = Mock() handle.projects.get.return_value = project - jobs = [{"status": "failed", "gpu": "Unknown"}] - monkeypatch.setattr(notify, "get_gitlab_handle", lambda: handle) - collector = Mock(return_value=jobs) + collector = Mock( + side_effect=[ + [{"status": "failed", "gpu": "Unknown"}], + [{"status": "failed", "gpu": "Unknown"}], + ] + ) monkeypatch.setattr(notify.notification, "get_jobs_from_pipeline", collector) - assert notify.get_pipeline_jobs(123, "functional:run_") == [ - ("functional:run_dev_dgx_h100", 101, [{"status": "failed", "gpu": "H100"}]) + assert notify.get_pipeline_jobs(123, notify.JOB_PREFIXES["functional-tests"]) == [ + ("functional:run_dev_dgx_h100", 101, [{"status": "failed", "gpu": "H100"}]), + ("functional:smoke-gb200", 102, [{"status": "failed", "gpu": "GB200"}]), ] - collector.assert_called_once_with(project, 101) + assert collector.call_args_list == [((project, 101),), ((project, 102),)] def test_build_linear_reports_groups_matching_failures(monkeypatch): @@ -221,6 +281,19 @@ def test_build_linear_reports_groups_matching_failures(monkeypatch): } ], ), + ( + "functional:smoke-gb200", + 103, + [ + { + "config_name": "gpt_smoke_fail", + "id": 4, + "status": "failed", + "allow_failure": False, + "error_type": "CUDA OOM", + } + ], + ), ] reports = { 2: { @@ -235,6 +308,12 @@ def test_build_linear_reports_groups_matching_failures(monkeypatch): "error_subtype": "torch.OutOfMemoryError", "excerpt": "CUDA out of memory", }, + 4: { + "exit_code_training": 1, + "category": "CUDA OOM", + "error_subtype": "torch.OutOfMemoryError", + "excerpt": "CUDA out of memory", + }, } summaries, buckets = linear_ci.build_pipeline_reports( @@ -242,9 +321,10 @@ def test_build_linear_reports_groups_matching_failures(monkeypatch): ) stats = summaries["modules"][linear_ci.LINEAR_MODULE] - assert stats == {"passed": 1, "failed": 2, "passed_tests": ["gpt_pass@dev-dgx-h100"]} + assert stats == {"passed": 1, "failed": 3, "passed_tests": ["gpt_pass@dev-dgx-h100"]} assert len(buckets["buckets"]) == 1 bucket = buckets["buckets"][0] + assert bucket["module"] == linear_ci.LINEAR_MODULE assert bucket["category"] == "CUDA OOM" assert bucket["rationale"] == "CUDA OOM: torch.OutOfMemoryError" assert bucket["tests"] == [ @@ -256,6 +336,10 @@ def test_build_linear_reports_groups_matching_failures(monkeypatch): "name": "gpt_fail_b@lts-dgx-h100", "job_url": "https://ci.example.com/ADLR/megatron-lm/-/jobs/3", }, + { + "name": "gpt_smoke_fail@smoke-gb200", + "job_url": "https://ci.example.com/ADLR/megatron-lm/-/jobs/4", + }, ] summarize.assert_called_once() subcategorize.assert_called_once() @@ -335,6 +419,8 @@ def test_triage_config_selects_megatron_and_enables_write_actions(): linear_ci.LINEAR_MODULE, { "build_module": "megatron-lm", + "channel_id_env": "MCORE_SLACK_CHANNEL_ID", + "reconcile_proposal": True, "team_key": "MCORE", "project_template": "MCore CI Testing", "enable_linear_open": True, @@ -348,17 +434,37 @@ def test_triage_config_selects_megatron_and_enables_write_actions(): } +def test_slack_followup_uses_upstream_detailed_and_execution_summaries(): + triage = yaml.safe_load(Path(".gitlab/stages/06.triage.yml").read_text()) + execution_summary, detailed_summary = triage["triage:slack_linear_followup"]["script"] + script = f"{execution_summary}\n{detailed_summary}" + + assert "--pipeline-summary slack_notification.json" in execution_summary + assert "--linear-plan linear_action_plan_post.json" in execution_summary + assert "--slack-channel-id" in execution_summary + assert "--module megatron_lm" in detailed_summary + assert "--only-followup" in detailed_summary + assert '--thread-ts "${THREAD_TIMESTAMP}"' in detailed_summary + assert "--failure-buckets failure_buckets.json" in detailed_summary + assert "--linear-report linear_status_report.json" in detailed_summary + assert "--action-plan linear_action_plan_post.json" in detailed_summary + assert "--slack-channel-id" not in detailed_summary + assert 'if [[ -z "${THREAD_TIMESTAMP}" ]]' in script + + def test_notification_delegates_to_triage_package(monkeypatch, notify_module): notify = notify_module + project = Mock() pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [{"status": "failed"}])] + collector = Mock(return_value=pipeline_jobs) sender = Mock() monkeypatch.setattr(notify, "WEBHOOK_URL", "https://slack.invalid/webhook") monkeypatch.setattr(notify, "SLACK_BOT_TOKEN", "") monkeypatch.setattr(notify, "SLACK_CHANNEL_ID", "") monkeypatch.setattr(notify, "PROJECT_URL", "https://ci.example.com/ADLR/megatron-lm") - monkeypatch.setattr(notify, "get_project", Mock()) - monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args, **_kwargs: pipeline_jobs) + monkeypatch.setattr(notify, "get_project", lambda: project) + monkeypatch.setattr(notify, "get_pipeline_jobs", collector) monkeypatch.setattr(notify.notification, "send_slack_notification", sender) result = CliRunner().invoke( @@ -386,6 +492,48 @@ def test_notification_delegates_to_triage_package(monkeypatch, notify_module): slack_channel_id=None, config=notify.TRIAGE_CONFIG, ) + collector.assert_called_once_with(123, notify.JOB_PREFIXES["functional-tests"], project=project) + + +@pytest.mark.parametrize("has_failure", [False, True]) +def test_smoke_notification_is_failure_only_and_aggregate(monkeypatch, notify_module, has_failure): + notify = notify_module + project = Mock() + pipeline_jobs = [ + ("functional:smoke-h100", 101, [{"status": "success"}]), + ("functional:smoke-gb200", 102, [{"status": "failed" if has_failure else "success"}]), + ] + collector = Mock(return_value=pipeline_jobs) + sender = Mock() + + monkeypatch.setattr(notify, "WEBHOOK_URL", "https://slack.invalid/webhook") + monkeypatch.setattr(notify, "SLACK_BOT_TOKEN", "") + monkeypatch.setattr(notify, "SLACK_CHANNEL_ID", "") + monkeypatch.setattr(notify, "get_project", lambda: project) + monkeypatch.setattr(notify, "get_pipeline_jobs", collector) + monkeypatch.setattr(notify.notification, "send_slack_notification", sender) + + result = CliRunner().invoke( + notify.main, + [ + "--pipeline-id", + "123", + "--check-for", + "smoke-tests", + "--pipeline-context", + "smoke-nightly", + "--pipeline-created-at", + "2026-07-12T00:00:00Z", + ], + ) + + assert result.exit_code == 0, result.output + collector.assert_called_once_with(123, notify.JOB_PREFIXES["smoke-tests"], project=project) + if has_failure: + sender.assert_called_once() + assert sender.call_args.args[2] == pipeline_jobs + else: + sender.assert_not_called() def test_notification_records_bot_thread_context(monkeypatch, tmp_path, notify_module): @@ -438,13 +586,14 @@ def test_notification_writes_linear_inputs_without_webhook(monkeypatch, tmp_path notify = notify_module project = Mock() pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [])] + collector = Mock(return_value=pipeline_jobs) writer = Mock() monkeypatch.setattr(notify, "WEBHOOK_URL", "") monkeypatch.setattr(notify, "SLACK_BOT_TOKEN", "") monkeypatch.setattr(notify, "SLACK_CHANNEL_ID", "") monkeypatch.setattr(notify, "get_project", lambda: project) - monkeypatch.setattr(notify, "get_pipeline_jobs", lambda *_args, **_kwargs: pipeline_jobs) + monkeypatch.setattr(notify, "get_pipeline_jobs", collector) monkeypatch.setattr(notify.linear_ci, "write_pipeline_reports", writer) summaries = tmp_path / "pipeline_summaries.json" buckets = tmp_path / "failure_buckets.json" @@ -468,6 +617,7 @@ def test_notification_writes_linear_inputs_without_webhook(monkeypatch, tmp_path ) assert result.exit_code == 0, result.output + collector.assert_called_once_with(123, notify.JOB_PREFIXES["functional-tests"], project=project) writer.assert_called_once_with( 123, "nightly", pipeline_jobs, project, notify.PROJECT_URL, summaries, buckets ) From 8d16c6729541f3b4f7d875043266737fdcb194ee Mon Sep 17 00:00:00 2001 From: Zhongbo Zhu <42691305+zhongbozhu@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:05:25 -0700 Subject: [PATCH 081/290] [Main] Numerical fix for moe single grouped weight with fp8 fp4 primary weight and grad norm spikes (#5487) Signed-off-by: Zhongbo Zhu Signed-off-by: zhongboz --- .../distributed/distributed_data_parallel.py | 18 +- .../core/distributed/param_and_grad_buffer.py | 222 ++++-- megatron/core/fp4_utils.py | 49 +- megatron/core/fp8_utils.py | 166 +++- megatron/core/optimizer/distrib_optimizer.py | 79 +- megatron/core/transformer/moe/experts.py | 78 +- .../core/transformer/transformer_config.py | 28 +- megatron/training/arguments.py | 11 + .../distributed/test_param_and_grad_buffer.py | 4 +- ...est_distrib_optimizer_grouped_quantized.py | 21 +- .../transformer/moe/test_grouped_mlp.py | 17 +- ...test_moe_single_grouped_weight_numerics.py | 726 ++++++++++++++++++ 12 files changed, 1295 insertions(+), 124 deletions(-) create mode 100644 tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py diff --git a/megatron/core/distributed/distributed_data_parallel.py b/megatron/core/distributed/distributed_data_parallel.py index 7e483722a69..8da6b1d74dc 100644 --- a/megatron/core/distributed/distributed_data_parallel.py +++ b/megatron/core/distributed/distributed_data_parallel.py @@ -408,7 +408,10 @@ def disable_forward_pre_hook(self, param_sync: bool = True): # Force synchronize parameters. if param_sync: - self.start_param_sync(force_sync=True) + # Hook-disable paths (eval/checkpointing/shutdown) synchronize params as an + # explicit state update, not as differentiable forward compute. + with torch.no_grad(): + self.start_param_sync(force_sync=True) def _make_forward_pre_hook(self): """ @@ -530,6 +533,19 @@ def start_param_sync(self, *unused, force_sync: bool = False, force_dispatch: bo for bucket_group in self.bucket_groups + self.expert_parallel_bucket_groups: self._start_bucket_group_param_sync(bucket_group, force_sync=force_sync) + def reset_param_sync_dispatch_state(self): + """Mark DDP param all-gathers as not dispatched for the next forward pre-hook.""" + for bucket_group in self.bucket_groups + self.expert_parallel_bucket_groups: + # A non-None handle means the previous all-gather is still in flight. Resetting only + # the dispatch flag would create the invalid state + # `param_gather_dispatched=False, param_gather_handle!=None` and could dispatch a + # second all-gather into the same parameter buffer. + assert bucket_group.param_gather_handle is None, ( + "Cannot reset parameter all-gather dispatch state while an asynchronous " + "parameter all-gather is still in flight." + ) + bucket_group.param_gather_dispatched = False + def start_grad_sync(self, *unused): """ Initiates grad sync (all-reduce or reduce-scatter) communication operations diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index 1e88bf8ddfa..a82c4fb8a77 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -20,10 +20,21 @@ from megatron.core.rerun_state_machine import get_rerun_state_machine from megatron.core.utils import log_single_rank -from ..fp4_utils import get_nvfp4_rowwise_packed_shape, is_nvfp4tensor +from ..fp4_utils import ( + get_nvfp4_rowwise_packed_shape, + is_grouped_nvfp4tensor, + is_nvfp4tensor, + modify_grouped_nvfp4_rowwise_storage, + modify_nvfp4_rowwise_storage, +) from ..fp8_utils import ( + copy_tensor_to_quantized_param, is_float8tensor, + is_grouped_mxfp8tensor, + is_grouped_tensor, + is_grouped_tensor_with_quantized_storage, is_mxfp8tensor, + modify_grouped_tensor_rowwise_storage, modify_underlying_storage, post_all_gather_processing, ) @@ -67,6 +78,17 @@ def shard_buffer(buffer: torch.Tensor, data_parallel_world_size: int): return sharded_buffer +def _param_uses_quantized_storage(param: torch.nn.Parameter) -> bool: + """Return whether the parameter owns TE quantized storage instead of plain tensor storage.""" + # In TE2, is_float8tensor() checks QuantizedTensor, so it includes plain MXFP8Tensor. + # Grouped quantized params are GroupedTensor wrappers, so check their backing storage. + return ( + is_float8tensor(param) + or is_nvfp4tensor(param) + or is_grouped_tensor_with_quantized_storage(param) + ) + + class _ParamAndGradBucket: """ Bucket to keep track of a subset of the model's parameters and gradients. @@ -280,17 +302,18 @@ def _post_param_sync(self): """Run post-processing after param all-gather completes.""" if self.ddp_config.reuse_grad_buf_for_mxfp8_param_ag: for bucket in self.buckets: - is_bf16_weight_bucket = False + has_non_quantized_weight = False for param in bucket.params: - # Skip copying since bf16 weights in the mxfp8 model - # are already mapped to param.data. - if not is_float8tensor(param): - is_bf16_weight_bucket = True + # Non-quantized weights are already mapped to param.data. Skip + # mixed buckets because zeroing bucket.param_data would also + # clear those model weights. + if not _param_uses_quantized_storage(param): + has_non_quantized_weight = True break param_start, param_end = bucket.param_to_index[param] param_slice = bucket.param_data.view(-1)[param_start:param_end] - param.data.copy_(param_slice.view(param.data.shape)) - if is_bf16_weight_bucket: + copy_tensor_to_quantized_param(param, param_slice) + if has_non_quantized_weight: continue # All-gathered params are not needed after being copied to param.data. # Zero out the param buffer (shared with grad buffer) for gradient accumulation. @@ -303,7 +326,7 @@ def _post_param_sync(self): quantized_params = [] for bucket in self.buckets: for param in bucket.params: - if is_float8tensor(param) or is_nvfp4tensor(param): + if _param_uses_quantized_storage(param): quantized_params.append(param) if len(quantized_params) > 0: post_all_gather_processing(quantized_params) @@ -614,9 +637,11 @@ def start_grad_sync(self, force_all_reduce: Optional[bool] = False): # gradient_scaling_factor already takes into account whether we are computing # an average or sum in the data-parallel collective. - for bucket in self.buckets: - if bucket.gradient_scaling_factor != 1.0: - bucket.grad_data *= bucket.gradient_scaling_factor + # This mutates gradient communication state and must not be tracked by autograd. + with torch.no_grad(): + for bucket in self.buckets: + if bucket.gradient_scaling_factor != 1.0: + bucket.grad_data *= bucket.gradient_scaling_factor # Decide reduce_op. reduce_op = torch.distributed.ReduceOp.SUM @@ -864,7 +889,7 @@ def group_params_for_buffers( assert param.requires_grad param_dtype = param.dtype - if is_float8tensor(param) or is_nvfp4tensor(param): + if _param_uses_quantized_storage(param): param_dtype = torch.uint8 grad_dtype = torch.float if grad_reduce_in_fp32 else param.dtype is_expert_parallel = not getattr(param, 'allreduce', True) @@ -1046,7 +1071,9 @@ def __init__( # The packed index map is derived from param_index_map by iterating through # the already-computed layout and halving numel for NVFP4 tensors. # - self.has_nvfp4_params = any(is_nvfp4tensor(p) for p in self.params) + self.has_nvfp4_params = any( + is_nvfp4tensor(p) or is_grouped_nvfp4tensor(p) for p in self.params + ) self.nvfp4_packed_param_index_map = None self.nvfp4_packed_bucket_indices = None if self.has_nvfp4_params: @@ -1103,7 +1130,7 @@ def __init__( # The buffer is mapped to weight gradients whose dtype is either bf16 or FP32. # It can be temporarily reused by param AG. if self.ddp_config.use_distributed_optimizer and any( - is_mxfp8tensor(p) for p in self.params + is_mxfp8tensor(p) or is_grouped_mxfp8tensor(p) for p in self.params ): self.shared_buffer = torch.zeros( self.numel, @@ -1181,50 +1208,129 @@ def _create_bucket(bucket_id, bucket_params, bucket_params_with_extra_main_grads nvfp4_packed_param_start_index = None if self.has_nvfp4_params: nvfp4_packed_param_start_index, _, _ = self.nvfp4_packed_param_index_map[param] - # For MXFP8 param: - # we only need to map bf16 weights (layernorm, embedding, etc) to the buffer. - if not self.ddp_config.reuse_grad_buf_for_mxfp8_param_ag or not is_mxfp8tensor(param): + # This branch remaps the parameter storage into persistent DDP param_data buffer. + # + # Enter when: + # - `reuse_grad_buf_for_mxfp8_param_ag` is off: param AG has a persistent + # param_data buffer instead of sharing storage with grad_data, + # so every parameter must be backed by param_data. + # - param is not quantized: BF16/FP16/plain params still need persistent + # param_data even when quantized params use grad_data as temporary AG storage. + # + # Skip only when both are true: AG reuses grad_data and the param is quantized. + # In that case AG writes into grad_data, then _post_param_sync copies the + # gathered values back into TE quantized storage. + # + # Remap cases below: + # non-grouped TE NVFP4 tensor -> remap packed rowwise bytes + # non-grouped TE quantized -> remap TE quantized storage + # regular torch.Tensor param -> replace param.data with param_data view + # TE GroupedTensor + NVFP4 -> remap packed rowwise bytes + # TE GroupedTensor + MXFP8 -> unsupported here; require grad-buffer AG reuse + # TE GroupedTensor + BF16/FP16 -> remap grouped rowwise_data + if ( + not self.ddp_config.reuse_grad_buf_for_mxfp8_param_ag + or not _param_uses_quantized_storage(param) + ): if self.param_data is not None: - if is_nvfp4tensor(param): - # Remap the NVFP4 tensor's internal rowwise uint8 storage so it - # points into the contiguous DDP param buffer. This enables the - # all-gather to communicate packed NVFP4 bytes directly. - from ..fp4_utils import modify_nvfp4_rowwise_storage - - packed_shape = get_nvfp4_rowwise_packed_shape(param.data.shape) - rowwise_bytes_view = self._get( - packed_shape, - nvfp4_packed_param_start_index, - buffer_type=BufferType.PARAM, - ) - modify_nvfp4_rowwise_storage(param, rowwise_bytes_view) - elif is_float8tensor(param): - new_param_data = self._get( - param.data.shape, - ( - nvfp4_packed_param_start_index - if self.has_nvfp4_params - else param_start_index - ), - buffer_type=BufferType.PARAM, - ) - modify_underlying_storage(param, new_param_data) + if not is_grouped_tensor(param): + # Plain NVFP4: remap packed rowwise bytes only. + if is_nvfp4tensor(param): + packed_shape = get_nvfp4_rowwise_packed_shape(param.data.shape) + rowwise_bytes_view = self._get( + packed_shape, + nvfp4_packed_param_start_index, + buffer_type=BufferType.PARAM, + ) + modify_nvfp4_rowwise_storage(param, rowwise_bytes_view) + # In TE2, is_float8tensor() checks QuantizedTensor, including MXFP8. + # NVFP4 is handled by the branch above. + elif is_float8tensor(param): + # NVFP4 packs two FP4 values per byte, so param_data uses + # packed-byte offsets instead of logical element offsets. + new_param_data = self._get( + param.data.shape, + ( + nvfp4_packed_param_start_index + if self.has_nvfp4_params + else param_start_index + ), + buffer_type=BufferType.PARAM, + ) + modify_underlying_storage(param, new_param_data) + # Plain torch param: replace param.data with DDP buffer view. + else: + # NVFP4 packs two FP4 values per byte, so param_data uses + # packed-byte offsets instead of logical element offsets. + new_param_data = self._get( + param.data.shape, + ( + nvfp4_packed_param_start_index + if self.has_nvfp4_params + else param_start_index + ), + buffer_type=BufferType.PARAM, + ) + old_param_data = param.data + param.data = new_param_data + assert old_param_data._base is None + # Copy tensor values (from initialization or checkpoint). + param.data.detach().copy_(old_param_data) + del old_param_data else: - new_param_data = self._get( - param.data.shape, - ( - nvfp4_packed_param_start_index - if self.has_nvfp4_params - else param_start_index - ), - buffer_type=BufferType.PARAM, - ) - old_param_data = param.data - param.data = new_param_data - assert old_param_data._base is None - # Copy tensor values (from initialization or checkpoint). - param.data.detach().copy_(old_param_data) - del old_param_data + # GroupedTensor: preserve wrapper/metadata; remap backing storage only. + # Grouped NVFP4: only rowwise bytes live in DDP param_data. + if is_grouped_nvfp4tensor(param): + packed_shape = get_nvfp4_rowwise_packed_shape(param.data.shape) + rowwise_bytes_view = self._get( + packed_shape, + nvfp4_packed_param_start_index, + buffer_type=BufferType.PARAM, + ) + modify_grouped_nvfp4_rowwise_storage(param, rowwise_bytes_view) + rowwise_data = getattr(param, "rowwise_data", None) + if ( + rowwise_data is None + or rowwise_data.data_ptr() != rowwise_bytes_view.view(-1).data_ptr() + ): + raise RuntimeError( + "Failed to remap grouped NVFP4 rowwise storage into DDP " + "param_data." + ) + # Grouped MXFP8: do not remap grouped quantized storage into param_data. + # Use grad-buffer AG reuse and copy gathered values back after AG. + elif is_grouped_mxfp8tensor(param): + raise RuntimeError( + "Single grouped MXFP8 params require " + "--reuse-grad-buf-for-mxfp8-param-ag." + ) + elif is_grouped_tensor_with_quantized_storage(param): + raise RuntimeError( + "Unsupported single grouped quantized parameter recipe." + ) + # Grouped BF16/FP16: remap full rowwise_data. + else: + # NVFP4 packs two FP4 values per byte, so param_data uses + # packed-byte offsets instead of logical element offsets. + new_param_data = self._get( + param.data.shape, + ( + nvfp4_packed_param_start_index + if self.has_nvfp4_params + else param_start_index + ), + buffer_type=BufferType.PARAM, + ) + modify_grouped_tensor_rowwise_storage(param, new_param_data) + rowwise_data = getattr(param, "rowwise_data", None) + if ( + rowwise_data is None + or rowwise_data.data_ptr() != new_param_data.view(-1).data_ptr() + ): + raise RuntimeError( + "Failed to remap high-precision TE GroupedTensor parameter " + "storage into DDP param_data." + ) # Grad buffer always uses full-numel offsets from param_index_map. param.main_grad = self._get( @@ -1357,7 +1463,7 @@ def _pad_end_of_bucket(bucket_end_index: int) -> int: cur_bucket_id = bucket_id # NVFP4 tensors use half the numel in the packed param buffer. - if is_nvfp4tensor(param): + if is_nvfp4tensor(param) or is_grouped_nvfp4tensor(param): assert ( param_numel % 2 == 0 ), f"NVFP4 requires even numel for packing, got {param_numel}" diff --git a/megatron/core/fp4_utils.py b/megatron/core/fp4_utils.py index 45e57285a8d..a31ba7630c8 100644 --- a/megatron/core/fp4_utils.py +++ b/megatron/core/fp4_utils.py @@ -7,7 +7,11 @@ import torch from megatron.core.enums import Fp4Recipe -from megatron.core.fp8_utils import _get_custom_recipe +from megatron.core.fp8_utils import ( + _get_custom_recipe, + _get_grouped_quantized_recipe, + _unwrap_parameter_data, +) from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_te_min_version @@ -55,6 +59,14 @@ def is_nvfp4tensor(tensor: torch.Tensor) -> bool: return HAVE_TE_FP4_TENSOR_CLASS and isinstance(tensor, FP4_TENSOR_CLASS) +def is_grouped_nvfp4tensor(tensor: torch.Tensor) -> bool: + """Check if a TE GroupedTensor stores NVFP4 member tensors.""" + if not HAVE_TE_FP4_TENSOR_CLASS: + return False + recipe = _get_grouped_quantized_recipe(tensor) + return recipe is not None and hasattr(recipe, "nvfp4") and recipe.nvfp4() + + def get_nvfp4_rowwise_packed_shape(shape: torch.Size) -> torch.Size: """Return packed byte shape for NVFP4 rowwise storage (last dim // 2).""" if len(shape) == 0: @@ -85,6 +97,41 @@ def modify_nvfp4_rowwise_storage(fp4_tensor: torch.Tensor, new_rowwise_data: tor del old_rowwise +def modify_grouped_nvfp4_rowwise_storage( + grouped_tensor: torch.Tensor, new_rowwise_data: torch.Tensor +) -> None: + """Replace grouped NVFP4 rowwise data with a new uint8 storage view. + + The name intentionally mirrors `modify_nvfp4_rowwise_storage`: only the + packed rowwise byte buffer is remapped into the DDP buffer. The grouped + scale, amax, and columnwise buffers remain owned by the original tensor. + """ + tensor = _unwrap_parameter_data(grouped_tensor) + if not is_grouped_nvfp4tensor(tensor): + raise ValueError("modify_grouped_nvfp4_rowwise_storage expects grouped NVFP4 storage") + + old_rowwise = getattr(tensor, "rowwise_data", None) + if old_rowwise is None: + raise RuntimeError("Grouped NVFP4 tensor is missing rowwise data to replace") + + new_rowwise_data = new_rowwise_data.view(-1) + if old_rowwise.numel() != new_rowwise_data.numel(): + raise ValueError( + "Grouped NVFP4 rowwise storage size mismatch: " + f"old numel={old_rowwise.numel()}, new numel={new_rowwise_data.numel()}" + ) + assert ( + old_rowwise.dtype == new_rowwise_data.dtype == torch.uint8 + ), "Grouped NVFP4 rowwise storage must be uint8" + + new_rowwise_data.detach().copy_(old_rowwise.view(-1)) + tensor.rowwise_data = new_rowwise_data + # Member views capture data pointers. Refresh them after swapping rowwise storage while + # preserving the existing scale/amax/columnwise grouped buffers. + tensor.quantized_tensors = tensor.split_into_quantized_tensors() + del old_rowwise + + def quantize_nvfp4_param_shard( model_params, main_params, start_offsets, data_parallel_group, fsdp_shard_model_params=None ): diff --git a/megatron/core/fp8_utils.py b/megatron/core/fp8_utils.py index c9335e3b9f8..5411b676d83 100644 --- a/megatron/core/fp8_utils.py +++ b/megatron/core/fp8_utils.py @@ -62,6 +62,14 @@ # MXFP8Tensor not found HAVE_TE_MXFP8TENSOR = False +try: + from transformer_engine.pytorch.tensor.grouped_tensor import GroupedTensor + + HAVE_TE_GROUPED_TENSOR_CLASS = True +except (ImportError, ModuleNotFoundError): + GroupedTensor = None + HAVE_TE_GROUPED_TENSOR_CLASS = False + if HAVE_TE: from megatron.core.extensions.transformer_engine import ( TEColumnParallelLinear, @@ -93,6 +101,24 @@ te_post_all_gather_processing = None +def _unwrap_parameter_data(tensor: torch.Tensor) -> torch.Tensor: + """Return underlying tensor data when PyTorch wraps a tensor subclass as a Parameter.""" + if HAVE_TE_GROUPED_TENSOR_CLASS and isinstance(tensor, GroupedTensor): + # TE GroupedTensor stores its real payload in Python-side metadata fields + # such as rowwise_data/scale_inv. PyTorch marks tensor-subclass parameters + # as Parameters, so tensor.data would create a detached wrapper copy. Return + # the live wrapper so storage metadata mutations update the module parameter. + return tensor + return tensor.data if isinstance(tensor, torch.nn.Parameter) else tensor + + +def _is_instance_or_param_data(tensor: torch.Tensor, tensor_class: type) -> bool: + """Check a tensor subclass, including when wrapped by torch.nn.Parameter.""" + return isinstance(tensor, tensor_class) or isinstance( + _unwrap_parameter_data(tensor), tensor_class + ) + + def is_float8tensor(tensor: torch.Tensor) -> bool: """Check if a tensor is a Transformer Engine Float8Tensor. @@ -102,12 +128,136 @@ def is_float8tensor(tensor: torch.Tensor) -> bool: are both inherited from QuantizedTensor. So, for TE1.x, FP8_TENSOR_CLASS is Float8Tensor, and for TE2.x, FP8_TENSOR_CLASS is QuantizedTensor. """ - return HAVE_TE_FP8_TENSOR_CLASS and isinstance(tensor, FP8_TENSOR_CLASS) + return HAVE_TE_FP8_TENSOR_CLASS and _is_instance_or_param_data(tensor, FP8_TENSOR_CLASS) def is_mxfp8tensor(tensor: torch.Tensor) -> bool: """Check if a tensor is a Transformer Engine MXFP8Tensor""" - return HAVE_TE_MXFP8TENSOR and isinstance(tensor, MXFP8Tensor) + return HAVE_TE_MXFP8TENSOR and _is_instance_or_param_data(tensor, MXFP8Tensor) + + +def is_grouped_tensor(tensor: torch.Tensor) -> bool: + """Check if a tensor is a Transformer Engine GroupedTensor.""" + return HAVE_TE_GROUPED_TENSOR_CLASS and _is_instance_or_param_data(tensor, GroupedTensor) + + +def is_grouped_tensor_with_quantized_storage(tensor: torch.Tensor) -> bool: + """Check if a Transformer Engine GroupedTensor owns quantized primary storage.""" + tensor = _unwrap_parameter_data(tensor) + if not is_grouped_tensor(tensor): + return False + rowwise_data = getattr(tensor, "rowwise_data", None) + return rowwise_data is not None and rowwise_data.dtype == torch.uint8 + + +def _get_grouped_quantized_recipe(tensor: torch.Tensor): + """Return TE recipe for grouped quantized storage, or None if unavailable.""" + tensor = _unwrap_parameter_data(tensor) + if not is_grouped_tensor_with_quantized_storage(tensor): + return None + + quantizer = getattr(tensor, "quantizer", None) + if quantizer is None or not hasattr(quantizer, "_get_compatible_recipe"): + return None + return quantizer._get_compatible_recipe() + + +def is_grouped_mxfp8tensor(tensor: torch.Tensor) -> bool: + """Check if a TE GroupedTensor stores MXFP8 member tensors.""" + if not HAVE_TE_MXFP8TENSOR: + return False + recipe = _get_grouped_quantized_recipe(tensor) + return recipe is not None and hasattr(recipe, "mxfp8") and recipe.mxfp8() + + +def get_grouped_quantized_members( + tensor: torch.Tensor, *, create_if_missing: bool = False +) -> List[torch.Tensor]: + """Return cached per-member views for a grouped quantized tensor.""" + grouped_tensor = _unwrap_parameter_data(tensor) + if not is_grouped_tensor_with_quantized_storage(grouped_tensor): + raise ValueError("get_grouped_quantized_members expects grouped quantized storage.") + + quantized_members = getattr(grouped_tensor, "quantized_tensors", None) + if quantized_members is None: + if not create_if_missing: + raise RuntimeError( + "Grouped quantized parameter is missing cached member tensors. " + "Create them outside the training critical path." + ) + quantized_members = grouped_tensor.split_into_quantized_tensors() + grouped_tensor.quantized_tensors = quantized_members + return quantized_members + + +def copy_tensor_to_quantized_param(param: torch.Tensor, src: torch.Tensor) -> None: + """Copy high-precision values into TE quantized parameter storage.""" + dst = _unwrap_parameter_data(param) + + if is_grouped_tensor_with_quantized_storage(dst): + if src.numel() != dst.numel(): + raise ValueError( + "Grouped quantized parameter copy size mismatch: " + f"src numel={src.numel()}, dst numel={dst.numel()}" + ) + if not dst.all_same_shape(): + raise NotImplementedError( + "Copying into grouped quantized parameters requires uniform member shapes." + ) + + # Grouped quantized tensors cannot use GroupedTensor.copy_ here because + # the generic grouped path can rebuild member tensors through + # split_into_quantized_tensors(), which is not graph safe. Update cached + # member tensors in place instead. + quantized_members = get_grouped_quantized_members(dst) + src_members = src.view(dst.shape).unbind(dim=0) + if len(src_members) != len(quantized_members): + raise RuntimeError( + "Grouped quantized parameter member count mismatch: " + f"src members={len(src_members)}, dst members={len(quantized_members)}" + ) + + for src_member, dst_member in zip(src_members, quantized_members): + dst.quantizer.update_quantized(src_member, dst_member) + return + + # Plain TE quantized tensors override copy_ to requantize into their + # backing storage. + dst.copy_(src.view(dst.shape)) + + +def modify_grouped_tensor_rowwise_storage(tensor: torch.Tensor, new_storage: torch.Tensor) -> None: + """Replace a high-precision Transformer Engine GroupedTensor's rowwise storage.""" + tensor = _unwrap_parameter_data(tensor) + if not is_grouped_tensor(tensor): + raise ValueError("modify_grouped_tensor_rowwise_storage expects a GroupedTensor.") + if is_grouped_tensor_with_quantized_storage(tensor): + raise ValueError( + "modify_grouped_tensor_rowwise_storage only supports high-precision GroupedTensor " + "storage. Quantized grouped storage also owns scale buffers." + ) + + old_rowwise_data = getattr(tensor, "rowwise_data", None) + if old_rowwise_data is None: + raise RuntimeError("GroupedTensor is missing rowwise_data.") + + new_storage = new_storage.view(-1) + if old_rowwise_data.numel() != new_storage.numel(): + raise ValueError( + "GroupedTensor backing storage size mismatch: " + f"old numel={old_rowwise_data.numel()}, new numel={new_storage.numel()}" + ) + if old_rowwise_data.dtype != new_storage.dtype: + raise ValueError( + "GroupedTensor backing storage dtype mismatch: " + f"old dtype={old_rowwise_data.dtype}, new dtype={new_storage.dtype}" + ) + + new_storage.detach().copy_(old_rowwise_data) + tensor.rowwise_data = new_storage + tensor.columnwise_data = None + tensor.quantized_tensors = None + del old_rowwise_data def dequantize_fp8_tensor(fp8_tensor: torch.Tensor) -> torch.Tensor: @@ -502,8 +652,18 @@ def post_all_gather_processing(model_params): - tensorwise: may need to create a transposed view to match backend GEMM. - blockwise: create column-wise storage. """ + if not isinstance(model_params, list): + model_params = [model_params] + + expanded_model_params = [] + for param in model_params: + if is_grouped_tensor_with_quantized_storage(param): + expanded_model_params.extend(get_grouped_quantized_members(param)) + else: + expanded_model_params.append(param) + if te_post_all_gather_processing is not None: - te_post_all_gather_processing(model_params) + te_post_all_gather_processing(expanded_model_params) else: # If the TE version is old and does not have post_all_gather_processing function, this is # a no-op, and the transpose/columnwise data will be created in the next forward pass. diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py index 4d17d59e437..e8d3995f087 100644 --- a/megatron/core/optimizer/distrib_optimizer.py +++ b/megatron/core/optimizer/distrib_optimizer.py @@ -52,8 +52,14 @@ group_params_for_buffers, partition_buckets, ) -from ..fp4_utils import is_nvfp4tensor, quantize_nvfp4_param_shard -from ..fp8_utils import dequantize_fp8_tensor, is_float8tensor, quantize_param_shard +from ..fp4_utils import is_grouped_nvfp4tensor, is_nvfp4tensor, quantize_nvfp4_param_shard +from ..fp8_utils import ( + dequantize_fp8_tensor, + get_grouped_quantized_members, + is_float8tensor, + is_grouped_tensor_with_quantized_storage, + quantize_param_shard, +) from ..transformer.fsdp_dtensor_checkpoint import handle_experts_in_state_dict from ..transformer.module import MegatronModule from .grad_scaler import MegatronGradScaler @@ -1097,19 +1103,31 @@ def _get_main_param_and_optimizer_states(self, model_param): @staticmethod def _is_grouped_quantized_tensor(tensor: torch.Tensor) -> bool: """Check if tensor is a TE GroupedTensor using quantized storage.""" - return ( - hasattr(tensor, "split_into_quantized_tensors") - and callable(tensor.split_into_quantized_tensors) - and getattr(tensor, "quantizer", None) is not None - ) + return is_grouped_tensor_with_quantized_storage(tensor) @classmethod def _is_distopt_quantized_param(cls, tensor: torch.Tensor) -> bool: """Check if tensor should follow quantized parameter path in dist optimizer.""" return is_float8tensor(tensor) or cls._is_grouped_quantized_tensor(tensor) + @classmethod + def _get_grouped_quantized_members(cls, tensor: torch.Tensor) -> List[torch.Tensor]: + """Return cached member tensors from a grouped quantized parameter.""" + return get_grouped_quantized_members(tensor, create_if_missing=True) + + @classmethod + def _is_grouped_nvfp4_param(cls, tensor: torch.Tensor) -> bool: + """Check if a grouped quantized parameter stores NVFP4 member tensors.""" + return is_grouped_nvfp4tensor(tensor) + + @classmethod + def _is_fp8_param_for_param_gather(cls, tensor: torch.Tensor) -> bool: + """Check if a quantized param should use the FP8/MXFP8 param-gather cast path.""" + return cls._is_distopt_quantized_param(tensor) and not cls._is_grouped_nvfp4_param(tensor) + + @classmethod def _expand_quantized_param_shard_for_cast( - self, + cls, model_param: torch.Tensor, shard_main_param: Optional[torch.Tensor], start_offset: Optional[int], @@ -1120,12 +1138,10 @@ def _expand_quantized_param_shard_for_cast( master slice to per-member offset ranges, while preserving deterministic ordering across DP ranks. """ - if not self._is_grouped_quantized_tensor(model_param): + if not cls._is_grouped_quantized_tensor(model_param): return [model_param], [shard_main_param], [start_offset] - quantized_members = model_param.quantized_tensors - if quantized_members is None: - quantized_members = model_param.split_into_quantized_tensors() + quantized_members = cls._get_grouped_quantized_members(model_param) shard_start = 0 if start_offset is None else start_offset shard_size = 0 if shard_main_param is None else shard_main_param.numel() @@ -2614,7 +2630,7 @@ def _get_fp8_params_and_shard_fp32_from_fp8(self): idx = 0 for buffer in buffers: for param in buffer.params: - if self._is_distopt_quantized_param(param): + if self._is_fp8_param_for_param_gather(param): fp8_params.append(param) shard_fp32_from_fp8.append(None) shard_offsets_in_fp8.append(None) @@ -2629,7 +2645,7 @@ def get_shard_fp32_from_fp8(shard_main_groups, model_groups): """ for shard_main_group, model_group in zip(shard_main_groups, model_groups): for shard_main_param, model_param in zip(shard_main_group, model_group): - if self._is_distopt_quantized_param(model_param): + if self._is_fp8_param_for_param_gather(model_param): param_range_map = self._get_model_param_range_map(model_param) param_range = param_range_map["param"] assert param_range.size == shard_main_param.nelement() @@ -2664,6 +2680,13 @@ def _get_nvfp4_params_and_shard_fp32_from_nvfp4(self): shard_offsets_in_nvfp4.append(None) nvfp4_param_to_idx_map[param] = idx idx += 1 + elif self._is_grouped_nvfp4_param(param): + members = self._get_grouped_quantized_members(param) + nvfp4_params.extend(members) + shard_fp32_from_nvfp4.extend([None] * len(members)) + shard_offsets_in_nvfp4.extend([None] * len(members)) + nvfp4_param_to_idx_map[param] = list(range(idx, idx + len(members))) + idx += len(members) def _get_shard_fp32_from_nvfp4(shard_main_groups, model_groups): """Populate shard_fp32_from_nvfp4 and shard_offsets_in_nvfp4 for NVFP4 params.""" @@ -2676,6 +2699,28 @@ def _get_shard_fp32_from_nvfp4(shard_main_groups, model_groups): idx = nvfp4_param_to_idx_map[model_param] shard_fp32_from_nvfp4[idx] = shard_main_param shard_offsets_in_nvfp4[idx] = param_range.start + elif self._is_grouped_nvfp4_param(model_param): + param_range_map = self._get_model_param_range_map(model_param) + param_range = param_range_map["param"] + assert param_range.size == shard_main_param.nelement() + ( + expanded_model_params, + expanded_shard_main_params, + expanded_start_offsets, + ) = self._expand_quantized_param_shard_for_cast( + model_param, shard_main_param, param_range.start + ) + indices = nvfp4_param_to_idx_map[model_param] + assert len(indices) == len(expanded_model_params) + for idx, member, member_master, member_offset in zip( + indices, + expanded_model_params, + expanded_shard_main_params, + expanded_start_offsets, + ): + assert nvfp4_params[idx] is member + shard_fp32_from_nvfp4[idx] = member_master + shard_offsets_in_nvfp4[idx] = member_offset _get_shard_fp32_from_nvfp4(self.shard_fp32_from_float16_groups, self.model_float16_groups) _get_shard_fp32_from_nvfp4(self.shard_fp32_groups, self.model_fp32_groups) @@ -2858,6 +2903,12 @@ def _copy_main_params_to_param_buffer(self): shard_param_buffer.copy_(shard_main_param) + # Staging params into the DDP param buffer invalidates any prior "already + # dispatched" state. The next forward pre-hook must run post-sync cleanup, + # especially when MXFP8 reuses grad_data as the param AG buffer. + for model_chunk in self.model_chunks: + model_chunk.reset_param_sync_dispatch_state() + @staticmethod def _normalize_state_dict_for_grouped_params(state_dict_flat, model_chunk): """Normalize state dict keys when grouped/indexed parameter formats differ. diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index 8ee1b4654ad..d60a83b9af7 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -368,27 +368,61 @@ def _is_fused_impl_supported(self) -> bool: if not (use_glu_fusion or use_srelu_fusion): return False if self.config.activation_func == F.silu: - return True - if self.config.activation_func == quick_gelu: + pass + elif self.config.activation_func == quick_gelu: try: from transformer_engine.pytorch.ops import ScaledClampedQGeGLU # noqa: F401 except ImportError: return False - return True - if self.config.activation_func == squared_relu: + elif self.config.activation_func == squared_relu: try: from transformer_engine.pytorch.ops import ScaledSReLU # noqa: F401 except ImportError: return False - return True + else: + return False - return False + # Check TE CuTe DSL fused kernel conditions (must match TE's + # fuse_grouped_mlp_ops matching logic). + import os + + if use_glu_fusion and int(os.environ.get("NVTE_CUTEDSL_FUSED_GROUPED_MLP", "0")) <= 0: + return False + return True def _make_fused_ops(self) -> torch.nn.Module: """Construct fused module for FC1, activation, and FC2.""" assert HAVE_TE, "_make_fused_ops requires Transformer Engine." + def register_grouped_linear_params( + op: torch.nn.Module, + linear: torch.nn.Module, + single_grouped_weight: bool, + single_grouped_bias: bool, + ) -> None: + """Register real GroupedLinear params on a meta TE op shell.""" + if single_grouped_weight: + op.register_parameter("weight", linear.get_parameter("weight")) + for idx in range(linear.num_gemms): + op.register_parameter(f"weight{idx}", None) + else: + op.register_parameter("weight", None) + for idx in range(linear.num_gemms): + op.register_parameter(f"weight{idx}", linear.get_parameter(f"weight{idx}")) + + if not linear.use_bias: + return + + if single_grouped_bias: + op.register_parameter("bias", linear.get_parameter("bias")) + for idx in range(linear.num_gemms): + op.register_parameter(f"bias{idx}", None) + else: + op.register_parameter("bias", None) + for idx in range(linear.num_gemms): + op.register_parameter(f"bias{idx}", linear.get_parameter(f"bias{idx}")) + # Container for fusible ops ops = te.pytorch.ops.Sequential() @@ -429,17 +463,11 @@ def _make_fused_ops(self) -> torch.nn.Module: delay_wgrad_compute=fc1_delay_wgrad_compute, ) - # Copy the weights from GroupedLinear module to GroupedLinear op. - if fc1_single_grouped_weight: - setattr(op, "weight", getattr(self.linear_fc1, "weight")) - - for idx in range(self.linear_fc1.num_gemms): - if not fc1_single_grouped_weight: - setattr(op, f"weight{idx}", getattr(self.linear_fc1, f"weight{idx}")) - if self.linear_fc1.use_bias and not fc1_single_grouped_bias: - setattr(op, f"bias{idx}", getattr(self.linear_fc1, f"bias{idx}")) - if self.linear_fc1.use_bias and fc1_single_grouped_bias: - setattr(op, "bias", getattr(self.linear_fc1, "bias")) + # In single grouped mode, clear stale per-expert meta params so TE does not reset + # the op and replace the shared DDP parameter with a fresh one lacking main_grad. + register_grouped_linear_params( + op, self.linear_fc1, fc1_single_grouped_weight, fc1_single_grouped_bias + ) ops.append(op) # Activation and post-multiply probs (SwiGLU, clamped quick-GeGLU, or SReLU) @@ -521,17 +549,11 @@ def _make_fused_ops(self) -> torch.nn.Module: **fc2_bias_kwargs, ) - # Copy the weights from GroupedLinear module to GroupedLinear op. - if fc2_single_grouped_weight: - setattr(op, "weight", getattr(self.linear_fc2, "weight")) - - for idx in range(self.linear_fc2.num_gemms): - if not fc2_single_grouped_weight: - setattr(op, f"weight{idx}", getattr(self.linear_fc2, f"weight{idx}")) - if self.linear_fc2.use_bias and not fc2_single_grouped_bias: - setattr(op, f"bias{idx}", getattr(self.linear_fc2, f"bias{idx}")) - if self.linear_fc2.use_bias and fc2_single_grouped_bias: - setattr(op, "bias", getattr(self.linear_fc2, "bias")) + # In single grouped mode, clear stale per-expert meta params so TE does not reset + # the op and replace the shared DDP parameter with a fresh one lacking main_grad. + register_grouped_linear_params( + op, self.linear_fc2, fc2_single_grouped_weight, fc2_single_grouped_bias + ) ops.append(op) # Emulate submodule pre-forward hooks diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 761504e614e..0c9ce022db7 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -824,7 +824,8 @@ class TransformerConfig(ModelParallelConfig): moe_single_grouped_weight: bool = False """When using TE GroupedLinear for MoE experts, store expert weights as a single grouped - parameter via Transformer Engine's `GroupedTensor`. Requires ``moe_grouped_gemm=True``. + parameter via Transformer Engine's `GroupedTensor`. Requires ``moe_grouped_gemm=True`` and + ``use_transformer_engine_op_fuser=True``. """ moe_single_grouped_bias: bool = False @@ -1545,16 +1546,23 @@ def __post_init__(self): f"transformer-engine>=2.14.0, but your version is {get_te_version()}." ) if self.moe_single_grouped_weight: - # The dist-optimizer's quantized-param shard path on the single-grouped-weight - # storage is only validated for fp8 mode with the mxfp8 recipe today; other - # combinations have a known numerical issue tracked in upstream PR - # NVIDIA/Megatron-LM#4621. Reject at construction time so users don't silently - # train on a broken numerical path. (moe_single_grouped_bias is not gated: - # biases aren't quantized, so they don't enter the buggy code path.) - if self.fp4 or not self.fp8 or self.fp8_recipe != Fp8Recipe.mxfp8: + # Single grouped weights are supported for high-precision primary weights + # (BF16/FP16), MXFP8 primary weights, and NVFP4 primary weights. + # Other quantized primary-weight paths need grouped partial-cast support + # before they are safe to enable. + if (self.fp8 and self.fp8_recipe != Fp8Recipe.mxfp8) or ( + self.fp4 and self.fp4_recipe != Fp4Recipe.nvfp4 + ): + raise ValueError( + "moe_single_grouped_weight is currently supported with high-precision " + "primary weights, fp8_recipe='mxfp8', or fp4_recipe='nvfp4'." + ) + if not self.use_transformer_engine_op_fuser: raise ValueError( - "moe_single_grouped_weight is currently supported only with fp8 mode " - "and fp8_recipe='mxfp8'." + "moe_single_grouped_weight requires " + "use_transformer_engine_op_fuser=True. The non-op-fuser TE GroupedLinear " + "path splits the grouped parameter into per-expert tensors and does not " + "support single-grouped-weight training." ) if self.moe_single_grouped_bias and not self.add_bias_linear: raise ValueError("moe_single_grouped_bias requires add_bias_linear=True.") diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 3929b69d606..7f84a30bae0 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1081,6 +1081,17 @@ def validate_args(args, defaults={}): args.use_distributed_optimizer = True # Optimizer step MXFP8 buffer operation that is not relevant or supported for Megatron-FSDP. args.reuse_grad_buf_for_mxfp8_param_ag = False + if args.moe_single_grouped_weight or args.moe_single_grouped_bias: + # Megatron-FSDP currently remaps module parameters through plain Tensor and TE + # Float8Tensor/MXFP8Tensor storage paths. TE GroupedTensor parameters need their + # grouped backing storage remapped instead; quantized grouped tensors also need + # grouped scale/amax handling. DDP has a separate GroupedTensor-aware path. + raise ValueError( + "Megatron-FSDP does not currently support moe_single_grouped_weight or " + "moe_single_grouped_bias. Disable single grouped MoE parameters or use the " + "regular DDP/distributed optimizer path until Megatron-FSDP supports TE " + "GroupedTensor param buffers." + ) # Optimizer compatibility check. assert args.optimizer in ('sgd', 'adam'), \ f"Megatron-FSDP does not support the {args.optimizer} optimizer yet." diff --git a/tests/unit_tests/distributed/test_param_and_grad_buffer.py b/tests/unit_tests/distributed/test_param_and_grad_buffer.py index ac1fdfe2ed6..445f9f0bcda 100644 --- a/tests/unit_tests/distributed/test_param_and_grad_buffer.py +++ b/tests/unit_tests/distributed/test_param_and_grad_buffer.py @@ -789,7 +789,9 @@ def mock_packed_shape(shape): 'megatron.core.distributed.param_and_grad_buffer.get_nvfp4_rowwise_packed_shape', side_effect=mock_packed_shape, ), - mock.patch('megatron.core.fp4_utils.modify_nvfp4_rowwise_storage'), + mock.patch( + 'megatron.core.distributed.param_and_grad_buffer.modify_nvfp4_rowwise_storage' + ), mock.patch('torch.cuda.current_device', return_value='cpu'), mock.patch( 'megatron.core.distributed.param_and_grad_buffer.log_on_each_pipeline_stage' diff --git a/tests/unit_tests/optimizer/test_distrib_optimizer_grouped_quantized.py b/tests/unit_tests/optimizer/test_distrib_optimizer_grouped_quantized.py index 59aebed27d9..1611a834926 100644 --- a/tests/unit_tests/optimizer/test_distrib_optimizer_grouped_quantized.py +++ b/tests/unit_tests/optimizer/test_distrib_optimizer_grouped_quantized.py @@ -1,20 +1,32 @@ # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +import pytest import torch +from megatron.core import fp8_utils from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer class _FakeGroupedQuantizedTensor: - def __init__(self, members, quantized_tensors=None): + def __init__(self, members, quantized_tensors=None, rowwise_dtype=torch.uint8): self._members = members self.quantized_tensors = quantized_tensors + self.rowwise_data = torch.empty( + sum(member.numel() for member in members), dtype=rowwise_dtype + ) self.quantizer = object() def split_into_quantized_tensors(self): return self._members +@pytest.fixture(autouse=True) +def _use_fake_grouped_tensor_class(monkeypatch): + """Make the CPU test double satisfy the production TE GroupedTensor type contract.""" + monkeypatch.setattr(fp8_utils, "GroupedTensor", _FakeGroupedQuantizedTensor) + monkeypatch.setattr(fp8_utils, "HAVE_TE_GROUPED_TENSOR_CLASS", True) + + def test_expand_quantized_param_shard_for_cast_splits_grouped_wrapper(): optimizer = DistributedOptimizer.__new__(DistributedOptimizer) members = [torch.empty(3), torch.empty(5), torch.empty(2)] @@ -58,9 +70,10 @@ def test_grouped_quantized_tensor_detection_allows_lazy_split_members(): assert DistributedOptimizer._is_distopt_quantized_param(grouped_param) -def test_grouped_quantized_tensor_detection_requires_quantizer(): - grouped_param = _FakeGroupedQuantizedTensor([torch.empty(1)], quantized_tensors=None) - grouped_param.quantizer = None +def test_grouped_quantized_tensor_detection_requires_quantized_storage(): + grouped_param = _FakeGroupedQuantizedTensor( + [torch.empty(1)], quantized_tensors=None, rowwise_dtype=torch.bfloat16 + ) assert not DistributedOptimizer._is_grouped_quantized_tensor(grouped_param) assert not DistributedOptimizer._is_distopt_quantized_param(grouped_param) diff --git a/tests/unit_tests/transformer/moe/test_grouped_mlp.py b/tests/unit_tests/transformer/moe/test_grouped_mlp.py index 2188e1d1946..75fa941769a 100644 --- a/tests/unit_tests/transformer/moe/test_grouped_mlp.py +++ b/tests/unit_tests/transformer/moe/test_grouped_mlp.py @@ -358,6 +358,12 @@ def register_forward_pre_hook(self, hook): ops = module._make_fused_ops() assert ops[0].weight is module.linear_fc1.weight + assert ops[0].weight0 is None + assert ops[0].weight1 is None + fc1_named_params = dict(ops[0].named_parameters()) + assert fc1_named_params["weight"] is module.linear_fc1.weight + assert "weight0" not in fc1_named_params + assert "weight1" not in fc1_named_params assert ops[1].glu_interleave_size == 8 assert ops[1].activation_recompute_in_mlp is True assert ops[2].weight0 is module.linear_fc2.weight0 @@ -701,10 +707,13 @@ def test_make_fused_ops_attaches_single_grouped_bias_for_fc1(monkeypatch): assert ops[0].weight0 is module.linear_fc1.weight0 assert ops[0].weight1 is module.linear_fc1.weight1 - assert ops[0].bias is module.linear_fc1.bias # ← single grouped bias attached at "bias" - assert not hasattr( - ops[0], "bias0" - ), "bias should not be split into bias{idx} when single_grouped_bias=True" + assert ops[0].bias is module.linear_fc1.bias + assert ops[0].bias0 is None + assert ops[0].bias1 is None + fc1_named_params = dict(ops[0].named_parameters()) + assert fc1_named_params["bias"] is module.linear_fc1.bias + assert "bias0" not in fc1_named_params + assert "bias1" not in fc1_named_params def test_backward_dw_dispatches_fused_children_in_fc2_then_fc1_order(): diff --git a/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py b/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py new file mode 100644 index 00000000000..f5a4afaeffc --- /dev/null +++ b/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py @@ -0,0 +1,726 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +import gc +import inspect +import os +import sys +import traceback + +import pytest +import torch + +from megatron.core.enums import ModelType +from megatron.core.fp4_utils import is_grouped_nvfp4tensor +from megatron.core.fp8_utils import ( + is_grouped_mxfp8tensor, + is_grouped_tensor, + is_grouped_tensor_with_quantized_storage, +) +from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec +from megatron.core.models.gpt.gpt_model import GPTModel +from megatron.core.num_microbatches_calculator import destroy_num_microbatches_calculator +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.utils import is_te_min_version +from megatron.training.arguments import core_transformer_config_from_args, parse_args, validate_args +from megatron.training.checkpointing import load_checkpoint, save_checkpoint +from megatron.training.global_vars import ( + destroy_global_vars, + get_args, + set_args, + set_global_variables, +) +from megatron.training.training import force_param_sync, setup_model_and_optimizer +from megatron.training.utils import get_device_arch_version +from tests.unit_tests.dist_checkpointing import TempNamedDir +from tests.unit_tests.test_utilities import Utils + +try: + from transformer_engine.pytorch.fp8 import check_fp8_support, check_nvfp4_support + + _FP8_AVAILABLE, _NO_FP8_REASON = check_fp8_support() + _NVFP4_AVAILABLE, _NO_NVFP4_REASON = check_nvfp4_support() +except ImportError: + _FP8_AVAILABLE = False + _NO_FP8_REASON = "Transformer Engine FP8 support is unavailable" + _NVFP4_AVAILABLE = False + _NO_NVFP4_REASON = "Transformer Engine NVFP4 support is unavailable" + + +_SEED = 1234 +_BLACKWELL_AVAILABLE = torch.cuda.is_available() and get_device_arch_version() >= 10 +try: + from transformer_engine.pytorch import GroupedLinear as TEGroupedLinear + + _TE_GROUPED_LINEAR_SUPPORTS_SINGLE_PARAM = ( + "single_grouped_weight" in inspect.signature(TEGroupedLinear.__init__).parameters + ) +except (ImportError, AttributeError): + _TE_GROUPED_LINEAR_SUPPORTS_SINGLE_PARAM = False + +pytestmark = [ + pytest.mark.internal, + pytest.mark.skipif( + not is_te_min_version("2.14.0"), + reason="moe_single_grouped_weight requires Transformer Engine >= 2.14.0", + ), + pytest.mark.skipif( + not _TE_GROUPED_LINEAR_SUPPORTS_SINGLE_PARAM, + reason="Installed TE GroupedLinear does not expose single_grouped_weight", + ), +] + + +def _skip_if_unsupported(precision: str) -> None: + if Utils.world_size < 2: + pytest.skip("distributed optimizer parity test requires torchrun with at least 2 ranks") + + if precision in ("mxfp8", "nvfp4") and not _BLACKWELL_AVAILABLE: + pytest.skip(f"{precision} single grouped weight parity requires Blackwell (SM >= 10)") + if precision == "mxfp8" and not _FP8_AVAILABLE: + pytest.skip(_NO_FP8_REASON) + if precision == "nvfp4" and not _NVFP4_AVAILABLE: + pytest.skip(_NO_NVFP4_REASON) + + +class TestMoESingleGroupedWeightNumerics: + """Numerical parity tests for MoE single grouped weights under DistOpt.""" + + seq_length = 128 + micro_batch_size = 2 + num_train_steps = 4 + + def setup_method(self, method): + self._old_single_param_env = os.environ.get("NVTE_GROUPED_LINEAR_SINGLE_PARAM") + self._old_cutedsl_fused_grouped_mlp_env = os.environ.get("NVTE_CUTEDSL_FUSED_GROUPED_MLP") + os.environ["CUDA_DEVICE_MAX_CONNECTIONS"] = "1" + os.environ["NVTE_GROUPED_LINEAR_SINGLE_PARAM"] = "1" + os.environ["NVTE_CUTEDSL_FUSED_GROUPED_MLP"] = "1" + + def teardown_method(self, method): + try: + self._cleanup() + finally: + if self._old_single_param_env is None: + os.environ.pop("NVTE_GROUPED_LINEAR_SINGLE_PARAM", None) + else: + os.environ["NVTE_GROUPED_LINEAR_SINGLE_PARAM"] = self._old_single_param_env + if self._old_cutedsl_fused_grouped_mlp_env is None: + os.environ.pop("NVTE_CUTEDSL_FUSED_GROUPED_MLP", None) + else: + os.environ["NVTE_CUTEDSL_FUSED_GROUPED_MLP"] = ( + self._old_cutedsl_fused_grouped_mlp_env + ) + + def _cleanup(self): + Utils.destroy_model_parallel() + destroy_global_vars() + destroy_num_microbatches_calculator() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + def model_provider( + self, pre_process=True, post_process=True, config=None, pg_collection=None, vp_stage=None + ): + model_parallel_cuda_manual_seed(_SEED) + args = get_args() + if config is None: + config = core_transformer_config_from_args(args) + transformer_layer_spec = get_gpt_layer_with_transformer_engine_spec( + num_experts=args.num_experts, moe_grouped_gemm=args.moe_grouped_gemm + ) + return GPTModel( + config=config, + transformer_layer_spec=transformer_layer_spec, + vocab_size=args.vocab_size, + max_sequence_length=args.max_position_embeddings, + pre_process=pre_process, + post_process=post_process, + fp16_lm_cross_entropy=args.fp16_lm_cross_entropy, + parallel_output=True, + share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights, + position_embedding_type=args.position_embedding_type, + rotary_percent=args.rotary_percent, + pg_collection=pg_collection, + vp_stage=vp_stage, + ) + + def create_test_args( + self, + precision: str, + primary_param_gather: bool, + single_weight: bool, + gradient_accumulation_fusion: bool, + use_transformer_engine_op_fuser: bool, + overlap_param_gather: bool = False, + overlap_grad_reduce: bool = False, + grad_reduce_in_fp32: bool = False, + ): + self._cleanup() + + sys.argv = ["test_moe_single_grouped_weight_numerics.py"] + args = parse_args() + args.num_layers = 1 + args.vocab_size = 1024 + args.hidden_size = 256 + args.ffn_hidden_size = 256 + args.num_attention_heads = 8 + args.max_position_embeddings = self.seq_length + args.seq_length = self.seq_length + args.micro_batch_size = self.micro_batch_size + args.global_batch_size = self.micro_batch_size * Utils.world_size + args.create_attention_mask_in_dataloader = True + args.tensor_model_parallel_size = 1 + args.pipeline_model_parallel_size = 1 + args.context_parallel_size = 1 + args.expert_model_parallel_size = 1 + args.train_iters = self.num_train_steps + args.lr = 3e-5 + args.bf16 = True + args.attention_backend = "unfused" + args.add_bias_linear = False + args.hidden_dropout = 0.0 + args.attention_dropout = 0.0 + args.swiglu = True + args.gradient_accumulation_fusion = gradient_accumulation_fusion + args.use_distributed_optimizer = True + args.use_transformer_engine_op_fuser = use_transformer_engine_op_fuser + args.overlap_param_gather = overlap_param_gather + args.overlap_grad_reduce = overlap_grad_reduce + args.accumulate_allreduce_grads_in_fp32 = grad_reduce_in_fp32 + args.ddp_bucket_size = 40960 + + args.num_experts = 2 + args.moe_layer_freq = 1 + args.moe_grouped_gemm = True + args.moe_single_grouped_weight = single_weight + args.moe_token_dispatcher_type = "alltoall" + args.moe_router_topk = 1 + args.moe_router_pre_softmax = True + args.moe_router_load_balancing_type = "none" + args.moe_aux_loss_coeff = 0.0 + args.moe_ffn_hidden_size = 256 + args.moe_mlp_glu_interleave_size = 32 + + if precision == "mxfp8": + args.fp8 = "e4m3" + args.fp8_recipe = "mxfp8" + args.fp8_param_gather = primary_param_gather + args.reuse_grad_buf_for_mxfp8_param_ag = primary_param_gather + elif precision == "nvfp4": + args.fp4 = "e2m1" + args.fp4_recipe = "nvfp4" + args.fp4_param_gather = primary_param_gather + elif precision != "bf16": + raise ValueError(f"Unknown precision test case: {precision}") + + validate_args(args) + set_global_variables(args, False) + return args + + def get_batch(self): + data = torch.arange(self.seq_length, dtype=torch.int64, device="cuda") + input_ids = data.repeat((self.micro_batch_size, 1)) + labels = (data + 1).repeat((self.micro_batch_size, 1)) + position_ids = data.repeat((self.micro_batch_size, 1)) + attention_mask = torch.ones( + (self.micro_batch_size, 1, self.seq_length, self.seq_length), dtype=bool, device="cuda" + ) + loss_mask = torch.ones( + (self.micro_batch_size, self.seq_length), dtype=torch.float32, device="cuda" + ) + return input_ids, labels, position_ids, attention_mask, loss_mask + + def assert_storage_path_is_exercised( + self, model, precision: str, primary_param_gather: bool, single_weight: bool + ): + params = list(model.named_parameters()) + if not single_weight: + assert not any(is_grouped_tensor(param) for _, param in params) + return + + grouped_params = [param for _, param in params if is_grouped_tensor(param)] + assert grouped_params, "Expected at least one TE GroupedTensor MoE parameter" + + if not primary_param_gather or precision == "bf16": + assert any( + not is_grouped_tensor_with_quantized_storage(param) for param in grouped_params + ), "Expected high-precision grouped primary weights" + return + + if precision == "mxfp8": + assert any(is_grouped_mxfp8tensor(param) for param in grouped_params) + elif precision == "nvfp4": + assert any(is_grouped_nvfp4tensor(param) for param in grouped_params) + + @staticmethod + def iter_distopt_buffers(optimizer): + optimizers = getattr(optimizer, "chained_optimizers", [optimizer]) + for optim_instance in optimizers: + for buffer in getattr(optim_instance, "buffers", []): + yield buffer + + def assert_grouped_params_remapped_to_ddp_param_data(self, optimizer, precision: str): + """Grouped BF16/NVFP4 params must point at the live DDP param_data slice.""" + num_checked = 0 + for buffer in self.iter_distopt_buffers(optimizer): + for bucket in buffer.buckets: + if bucket.param_data is None: + continue + for param in bucket.params: + if not is_grouped_tensor(param): + continue + + rowwise_data = getattr(param, "rowwise_data", None) + assert rowwise_data is not None, "GroupedTensor is missing rowwise_data" + + if precision == "bf16": + if is_grouped_tensor_with_quantized_storage(param): + continue + start, end = bucket.param_to_index[param] + expected = bucket.param_data.view(-1)[start:end] + elif precision == "nvfp4": + if not is_grouped_nvfp4tensor(param): + continue + packed_start, packed_end, bucket_id = buffer.nvfp4_packed_param_index_map[ + param + ] + assert bucket_id == bucket.bucket_id + bucket_start, _ = buffer.nvfp4_packed_bucket_indices[bucket_id] + expected = bucket.param_data.view(-1)[ + packed_start - bucket_start : packed_end - bucket_start + ] + else: + raise ValueError(f"Unsupported remap precision: {precision}") + + rowwise_flat = rowwise_data.view(-1) + assert rowwise_flat.numel() == expected.numel() + assert rowwise_flat.dtype == expected.dtype + assert rowwise_flat.data_ptr() == expected.data_ptr(), ( + "Live grouped parameter rowwise_data is not mapped to the DDP " + f"param_data slice for precision={precision}" + ) + num_checked += 1 + + assert num_checked > 0, f"Did not find any {precision} grouped params to verify" + + def assert_execution_path_is_exercised( + self, model, use_transformer_engine_op_fuser: bool, after_forward: bool = False + ): + grouped_mlps = [ + module for module in model.modules() if module.__class__.__name__ == "TEGroupedMLP" + ] + assert grouped_mlps, "Expected at least one TEGroupedMLP module" + assert all( + module._with_fused_impl == use_transformer_engine_op_fuser for module in grouped_mlps + ), "Unexpected TEGroupedMLP execution path" + if after_forward: + assert all( + (module._fused_ops is not None) == use_transformer_engine_op_fuser + for module in grouped_mlps + ), "Unexpected TEGroupedMLP fused-op construction state" + + def run_training_case( + self, + precision: str, + primary_param_gather: bool, + single_weight: bool, + gradient_accumulation_fusion: bool, + use_transformer_engine_op_fuser: bool, + ): + args = self.create_test_args( + precision, + primary_param_gather, + single_weight, + gradient_accumulation_fusion, + use_transformer_engine_op_fuser, + ) + set_args(args) + torch.manual_seed(_SEED) + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, expert_model_parallel_size=args.expert_model_parallel_size + ) + + batch = self.get_batch() + model, optimizer, _ = setup_model_and_optimizer( + model_type=ModelType.encoder_or_decoder, model_provider_func=self.model_provider + ) + assert len(model) == 1 + self.assert_storage_path_is_exercised( + model[0], precision, primary_param_gather, single_weight + ) + self.assert_execution_path_is_exercised(model[0], use_transformer_engine_op_fuser) + + losses = [] + for _ in range(self.num_train_steps): + model[0].zero_grad_buffer() + optimizer.zero_grad() + model[0].set_is_first_microbatch() + output = model[0].forward( + input_ids=batch[0], + labels=batch[1], + position_ids=batch[2], + attention_mask=batch[3], + loss_mask=batch[4], + ) + loss = output.mean() + assert torch.isfinite(loss) + loss.backward() + + # Wait for an overlapped reduction, or launch it synchronously when overlap is off. + model[0].finish_grad_sync() + + update_successful, _, _ = optimizer.step() + assert update_successful + losses.append(loss.detach().float().cpu()) + + self.assert_execution_path_is_exercised( + model[0], use_transformer_engine_op_fuser, after_forward=True + ) + return torch.stack(losses) + + def run_one_mxfp8_overlap_train_step(self, args, model, optimizer, batch): + model[0].zero_grad_buffer() + optimizer.zero_grad() + if args.reuse_grad_buf_for_mxfp8_param_ag and args.overlap_param_gather: + optimizer.prepare_model_params_for_param_sync() + model[0].set_is_first_microbatch() + output = model[0].forward( + input_ids=batch[0], + labels=batch[1], + position_ids=batch[2], + attention_mask=batch[3], + loss_mask=batch[4], + ) + loss = output.mean() + assert torch.isfinite(loss) + loss.backward() + + # Wait for an overlapped reduction, or launch it synchronously when overlap is off. + model[0].finish_grad_sync() + + update_successful, _, _ = optimizer.step() + assert update_successful + return loss.detach().float().cpu() + + def run_mxfp8_eval_step(self, args, model, optimizer, batch): + if args.reuse_grad_buf_for_mxfp8_param_ag and args.overlap_param_gather: + optimizer.prepare_model_params_for_param_sync() + + model[0].disable_forward_pre_hook(param_sync=True) + model[0].eval() + with torch.no_grad(): + output = model[0].forward( + input_ids=batch[0], + labels=batch[1], + position_ids=batch[2], + attention_mask=batch[3], + loss_mask=batch[4], + ) + assert torch.isfinite(output.mean()) + model[0].train() + model[0].enable_forward_pre_hook() + + def setup_mxfp8_overlap_case(self, single_weight: bool, checkpoint_dir=None): + args = self.create_test_args( + precision="mxfp8", + primary_param_gather=True, + single_weight=single_weight, + gradient_accumulation_fusion=True, + use_transformer_engine_op_fuser=True, + overlap_param_gather=True, + overlap_grad_reduce=True, + grad_reduce_in_fp32=True, + ) + if checkpoint_dir is not None: + args.save = checkpoint_dir + args.load = checkpoint_dir + args.ckpt_format = "torch_dist" + args.use_dist_ckpt = True + args.auto_detect_ckpt_format = False + args.async_save = False + args.ckpt_assume_constant_structure = False + args.ckpt_load_validate_sharding_integrity = True + args.dist_ckpt_strictness = "assume_ok_unexpected" + args.no_save_optim = True + args.no_load_optim = True + args.no_save_rng = True + args.no_load_rng = True + args.load_main_params_from_ckpt = True + set_args(args) + torch.manual_seed(_SEED) + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, expert_model_parallel_size=args.expert_model_parallel_size + ) + + model, optimizer, opt_param_scheduler = setup_model_and_optimizer( + model_type=ModelType.encoder_or_decoder, model_provider_func=self.model_provider + ) + assert len(model) == 1 + self.assert_storage_path_is_exercised(model[0], "mxfp8", True, single_weight) + self.assert_execution_path_is_exercised(model[0], True) + + batch = self.get_batch() + return args, model, optimizer, opt_param_scheduler, batch + + def run_mxfp8_training_losses_with_optional_eval( + self, eval_after_step: int | None, single_weight: bool = True + ): + args, model, optimizer, _, batch = self.setup_mxfp8_overlap_case( + single_weight=single_weight + ) + losses = [] + for step in range(4): + if eval_after_step is not None and step == eval_after_step: + self.run_mxfp8_eval_step(args, model, optimizer, batch) + losses.append(self.run_one_mxfp8_overlap_train_step(args, model, optimizer, batch)) + return torch.stack(losses) + + def run_mxfp8_training_losses_with_optional_checkpoint( + self, checkpoint_dir, checkpoint_before_step: int | None + ): + args, model, optimizer, opt_param_scheduler, batch = self.setup_mxfp8_overlap_case( + single_weight=True, checkpoint_dir=checkpoint_dir + ) + losses = [] + for step in range(4): + if checkpoint_before_step is not None and step == checkpoint_before_step: + force_param_sync(model, optimizer=optimizer) + save_checkpoint(step, model, optimizer, opt_param_scheduler, 0) + if torch.distributed.is_initialized(): + torch.distributed.barrier() + losses.append(self.run_one_mxfp8_overlap_train_step(args, model, optimizer, batch)) + return torch.stack(losses) + + def run_mxfp8_checkpoint_save_load_next_loss( + self, checkpoint_dir, save_single_weight: bool, load_single_weight: bool + ): + args, model, optimizer, opt_param_scheduler, batch = self.setup_mxfp8_overlap_case( + single_weight=save_single_weight, checkpoint_dir=checkpoint_dir + ) + + for _ in range(2): + self.run_one_mxfp8_overlap_train_step(args, model, optimizer, batch) + force_param_sync(model, optimizer=optimizer) + save_checkpoint(2, model, optimizer, opt_param_scheduler, 0) + if torch.distributed.is_initialized(): + torch.distributed.barrier() + + self._cleanup() + + args, model, optimizer, opt_param_scheduler, batch = self.setup_mxfp8_overlap_case( + single_weight=load_single_weight, checkpoint_dir=checkpoint_dir + ) + loaded_iteration, _ = load_checkpoint(model, optimizer, opt_param_scheduler, strict=True) + assert loaded_iteration == 2 + return self.run_one_mxfp8_overlap_train_step(args, model, optimizer, batch) + + @staticmethod + def assert_loss_parity(precision: str, single_weight_losses, discrete_weight_losses): + if precision == "bf16": + atol = rtol = 5e-3 + else: + atol = rtol = 2e-2 + torch.testing.assert_close( + single_weight_losses, discrete_weight_losses, atol=atol, rtol=rtol + ) + + @staticmethod + def assert_all_ranks_passed(local_passed: bool, local_error: str) -> None: + if not torch.distributed.is_available() or not torch.distributed.is_initialized(): + if not local_passed: + pytest.fail(local_error) + return + + pass_flag = torch.tensor( + [1 if local_passed else 0], dtype=torch.int32, device=torch.cuda.current_device() + ) + torch.distributed.all_reduce(pass_flag, op=torch.distributed.ReduceOp.MIN) + if pass_flag.item() == 1: + return + + rank = torch.distributed.get_rank() + if local_passed: + pytest.fail("At least one distributed rank failed this parity case.") + pytest.fail(f"Rank {rank} failed this parity case:\n{local_error}") + + def run_parity_case( + self, + precision: str, + primary_param_gather: bool, + gradient_accumulation_fusion: bool, + use_transformer_engine_op_fuser: bool, + ) -> None: + local_passed = True + local_error = "" + try: + single_losses = self.run_training_case( + precision=precision, + primary_param_gather=primary_param_gather, + single_weight=True, + gradient_accumulation_fusion=gradient_accumulation_fusion, + use_transformer_engine_op_fuser=use_transformer_engine_op_fuser, + ) + discrete_losses = self.run_training_case( + precision=precision, + primary_param_gather=primary_param_gather, + single_weight=False, + gradient_accumulation_fusion=gradient_accumulation_fusion, + use_transformer_engine_op_fuser=use_transformer_engine_op_fuser, + ) + self.assert_loss_parity(precision, single_losses, discrete_losses) + except Exception: + local_passed = False + local_error = traceback.format_exc() + + self.assert_all_ranks_passed(local_passed, local_error) + + def run_remap_case(self, precision: str) -> None: + local_passed = True + local_error = "" + try: + primary_param_gather = precision == "nvfp4" + args = self.create_test_args( + precision=precision, + primary_param_gather=primary_param_gather, + single_weight=True, + gradient_accumulation_fusion=True, + use_transformer_engine_op_fuser=True, + ) + set_args(args) + torch.manual_seed(_SEED) + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + expert_model_parallel_size=args.expert_model_parallel_size, + ) + + model, optimizer, _ = setup_model_and_optimizer( + model_type=ModelType.encoder_or_decoder, model_provider_func=self.model_provider + ) + assert len(model) == 1 + self.assert_storage_path_is_exercised( + model[0], precision, primary_param_gather, single_weight=True + ) + self.assert_grouped_params_remapped_to_ddp_param_data(optimizer, precision) + except Exception: + local_passed = False + local_error = traceback.format_exc() + + self.assert_all_ranks_passed(local_passed, local_error) + + @pytest.mark.parametrize("precision", ["bf16", "nvfp4"]) + def test_single_grouped_weight_ddp_param_data_remap_data_ptr(self, precision): + """BF16/NVFP4 single grouped weights must alias DDP param_data after buffer setup.""" + _skip_if_unsupported(precision) + self.run_remap_case(precision) + + def test_single_grouped_mxfp8_train_eval_train_matches_train_only(self): + """Eval should not change subsequent MXFP8 single grouped weight training losses.""" + _skip_if_unsupported("mxfp8") + local_passed = True + local_error = "" + try: + train_only_losses = self.run_mxfp8_training_losses_with_optional_eval( + eval_after_step=None + ) + train_eval_train_losses = self.run_mxfp8_training_losses_with_optional_eval( + eval_after_step=2 + ) + torch.testing.assert_close( + train_eval_train_losses, train_only_losses, atol=1e-4, rtol=1e-4 + ) + except Exception: + local_passed = False + local_error = traceback.format_exc() + + self.assert_all_ranks_passed(local_passed, local_error) + + @pytest.mark.parametrize( + "checkpoint_case, save_single_weight, load_single_weight", + [ + # Save-only: checkpointing should not perturb live training state. + pytest.param("save_only", True, None, id="save-only-single"), + # Layout interchange: torch_dist saves grouped MoE weights as per-expert keys. + pytest.param("save_load", True, False, id="save-single-load-discrete"), + # Reverse interchange: per-expert checkpoint keys must fold into one grouped param. + pytest.param("save_load", False, True, id="save-discrete-load-single"), + ], + ) + def test_mxfp8_single_weight_torch_dist_checkpoint_matches_discrete_baseline( + self, tmp_path_dist_ckpt, checkpoint_case, save_single_weight, load_single_weight + ): + """torch_dist checkpoint save/load should preserve MXFP8 discrete baseline numerics.""" + _skip_if_unsupported("mxfp8") + local_passed = True + local_error = "" + try: + discrete_train_only_losses = self.run_mxfp8_training_losses_with_optional_eval( + eval_after_step=None, single_weight=False + ) + with TempNamedDir( + tmp_path_dist_ckpt / "test_mxfp8_single_weight_torch_dist_checkpoint", sync=True + ) as checkpoint_dir: + if checkpoint_case == "save_only": + # This catches forced-param-sync/checkpoint side effects without reload. + checkpoint_losses = self.run_mxfp8_training_losses_with_optional_checkpoint( + checkpoint_dir=checkpoint_dir, checkpoint_before_step=2 + ) + self.assert_loss_parity("mxfp8", checkpoint_losses, discrete_train_only_losses) + else: + # This catches checkpoint key/layout conversion bugs across single/discrete. + loaded_next_loss = self.run_mxfp8_checkpoint_save_load_next_loss( + checkpoint_dir, + save_single_weight=save_single_weight, + load_single_weight=load_single_weight, + ) + torch.testing.assert_close( + loaded_next_loss, discrete_train_only_losses[2], atol=2e-2, rtol=2e-2 + ) + except Exception: + local_passed = False + local_error = traceback.format_exc() + + self.assert_all_ranks_passed(local_passed, local_error) + + @pytest.mark.parametrize("precision", ["bf16", "mxfp8", "nvfp4"]) + @pytest.mark.parametrize("gradient_accumulation_fusion", [False, True]) + def test_single_grouped_weight_parity_with_primary_param_gather( + self, precision, gradient_accumulation_fusion + ): + """Compare single vs discrete MoE weights with primary param gather enabled if applicable.""" + _skip_if_unsupported(precision) + self.run_parity_case( + precision=precision, + primary_param_gather=True, + gradient_accumulation_fusion=gradient_accumulation_fusion, + use_transformer_engine_op_fuser=True, + ) + + @pytest.mark.parametrize("precision", ["bf16", "mxfp8", "nvfp4"]) + @pytest.mark.parametrize("gradient_accumulation_fusion", [False, True]) + def test_single_grouped_weight_parity_without_primary_param_gather( + self, precision, gradient_accumulation_fusion + ): + """Compare single vs discrete MoE weights when primary weights stay BF16.""" + _skip_if_unsupported(precision) + self.run_parity_case( + precision=precision, + primary_param_gather=False, + gradient_accumulation_fusion=gradient_accumulation_fusion, + use_transformer_engine_op_fuser=True, + ) + + def test_single_grouped_weight_parity_module_grouped_linear(self): + """Single grouped weights require the TE op-fuser execution path.""" + args = self.create_test_args( + precision="bf16", + primary_param_gather=False, + single_weight=True, + gradient_accumulation_fusion=False, + use_transformer_engine_op_fuser=False, + ) + with pytest.raises( + ValueError, + match="moe_single_grouped_weight requires use_transformer_engine_op_fuser=True", + ): + core_transformer_config_from_args(args) From 602fad039a9617ad3d66e1a19f0066f14ebb537c Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Wed, 22 Jul 2026 14:31:38 -0700 Subject: [PATCH 082/290] Inference: Do not let prompt tokens return from the engine, unless requested (#5918) Signed-off-by: Siddharth Singh Signed-off-by: shanmugamr1992 Co-authored-by: Shanmugam Ramasamy <111910568+shanmugamr1992@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- megatron/core/inference/inference_request.py | 27 ++++++++- megatron/core/inference/sampling_params.py | 4 ++ .../endpoints/chat_completions.py | 31 ++++++---- .../endpoints/completions.py | 3 + .../inference/test_inference_request.py | 57 +++++++++++++++++++ 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/megatron/core/inference/inference_request.py b/megatron/core/inference/inference_request.py index 3ed4b89bf94..d325a4e89a0 100644 --- a/megatron/core/inference/inference_request.py +++ b/megatron/core/inference/inference_request.py @@ -142,6 +142,10 @@ class InferenceRequest: sampling_params: Optional[SamplingParams] = None inference_parameters: Optional[SamplingParams] = None prompt_tokens: Optional[List[int]] = None + # Prompt token count. Always populated when serializing a finished request so the + # API can report usage.prompt_tokens even when the prompt_tokens tensor itself is + # dropped from the payload (see SamplingParams.return_prompt_tokens). + prompt_length: Optional[int] = None arrival_time: Optional[float] = None status: Optional[Status] = None encoder_prompt: Optional[str] = None @@ -441,13 +445,31 @@ def serialize(self): serialization. """ nvtx_range_push("DynamicInferenceRequest.serialize") + + # The prompt length is always reported (needed for usage.prompt_tokens), + # but the prompt_tokens tensor is dropped from the wire payload unless the + # client asked for it back (return_prompt_tokens). This keeps the large + # prompt tensor off the engine->coordinator->API path. Null it around + # super() so the tensor is never serialized, then restore local state. + prompt_len = len(self.prompt_tokens) if self.prompt_tokens is not None else None + drop_prompt = ( + self.prompt_tokens is not None + and self.sampling_params is not None + and not getattr(self.sampling_params, "return_prompt_tokens", False) + ) + saved_prompt_tokens = None + if drop_prompt: + saved_prompt_tokens = self.prompt_tokens + self.prompt_tokens = None + obj = super().serialize() obj["events"] = [e.serialize() for e in self.events] obj.pop("event_add_engine", None) + obj["prompt_length"] = prompt_len # Sanity check routing_indices: ndarray [total_tokens - 1, num_layers, topk] if self.routing_indices is not None: - total_tokens = len(self.prompt_tokens) + len(self.generated_tokens) + total_tokens = prompt_len + len(self.generated_tokens) # the last generated token does not undergo a forward pass # hence we expect routing indices for total_tokens - 1 assert self.routing_indices.shape[0] == total_tokens - 1, ( @@ -455,6 +477,9 @@ def serialize(self): f"total tokens {total_tokens-1}." ) + if drop_prompt: + self.prompt_tokens = saved_prompt_tokens + nvtx_range_pop("DynamicInferenceRequest.serialize") return obj diff --git a/megatron/core/inference/sampling_params.py b/megatron/core/inference/sampling_params.py index 13bc8ac0d7b..f7f95060cef 100644 --- a/megatron/core/inference/sampling_params.py +++ b/megatron/core/inference/sampling_params.py @@ -34,6 +34,10 @@ class SamplingParams: None # List of strings that will stop generation when produced ) detokenize_stop_sequence: bool = False # Keep stop words and EOD in generated text + # Echo prompt token ids back in the response. When False (default), the engine + # drops prompt_tokens before serializing the finished request, saving the ZMQ + # transmission cost for long prompts. Opt in when the client needs them. + return_prompt_tokens: bool = False def __post_init__(self): """Ensure backward compatibility for return_prompt_top_n_logprobs. diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py index 1f0cace28b5..993499ce02b 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py @@ -581,6 +581,17 @@ async def chat_completions(): max_tokens = req.get("max_completion_tokens", None) or req.get("max_tokens", None) ignore_eos = bool(req.get("ignore_eos", False)) + # Does the client want the prompt tokens echoed back? Only then does the + # engine need to keep the prompt_tokens tensor on the response payload. + # return_tokenized_data (implied by prevent_retokenization) needs the ids; + # return_raw_text needs the ids to detokenize the prompt into raw_text. + prevent_retokenization = req.get("prevent_retokenization", True) + return_tokenized_data = ( + req.get("return_tokenized_data", False) or prevent_retokenization + ) + return_raw_text = req.get("return_raw_text", False) + return_prompt_tokens = return_tokenized_data or return_raw_text + sampling_params = SamplingParams( temperature=temperature, top_k=top_k, @@ -591,6 +602,7 @@ async def chat_completions(): skip_prompt_log_probs=skip_prompt_log_probs, add_BOS=add_BOS, termination_id=-1 if ignore_eos else None, + return_prompt_tokens=return_prompt_tokens, ) except ValueError as e: return Response(f"Invalid sampling parameter: {e}", status=400) @@ -653,21 +665,20 @@ async def chat_completions(): prompt_tokens_counts = [] cached_tokens_counts = [] - prevent_retokenization = req.get("prevent_retokenization", True) - # return_tokenized_data controls whether prompt/generation token ids are - # included in the response. It is independent of prevent_retokenization - # (a client may want token ids without prevent_retokenization, or vice versa), - # but prevent_retokenization implicitly requires token ids so the client - # can echo them back next turn. - return_tokenized_data = req.get("return_tokenized_data", False) or prevent_retokenization - return_raw_text = req.get("return_raw_text", False) + # return_tokenized_data / return_raw_text / return_prompt_tokens were computed + # at submit time (above) and drive both the response shape here and whether the + # engine kept the prompt_tokens tensor on the payload. request_idx = 0 for result_item in batch_results: result = unwrap_serialized_tensors(result_item) - prompt_tokens_out = result["prompt_tokens"] # The engine can modify prompt_tokens. text_output = result["generated_text"] - prompt_tokens_count = len(prompt_tokens_out) if prompt_tokens_out is not None else 0 + # The engine always reports prompt_length (for usage), but drops the + # prompt_tokens tensor unless return_prompt_tokens was set. + prompt_tokens_count = result.get("prompt_length") + if prompt_tokens_count is None: + prompt_tokens_out = result["prompt_tokens"] + prompt_tokens_count = len(prompt_tokens_out) if prompt_tokens_out is not None else 0 prompt_tokens_counts.append(prompt_tokens_count) cached_tokens_counts.append(result.get("num_cached_tokens", 0)) diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py index 6f57a863c1c..2e2a57d6fc1 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py @@ -122,6 +122,9 @@ async def completions(): num_tokens_to_generate=sampling_params.num_tokens_to_generate, stop_words=sampling_params.stop_words, termination_id=sampling_params.termination_id, + # This endpoint always echoes prompt_token_ids in its response, so + # keep the prompt tokens on the payload (default is now to drop them). + return_prompt_tokens=True, ) tasks.append(client.add_request(prompt_tokens, per_req_params)) diff --git a/tests/unit_tests/inference/test_inference_request.py b/tests/unit_tests/inference/test_inference_request.py index eafed6cecc6..559c20af18f 100644 --- a/tests/unit_tests/inference/test_inference_request.py +++ b/tests/unit_tests/inference/test_inference_request.py @@ -4,6 +4,7 @@ import msgpack import numpy as np +import pytest import torch from megatron.core.inference.inference_request import ( @@ -261,3 +262,59 @@ def test_dynamic_inference_request_serialize_strips_event_add_engine(): rec_out = DynamicInferenceRequestRecord.deserialize(rec.serialize()) assert rec_out.latency == 1.0 assert rec_out.requests[0].request_id == 7 + + +@pytest.mark.parametrize( + ("return_prompt_tokens", "expected_prompt_field"), + [ + (False, None), # default: prompt_tokens dropped from payload + (True, ("tensor", [1, 2, 3, 4])), # opt-in: prompt_tokens preserved + ], +) +def test_dynamic_inference_request_serialize_return_prompt_tokens( + return_prompt_tokens, expected_prompt_field +): + """DynamicInferenceRequest.serialize() reports prompt_length unconditionally + (the API uses it for `usage.prompt_tokens` on the response) and drops the + prompt_tokens tensor from the wire payload unless + SamplingParams.return_prompt_tokens is True. This is the load-bearing + wire-cost optimization for long agentic-RL prompts. The same call must + (a) leave self.prompt_tokens intact on the local instance — the drop is + wire-only — and (b) keep the routing_indices shape check honest, which + now relies on the saved prompt_len rather than self.prompt_tokens (which + is temporarily None during the drop).""" + sp = SamplingParams( + num_tokens_to_generate=5, termination_id=0, return_prompt_tokens=return_prompt_tokens + ) + prompt = torch.tensor([1, 2, 3, 4]) + # prompt_len=4 + generated=[10] → total_tokens=5 → routing_indices.shape[0] must be 4. + routing = np.zeros((4, 2, 1), dtype=np.int32) + req = _make_dynamic_request( + prompt_tokens=prompt, sampling_params=sp, generated_tokens=[10], routing_indices=routing + ) + + obj = req.serialize() + + # prompt_length is always populated (independent of the drop). + assert obj["prompt_length"] == 4 + # Payload either preserves the tensor wrapper or drops it (present but None). + assert obj["prompt_tokens"] == expected_prompt_field + # Local instance is unaffected — the drop is wire-only. + assert torch.equal(req.prompt_tokens, prompt) + # routing_indices survives the drop path (shape check would have crashed on + # the temporarily-None self.prompt_tokens if the fix used self.prompt_tokens). + assert isinstance(obj["routing_indices"], tuple) and obj["routing_indices"][0] == "ndarray" + + +def test_dynamic_inference_request_serialize_prompt_length_absent(): + """When prompt_tokens is None on the request, serialize() must not crash + (the drop path is guarded on `prompt_tokens is not None`) and prompt_length + must be reported as None. The DP coordinator can dispatch error/finish + records without prompt_tokens, so this path is real.""" + sp = SamplingParams(num_tokens_to_generate=1, termination_id=0) + req = DynamicInferenceRequest(request_id=99, prompt_tokens=None, sampling_params=sp) + + obj = req.serialize() + + assert obj["prompt_length"] is None + assert obj["prompt_tokens"] is None From bb5647a9bdd044af85ca323b1aef5ede02e96a37 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Thu, 23 Jul 2026 09:39:59 +0200 Subject: [PATCH 083/290] fix(ci): AUT-957 support golden checks in merge queue (#5989) Signed-off-by: [Your Name] Signed-off-by: Ajay Balasa Signed-off-by: svcnemo-autobot Co-authored-by: Ajay Balasa Co-authored-by: [Co-author Name] --- .github/workflows/cicd-main.yml | 20 ++++++++ tools/check_golden_values.py | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tools/check_golden_values.py diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index ff39b026c1b..fcfe98c8edb 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -359,6 +359,26 @@ jobs: if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' uses: nv-gha-runners/get-pr-info@main + - name: Validate updated golden values + if: github.event_name == 'merge_group' || (startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push') + env: + BASE_REF: ${{ github.event.merge_group.base_ref || fromJSON(steps.get-pr-info.outputs.pr-info || '{}').base.ref }} + run: | + BASE_REF="${BASE_REF#refs/heads/}" + git fetch origin "$BASE_REF" + mapfile -t GOLDEN_VALUES_FILES < <( + git diff --name-only --diff-filter=ACMR \ + --merge-base "origin/$BASE_REF" -- \ + ':(glob)tests/functional_tests/test_cases/**/golden_values*.json' + ) + + if (( ${#GOLDEN_VALUES_FILES[@]} == 0 )); then + echo "No golden value files were updated; skipping validation." + exit 0 + fi + + python3 tools/check_golden_values.py "${GOLDEN_VALUES_FILES[@]}" + - name: Run linting if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' run: | diff --git a/tools/check_golden_values.py b/tools/check_golden_values.py new file mode 100644 index 00000000000..270786f496f --- /dev/null +++ b/tools/check_golden_values.py @@ -0,0 +1,82 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Check golden-value JSON files for NaN and infinity values.""" + +import argparse +import json +import logging +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +NOT_ACCEPTED_VALUES = [ + "nan", + "+nan", + "-nan", + "inf", + "+inf", + "-inf", + "infinity", + "+infinity", + "-infinity", +] + + +def _find_non_finite_values(value: Any, location: str = "$") -> Iterator[tuple[str, Any]]: + if isinstance(value, dict): + for key, child in value.items(): + yield from _find_non_finite_values(child, f"{location}[{key!r}]") + elif isinstance(value, list): + for index, child in enumerate(value): + yield from _find_non_finite_values(child, f"{location}[{index}]") + elif str(value).strip().lower() in NOT_ACCEPTED_VALUES: + yield location, value + + +def _format_failures(failures: list[tuple[str, Any]], limit: int = 20) -> str: + lines = [f" {location} = {value!r}" for location, value in failures[:limit]] + if len(failures) > limit: + lines.append(f" ... and {len(failures) - limit} more") + return "\n".join(lines) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Fail if any golden-value JSON file contains NaN or infinity." + ) + parser.add_argument("files", nargs="+", type=Path, help="Golden-value JSON files to check.") + return parser.parse_args() + + +def main() -> int: + """Check the requested golden-value files and return a process exit code.""" + failed = False + files = _parse_args().files + + for golden_value_file in files: + try: + with golden_value_file.open() as file: + golden_values = json.load(file) + except (OSError, json.JSONDecodeError) as error: + logger.error("Could not read %s: %s", golden_value_file, error) + failed = True + continue + + failures = list(_find_non_finite_values(golden_values)) + if failures: + logger.error( + "Found non-finite values in %s:\n%s", golden_value_file, _format_failures(failures) + ) + failed = True + + if not failed: + logger.info("Checked %d golden-value file(s); all values are finite.", len(files)) + + return int(failed) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + raise SystemExit(main()) From 6cd6ea530e18776da54297bbf88292264264bcd3 Mon Sep 17 00:00:00 2001 From: Jon Barker Date: Wed, 22 Jul 2026 18:17:14 -0600 Subject: [PATCH 084/290] Fix formatting error in qwen3_30b_a3b config (#5978) Signed-off-by: Jon Barker Co-authored-by: Jon Barker --- .../rl/model_configs/qwen3_30b_a3b_moe.sh | 125 +++++++++--------- 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/examples/rl/model_configs/qwen3_30b_a3b_moe.sh b/examples/rl/model_configs/qwen3_30b_a3b_moe.sh index eb55ba35cc6..cb18f8e9885 100644 --- a/examples/rl/model_configs/qwen3_30b_a3b_moe.sh +++ b/examples/rl/model_configs/qwen3_30b_a3b_moe.sh @@ -1,7 +1,8 @@ -#!/bin/bash +#!/bin/bash TP=${TP:-4} PP=${PP:-1} +EP=${EP:-2} NODES_REQUIRED=${NODES_REQUIRED:-1} echo "Using Qwen3-30B-A3B model checkpoint" @@ -33,65 +34,63 @@ ENV_DEPENDENT="\ --grpo-kl-beta $GRPO_KL_BETA \ --langrl-env-config $ENV_CONFIG " - -MODEL_OPTIONS=" ---seq-length $MAX_SEQ_LENGTH \ ---inference-max-seq-length $MAX_SEQ_LENGTH \ ---inference-max-requests $MAX_INFERENCE_BS \ ---pretrained-checkpoint $CHECKPOINT \ ---no-use-tokenizer-model-from-checkpoint-args \ ---seq-length 8192 \ ---inference-max-seq-length 8192 \ ---bf16 \ ---tensor-model-parallel-size $TP \ ---pipeline-model-parallel-size $PP \ ---expert-model-parallel-size $EP \ ---attention-backend flash \ ---transformer-impl transformer_engine \ ---te-rng-tracker \ ---tokenizer-type HuggingFaceTokenizer \ ---tokenizer-model Qwen/Qwen3-30B-A3B \ ---untie-embeddings-and-output-weights \ ---num-layers 48 \ ---hidden-size 2048 \ ---ffn-hidden-size 6144 \ ---num-attention-heads 32 \ ---kv-channels 128 \ ---max-position-embeddings 8192 \ ---group-query-attention \ ---num-query-groups 4 \ ---normalization RMSNorm \ ---norm-epsilon 1e-6 \ ---position-embedding-type rope \ ---rotary-percent 1.0 \ ---rotary-base 1000000 \ ---use-rotary-position-embeddings \ ---swiglu \ ---disable-bias-linear \ ---num-experts 128 \ ---moe-router-topk 8 \ ---moe-ffn-hidden-size 768 \ ---moe-aux-loss-coeff 0.001 \ ---moe-router-load-balancing-type aux_loss \ ---attention-dropout 0.0 \ ---hidden-dropout 0.0 \ ---no-masked-softmax-fusion \ ---attention-softmax-in-fp32 \ ---vocab-size 151936 \ ---make-vocab-size-divisible-by 128 \ ---dist-ckpt-strictness log_unexpected \ ---qk-layernorm \ ---moe-token-dispatcher-type alltoall \ ---moe-layer-freq 1 \ ---optimizer adam \ ---adam-beta1 0.9 \ ---adam-beta2 0.999 \ ---adam-eps 1e-8 \ ---lr 1e-6 \ ---min-lr 1e-7 \ ---lr-warmup-samples 0 \ ---clip-grad 1.0 \ ---weight-decay 0.01 \ ---no-load-optim \ ---ckpt-format torch_dist -" +MODEL_OPTIONS="\ + --seq-length $MAX_SEQ_LENGTH \ + --inference-max-seq-length $MAX_SEQ_LENGTH \ + --inference-max-requests $MAX_INFERENCE_BS \ + --pretrained-checkpoint $CHECKPOINT \ + --no-use-tokenizer-model-from-checkpoint-args \ + --bf16 \ + --tensor-model-parallel-size $TP \ + --pipeline-model-parallel-size $PP \ + --expert-model-parallel-size $EP \ + --attention-backend flash \ + --transformer-impl transformer_engine \ + --te-rng-tracker \ + --tokenizer-type HuggingFaceTokenizer \ + --tokenizer-model Qwen/Qwen3-30B-A3B \ + --tokenizer-hf-include-special-tokens \ + --untie-embeddings-and-output-weights \ + --num-layers 48 \ + --hidden-size 2048 \ + --ffn-hidden-size 6144 \ + --num-attention-heads 32 \ + --kv-channels 128 \ + --max-position-embeddings 8192 \ + --group-query-attention \ + --num-query-groups 4 \ + --normalization RMSNorm \ + --norm-epsilon 1e-6 \ + --position-embedding-type rope \ + --rotary-percent 1.0 \ + --rotary-base 1000000 \ + --use-rotary-position-embeddings \ + --swiglu \ + --disable-bias-linear \ + --num-experts 128 \ + --moe-router-topk 8 \ + --moe-ffn-hidden-size 768 \ + --moe-aux-loss-coeff 0.001 \ + --moe-router-load-balancing-type aux_loss \ + --attention-dropout 0.0 \ + --hidden-dropout 0.0 \ + --no-masked-softmax-fusion \ + --attention-softmax-in-fp32 \ + --vocab-size 151936 \ + --make-vocab-size-divisible-by 128 \ + --dist-ckpt-strictness log_unexpected \ + --qk-layernorm \ + --moe-token-dispatcher-type alltoall \ + --moe-layer-freq 1 \ + --optimizer adam \ + --adam-beta1 0.9 \ + --adam-beta2 0.999 \ + --adam-eps 1e-8 \ + --lr 1e-6 \ + --min-lr 1e-7 \ + --lr-warmup-samples 0 \ + --clip-grad 1.0 \ + --weight-decay 0.01 \ + --no-load-optim \ + --ckpt-format torch_dist \ + " From 52cf20d06a22cf0ba26033722b7c5f0442f33ad8 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Wed, 22 Jul 2026 17:33:48 -0700 Subject: [PATCH 085/290] Make LRU prefix caching eviction policy only evict child blocks (#5822) Signed-off-by: Keshav Santhanam --- .../inference/contexts/dynamic_context.py | 51 ++- .../inference/contexts/kv_block_allocator.py | 180 +++++++++- .../contexts/test_dynamic_prefix_caching.py | 107 ++++++ .../contexts/test_kv_block_allocator.py | 338 +++++++++++++++++- 4 files changed, 651 insertions(+), 25 deletions(-) diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 258917d07ab..59e8d392c1d 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -2929,14 +2929,27 @@ def check_availability(self, req: DynamicInferenceRequest) -> Tuple[bool, bool, self.total_request_count < self.max_requests and self.paused_request_count == 0 ) - (_, num_blocks_from_pool, _, _, _, effective_prefill_chunk_length) = ( + (matched_block_ids, num_blocks_from_pool, _, _, _, effective_prefill_chunk_length) = ( self._compute_prefix_match(req, req.remaining_prompt_length) ) request_tokens_can_be_added = ( self.active_token_count + effective_prefill_chunk_length <= self.max_tokens ) - kv_cache_available = self.kv_block_allocator.is_memory_available(num_blocks_from_pool) + # add_request pins the matched blocks before allocating. Only matches that + # are currently evictable (ref_count == 0) count against the evictable + # pool; matches already pinned by another in-flight request are not in + # get_evictable_block_count() and pinning them frees nothing. Reserve only + # the ref_count == 0 matches so availability is not under-reported. + potential_matched_count = 0 + if matched_block_ids: + matched_tensor = torch.tensor(matched_block_ids, dtype=torch.int32, device='cpu') + potential_matched_count = int( + (self.kv_block_allocator.block_ref_counts[matched_tensor] == 0).sum() + ) + kv_cache_available = self.kv_block_allocator.is_memory_available( + num_blocks_from_pool, potential_matched_count=potential_matched_count + ) return request_can_be_added, request_tokens_can_be_added, kv_cache_available def _find_kv_match_count( @@ -3037,19 +3050,31 @@ def add_request( # Slice tokens to skip matched prefix this_round_tokens = req.remaining_prompt_tokens[prefix_skip_tokens:prefill_chunk_length] - new_block_ids = None - if num_blocks_from_pool > 0: - new_block_ids = self.kv_block_allocator.allocate_memory_blocks(num_blocks_from_pool) - if new_block_ids is None or len(new_block_ids) != num_blocks_from_pool: - raise BlockOverflowError(req.request_id) - - # Increment ref counts and update timestamps for matched (shared) blocks + # Pin matched (shared) blocks BEFORE allocation. allocate_memory_blocks() + # may trigger LRU eviction, and a matched block still at ref_count == 0 + # would be an eviction candidate — descendant-first LRU could evict a + # matched leaf and immediately reuse its ID for a new block, leaving the + # block table with a duplicate ID and a dangling parent. Incrementing ref + # counts first removes matched blocks from the evictable set (see + # evict_lru_blocks / get_evictable_block_count), so eviction falls back to + # genuinely unused cached blocks. + matched_tensor = None if num_matched_blocks > 0: matched_tensor = torch.tensor(matched_block_ids, dtype=torch.int32, device='cpu') self.kv_block_allocator.block_ref_counts[matched_tensor] += 1 if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: self.kv_block_allocator.update_timestamps(matched_tensor) + new_block_ids = None + if num_blocks_from_pool > 0: + new_block_ids = self.kv_block_allocator.allocate_memory_blocks(num_blocks_from_pool) + if new_block_ids is None or len(new_block_ids) != num_blocks_from_pool: + # Roll back the pin so a failed add does not leak ref counts on + # the matched blocks (which would make them permanently unevictable). + if matched_tensor is not None: + self.kv_block_allocator.block_ref_counts[matched_tensor] -= 1 + raise BlockOverflowError(req.request_id) + # Note that we decremented the total_request_count for the chunked prefill request # in update_requests, so setting current_id to the total_request_count will again # make the last request the continuing chunked prefill request if one exists. @@ -3151,8 +3176,14 @@ def _register_range(start: int, end: int): return block_ids_to_hash = self.request_to_kv_block_ids[current_id][start:end].tolist() block_hashes_slice = req.precomputed_block_hashes[start:end] + # Parent hash of block k is the hash of block k-1 in the chain; + # block 0 is a root (parent hash 0). Enables LRU eviction to keep + # parents cached until their children are gone. + parent_hashes_slice = [ + req.precomputed_block_hashes[k - 1] if k > 0 else 0 for k in range(start, end) + ] self.kv_block_allocator.register_kv_block_hashes( - block_ids_to_hash, block_hashes_slice + block_ids_to_hash, block_hashes_slice, parent_hashes_slice ) # Range 1: prior-chunk partial block that this chunk just completed diff --git a/megatron/core/inference/contexts/kv_block_allocator.py b/megatron/core/inference/contexts/kv_block_allocator.py index d555c925c93..6711cb2e606 100644 --- a/megatron/core/inference/contexts/kv_block_allocator.py +++ b/megatron/core/inference/contexts/kv_block_allocator.py @@ -1,5 +1,6 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +import heapq from collections import deque from typing import Callable, Dict, Optional @@ -70,6 +71,24 @@ def __init__( (self.total_count,), dtype=torch.int64, device='cpu' ) + # Persisted prefix-chain bookkeeping for LRU eviction, maintained + # incrementally on register/deregister. Block hashes are + # parent-chained: a cached block that is another cached block's + # parent must not be evicted before its child (see evict_lru_blocks). + # + # block_parent_id[b] = block id of b's parent in the prefix chain, + # or -1 when b is a root block or its parent is not registered. + self.block_parent_id = torch.full( + (self.total_count,), -1, dtype=torch.int64, device='cpu' + ) + # block_child_count[b] = number of currently-registered children of b. + # For a cached block all of its children are cached too, so this + # equals its cached-child count and b is an evictable leaf exactly + # when it reaches 0. + self.block_child_count = torch.zeros( + (self.total_count,), dtype=torch.int64, device='cpu' + ) + # Per-block MoE routing storage (populated when routing replay is enabled) self.block_routing: Dict[int, np.ndarray] = {} @@ -128,13 +147,20 @@ def get_paused_avail(self): """Compute number of paused blocks available.""" return self.paused_count - self.get_paused_used() - def is_memory_available(self, num_blocks: int) -> bool: + def is_memory_available(self, num_blocks: int, potential_matched_count: int = 0) -> bool: """Check if memory blocks are available. Includes both free pool blocks and evictable cached blocks (ref_count == 0). Args: num_blocks (int): Number of blocks to check. + potential_matched_count (int): Number of currently-evictable cached + blocks to subtract from the evictable count because the caller + will pin them before allocating (e.g. prefix-matched blocks that + get their ref counts bumped in add_request). These blocks are + ref_count == 0 now, so they are included in the evictable count, + but they will be protected from eviction, so they cannot supply + the requested ``num_blocks``. Return: (bool) Is memory available? @@ -146,8 +172,8 @@ def is_memory_available(self, num_blocks: int) -> bool: return False if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.REF_ZERO: return False # RZ: no cached blocks to evict - # Also count evictable cached blocks - evictable_count = self.get_evictable_block_count() + # Also count evictable cached blocks, excluding those the caller will pin. + evictable_count = int(self.get_evictable_block_count()) - potential_matched_count return (self.total_avail + evictable_count) >= num_blocks def allocate_memory_blocks(self, num_blocks: int) -> Optional[Tensor]: @@ -256,6 +282,8 @@ def reset(self) -> None: self.block_ref_counts.fill_(0) if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: self.block_timestamps.fill_(0) + self.block_parent_id.fill_(-1) + self.block_child_count.fill_(0) # Clear per-block routing storage self.block_routing.clear() @@ -264,20 +292,54 @@ def reset(self) -> None: # Prefix caching methods # ========================================================================= - def register_kv_block_hashes(self, block_ids: list[int], block_hashes: list[int]) -> None: + def register_kv_block_hashes( + self, + block_ids: list[int], + block_hashes: list[int], + parent_hashes: Optional[list[int]] = None, + ) -> None: """Register blocks in the hash-to-block mapping for discovery (batch). Args: block_ids: List of block IDs. block_hashes: List of computed hash values (same length as block_ids). + parent_hashes: Parent hash for each block in the prefix chain (same + length as block_ids); 0 marks a root block with no parent. Used + by LRU eviction to avoid evicting a parent before its children. + If None, parents default to 0. """ if not block_ids: return id_tensor = torch.tensor(block_ids, dtype=torch.int64, device=self.block_hashes.device) hash_tensor = torch.tensor(block_hashes, dtype=torch.int64, device=self.block_hashes.device) self.block_hashes[id_tensor] = hash_tensor + if parent_hashes is not None: + assert len(parent_hashes) == len(block_ids) + # Add the new blocks to the hash map first so that a block whose parent is + # elsewhere in this same batch (block k's parent is block k-1) resolves. self.kv_hash_to_block_id.update(zip(block_hashes, block_ids)) + if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: + # Persist the resolved parent block id and bump each parent's child count. + # Parents are earlier in the prefix chain and already registered + # (a matched block or a prior chunk / earlier entry in this batch), + # so a valid parent hash resolves; 0 marks a root and an unknown hash + # falls back to -1. + if parent_hashes is None: + parent_hashes = [0] * len(block_ids) + parent_ids = [ + self.kv_hash_to_block_id.get(ph, -1) if ph != 0 else -1 for ph in parent_hashes + ] + parent_id_tensor = torch.tensor(parent_ids, dtype=torch.int64, device=id_tensor.device) + self.block_parent_id[id_tensor] = parent_id_tensor + has_parent = parent_id_tensor >= 0 + if has_parent.any(): + self.block_child_count.scatter_add_( + 0, + parent_id_tensor[has_parent], + torch.ones(int(has_parent.sum()), dtype=torch.int64), + ) + def _deregister_blocks(self, block_ids: Tensor) -> None: """Remove blocks from prefix caching state and return to free pool. @@ -306,10 +368,23 @@ def _deregister_blocks(self, block_ids: Tensor) -> None: self.on_blocks_deregistered(block_ids.tolist(), keys_to_delete) # Reset block state (batched tensor ops) - self.block_hashes[block_ids] = -1 - self.block_ref_counts[block_ids] = 0 if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: + # Drop these blocks from their parents' child counts before clearing + # their own bookkeeping, keeping block_child_count in sync so a parent + # becomes an evictable leaf once its last child is deregistered. + parent_ids = self.block_parent_id[block_ids_i64] + has_parent = parent_ids >= 0 + if has_parent.any(): + self.block_child_count.scatter_add_( + 0, + parent_ids[has_parent], + torch.full((int(has_parent.sum()),), -1, dtype=torch.int64), + ) + self.block_parent_id[block_ids] = -1 + self.block_child_count[block_ids] = 0 self.block_timestamps[block_ids] = 0 + self.block_hashes[block_ids] = -1 + self.block_ref_counts[block_ids] = 0 # Return blocks to free pool self.block_bag[self.total_avail : self.total_avail + num_blocks] = block_ids @@ -340,7 +415,53 @@ def get_evictable_block_count(self) -> Tensor: def evict_lru_blocks(self, num_blocks_needed: int) -> bool: """Evict LRU cached blocks to free up space in the pool. - Evicts blocks with ref_count == 0, starting with oldest timestamps. + Evicts blocks with ref_count == 0, least-recently-used first, while never + evicting a parent before its children. Block hashes are parent-chained, + and ``_find_kv_match_count`` relies on the invariant that a cached child + block always has all of its ancestors cached too. A naive oldest-first + eviction breaks this: with chunked prefill, earlier chunks are allocated + first (older timestamps) yet are ancestors of later chunks (newer + timestamps), so once the request finishes and its blocks are cached, an + ancestor can be older than its descendant and get evicted first, leaving a + dangling child. + + To preserve the invariant while staying optimal we peel the cached forest + from its leaves inward with a min-heap: only a leaf (a cached block with + no cached children) is ever evictable, and among the currently-evictable + leaves we always take the one with the oldest *own* timestamp. Evicting a + leaf can turn its parent into a leaf, which is then pushed onto the heap. + Repeating ``num_blocks_needed`` times gives, at each step, the globally + least-recently-used block that can be removed without orphaning a child — + the natural generalization of LRU to the parent-chain constraint. Keying + each block by its *own* recency (and only reconsidering a parent once its + children are gone) is what makes this optimal: a block is retained purely + because it is recently used, never because a hot descendant props it up, + so a colder evictable block is always evicted before a hotter one. + + Worked example, evicting 3 from:: + + A(ts 1) -> B(ts 2) -> C(ts 5) (C, F are leaves under B) + \-> F(ts 3) + \-> D(ts 3) -> E(ts 5) (E is a leaf under D) + + Leaf-peel evicts F(3), then C(5); B is now childless so it joins the + leaves with its own ts=2 and is evicted next -> retains {A, D, E}, keeping + the hottest block E(5) rather than the colder interior block B(2). + + Note: because a request holds a contiguous block prefix [0..k], any in-use + (ref_count > 0) block keeps all of its ancestors in use too. Hence a cached + (ref_count == 0) block can only have cached children, and considering the + cached set alone is sufficient to avoid dangling children. + + The parent block id of each block and its live child count are maintained + incrementally on register/deregister (``block_parent_id`` / + ``block_child_count``), so this method reads the prefix forest directly + rather than rebuilding it from hashes with a per-eviction sort. Only the + inherently-sequential leaf peel below is per-element. + + The parent graph is assumed acyclic (a forest), which holds for any hashes + produced by the prefix-chain builder; an assertion guards against a + pathological hash collision wedging the peel. Args: num_blocks_needed: Number of blocks to evict. @@ -352,14 +473,49 @@ def evict_lru_blocks(self, num_blocks_needed: int) -> bool: cached_mask = (self.block_ref_counts == 0) & (self.block_hashes != -1) cached_block_ids = torch.nonzero(cached_mask, as_tuple=True)[0] - if cached_block_ids.numel() < num_blocks_needed: + num_cached = cached_block_ids.numel() + if num_cached < num_blocks_needed: return False # Not enough cached blocks to evict + if num_blocks_needed <= 0: + return True - # Sort by timestamp (ascending = oldest first) - cached_timestamps = self.block_timestamps[cached_block_ids] - sorted_indices = torch.argsort(cached_timestamps) - blocks_to_evict = cached_block_ids[sorted_indices[:num_blocks_needed]] + ts = self.block_timestamps[cached_block_ids].tolist() + bid = cached_block_ids.tolist() + parent_global = self.block_parent_id[cached_block_ids].tolist() + child_count = self.block_child_count[cached_block_ids].tolist() + + # Map a cached block's global id to its local index so the peel can find a + # parent's slot to decrement. Parents that are not cached (root, or a + # parent still in use) are absent and are simply treated as peel roots. + global_to_local = {bid[i]: i for i in range(num_cached)} + parent_local = [global_to_local.get(p, -1) for p in parent_global] + + # Min-heap of currently-evictable leaves keyed by (own timestamp, block + # id). Block ids are unique, so the tie-break is total and deterministic. + heap = [(ts[i], bid[i], i) for i in range(num_cached) if child_count[i] == 0] + heapq.heapify(heap) + + evicted_local = [] + while heap and len(evicted_local) < num_blocks_needed: + _, _, i = heapq.heappop(heap) + evicted_local.append(i) + p = parent_local[i] + if p >= 0: + child_count[p] -= 1 + if child_count[p] == 0: + heapq.heappush(heap, (ts[p], bid[p], p)) + + # A forest is always fully peelable, so the heap always exposes enough + # leaves to collect num_blocks_needed (guaranteed by the num_cached >= + # num_blocks_needed check above). Falling short means the parent graph is + # cyclic — only possible under a hash collision, which we treat as a bug. + assert len(evicted_local) == num_blocks_needed, ( + f"leaf peel evicted {len(evicted_local)} of {num_blocks_needed} " + f"requested from {num_cached} cached blocks; parent graph is not a " + f"forest (likely a block-hash collision)" + ) + blocks_to_evict = cached_block_ids[torch.tensor(evicted_local, dtype=torch.int64)] self._deregister_blocks(blocks_to_evict) return True diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index 66d3a963786..c1f6bcbfa1d 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -429,6 +429,113 @@ def test_ref_count_lru(self): for bid in active_blocks: assert alloc3.block_ref_counts[bid.item()].item() == 1 + @pytest.mark.internal + def test_add_request_full_cache_partial_hit_pins_matched_blocks(self): + """On a partial prefix hit against a FULL cache, the matched + blocks must be pinned before allocation so LRU eviction cannot reclaim + one of them for the new (non-matched) block. + + Scenario (mirrors the descendant-first LRU edge case): a cached chain + H0/S0 -> H1/S1 (older) plus an unrelated cached root HX/SX (newer) fill + the pool. An incoming prompt H0 -> H1 -> H2 matches [S0, S1] and needs one + new block. If S0/S1 are not pinned first, descendant-first LRU evicts the + older leaf S1 and immediately reuses it, yielding block_table [S0, S1, S1] + and a dangling H2 -> missing H1 chain. Correct behavior evicts SX and + yields [S0, S1, SX] with a contiguous H0 -> H1 -> H2 chain. + """ + ctx = self._ctx() + bs = ctx.block_size_tokens + alloc = ctx.kv_block_allocator + + # Cached chain H0/S0 -> H1/S1, seeded with an OLD timestamp. + ctx.prefix_cache_lru_clock = 1 + req_chain = self._req(ctx, self._prompt(bs * 2)) + ctx.add_request(req_chain) + s0, s1 = self._block_ids(ctx, 0, 2) + h0, h1 = req_chain.precomputed_block_hashes[0], req_chain.precomputed_block_hashes[1] + + # Unrelated cached root HX/SX, seeded with a NEWER timestamp, so a naive + # oldest-first / descendant-first eviction would prefer the chain leaf. + ctx.prefix_cache_lru_clock = 10 + ctx.add_request(self._req(ctx, self._prompt(bs, offset=9000), request_id=2)) + (sx,) = self._block_ids(ctx, 1, 1) + + # All three slots are distinct and now cached (ref_count drops to 0). + assert len({s0, s1, sx}) == 3 + ctx.release_memory_blocks_from_request_indexes(torch.tensor([0, 1])) + ctx.total_request_count = 0 + assert alloc.block_ref_counts[s0].item() == 0 + assert alloc.block_ref_counts[s1].item() == 0 + assert alloc.block_ref_counts[sx].item() == 0 + + # Force a full pool: the new block for H2 can only come from eviction. + alloc.total_avail = 0 + + # Incoming prompt H0 -> H1 -> H2: first two blocks match the cached chain, + # the third (H2) is new and must trigger a single eviction. + ctx.prefix_cache_lru_clock = 20 + req_new = self._req(ctx, self._prompt(bs * 3), request_id=3) + ctx.add_request(req_new) + h2 = req_new.precomputed_block_hashes[2] + + block_table = self._block_ids(ctx, 0, 3) + + # Matched blocks are preserved and SX (the unrelated root) is evicted/reused. + assert block_table == [s0, s1, sx] + # All three block IDs are distinct — no duplicate from a reclaimed match. + assert len(set(block_table)) == 3 + # Matched blocks stay pinned for the new request. + assert alloc.block_ref_counts[s0].item() == 1 + assert alloc.block_ref_counts[s1].item() == 1 + assert alloc.block_ref_counts[sx].item() == 1 + # Contiguous H0 -> H1 -> H2 hash chain over [S0, S1, SX]. + assert alloc.block_hashes[s0].item() == h0 + assert alloc.block_hashes[s1].item() == h1 + assert alloc.block_hashes[sx].item() == h2 + # Parent bookkeeping is stored as resolved block ids: S1's parent is S0 + # and SX's parent is S1 along the H0 -> H1 -> H2 chain. + assert alloc.block_parent_id[s1].item() == s0 + assert alloc.block_parent_id[sx].item() == s1 + assert alloc.kv_hash_to_block_id[h1] == s1 + + @pytest.mark.internal + def test_check_availability_excludes_already_pinned_matches(self): + """check_availability reserves only matched blocks that are currently + evictable (ref_count == 0). A matched prefix already pinned by an + in-flight request frees no capacity when re-pinned, so reserving it would + under-report availability and needlessly defer shared-prefix requests.""" + ctx = self._ctx() + bs = ctx.block_size_tokens + alloc = ctx.kv_block_allocator + + # Request A stays active, pinning the shared prefix H0/S0 -> H1/S1. + ctx.add_request(self._req(ctx, self._prompt(bs * 2))) + s0, s1 = self._block_ids(ctx, 0, 2) + assert alloc.block_ref_counts[s0].item() == 1 + assert alloc.block_ref_counts[s1].item() == 1 + + # One unrelated block is cached and evictable (ref_count == 0). + ctx.add_request(self._req(ctx, self._prompt(bs, offset=9000), request_id=2)) + (sx,) = self._block_ids(ctx, 1, 1) + ctx.release_memory_blocks_from_request_indexes(torch.tensor([1])) + assert alloc.block_ref_counts[sx].item() == 0 + assert int(alloc.get_evictable_block_count()) == 1 + + # Free pool exhausted: the one new block B needs (H2) can only come from + # evicting SX. The already-pinned matches S0/S1 must not be reserved. + alloc.total_avail = 0 + + # Request B shares H0/H1 with A and needs one new block for H2. + req_b = self._req(ctx, self._prompt(bs * 3), request_id=3) + matched, num_from_pool, *_ = ctx._compute_prefix_match(req_b, req_b.remaining_prompt_length) + assert matched == [s0, s1] + assert num_from_pool == 1 + + _, _, kv_cache_available = ctx.check_availability(req_b) + # SX (the sole evictable block) can satisfy H2; reserving the pinned + # matches would wrongly report the request as un-addable. + assert kv_cache_available is True + @pytest.mark.internal def test_ref_count_refzero(self): bs = 32 diff --git a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py index 57b58230171..da068087579 100644 --- a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py +++ b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py @@ -19,6 +19,7 @@ def _make_context( total_request_count=0, request_kv_block_counts=None, request_to_kv_block_ids=None, + prefix_cache_lru_clock=0, ): """Build a minimal DynamicInferenceContext-like fake for the allocator.""" if request_kv_block_counts is None: @@ -30,6 +31,7 @@ def _make_context( total_request_count=total_request_count, request_kv_block_counts=request_kv_block_counts, request_to_kv_block_ids=request_to_kv_block_ids, + prefix_cache_lru_clock=prefix_cache_lru_clock, ) @@ -112,7 +114,9 @@ def test_block_usage_counts_no_prefix_caching( ) def test_prefix_caching_state_layout(policy, expect_timestamps): """Prefix-caching mode allocates block_hashes (initially -1) and ref_counts - (initially 0). LRU policy also allocates timestamps; REF_ZERO does not.""" + (initially 0). LRU policy also allocates timestamps and the persisted + prefix-forest bookkeeping (block_parent_id / block_child_count); REF_ZERO + does not.""" a = KVBlockAllocator( _make_context(), total_count=8, @@ -124,6 +128,11 @@ def test_prefix_caching_state_layout(policy, expect_timestamps): assert (a.block_ref_counts == 0).all().item() assert a.kv_hash_to_block_id == {} assert hasattr(a, "block_timestamps") is expect_timestamps + assert hasattr(a, "block_parent_id") is expect_timestamps + assert hasattr(a, "block_child_count") is expect_timestamps + if expect_timestamps: + assert (a.block_parent_id == -1).all().item() + assert (a.block_child_count == 0).all().item() def test_prefix_caching_allocate_and_hash_registration(): @@ -143,15 +152,25 @@ def test_prefix_caching_allocate_and_hash_registration(): ids = a.allocate_memory_blocks(2) assert (a.block_ref_counts[ids] == 1).all().item() - # Hash registration populates both the tensor and the dict. + # Hash registration populates both the tensor and the dict. Parent hashes are + # ignored under REF_ZERO (they only drive LRU eviction ordering), so this mode + # keeps no per-block parent bookkeeping. a.register_kv_block_hashes(block_ids=[1, 3], block_hashes=[111, 333]) assert a.block_hashes[1].item() == 111 assert a.block_hashes[3].item() == 333 + assert not hasattr(a, "block_parent_id") assert a.kv_hash_to_block_id == {111: 1, 333: 3} + # Supplying parent hashes is accepted (and ignored) under REF_ZERO. + a.register_kv_block_hashes(block_ids=[2, 4], block_hashes=[222, 444], parent_hashes=[111, 222]) + + # Mismatched parent-hash length is rejected regardless of policy. + with pytest.raises(AssertionError): + a.register_kv_block_hashes(block_ids=[5], block_hashes=[555], parent_hashes=[1, 2]) + # Empty inputs are a no-op (avoids zero-element tensor construction). a.register_kv_block_hashes(block_ids=[], block_hashes=[]) - assert a.kv_hash_to_block_id == {111: 1, 333: 3} + assert a.kv_hash_to_block_id == {111: 1, 333: 3, 222: 2, 444: 4} # REF_ZERO has no eviction path when the free pool is short. small = KVBlockAllocator( @@ -189,3 +208,316 @@ def test_block_usage_counts_with_prefix_caching( a = KVBlockAllocator(ctx, total_count=TOTAL_COUNT, paused_count=3, enable_prefix_caching=True) assert a.get_active_used() == expected_active assert a.get_paused_used() == expected_paused + + +# --------------------------------------------------------------------------- +# LRU eviction: parent-chain safety +# --------------------------------------------------------------------------- + + +def _lru_allocator(total_count=16, paused_count=1): + """LRU-mode prefix-caching allocator over a fresh fake context.""" + return KVBlockAllocator( + _make_context(), + total_count=total_count, + paused_count=paused_count, + enable_prefix_caching=True, + prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, + ) + + +def _seed_cached_chain(a, block_ids, hashes, parents, timestamps): + """Register a chain of cached (ref_count == 0) blocks with explicit LRU + timestamps, bypassing the allocation path to control the layout directly.""" + a.register_kv_block_hashes(block_ids=block_ids, block_hashes=hashes, parent_hashes=parents) + ids = torch.tensor(block_ids, dtype=torch.int64) + a.block_ref_counts[ids] = 0 # cached / evictable + a.block_timestamps[ids] = torch.tensor(timestamps, dtype=torch.int64) + # Mark the blocks as out of the free pool so _deregister_blocks (which pushes + # them back) keeps total_avail bookkeeping consistent. + a.total_avail -= len(block_ids) + + +def _assert_prefix_invariant(a): + """Every cached block must have its parent cached too (or be a root). This is + exactly the invariant _find_kv_match_count relies on.""" + cached_ids = set(a.kv_hash_to_block_id.values()) + for block_hash, block_id in a.kv_hash_to_block_id.items(): + parent_id = a.block_parent_id[block_id].item() + if parent_id >= 0: + assert parent_id in cached_ids, ( + f"dangling child: block {block_id} (hash {block_hash}) parent " + f"block {parent_id} not cached" + ) + + +def test_evict_lru_never_orphans_a_child(): + """Regression: with chunked prefill an ancestor block can end up OLDER than + its descendant. A naive oldest-first eviction would evict the parent and leave + a dangling child; leaf-only eviction must evict the child instead.""" + a = _lru_allocator() + # Chain b0 -> b1 -> b2. Parent b1 (ts=1) is older than child b2 (ts=5). + _seed_cached_chain( + a, block_ids=[0, 1, 2], hashes=[10, 20, 30], parents=[0, 10, 20], timestamps=[1, 1, 5] + ) + + assert a.evict_lru_blocks(1) is True + # The leaf (b2, hash 30) is evicted, not the older parent b1 (hash 20). + assert a.kv_hash_to_block_id == {10: 0, 20: 1} + assert a.block_hashes[2].item() == -1 + assert a.block_parent_id[2].item() == -1 + # Evicting the leaf drops it from its parent's child count. + assert a.block_child_count[1].item() == 0 + _assert_prefix_invariant(a) + + +def test_evict_lru_cascades_up_the_chain(): + """Evicting more blocks than there are leaves walks up the chain from the + deepest descendant, always keeping the retained set descendant-closed.""" + a = _lru_allocator() + _seed_cached_chain( + a, block_ids=[0, 1, 2], hashes=[10, 20, 30], parents=[0, 10, 20], timestamps=[1, 1, 5] + ) + + assert a.evict_lru_blocks(2) is True + # b2 then b1 evicted; only the root b0 remains. + assert a.kv_hash_to_block_id == {10: 0} + _assert_prefix_invariant(a) + + +def test_evict_lru_normal_lru_order_when_leaf_is_oldest(): + """When the oldest block is already a leaf (the common partial-match case, + where ancestors are refreshed and descendants are stale), plain LRU order + applies and the oldest leaf is evicted first.""" + a = _lru_allocator() + # Ancestors refreshed (ts=9); descendant stale (ts=3) and is the leaf. + _seed_cached_chain( + a, block_ids=[0, 1, 2], hashes=[10, 20, 30], parents=[0, 10, 20], timestamps=[9, 9, 3] + ) + + assert a.evict_lru_blocks(1) is True + assert a.kv_hash_to_block_id == {10: 0, 20: 1} + _assert_prefix_invariant(a) + + +def test_evict_lru_branching_prefix_tree(): + """A shared parent with two divergent children (branching prefixes) must keep + the parent cached until BOTH children are evicted.""" + a = _lru_allocator() + # b0 is the parent of both b1 and b2 (e.g. prompts "P+X" and "P+Y"). + _seed_cached_chain( + a, block_ids=[0, 1, 2], hashes=[10, 20, 30], parents=[0, 10, 10], timestamps=[1, 2, 8] + ) + + # Evicting one block takes a leaf (b1, the older child), never the parent. + assert a.evict_lru_blocks(1) is True + assert a.kv_hash_to_block_id == {10: 0, 30: 2} + _assert_prefix_invariant(a) + + # Evicting the second child leaves only the parent. + assert a.evict_lru_blocks(1) is True + assert a.kv_hash_to_block_id == {10: 0} + _assert_prefix_invariant(a) + + +def test_evict_lru_cached_child_with_pinned_parent_treated_as_root(): + """Multi-turn / agentic case: a shared prefix block S0 stays pinned by an + active request (ref_count > 0) while a descendant S1 from a finished turn is + cached (ref_count == 0). S0 is not in the candidate set, so S1's parent + resolves to -1 (S1 is treated as a forest root) and is safely evicted by + normal LRU. The pinned parent must never be touched, even when it is the + oldest block of all.""" + a = _lru_allocator() + # Chain S0 -> S1, plus an unrelated cached root SX. + a.register_kv_block_hashes( + block_ids=[0, 1, 2], block_hashes=[10, 20, 30], parent_hashes=[0, 10, 0] + ) + ids = torch.tensor([0, 1, 2], dtype=torch.int64) + # S0 pinned (active request), S1 and SX cached/evictable. S0 is the OLDEST + # (ts=0) — a pin-blind oldest-first eviction would wrongly take it and orphan + # nothing here, but in general orphan its children. + a.block_ref_counts[ids] = torch.tensor([1, 0, 0], dtype=torch.int32) + a.block_timestamps[ids] = torch.tensor([0, 1, 9], dtype=torch.int64) + a.total_avail -= 3 + + # Only S1 and SX are candidates; the pinned S0 is excluded. + assert int(a.get_evictable_block_count()) == 2 + + # Evict one: S1 (ts=1) is the oldest candidate and a leaf; evicted first. + assert a.evict_lru_blocks(1) is True + assert a.kv_hash_to_block_id == {10: 0, 30: 2} # S0 (pinned) + SX survive + assert a.block_ref_counts[0].item() == 1 # parent still pinned + assert a.block_hashes[0].item() == 10 # parent hash intact + assert a.block_hashes[1].item() == -1 # child deregistered + _assert_prefix_invariant(a) + + # Evict again: only SX remains as a candidate; S0 stays pinned throughout. + assert a.evict_lru_blocks(1) is True + assert a.kv_hash_to_block_id == {10: 0} + assert a.block_ref_counts[0].item() == 1 + # The pinned parent can never be evicted, so a third eviction fails. + assert a.evict_lru_blocks(1) is False + + +def test_evict_lru_partial_chain_eviction_peels_from_leaf_keeping_root(): + """Evicting fewer blocks than a chain's length peels from the leaf end, even + when the root is the least-recently-used block. + + Chain A -> B -> C with the root A oldest (ts 1 < 2 < 3); evict 2. Eviction + proceeds leaf-first, so C then B are removed and the root A is retained. The + retained cache stays descendant-closed (no cached block is left with an + evicted parent). + """ + a = _lru_allocator() + _seed_cached_chain( + a, block_ids=[0, 1, 2], hashes=[10, 20, 30], parents=[0, 10, 20], timestamps=[1, 2, 3] + ) + + assert a.evict_lru_blocks(2) is True + # Leaf C and its parent B are evicted; the root A survives despite being oldest. + assert a.kv_hash_to_block_id == {10: 0} + assert a.block_hashes[0].item() == 10 # root A retained + assert a.block_hashes[1].item() == -1 # B deregistered + assert a.block_hashes[2].item() == -1 # C deregistered + _assert_prefix_invariant(a) + + +def test_evict_lru_insufficient_cached_blocks_returns_false(): + """When fewer cached blocks exist than requested, eviction fails without + touching the cache.""" + a = _lru_allocator() + _seed_cached_chain(a, block_ids=[0, 1], hashes=[10, 20], parents=[0, 10], timestamps=[1, 2]) + assert a.evict_lru_blocks(3) is False + assert a.kv_hash_to_block_id == {10: 0, 20: 1} + + +def test_evict_lru_keeps_hottest_leaf_over_cold_interior_parent(): + """Optimality: leaf-peeling must retain the single most-recently-used block + even when reaching it means evicting a colder interior parent elsewhere. A + block is kept only for its own recency, never because a hot descendant props + it up, so the hot leaf E survives while the colder interior block B is evicted. + + A(ts 1) -> B(ts 2) -> C(ts 5) + \-> F(ts 3) + \-> D(ts 3) -> E(ts 5) + """ + a = _lru_allocator(total_count=8) + # hashes: A=10, B=20, C=30, F=40, D=50, E=60 + _seed_cached_chain( + a, + block_ids=[0, 1, 2, 3, 4, 5], + hashes=[10, 20, 30, 40, 50, 60], + parents=[0, 10, 20, 20, 10, 50], + timestamps=[1, 2, 5, 3, 3, 5], + ) + + assert a.evict_lru_blocks(3) is True + # Evicted F(3), C(5), then B(2) once childless. Retains A, D, and the hottest + # block E -- never evicting E in favor of the colder interior B. + assert a.kv_hash_to_block_id == {10: 0, 50: 4, 60: 5} + assert a.block_hashes[5].item() == 60 # hottest leaf E retained + assert a.block_hashes[1].item() == -1 # cold interior B evicted + _assert_prefix_invariant(a) + + +def test_evict_lru_asserts_on_cyclic_parent_graph(): + """The parent graph is assumed acyclic (a forest). A hash collision producing + a cycle exposes no leaf, so the peel cannot collect enough blocks; this is a + bug and must fail loudly rather than silently under-evict.""" + a = _lru_allocator() + # 2-cycle: block 0's parent hash is 20 (block 1) and block 1's parent hash is + # 10 (block 0). register_kv_block_hashes never produces this — we seed it + # directly to model the pathological collision case. + _seed_cached_chain(a, block_ids=[0, 1], hashes=[10, 20], parents=[20, 10], timestamps=[1, 2]) + assert int(a.get_evictable_block_count()) == 2 + + with pytest.raises(AssertionError): + a.evict_lru_blocks(1) + + +def test_is_memory_available_excludes_soon_to_be_pinned_blocks(): + """potential_matched_count removes soon-to-be-pinned cached blocks from the + evictable capacity, so availability matches what allocation can satisfy + once those blocks (e.g. prefix matches) are pinned.""" + a = _lru_allocator(total_count=6, paused_count=1) + # Drain the free pool: every block is allocated (ref_count == 1), none free. + a.allocate_memory_blocks(a.total_avail) + assert a.total_avail == 0 + # Mark two blocks as cached/evictable, mirroring an LRU release: ref_count + # drops to 0 and the hash is retained, but the block stays out of the free + # pool (total_avail unchanged). + a.register_kv_block_hashes(block_ids=[0, 1], block_hashes=[10, 20], parent_hashes=[0, 10]) + a.block_ref_counts[torch.tensor([0, 1])] = 0 + assert a.total_avail == 0 + assert int(a.get_evictable_block_count()) == 2 + + # Both evictable blocks count toward availability by default. + assert a.is_memory_available(2) is True + # Excluding one (it will be pinned) leaves only one usable for the request. + assert a.is_memory_available(2, potential_matched_count=1) is False + assert a.is_memory_available(1, potential_matched_count=1) is True + # Excluding all evictable blocks leaves nothing to satisfy a new block. + assert a.is_memory_available(1, potential_matched_count=2) is False + + +def _reference_leaf_peel(block_ids, hashes, parents, timestamps, k_evict): + """Independent, straightforward greedy reference: repeatedly evict the + currently-evictable leaf with the oldest (timestamp, block_id). Returns the + set of evicted block ids. Used to pin the optimal eviction choice.""" + hash_to_id = dict(zip(hashes, block_ids)) + ts = dict(zip(block_ids, timestamps)) + child_count = {b: 0 for b in block_ids} + parent_of = {} + for b, p in zip(block_ids, parents): + pid = hash_to_id.get(p) + parent_of[b] = pid + if pid is not None: + child_count[pid] += 1 + + import heapq as _heapq + + heap = [(ts[b], b) for b in block_ids if child_count[b] == 0] + _heapq.heapify(heap) + evicted = set() + while heap and len(evicted) < k_evict: + _, b = _heapq.heappop(heap) + evicted.add(b) + pid = parent_of[b] + if pid is not None: + child_count[pid] -= 1 + if child_count[pid] == 0: + _heapq.heappush(heap, (ts[pid], pid)) + return evicted + + +def test_evict_lru_preserves_invariant_under_random_chains(): + """Property test: across many randomized multi-chain layouts and eviction + counts, eviction (a) preserves the parent-chain invariant and (b) evicts + exactly the optimal leaf-peel set (matched against an independent + reference).""" + torch.manual_seed(0) + for _ in range(50): + n = int(torch.randint(2, 10, (1,)).item()) + a = _lru_allocator(total_count=n + 4) + block_ids = list(range(n)) + # Build a forest: block k's parent is a random earlier block or a root. + hashes = [100 + k for k in range(n)] + parents = [] + for k in range(n): + if k == 0 or int(torch.randint(0, 2, (1,)).item()) == 0: + parents.append(0) # root + else: + parents.append(hashes[int(torch.randint(0, k, (1,)).item())]) + # Distinct timestamps so the optimal evicted set is unique and the + # reference comparison is exact (no tie-break ambiguity). + timestamps = torch.randperm(50)[:n].add(1).tolist() + _seed_cached_chain(a, block_ids, hashes, parents, timestamps) + + k_evict = int(torch.randint(1, n + 1, (1,)).item()) + expected_evicted = _reference_leaf_peel(block_ids, hashes, parents, timestamps, k_evict) + + assert a.evict_lru_blocks(k_evict) is True + retained = set(a.kv_hash_to_block_id.values()) + assert retained == set(block_ids) - expected_evicted + assert len(retained) == n - k_evict + _assert_prefix_invariant(a) From 411a5d8b21ce5a5b6675860cb9fb4c2b0e29419f Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Wed, 22 Jul 2026 21:14:22 -0700 Subject: [PATCH 086/290] Inference: Reduce mamba scratch space size by an order of magnitude. (#5863) --- megatron/core/inference/config.py | 10 +- .../attention_context/mamba_metadata.py | 39 +++- .../inference/contexts/dynamic_context.py | 26 ++- .../contexts/mamba_slot_allocator.py | 14 +- megatron/training/arguments.py | 7 +- .../attention_metadata/test_mamba_metadata.py | 9 +- .../contexts/test_dynamic_prefix_caching.py | 166 +++++++++++++++++- 7 files changed, 238 insertions(+), 33 deletions(-) diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index 62729c3d29f..1ee1f64f1db 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -333,9 +333,13 @@ class InferenceConfig: This budget covers both buffers allocated by MambaSlotAllocator: the durable cache (ssm_states/conv_states, max_slots slots reused across requests) and the per-step - extraction scratch (intermediate_ssm_out/intermediate_conv_out, sized to the - worst-case 3 * max_requests slots). The scratch is reserved from this budget first, - so a larger max_requests leaves fewer durable slots.""" + extraction scratch (intermediate_ssm_out/intermediate_conv_out). The scratch is + sized to the tighter of two per-step bounds, + ``min(ceil(max_tokens / block_size_tokens), 3 * max_requests)``, since a single + engine step can extract at most one state per block_size_tokens of its token budget + (and at most 3 per request). The scratch is reserved from this budget first, so a + smaller ``max_tokens`` (or ``max_requests``) shrinks the scratch and leaves more + durable cache slots.""" # ================================= # Logging config diff --git a/megatron/core/inference/contexts/attention_context/mamba_metadata.py b/megatron/core/inference/contexts/attention_context/mamba_metadata.py index 3d8c6d4f5b8..953202a3c5b 100644 --- a/megatron/core/inference/contexts/attention_context/mamba_metadata.py +++ b/megatron/core/inference/contexts/attention_context/mamba_metadata.py @@ -14,7 +14,13 @@ class MambaMetadata: """Manages the metadata tensors required for Mamba layers during inference.""" def __init__( - self, max_requests: int, max_tokens: int, mamba_chunk_size: int = 128, d_conv: int = 0 + self, + max_requests: int, + max_tokens: int, + *, + max_intermediate_count: int, + mamba_chunk_size: int = 128, + d_conv: int = 0, ): """ Initializes the Mamba slot allocator. @@ -22,6 +28,11 @@ def __init__( Args: max_requests (int): The maximum number of concurrent requests. max_tokens (int): The maximum number of tokens. + max_intermediate_count (int): Per-step upper bound on Mamba + intermediate-state extractions; sizes the intermediate metadata + buffers. Computed once by DynamicInferenceContext (as + max_mamba_intermediate_states_per_step) and shared with + MambaSlotAllocator. mamba_chunk_size (int): The chunk size used by the Mamba SSM Triton kernels. d_conv (int): Convolution window size (from mamba_conv_states_shape[-1]). Used for vectorized conv state extraction at intermediate offsets. @@ -91,9 +102,9 @@ def __init__( ) self.mamba_state_free_slot_count = self.max_requests - # Intermediate state extraction buffers (CUDA graph compatible) - # Each prefill request can produce up to 3 intermediate offsets - self.max_intermediate_count = MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * max_requests + # Intermediate state extraction buffers (CUDA graph compatible). Sized by + # the per-step token-budget cap shared from DynamicInferenceContext. + self.max_intermediate_count = max_intermediate_count self._intermediate_chunk_indices_buffer = torch.zeros( self.max_intermediate_count, dtype=torch.int64, device=self.device ) @@ -381,13 +392,24 @@ def _update_intermediate_metadata( intermediate_counts_gpu: [real_prefill_count] int32 GPU tensor of per-request offset counts (0-3), or None. real_prefill_count: Number of real (non-padding) prefill requests. + padded_prefill_count: Prefill request count after batch padding + (equals the captured graph bucket under CUDA graphs, or the + round-up-padded count in eager mode; always >= real_prefill_count). + Bounds the exposed/padded extent of the intermediate views via + ``max_count`` so CUDA graph replay always touches a fixed-size + region within the scratch buffers. cu_seqlens_gpu: GPU cu_seqlens tensor to read from. Defaults to the legacy standalone ``_cu_seqlens_buffer`` used by :meth:`update`; the coalesced production path passes the shared ``ContextGPUView.mamba_cu_seqlens`` view. """ chunk_size = self.mamba_chunk_size - max_count = padded_prefill_count * MAX_INTERMEDIATE_OFFSETS_PER_REQUEST + # Cap at the token-budget bound so the per-step views never exceed the + # buffers, even for high-prefill-count graph buckets where + # padded_prefill_count * MAX_INTERMEDIATE_OFFSETS_PER_REQUEST would. + max_count = min( + padded_prefill_count * MAX_INTERMEDIATE_OFFSETS_PER_REQUEST, self.max_intermediate_count + ) if cu_seqlens_gpu is None: cu_seqlens_gpu = self._cu_seqlens_buffer @@ -438,6 +460,13 @@ def _update_intermediate_metadata( valid_abs_positions = abs_positions_2d[valid_mask] real_count = valid_chunk_indices.numel() + # The token-budget bound guarantees this; fail loudly rather than + # silently overrun the scratch buffers if the candidate-offset + # logic in MambaSlotAllocator.compute_and_store_offsets changes. + assert real_count <= self.max_intermediate_count, ( + f"Mamba intermediate count {real_count} exceeds buffer size " + f"{self.max_intermediate_count}" + ) self._intermediate_chunk_indices_buffer[:real_count] = valid_chunk_indices self._intermediate_abs_positions_buffer[:real_count] = valid_abs_positions.to( torch.int32 diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 59e8d392c1d..3cc8f5c72df 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -598,6 +598,15 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC self.max_tokens = inference_config.max_tokens or self.DEFAULT_MAX_TOKENS + # Per-step upper bound on Mamba intermediate-state extractions, shared with + # MambaMetadata and MambaSlotAllocator so scratch/metadata buffers and the + # budget accounting agree. Bounded both by the token budget (one block + # boundary per block_size_tokens) and by the request budget + # (MAX_INTERMEDIATE_OFFSETS_PER_REQUEST per request); + token_based_count = math.ceil(self.max_tokens / self.block_size_tokens) + request_based_count = MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * self.max_requests + self.max_mamba_intermediate_states_per_step = min(token_based_count, request_based_count) + assert self.max_tokens >= self.max_requests, ( f"max_tokens ({self.max_tokens}) must be >= " f"max_requests ({self.max_requests}), " @@ -805,7 +814,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC # budget first, then the rest sizes the "durable" cache # (ssm_states/conv_states). mamba_bytes_per_req is the shared # per-slot footprint of both. - scratch_slots = MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * self.max_requests + scratch_slots = self.max_mamba_intermediate_states_per_step scratch_bytes = scratch_slots * mamba_bytes_per_req durable_slots = (prefix_cache_bytes - scratch_bytes) // mamba_bytes_per_req durable_slots = max(durable_slots, 0) @@ -864,6 +873,7 @@ def _allocate_mamba_states(self): self.mamba_metadata = MambaMetadata( max_requests=self.max_requests, max_tokens=self.max_tokens, + max_intermediate_count=self.max_mamba_intermediate_states_per_step, mamba_chunk_size=self.mamba_chunk_size, d_conv=self.mamba_conv_states_shape[-1], ) @@ -1700,26 +1710,24 @@ def _allocate_mamba_cache(self, mamba_gb: float) -> None: # `max_slots` slots (computed below). # - "scratch" buffers: self.intermediate_ssm_out / self.intermediate_conv_out, # fixed CUDA-graph-safe staging for intermediate-state - # extraction, sized to the per-step worst case of - # `scratch_slots` = MAX_INTERMEDIATE_OFFSETS_PER_REQUEST - # * max_requests slots. + # extraction, sized to the per-step token-budget cap + # `scratch_slots` = max_mamba_intermediate_states_per_step. # The scratch is not part of the durable cache but consumes the same # per-slot bytes, so reserve it from the budget up front before sizing the # durable cache; otherwise total usage silently exceeds mamba_gb (and can # OOM) when scratch_slots > max_slots. - scratch_slots = MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * self.max_requests + scratch_slots = self.max_mamba_intermediate_states_per_step scratch_bytes = scratch_slots * per_slot_bytes max_slots = (total_bytes - scratch_bytes) // per_slot_bytes # durable slots if max_slots < 1: raise ValueError( f"Mamba prefix cache budget (prefix_caching_mamba_gb={mamba_gb:.4g} GB) " f"is too small. The CUDA-graph extraction scratch reserves " - f"{scratch_bytes / 1024**3:.4g} GB ({scratch_slots} slots = " - f"{MAX_INTERMEDIATE_OFFSETS_PER_REQUEST} offsets x {self.max_requests} " - f"requests x {per_slot_bytes / 1024:.1f} KB/slot), leaving room for " + f"{scratch_bytes / 1024**3:.4g} GB ({scratch_slots} slots x " + f"{per_slot_bytes / 1024:.1f} KB/slot), leaving room for " f"fewer than one durable cache slot. Increase prefix_caching_mamba_gb " f"to at least {(scratch_bytes + per_slot_bytes) / 1024**3:.4g} GB, or " - f"reduce max_requests." + f"reduce max_tokens." ) self.mamba_slot_allocator = MambaSlotAllocator( diff --git a/megatron/core/inference/contexts/mamba_slot_allocator.py b/megatron/core/inference/contexts/mamba_slot_allocator.py index 69f19d442bc..21116b0cb7e 100644 --- a/megatron/core/inference/contexts/mamba_slot_allocator.py +++ b/megatron/core/inference/contexts/mamba_slot_allocator.py @@ -106,10 +106,10 @@ def __init__( # Pre-allocated "scratch" output buffers for CUDA graph compatible # extraction (GPU): per-step staging that the kernel writes intermediate # states into before commit copies them to the durable cache above. Sized - # to the per-step worst case (MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * - # max_requests); the budget accounting in DynamicInferenceContext refers to - # these as the "scratch" buffers. - self.max_intermediate_count = MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * context.max_requests + # by the per-step token budget computed once on the context; the budget + # accounting in DynamicInferenceContext refers to these as the "scratch" + # buffers. + self.max_intermediate_count = context.max_mamba_intermediate_states_per_step self.intermediate_ssm_out = torch.zeros( (num_mamba_layers, self.max_intermediate_count) + ssm_states_shape, dtype=ssm_states_dtype, @@ -432,8 +432,10 @@ def compute_and_store_offsets( last_aligned_abs = (prompt_len // bs) * bs # last complete block boundary penultimate_abs = (overall_required_blocks - 1) * bs - # Determine mamba_chunk_size from mamba config (128 is the standard SSM kernel chunk size) - mamba_chunk_size = 128 + # SSM chunk size the mamba kernel actually runs with. States can only be + # extracted at multiples of this value, and it must match the value used + # in MambaMetadata (offset -> chunk-index conversion) to stay consistent. + mamba_chunk_size = ctx.mamba_chunk_size # Keep only boundaries that land inside this chunk's computed tokens and on # a mamba-chunk boundary (required for mid-sequence state extraction). diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 7f84a30bae0..fccfda3d199 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1989,9 +1989,10 @@ def _add_inference_args(parser): 'covers both the durable cache (the ssm_states/conv_states ' 'slots reused across requests) and the per-step extraction ' 'scratch (the intermediate_ssm_out/intermediate_conv_out ' - 'buffers, sized to 3 * max_requests slots); the scratch is ' - 'reserved first, so a larger max_requests leaves fewer durable ' - 'slots.') + 'buffers, sized to min(ceil(max_tokens / block_size), ' + '3 * max_requests) slots); the scratch is reserved first, so a ' + 'smaller max_tokens (or max_requests) shrinks the scratch and ' + 'leaves more durable slots.') group.add_argument('--inference-dynamic-batching-cuda-graph-mixed-prefill-count', type=int, default=16, help='Number of mixed prefill requests to capture in a cuda graph.') diff --git a/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py b/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py index a7f579051ac..99bb046d97d 100644 --- a/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py +++ b/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py @@ -14,7 +14,14 @@ def metadata_context(self): """Fixture to initialize MambaMetadata with standard constraints.""" max_requests = 16 max_tokens = 2048 - metadata = MambaMetadata(max_requests=max_requests, max_tokens=max_tokens) + # Per-step intermediate-state cap (token budget / block_size + margin); + # value is irrelevant to these update() tests, which don't extract state. + max_intermediate_count = 17 + metadata = MambaMetadata( + max_requests=max_requests, + max_tokens=max_tokens, + max_intermediate_count=max_intermediate_count, + ) # Manually allocate some slots to simulate a running state. # We assume request_id i maps to mamba_slot i for simplicity in assertions. diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index c1f6bcbfa1d..65cf85a172e 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -36,7 +36,7 @@ def teardown_class(cls): Utils.destroy_model_parallel() @staticmethod - def _mamba_config(): + def _mamba_config(mamba_chunk_size=128): from megatron.core.inference.config import MambaInferenceStateConfig return MambaInferenceStateConfig( @@ -45,6 +45,7 @@ def _mamba_config(): ssm_states_shape=(4, 16), conv_states_dtype=torch.float32, ssm_states_dtype=torch.float32, + mamba_chunk_size=mamba_chunk_size, ) def _ctx( @@ -56,6 +57,7 @@ def _ctx( rounder=64, enable_prefix_caching=True, max_tokens=None, + max_requests=None, prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, mamba_config=None, prefix_caching_mamba_gb=None, @@ -80,6 +82,7 @@ def _ctx( paused_buffer_size_gb=0.2 * buffer_size_gb, block_size_tokens=block_size_tokens, max_tokens=max_tokens, + max_requests=max_requests, mamba_inference_state_config=mamba_config, use_flashinfer_fused_rope=None, unified_memory_level=0, @@ -842,11 +845,12 @@ def test_hybrid_prefix_caching_without_mamba_budget_warns(self, caplog): @pytest.mark.internal def test_mamba_cache_budget_too_small_raises(self): - # The CUDA-graph extraction scratch (MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * - # max_requests slots) is reserved from prefix_caching_mamba_gb before the - # durable cache is sized. A budget too small to fit the scratch plus at - # least one durable slot is a hard configuration error, not a silent - # over-allocation (which previously could OOM at startup). + # The CUDA-graph extraction scratch (sized to the per-step token-budget + # cap, max_mamba_intermediate_states_per_step) is reserved from + # prefix_caching_mamba_gb before the durable cache is sized. A budget too + # small to fit the scratch plus at least one durable slot is a hard + # configuration error, not a silent over-allocation (which previously + # could OOM at startup). with pytest.raises(ValueError, match="prefix cache budget"): self._mctx(prefix_caching_mamba_gb=1e-5) @@ -996,6 +1000,156 @@ def test_mamba_intermediate_offsets(self): torch.full_like(ctx5.mamba_slot_allocator.conv_states[layer, slot5], layer + 1.0), ) + @pytest.mark.internal + def test_max_intermediate_states_per_step_formula(self): + # The extraction buffers are sized by the tighter of two per-step bounds: + # token-based: ceil(max_tokens / block_size) + 1 + # request-based: MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * max_requests + import math + + from megatron.core.inference.contexts.mamba_slot_allocator import ( + MAX_INTERMEDIATE_OFFSETS_PER_REQUEST, + ) + + def token_based(ctx): + return math.ceil(ctx.max_tokens / ctx.block_size_tokens) + + def request_based(ctx): + return MAX_INTERMEDIATE_OFFSETS_PER_REQUEST * ctx.max_requests + + # Token-limited regime: many requests, so the token budget is tighter. + ctx = self._mctx(block_size_tokens=256, max_tokens=2048) + assert ctx.max_requests >= 3 # ensure this regime is actually token-limited + expected = min(token_based(ctx), request_based(ctx)) + assert expected == token_based(ctx) # token bound wins here + assert ctx.max_mamba_intermediate_states_per_step == expected + # The single value is shared everywhere it's consumed. + assert ctx.mamba_slot_allocator.max_intermediate_count == expected + assert ctx.mamba_metadata.max_intermediate_count == expected + assert ctx.mamba_slot_allocator.intermediate_ssm_out.shape[1] == expected + + # Request-limited regime: few requests but a large token budget, so + # 3 * max_requests is the tighter bound. This is the case the token-only + # formula over-allocated for (e.g. 1 request + 16384 tokens once reserved + # 65 scratch slots a single request could never fill). + ctx2 = self._mctx(block_size_tokens=256, max_tokens=2048, max_requests=2) + expected2 = min(token_based(ctx2), request_based(ctx2)) + assert expected2 == request_based(ctx2) # request bound wins here + assert expected2 < token_based(ctx2) # ...and it is strictly tighter + assert ctx2.max_mamba_intermediate_states_per_step == expected2 + assert ctx2.mamba_slot_allocator.max_intermediate_count == expected2 + assert ctx2.mamba_metadata.max_intermediate_count == expected2 + assert ctx2.mamba_slot_allocator.intermediate_ssm_out.shape[1] == expected2 + + @pytest.mark.internal + def test_intermediate_count_bounded_by_token_budget(self): + # Claim: a single engine step emits at most max_tokens / block_size Mamba + # intermediate states, regardless of how many prefill requests it packs. + # Fill the token budget with fresh multi-block prefills and confirm the + # extracted count never exceeds the scratch buffer. + bs = 256 + ctx = self._mctx(block_size_tokens=bs, max_tokens=2048, max_sequence_length=4096) + budget = ctx.max_mamba_intermediate_states_per_step + + # Non-block-aligned 2.5-block prefills (each crosses a block boundary on a + # mamba-chunk multiple -> one intermediate offset). Distinct content so + # they never prefix-match one another. + per_req = bs * 2 + bs // 2 # 640 tokens + n = ctx.max_tokens // per_req + assert n >= 2 + for i in range(n): + ctx.add_request( + self._req(ctx, self._prompt(per_req, offset=i * 100000), request_id=i + 1) + ) + + # Drive the step's metadata computation (populates intermediate_count). + ctx.initialize_attention_state() + ctx.transfer_bookkeeping_to_gpu() + + md = ctx.mamba_metadata + # Extraction actually fired (guards against a silent no-op test)... + assert md.intermediate_count > 0 + # ...and the packed step never exceeds the token-budget bound. + assert md.intermediate_count <= budget + assert md.intermediate_count == sum(md.per_request_intermediate_counts) + + @pytest.mark.internal + def test_intermediate_count_fills_scratch_buffer(self): + # Reviewer follow-up: drive a single step that consumes nearly the whole + # scratch buffer (not just a few slots) and confirm it is never overrun. + # + # Realistic config: attention block_size=256, mamba_chunk_size=128. Since + # 256 is a multiple of 128, a 256-aligned block boundary is also a mamba + # chunk boundary (extractable); the mamba boundary at 128 is NOT a block + # boundary, so it is never a candidate. + # + # Each request is a fresh 257-token prompt (one token past a block): it + # crosses exactly one block boundary at token 256 -> exactly one + # intermediate offset, while consuming ~one block of tokens. Packing the + # token budget with these drives intermediate_count to max_tokens // 257, + # close to the token-budget bound -- filling nearly every scratch slot, + # unlike test_intermediate_count_bounded_by_token_budget (~1/3 of them). + import math + + bs = 256 + ctx = self._mctx(block_size_tokens=bs, max_tokens=2048, max_sequence_length=4096) + assert ctx.mamba_chunk_size == 128 # bs=256 is a multiple of mamba chunk 128 + budget = ctx.max_mamba_intermediate_states_per_step + + per_req = bs + 1 # 257: crosses the block boundary at bs=256 (a mamba-chunk multiple) + n = ctx.max_tokens // per_req + assert n >= 2 + assert n <= ctx.max_requests # all packed into a single step + for i in range(n): + ctx.add_request( + self._req(ctx, self._prompt(per_req, offset=i * 100000), request_id=i + 1) + ) + + ctx.initialize_attention_state() + ctx.transfer_bookkeeping_to_gpu() + + md = ctx.mamba_metadata + # Every request contributed exactly one intermediate offset... + assert md.intermediate_count == n + assert md.intermediate_count == sum(md.per_request_intermediate_counts) + # ...the step never overruns the scratch buffer... + assert md.intermediate_count <= budget + # ...and it fills all but a small, *derived* deficit: the block-spillover + # (per_req > bs, so fewer requests fit than there are blocks). n <= + # max_requests forces the token-based bound, so budget == ceil(max_tokens / bs). + assert budget == math.ceil(ctx.max_tokens / bs) + expected_unfilled = math.ceil(ctx.max_tokens / bs) - n + assert budget - md.intermediate_count == expected_unfilled + + @pytest.mark.internal + def test_intermediate_offsets_use_configured_mamba_chunk_size(self): + # Regression guard for the fix that reads mamba_chunk_size from the model + # config instead of hardcoding 128 in compute_and_store_offsets. + # + # With a 64-token mamba chunk and 64-token blocks, a 65-token prompt's + # block boundary at token 64 is a valid mamba-chunk multiple + # (64 % 64 == 0), so its state must be extracted and cached. The old + # hardcoded filter (64 % 128 != 0) would have wrongly skipped it, caching + # nothing and leaving no resume point for a later turn. + bs = 64 + ctx = self._mctx( + mamba_config=self._mamba_config(mamba_chunk_size=64), + block_size_tokens=bs, + max_sequence_length=512, + ) + assert ctx.mamba_chunk_size == 64 + msa = ctx.mamba_slot_allocator + + # Fresh 65-token prompt: crosses exactly the block boundary at token 64. + ctx.add_request(self._req(ctx, self._prompt(bs + 1))) + + count = msa._intermediate_counts_cpu[0].item() + # The boundary at token 64 was recorded -- would be 0 under the old + # hardcoded-128 filter, since 64 % 128 != 0. + assert count == 1 + offsets = msa._intermediate_offsets_cpu[0, :count].tolist() + assert offsets == [bs] + class TestMixedCachedAndFreshPrefill(PrefixCachingTestBase): From c5398de5e126502e303c85049af84ba105571fbe Mon Sep 17 00:00:00 2001 From: Shijie Date: Thu, 23 Jul 2026 14:28:30 +0800 Subject: [PATCH 087/290] Fix TE grouped MLP fused main-grad setup (#5209) Signed-off-by: Shijie Wang --- megatron/core/transformer/moe/experts.py | 20 +++++++++- .../transformer/moe/test_grouped_mlp.py | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index d60a83b9af7..090e37c4f3e 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -565,7 +565,7 @@ def _make_fused_impl_pre_forward_hook(self) -> Callable: """Make function that calls submodule pre-forward callback hooks. This is intended for compatibility with - DistributedDataParallel hooks that trigger parameter + DistributedDataParallel/FSDP hooks that trigger parameter all-gathers. It does not support general pre-forward hooks since they may manipulate intermediate tensors that are never instantiated by the fused implementation. @@ -583,9 +583,27 @@ def forward_pre_hook(module, *_) -> None: f"but a {submodule.__class__.__name__} submodule " "has a pre-forward hook that modifies the input tensor." ) + self._ensure_main_grad_for_fused_impl() return forward_pre_hook + @staticmethod + def _ensure_main_grad(linear_module: torch.nn.Module) -> None: + """Expose FSDP main_grad buffers required by TE fused wgrad accumulation.""" + if not getattr(linear_module, "fuse_wgrad_accumulation", False): + return + for param in linear_module.parameters(recurse=False): + get_main_grad = getattr(param, "get_main_grad", None) + if get_main_grad is not None and getattr(param, "main_grad", None) is None: + param.main_grad = get_main_grad() + if hasattr(param, "overwrite_main_grad"): + param.overwrite_main_grad = True + + def _ensure_main_grad_for_fused_impl(self) -> None: + """Expose wrapper parameter main_grad buffers before TE fused ops run.""" + self._ensure_main_grad(self.linear_fc1) + self._ensure_main_grad(self.linear_fc2) + def _fused_forward( self, permuted_local_hidden_states: torch.Tensor, diff --git a/tests/unit_tests/transformer/moe/test_grouped_mlp.py b/tests/unit_tests/transformer/moe/test_grouped_mlp.py index 75fa941769a..b9e7fa346d2 100644 --- a/tests/unit_tests/transformer/moe/test_grouped_mlp.py +++ b/tests/unit_tests/transformer/moe/test_grouped_mlp.py @@ -263,6 +263,43 @@ def test_make_fused_impl_pre_forward_hook_rejects_input_modifying_hook(): hook(object()) +def test_make_fused_impl_pre_forward_hook_exposes_fsdp_main_grad_for_fused_wgrad(): + class FakeGroupedLinear(torch.nn.Module): + def __init__(self, *, fuse_wgrad_accumulation): + super().__init__() + self.fuse_wgrad_accumulation = fuse_wgrad_accumulation + self.weight = torch.nn.Parameter(torch.ones(2, 2)) + self.bias = torch.nn.Parameter(torch.zeros(2)) + + module = TEGroupedMLP.__new__(TEGroupedMLP) + torch.nn.Module.__init__(module) + module.linear_fc1 = FakeGroupedLinear(fuse_wgrad_accumulation=True) + module.linear_fc2 = FakeGroupedLinear(fuse_wgrad_accumulation=False) + + fc1_main_grad = torch.empty_like(module.linear_fc1.weight) + module.linear_fc1.weight.get_main_grad = lambda: fc1_main_grad + module.linear_fc1.weight.overwrite_main_grad = False + + existing_main_grad = torch.empty_like(module.linear_fc1.bias) + module.linear_fc1.bias.main_grad = existing_main_grad + module.linear_fc1.bias.get_main_grad = pytest.fail + module.linear_fc1.bias.overwrite_main_grad = False + + fc2_main_grad = torch.empty_like(module.linear_fc2.weight) + module.linear_fc2.weight.get_main_grad = lambda: fc2_main_grad + module.linear_fc2.weight.overwrite_main_grad = False + + hook = module._make_fused_impl_pre_forward_hook() + hook(object()) + + assert module.linear_fc1.weight.main_grad is fc1_main_grad + assert module.linear_fc1.weight.overwrite_main_grad is True + assert module.linear_fc1.bias.main_grad is existing_main_grad + assert module.linear_fc1.bias.overwrite_main_grad is True + assert getattr(module.linear_fc2.weight, "main_grad", None) is None + assert module.linear_fc2.weight.overwrite_main_grad is False + + def test_make_fused_ops_handles_single_grouped_weight_for_fc1(monkeypatch): class FakeGroupedLinear(torch.nn.Module): def __init__( From 51e915a404268f2f6eec37b81b0d286065fa0f4c Mon Sep 17 00:00:00 2001 From: wdykas <73254672+wdykas@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:08:59 -0400 Subject: [PATCH 088/290] Batch-invariant train/inference logprob parity (#5897) Signed-off-by: wdykas --- .../common/language_module/language_module.py | 11 ++ megatron/core/transformer/attention.py | 36 +++++- .../custom_layers/batch_invariant_kernels.py | 109 +++++++++++++++++- .../core/transformer/transformer_config.py | 35 +++++- megatron/rl/rl_utils.py | 14 ++- megatron/rl/sequence_packing_utils.py | 27 ++++- .../models/test_gpt_model_batch_invariant.py | 37 ++++-- .../models/test_hybrid_moe_model.py | 1 + .../test_te_layers_batch_invariant.py | 19 +++ 9 files changed, 263 insertions(+), 26 deletions(-) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 7b4cb5cf5a0..9fc94365045 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -141,6 +141,17 @@ def check_and_set_env_variable( check_and_set_env_variable("NVTE_FUSED_ATTN", 1, AttnBackend.auto) check_and_set_env_variable("NVTE_UNFUSED_ATTN", 1, AttnBackend.auto) + # Pin the FlashAttention generation for TransformerEngine by disabling the + # other versions via NVTE_FLASH_ATTN_V2/V3/V4 (default 1). This keeps the + # training-side attention on the same kernel as the mcore inference path, + # which honors config.flash_attention_version directly. + if self.config.flash_attention_version is not None: + for version in (2, 3, 4): + if version != self.config.flash_attention_version: + check_and_set_env_variable( + f"NVTE_FLASH_ATTN_V{version}", 0, self.config.attention_backend + ) + def compute_language_model_loss(self, labels: Tensor, logits: Tensor) -> Tensor: """Computes the language model loss (Cross entropy across vocabulary) diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py index 7b1a09ea333..3a5876bc0fe 100644 --- a/megatron/core/transformer/attention.py +++ b/megatron/core/transformer/attention.py @@ -318,6 +318,7 @@ def __init__( self.attn_mask_type = attn_mask_type self.attention_type = attention_type self.batch_invariant_mode = config.batch_invariant_mode + self.flash_attention_version = config.flash_attention_version # Cache the YaRN concentration factor (a.k.a. attention factor / mscale), # which is a pure function of the config and is reused on every forward @@ -995,6 +996,29 @@ def _flash_attention_3_forward_wrapper( ) return output_total, softmax_lse + def _resolve_flash_version(self) -> Tuple[bool, bool]: + """Resolve which FlashAttention generation this attention should run. + + Honors ``config.flash_attention_version`` when pinned, otherwise falls back + to the auto preference order (FA4 > FA3 > FA2). Returns ``(use_fa4, use_fa3)``; + when both are False the FA2 kernel is used. + """ + pinned = self.flash_attention_version + if pinned == 4: + assert ( + HAVE_FA4 + ), "flash_attention_version=4 requested but FlashAttention-4 is not installed" + return True, False + if pinned == 3: + assert ( + HAVE_FA3 + ), "flash_attention_version=3 requested but FlashAttention-3 is not installed" + return False, True + if pinned == 2: + return False, False + # Auto: prefer the newest available generation. + return HAVE_FA4, (HAVE_FA3 and not HAVE_FA4) + def flash_decode_and_prefill( self, q: Tensor, @@ -1053,6 +1077,8 @@ def flash_decode_and_prefill( # the sink (off-by-one / learnable) softmax correction post-hoc. need_lse = softmax_offset is not None + use_fa4, use_fa3 = self._resolve_flash_version() + # Flash attn kernel. if not is_decode_only: q = q.squeeze(1) @@ -1060,7 +1086,7 @@ def flash_decode_and_prefill( softmax_scale = self.softmax_scale else: softmax_scale = q.shape[-1] ** -0.5 - if HAVE_FA4: + if use_fa4: output_total, softmax_lse = flash_attn4_varlen_func( q, k, @@ -1075,7 +1101,7 @@ def flash_decode_and_prefill( window_size=window_size, num_splits=0 if not self.batch_invariant_mode else 1, ) - elif HAVE_FA3: + elif use_fa3: # TODO(ksanthanam): Replace with call to flash_attn_varlen_func once # it accepts block_table fa3_ret = self._flash_attention_3_forward_wrapper( @@ -1176,7 +1202,7 @@ def flash_decode_and_prefill( output_total, softmax_lse, softmax_offset ) else: - if HAVE_FA4: + if use_fa4: if getattr(self, "softmax_scale", None) is not None: softmax_scale = self.softmax_scale else: @@ -1219,12 +1245,12 @@ def flash_decode_and_prefill( "softmax_scale": softmax_scale, "causal": True, "window_size": window_size, - "page_table" if HAVE_FA3 else "block_table": block_table, + "page_table" if use_fa3 else "block_table": block_table, "num_splits": 0 if not self.batch_invariant_mode else 1, } if need_lse: flash_attn_args["return_softmax_lse"] = True - if HAVE_FA3: + if use_fa3: kvcache_ret = flash_attn3_with_kvcache(**flash_attn_args) else: assert ( diff --git a/megatron/core/transformer/custom_layers/batch_invariant_kernels.py b/megatron/core/transformer/custom_layers/batch_invariant_kernels.py index 6b4311fe540..a83e298eee2 100644 --- a/megatron/core/transformer/custom_layers/batch_invariant_kernels.py +++ b/megatron/core/transformer/custom_layers/batch_invariant_kernels.py @@ -525,10 +525,15 @@ def get_batch_invariant_attention_block_size() -> AttentionBlockSize: _MEG_TE_GENERAL_GEMM_ORIG = None _TE_RMSNORM_FUNC_ORIGS: Dict[str, Any] = {} _TE_GEMM_FUNC_ORIGS: Dict[str, Any] = {} +_TE_APPLY_NORM_ORIGS: Dict[str, Any] = {} def _import_module_if_available(name: str): - spec = importlib.util.find_spec(name) + try: + spec = importlib.util.find_spec(name) + except ModuleNotFoundError: + # find_spec on a submodule raises when the parent package is absent. + return None if spec is None: return None return importlib.import_module(name) @@ -617,6 +622,34 @@ def _patched(*args, **kwargs): _TE_RMSNORM_FUNC_ORIGS[name] = orig setattr(te_layernorm_mod, name, _make_rmsnorm_patched(orig)) + # Patch the fused-module normalization entry (`apply_normalization`). TE's + # fused LayerNormLinear / LayerNormMLP call this instead of RMSNorm.forward, + # so without this patch their internal RMSNorm runs TE's tex kernel, whose + # within-row reduction strategy depends on the total row count — i.e. it is + # NOT batch-invariant (observed: same rows, different output at 928 vs 2274 + # rows on GB200, 1 bf16 ulp per layer, amplifying across depth). + import transformer_engine.pytorch.module._common as te_common + + for mod_name, mod in ( + ("module._common", te_common), + ( + "module.layernorm_linear", + _import_module_if_available("transformer_engine.pytorch.module.layernorm_linear"), + ), + ( + "module.layernorm_mlp", + _import_module_if_available("transformer_engine.pytorch.module.layernorm_mlp"), + ), + ): + key = f"{mod_name}.apply_normalization" + if ( + mod is not None + and hasattr(mod, "apply_normalization") + and key not in _TE_APPLY_NORM_ORIGS + ): + _TE_APPLY_NORM_ORIGS[key] = mod.apply_normalization + mod.apply_normalization = _te_apply_normalization_patched + def _te_unpatch_for_batch_invariant(): """Restore original Transformer Engine functions if they were patched.""" @@ -652,6 +685,14 @@ def _te_unpatch_for_batch_invariant(): elif meg_te is None: _MEG_TE_GENERAL_GEMM_ORIG = None + # Restore fused-module apply_normalization entries + for key, orig in list(_TE_APPLY_NORM_ORIGS.items()): + mod_name = key.rsplit(".apply_normalization", 1)[0] + mod = _import_module_if_available(f"transformer_engine.pytorch.{mod_name}") + if mod is not None and hasattr(mod, "apply_normalization"): + mod.apply_normalization = orig + _TE_APPLY_NORM_ORIGS.pop(key, None) + # Restore TE module-level RMSNorm functions te_layernorm_mod = _import_module_if_available("transformer_engine.pytorch.module.layernorm") if te_layernorm_mod is not None: @@ -827,6 +868,72 @@ def backward(ctx, grad_output: torch.Tensor): return dA, dB, dbias, None, None +def _te_apply_normalization_patched( + inputmat, + ln_out, + ln_weight, + ln_bias, + eps, + output_quantizer, + output_dtype, + normalization, + fwd_ln_sm_margin, + zero_centered_gamma, +): + """Batch-invariant replacement for TE's fused-module `apply_normalization`. + + Routes RMSNorm through the batch-invariant implementation (fp32 stats via + `mean_dim`, deterministic within-row reduction independent of row count). + Falls back to the original TE kernel for configurations the BI path does + not cover (LayerNorm, fp8 quantized output). + + Returns `(ln_out, mu, rsigma)` like TE: `mu` is None for RMSNorm and + `rsigma` is fp32 with shape [rows], which TE's rmsnorm backward consumes. + """ + orig = _TE_APPLY_NORM_ORIGS.get("module._common.apply_normalization") + if ( + not is_batch_invariant_mode_enabled() + or normalization != "RMSNorm" + or output_quantizer is not None + or ln_bias is not None + ): + assert orig is not None, "TE apply_normalization original not captured" + return orig( + inputmat, + ln_out, + ln_weight, + ln_bias, + eps, + output_quantizer, + output_dtype, + normalization, + fwd_ln_sm_margin, + zero_centered_gamma, + ) + + x_fp32 = inputmat.float() + w_fp32 = ln_weight.float() + if zero_centered_gamma: + w_fp32 = w_fp32 + 1.0 + ms = mean_dim(x_fp32 * x_fp32, dim=-1, keepdim=True) + rsigma = torch.rsqrt(ms + eps) + out_fp32 = (x_fp32 * rsigma) * w_fp32 + + # The fused-module callers (layernorm_linear / layernorm_mlp) pass a torch.dtype + # output_dtype (inputmat.dtype); assert that so we never silently ignore a TE + # DType or other unexpected value here. + assert isinstance(output_dtype, torch.dtype), ( + "batch-invariant apply_normalization expects a torch.dtype output_dtype, got " + f"{type(output_dtype)}" + ) + if ln_out is not None: + # copy_ casts to ln_out.dtype in place, avoiding an intermediate allocation. + ln_out.copy_(out_fp32) + else: + ln_out = out_fp32.to(output_dtype) + return ln_out, None, rsigma.squeeze(-1) + + def _te_general_gemm_patched(*args, **kwargs) -> List[torch.Tensor]: """ Batch-invariant replacement for TE general_gemm. diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 0c9ce022db7..8c70baf25b7 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -147,6 +147,16 @@ class TransformerConfig(ModelParallelConfig): If attention backend is local we use the local pytorch implementation in mcore. Users can specify exact backend by changing this config. """ + flash_attention_version: Optional[Literal[2, 3, 4]] = None + """Pin the FlashAttention generation (2, 3, or 4) used by both the training + (TransformerEngine) and inference (mcore dynamic-batching) attention paths. When + None, each path selects a version automatically based on what is installed. Pinning + is required for batch-invariant mode: the training-side logprob recompute and the + inference engine must run the same kernel, since different FlashAttention + generations use different tile sizes and softmax accumulation orders and therefore + differ bitwise. On the training side this is enforced via TransformerEngine's + NVTE_FLASH_ATTN_V2/V3/V4 selection environment variables.""" + softmax_scale: Optional[float] = None """Softmax scale for attention scaling.""" @@ -2824,10 +2834,33 @@ def _scope_to_str(s): "for inference_optimized transformer implementation." ) + if self.flash_attention_version is not None: + assert self.flash_attention_version in (2, 3, 4), ( + "flash_attention_version must be one of 2, 3, or 4, got " + f"{self.flash_attention_version}" + ) + if self.batch_invariant_mode: assert ( self.attention_backend == AttnBackend.flash - ), "Batch invariant mode only supports FlashAttention" + ), "Batch invariant mode only supports FlashAttention (--attention-backend flash)" + # The training (TransformerEngine) and inference attention paths must run + # the same FlashAttention kernel, so the version cannot be left to each + # path's autodetection. FlashAttention-2 is excluded because it does not + # expose the fixed num_splits schedule the batch-invariant kernels require. + assert self.flash_attention_version in (3, 4), ( + "Batch invariant mode requires --flash-attention-version 3 or 4 so the " + "training and inference attention paths run the same batch-invariant " + f"FlashAttention kernel (got {self.flash_attention_version})." + ) + # Context parallelism routes through TE's FA2 fwd/bwd kernels directly, which + # cannot be pinned to another version; dropout is not batch-invariant. + assert ( + self.context_parallel_size == 1 + ), "Batch invariant mode does not support context parallelism" + assert ( + self.attention_dropout == 0.0 + ), "Batch invariant mode does not support attention dropout" @dataclass diff --git a/megatron/rl/rl_utils.py b/megatron/rl/rl_utils.py index 76791720e9d..a8922e69709 100644 --- a/megatron/rl/rl_utils.py +++ b/megatron/rl/rl_utils.py @@ -1688,11 +1688,13 @@ def prepare_data_for_update( if inference_logprobs is not None: # Pack the inference logprobs using the helper function # We do this for logging purposes even if is_correction is disabled - packed_inference_logprobs = pack_inference_logprobs( - inference_logprobs=packing_context.original_inference_logprobs, - packing_info=packing_context.packing_info, - generation_masks=packing_context.original_generation_masks, - bin_size=args.seq_length, + packed_inference_logprobs, packed_inference_filled_mask = ( + pack_inference_logprobs( + inference_logprobs=packing_context.original_inference_logprobs, + packing_info=packing_context.packing_info, + generation_masks=packing_context.original_generation_masks, + bin_size=args.seq_length, + ) ) # Compute statistics for logging using packed data @@ -1701,8 +1703,10 @@ def prepare_data_for_update( packed_inference_logprobs=packed_inference_logprobs, packed_loss_mask=packing_context.packed_loss_mask, group_stats=group_stats, + filled_mask=packed_inference_filled_mask, ) + # Store packed inference logprobs in packing context packing_context.packed_inference_logprobs = packed_inference_logprobs.cuda() # Only mark as having inference logprobs for IS correction if enabled diff --git a/megatron/rl/sequence_packing_utils.py b/megatron/rl/sequence_packing_utils.py index ff98b0a58e2..df94f633ff6 100644 --- a/megatron/rl/sequence_packing_utils.py +++ b/megatron/rl/sequence_packing_utils.py @@ -503,7 +503,10 @@ def pack_inference_logprobs( bin_size: Size of each bin Returns: - Packed inference logprobs tensor of shape [num_bins, bin_size - 1] + Packed inference logprobs tensor of shape [num_bins, bin_size - 1], and a + bool mask of the same shape marking positions actually filled from the + engine (the train side appends tokens, e.g. EOD, that the engine never + reported a logprob for; those stay zero-filled and must not be compared). """ num_bins = len(packing_info.bin_seq_indices) @@ -511,6 +514,7 @@ def pack_inference_logprobs( packed_inference_logprobs = torch.zeros( (num_bins, bin_size - 1), dtype=torch.float32, device='cpu' ) + filled_mask = torch.zeros((num_bins, bin_size - 1), dtype=torch.bool, device='cpu') # Create mapping from global sequence index to local bin index # This is needed because seq_to_bin_idx uses global bin indices, @@ -555,8 +559,9 @@ def pack_inference_logprobs( packed_inference_logprobs[local_bin_idx, pack_start:pack_end] = seq_inf_logprobs[ :actual_len ] + filled_mask[local_bin_idx, pack_start:pack_end] = True - return packed_inference_logprobs + return packed_inference_logprobs, filled_mask def compute_packed_inference_logprobs_stats( @@ -564,6 +569,7 @@ def compute_packed_inference_logprobs_stats( packed_inference_logprobs: torch.Tensor, packed_loss_mask: torch.Tensor, group_stats: Any, + filled_mask: Optional[torch.Tensor] = None, ) -> None: """Compute statistics for packed inference logprobs for logging purposes. @@ -575,17 +581,30 @@ def compute_packed_inference_logprobs_stats( packed_inference_logprobs: Packed inference logprobs [num_bins, seq_len-1] packed_loss_mask: Loss mask indicating valid positions [num_bins, seq_len] group_stats: Statistics object to update with computed metrics + filled_mask: Optional bool mask [num_bins, seq_len-1] marking positions actually + filled from the engine's reported logprobs. Positions the engine never + reported (e.g. the train-side EOD append) stay zero-filled and are excluded + from the stats so they do not contribute spurious |p_old - 1| terms. """ # Lazy import to avoid circular dependency (rl_utils imports from this module) from megatron.rl.rl_utils import update_inference_logprobs_group_stats - # Ensure all tensors are on the same device (CPU for stats computation) + # Ensure all tensors are on the same device (CPU for stats computation). + # Compare in the training dtype: old_logprobs are bf16 while the engine reports + # fp32 logprobs, so comparing raw values shows bf16-rounding noise even when the + # two sides are bitwise identical (the unpacked path already rounds by writing + # into an old_logprobs-dtype buffer in align_unpacked_inference_logprobs). old_logprobs = old_logprobs.cpu() - packed_inference_logprobs = packed_inference_logprobs.cpu() + packed_inference_logprobs = packed_inference_logprobs.cpu().to(old_logprobs.dtype) packed_loss_mask = packed_loss_mask.cpu() # Use packed_loss_mask to identify valid positions for stats (shift by 1 for logprobs) mask = packed_loss_mask[:, 1:].bool() + if filled_mask is not None: + # Exclude positions the engine never reported (zero-filled in packing), e.g. + # the train-side EOD append: comparing exp(0)=1 against a real prob there + # poisons the mismatch stats with spurious |p_old - 1| terms. + mask = mask & filled_mask.to(mask.device) # Ensure shapes match if mask.shape != old_logprobs.shape: diff --git a/tests/unit_tests/models/test_gpt_model_batch_invariant.py b/tests/unit_tests/models/test_gpt_model_batch_invariant.py index 9ab7e445c0d..b52fd64f592 100644 --- a/tests/unit_tests/models/test_gpt_model_batch_invariant.py +++ b/tests/unit_tests/models/test_gpt_model_batch_invariant.py @@ -36,6 +36,19 @@ except ImportError: HAVE_FA3 = False +try: + # Blackwell (e.g. GB200) ships FlashAttention-4 instead of FA3; the batch-invariant + # attention paths honor config.flash_attention_version, so these tests run there too. + from flash_attn.cute import flash_attn_varlen_func as _fa4_varlen_func # noqa: F401 + + HAVE_FA4 = True +except ImportError: + HAVE_FA4 = False + +# Batch-invariant mode requires an explicit FlashAttention version; pick the newest +# one available so training and inference run the same kernel. +_BIK_FA_VERSION = 4 if HAVE_FA4 else 3 + class DummyTokenizer: def __init__(self, vocab_size: int, bos: int | None = None, eod: int = 0, pad: int = 0): @@ -86,6 +99,7 @@ def _build_flash_attn_bik_model(seq_len: int, vocab_size: int, hidden_size: int hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, normalization="RMSNorm", params_dtype=torch.bfloat16, attention_backend=AttnBackend.flash, @@ -112,14 +126,21 @@ def _train_forward_logprobs(model: torch.nn.Module, tokens: torch.Tensor) -> tor batch_size, 1, seq_len, seq_len, dtype=torch.bool, device=tokens.device ) with torch.no_grad(): - logits = model(input_ids=tokens, position_ids=position_ids, attention_mask=attention_mask) + # runtime_gather_output matches rl_utils.get_logprobs; without it the model + # asserts once it has served inference requests (in-inference-mode postprocess). + logits = model( + input_ids=tokens, + position_ids=position_ids, + attention_mask=attention_mask, + runtime_gather_output=True, + ) logprobs = selective_log_softmax(logits[:, :-1, :], tokens[:, 1:]) return logprobs @pytest.mark.skipif( - not (is_te_min_version("2.10.0") and HAVE_FA3), - reason="TestGPTModelBatchInvariant requires TE >= 2.10.0 and FlashAttention-3", + not (is_te_min_version("2.10.0") and (HAVE_FA3 or HAVE_FA4)), + reason="TestGPTModelBatchInvariant requires TE >= 2.10.0 and FlashAttention-3 or -4", ) class TestGPTModelBatchInvariant: """End-to-end batch-invariance tests for GPT.""" @@ -199,9 +220,7 @@ def test_dynamic_engine_matches_batched_forward_rl(self): wrapper = GPTInferenceWrapper(inference_model, ctx) tokenizer = DummyTokenizer(vocab_size=vocab_size, bos=None, eod=vocab_size - 1, pad=0) controller = TextGenerationController(wrapper, tokenizer) - engine = DynamicInferenceEngine( - controller=controller, context=ctx, enable_cuda_graph=False, random_seed=123 - ) + engine = DynamicInferenceEngine(controller=controller, context=ctx) base_vals = [3, 15, 27, 39] lengths = [18, 11, 23, 13] @@ -262,7 +281,7 @@ def test_dynamic_engine_is_batch_invariant(self): def _run_engine_with_order(order): ctx = DynamicInferenceContext( - model_config=based_model.config, + model_config=base_model.config, inference_config=InferenceConfig( max_sequence_length=seq_len, buffer_size_gb=0.125, @@ -277,9 +296,7 @@ def _run_engine_with_order(order): wrapper = GPTInferenceWrapper(inference_model, ctx) tokenizer = DummyTokenizer(vocab_size=vocab_size, bos=None, eod=vocab_size - 1, pad=0) controller = TextGenerationController(wrapper, tokenizer) - engine = DynamicInferenceEngine( - controller=controller, context=ctx, enable_cuda_graph=False, random_seed=123 - ) + engine = DynamicInferenceEngine(controller=controller, context=ctx) base_vals = [3, 15, 27, 39] lengths = [18, 11, 23, 13] diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index f7dc78ce9a2..a9f688fe2e0 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -109,6 +109,7 @@ "ffn_hidden_size": 1856, "finalize_model_grads_func": None, "first_last_layers_bf16": False, + "flash_attention_version": None, "flash_decode": False, "fp16": False, "fp32_residual_connection": False, diff --git a/tests/unit_tests/transformer/test_te_layers_batch_invariant.py b/tests/unit_tests/transformer/test_te_layers_batch_invariant.py index e2d52727925..685e9332025 100644 --- a/tests/unit_tests/transformer/test_te_layers_batch_invariant.py +++ b/tests/unit_tests/transformer/test_te_layers_batch_invariant.py @@ -29,6 +29,16 @@ except ImportError: HAVE_FA3 = False +try: + from flash_attn.cute import flash_attn_varlen_func as _fa4_varlen_func # noqa: F401 + + HAVE_FA4 = True +except ImportError: + HAVE_FA4 = False + +# Batch-invariant mode requires an explicit FlashAttention version. +_BIK_FA_VERSION = 4 if HAVE_FA4 else 3 + # ============================================================================ # Batch-Invariant test helpers @@ -108,6 +118,7 @@ def test_te_column_parallel_linear_batch_invariant_randomized(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -154,6 +165,7 @@ def test_te_row_parallel_linear_batch_invariant_randomized(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -200,6 +212,7 @@ def test_te_layernorm_column_parallel_linear_batch_invariant_randomized(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -246,6 +259,7 @@ def test_te_norm_batch_invariant_randomized(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -279,6 +293,7 @@ def test_column_parallel_linear_batch_invariant_randomized(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -332,6 +347,7 @@ def test_te_attention_layer_batch_invariant_randomized(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -422,6 +438,7 @@ def test_te_column_parallel_linear_parity(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -517,6 +534,7 @@ def test_te_rmsnorm_parity(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, @@ -596,6 +614,7 @@ def test_te_layernorm_linear_parity(): hidden_dropout=0.0, attention_dropout=0.0, batch_invariant_mode=True, + flash_attention_version=_BIK_FA_VERSION, params_dtype=torch.bfloat16, normalization="RMSNorm", layernorm_epsilon=1e-5, From 15d4b349def61d350c15998ced373404b1abfa58 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 23 Jul 2026 10:23:17 -0700 Subject: [PATCH 089/290] Move FSDP model weight sync to optimizer post-step (#5949) Signed-off-by: Jingyue Wu --- .../megatron_fsdp/experimental/fully_shard.py | 6 ++- .../src/megatron_fsdp/experimental/module.py | 18 +++----- .../megatron_fsdp/experimental/optimizer.py | 30 ++++++++++--- .../experimental/parameter_group.py | 9 ++-- .../distributed/mfsdp_v2/test_fully_shard.py | 43 +++++++++++++++++++ .../distributed/mfsdp_v2/test_optimizer.py | 6 ++- 6 files changed, 88 insertions(+), 24 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py index 9bcfca26fab..0f53c34f359 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py @@ -68,7 +68,11 @@ def fully_shard( @contextmanager def microbatch(module: nn.Module, is_last: bool) -> Iterator[None]: - """Scope FSDP state to one microbatch. + """Mark an FSDP microbatch as the last accumulation microbatch. + + At present, this is only needed for HSDP/HFSDP gradient accumulation, so + FSDP finalizes gradients only on the last backward. Plain all-Flat data + parallelism finalizes gradients on every backward and does not need it. Args: module: Module tree whose FSDP roots should use this microbatch state. diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 125f8982744..33e6985d8ed 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -23,7 +23,7 @@ from ..mixed_precision import MixedPrecisionPolicy from .indexed_order import IndexedOrder -from .parameter_group import FsdpParameterGroup, contained_in_parameter_group +from .parameter_group import FsdpParameterGroup, get_containing_parameter_group from .placement import MeshAxis, Placements @@ -238,7 +238,7 @@ def pre_forward(self) -> None: if self.is_root(): allgather_stream.wait_stream(current_stream) - self._unshard_parameter_groups(sync_model_weight=True) + self._unshard_parameter_groups() assert self._unshard_event is not None # Compute waits only for this FsdpModule's all-gather (the prefetch below is # issued afterwards, so it is free to run concurrently with this FsdpModule). @@ -246,9 +246,9 @@ def pre_forward(self) -> None: next_module = context.forward_order.next_item(self) if next_module is not None: - next_module._unshard_parameter_groups(sync_model_weight=True) + next_module._unshard_parameter_groups() - def _unshard_parameter_groups(self, *, sync_model_weight: bool) -> None: + def _unshard_parameter_groups(self) -> None: """Unshard this FsdpModule's parameter groups on the all-gather stream. If ``_unshard_event`` is already set, this FsdpModule was already @@ -262,10 +262,6 @@ def _unshard_parameter_groups(self, *, sync_model_weight: bool) -> None: allgather_stream = self.context.allgather_stream with torch.cuda.stream(allgather_stream): for group in self._parameter_groups: - if sync_model_weight: - # TODO: After NVIDIA/Megatron-LM#5411 lands, move this sync to the - # optimizer post-step hook instead of running it every microbatch. - group.sync_model_weight_from_main_weight() group.unshard_parameters() self._unshard_event = allgather_stream.record_event() @@ -308,13 +304,13 @@ def pre_backward(self) -> None: # fork each preceding module issues before its collective. context.reduce_scatter_stream.wait_stream(current_stream) - self._unshard_parameter_groups(sync_model_weight=False) + self._unshard_parameter_groups() assert self._unshard_event is not None current_stream.wait_event(self._unshard_event) next_module = context.backward_order.next_item(self) if next_module is not None: - next_module._unshard_parameter_groups(sync_model_weight=False) + next_module._unshard_parameter_groups() def post_backward(self) -> None: """Reduce gradients and return parameters to their sharded resting state.""" @@ -387,7 +383,7 @@ def visit(submodule: nn.Module, submodule_fqn: str) -> None: parameter_fqn = ( f"{submodule_fqn}.{local_parameter_name}" if submodule_fqn else local_parameter_name ) - if contained_in_parameter_group(parameter): + if get_containing_parameter_group(parameter) is not None: raise ValueError( f"Parameter {parameter_fqn!r} is already owned by another FsdpModule." ) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py index 3f7745da9ff..f1617141569 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py @@ -19,15 +19,18 @@ import torch from torch import nn -from .parameter_group import contained_in_parameter_group +from .parameter_group import FsdpParameterGroup, get_containing_parameter_group -def fully_shard_optimizer(optimizer: torch.optim.Optimizer) -> None: +def fully_shard_optimizer( + optimizer: torch.optim.Optimizer, *, precision_aware: bool = False +) -> None: """Attach FSDP-aware step hooks to an optimizer instance. - The adapted optimizer preserves its existing parameter groups and only adds - temporary gradient casting around optimizer steps for FSDP sharded - parameters whose data dtype differs from their grad dtype. + The adapted optimizer preserves its existing parameter groups, temporarily + casts gradients around optimizer steps for FSDP sharded parameters whose + data dtype differs from their grad dtype unless the optimizer is precision + aware, and refreshes compute weights after each optimizer step. Alternatives considered: - Monkey-patching optimizer methods directly on the instance. This is @@ -46,6 +49,8 @@ def fully_shard_optimizer(optimizer: torch.optim.Optimizer) -> None: Args: optimizer: Optimizer instance to adapt in place. + precision_aware: Whether the optimizer accepts FSDP's mixed-precision + gradients without temporary casting. """ class CastedGrad(NamedTuple): @@ -86,7 +91,7 @@ def step_pre_hook( "fully_shard_optimizer expected optimizer param groups to contain " f"nn.Parameter values, got {type(parameter)!r}." ) - if not contained_in_parameter_group(parameter): + if precision_aware or get_containing_parameter_group(parameter) is None: continue if parameter.grad is None: continue @@ -99,10 +104,21 @@ def step_pre_hook( def step_post_hook( hooked_optimizer: torch.optim.Optimizer, args: tuple[Any, ...], kwargs: dict[str, Any] ) -> None: - del hooked_optimizer, args, kwargs + del args, kwargs for parameter, original_grad in casted_grads: set_grad(parameter, original_grad) casted_grads.clear() + fsdp_parameter_groups: set[FsdpParameterGroup] = set() + for optimizer_group in hooked_optimizer.param_groups: + for parameter in optimizer_group["params"]: + parameter_group = get_containing_parameter_group(parameter) + if parameter_group is None: + continue + fsdp_parameter_groups.add(parameter_group) + + for parameter_group in fsdp_parameter_groups: + parameter_group.sync_model_weight_from_main_weight() + optimizer.register_step_pre_hook(step_pre_hook) optimizer.register_step_post_hook(step_post_hook) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 6ea39031e60..fd5eb5d2033 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -29,9 +29,9 @@ _CONTAINING_PARAMETER_GROUP_ATTR = "_mfsdp_parameter_group" -def contained_in_parameter_group(parameter: nn.Parameter) -> bool: - """Return whether a parameter is already owned by an FsdpParameterGroup.""" - return hasattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR) +def get_containing_parameter_group(parameter: nn.Parameter) -> "FsdpParameterGroup | None": + """Return the FSDP parameter group that owns ``parameter``, if any.""" + return getattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, None) class FsdpParameterGroup: @@ -172,6 +172,9 @@ def __init__( self.sharded_parameters = tuple(sharded_parameters) self.unsharded_parameters = tuple(unsharded_parameters) + # Compute weights must be initialized before the first forward; subsequent + # refreshes happen from the FSDP optimizer's post-step hook. + self.sync_model_weight_from_main_weight() self._switch_to_sharded_parameters() self._unsharded_model_weight.release_storage() diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 081cc5c260f..510690d09ec 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -699,6 +699,7 @@ def test_next_forward_uses_optimizer_updated_weights(distributed_setup): # SGD's foreach/fused CUDA paths require matching parameter and gradient dtypes. # Use the scalar path to exercise FP32 main weights with default BF16 main grads. optimizer = torch.optim.SGD(model.parameters(), lr=0.25, foreach=False) + fully_shard_optimizer(optimizer) x = torch.ones(1, 1, device=device, dtype=torch.bfloat16) def train_iteration() -> torch.Tensor: @@ -715,6 +716,48 @@ def train_iteration() -> torch.Tensor: torch.testing.assert_close(second_loss, first_loss) +def test_optimizer_post_step_syncs_once_per_parameter_group(distributed_setup, monkeypatch): + """Optimizer synchronization should run once per group, not once per microbatch.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + if world_size < 2: + pytest.skip("This test requires at least 2 ranks.") + + mesh = init_device_mesh(device.type, (world_size,)) + model = TinyModel().to(device=device, dtype=torch.bfloat16) + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + parameter_groups = (*model.fc1.parameter_groups, *model.fc2.parameter_groups) + sync_counts = {parameter_group: 0 for parameter_group in parameter_groups} + + def make_count_sync(parameter_group): + sync_model_weight = parameter_group.sync_model_weight_from_main_weight + + def count_sync(): + sync_counts[parameter_group] += 1 + sync_model_weight() + + return count_sync + + for parameter_group in parameter_groups: + monkeypatch.setattr( + parameter_group, "sync_model_weight_from_main_weight", make_count_sync(parameter_group) + ) + + optimizer = torch.optim.Adam(model.parameters(), lr=0.01) + fully_shard_optimizer(optimizer) + inputs = torch.randn(3, 2, 8, device=device, dtype=torch.bfloat16) + + for step in range(3): + optimizer.zero_grad(set_to_none=True) + for microbatch_input in inputs: + (model(microbatch_input).sum() / len(inputs)).backward() + + assert all(sync_count == step for sync_count in sync_counts.values()) + optimizer.step() + assert all(sync_count == step + 1 for sync_count in sync_counts.values()) + + def test_fully_shard_adam_mixed_precision_losses_match_baseline(distributed_setup): """Mixed-precision FSDP Adam should track an unsharded Adam baseline.""" world_size = distributed_setup.world_size diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py b/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py index 3f3b636dd19..4ba4d1be271 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py @@ -12,6 +12,7 @@ Flat, Placements, fully_shard, + fully_shard_optimizer, ) from megatron.core.distributed.fsdp.src.megatron_fsdp.mixed_precision import MixedPrecisionPolicy @@ -54,8 +55,8 @@ def test_adam_without_adapter_raises_precision_error(distributed_setup): optimizer.step() -def test_fused_adam_without_adapter_accepts_mismatched_grads(distributed_setup): - """TE FusedAdam should handle mixed-precision FSDP grads without the adapter.""" +def test_fused_adam_adapter_accepts_mismatched_grads(distributed_setup): + """TE FusedAdam should handle mixed-precision FSDP grads through the adapter.""" world_size = distributed_setup.world_size device = distributed_setup.device @@ -80,6 +81,7 @@ def test_fused_adam_without_adapter_accepts_mismatched_grads(distributed_setup): mixed_precision_policy=mixed_precision_policy, ) optimizer = FusedAdam(model.parameters(), lr=0.01) + fully_shard_optimizer(optimizer, precision_aware=True) x = torch.randn(6, 8, device=device, dtype=torch.bfloat16) optimizer.zero_grad(set_to_none=True) From acd0e358ae5cbb8872010ff59b5ede9195904dcc Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Thu, 23 Jul 2026 11:07:12 -0700 Subject: [PATCH 090/290] fix: allow mtp_num_layers=0 with overlap_moe_expert_parallel_comm (#5912) Signed-off-by: Chen Cui --- .../core/transformer/transformer_config.py | 8 +++-- .../transformer/test_transformer_config.py | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/transformer/test_transformer_config.py diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 8c70baf25b7..3e5531fadbd 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -2676,9 +2676,11 @@ def _scope_to_str(s): assert ( not self.moe_shared_expert_overlap ), 'disable moe_shared_expert_overlap when enabling overlap_moe_expert_parallel_comm' - assert ( - self.mtp_num_layers is None or self.mtp_num_layers == 1 - ), 'MTP layernum only supports 1 when enabling overlap_moe_expert_parallel_comm.' + assert self.mtp_num_layers in ( + None, + 0, + 1, + ), 'MTP supports at most one layer when enabling overlap_moe_expert_parallel_comm.' # NCCL EP (ncclep flex backend) mirrors hybridep's comm/compute overlap, but a few # configs are not yet safe under the 1F1B split and are gated here. diff --git a/tests/unit_tests/transformer/test_transformer_config.py b/tests/unit_tests/transformer/test_transformer_config.py new file mode 100644 index 00000000000..febb3842789 --- /dev/null +++ b/tests/unit_tests/transformer/test_transformer_config.py @@ -0,0 +1,32 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import pytest + +from megatron.core.transformer.transformer_config import TransformerConfig + + +def _make_overlap_config(mtp_num_layers: int | None) -> TransformerConfig: + return TransformerConfig( + num_layers=1, + hidden_size=128, + num_attention_heads=4, + num_moe_experts=2, + expert_model_parallel_size=2, + moe_token_dispatcher_type="alltoall", + overlap_moe_expert_parallel_comm=True, + bf16=True, + mtp_num_layers=mtp_num_layers, + ) + + +@pytest.mark.parametrize("mtp_num_layers", [None, 0, 1]) +def test_ep_a2a_overlap_accepts_supported_mtp_layer_counts(mtp_num_layers: int | None): + config = _make_overlap_config(mtp_num_layers) + + assert config.mtp_num_layers == mtp_num_layers + + +@pytest.mark.parametrize("mtp_num_layers", [-1, 2]) +def test_ep_a2a_overlap_rejects_unsupported_mtp_layer_counts(mtp_num_layers: int): + with pytest.raises(AssertionError, match="MTP supports at most one layer"): + _make_overlap_config(mtp_num_layers) From 4464d1c1976b2563a70936f9d5d037817e60b2fa Mon Sep 17 00:00:00 2001 From: janEbert Date: Thu, 23 Jul 2026 18:33:46 +0000 Subject: [PATCH 091/290] Port Multi-Latent Attention to `HybridModel` (#4452) Signed-off-by: janEbert --- megatron/core/models/backends.py | 18 +- megatron/core/models/hybrid/hybrid_block.py | 38 +- .../models/hybrid/hybrid_layer_allocation.py | 7 +- .../core/models/hybrid/hybrid_layer_specs.py | 44 ++ .../absorbed_mla.py | 15 +- .../core/transformer/mla_qk_norm_config.py | 293 +++++++ .../transformer/multi_latent_attention.py | 32 +- megatron/training/arguments.py | 5 +- .../a2a_overlap/test_schedule_layer_1f1b.py | 9 +- .../distributed/test_finalize_model_grads.py | 7 +- tests/unit_tests/models/test_hybrid_model.py | 744 +++++++++++++++++- tests/unit_tests/ssm/test_hybrid_block.py | 57 +- .../ssm/test_hybrid_layer_allocation.py | 162 +++- .../test_absorbed_mla.py | 12 +- .../transformer/test_submodule_callables.py | 4 +- 15 files changed, 1368 insertions(+), 79 deletions(-) create mode 100644 megatron/core/transformer/mla_qk_norm_config.py diff --git a/megatron/core/models/backends.py b/megatron/core/models/backends.py index a270161ddd6..e29265c04f4 100644 --- a/megatron/core/models/backends.py +++ b/megatron/core/models/backends.py @@ -4,7 +4,7 @@ import warnings from abc import abstractmethod from functools import partial -from typing import Optional, Protocol, cast +from typing import Literal, Optional, Protocol, cast from megatron.core.extensions.transformer_engine import ( TEColumnParallelGroupedLinear, @@ -200,3 +200,19 @@ def grouped_mlp_modules(self, moe_use_grouped_gemm: bool) -> ExpertsBuilder: activation_func=self.activation_func(), ), ) + + +def get_backend( + transformer_impl: Literal["local", "transformer_engine", "inference_optimized"] +) -> BackendSpecProvider: + """Return the backend that's selected with the given `transformer_impl`.""" + if transformer_impl == "transformer_engine": + from megatron.core.extensions.transformer_engine_spec_provider import TESpecProvider + + return TESpecProvider() + elif transformer_impl == "inference_optimized": + return InferenceSpecProvider() + elif transformer_impl == "local": + return LocalSpecProvider() + else: + raise ValueError(f"unknown transformer_impl='{transformer_impl}'") diff --git a/megatron/core/models/hybrid/hybrid_block.py b/megatron/core/models/hybrid/hybrid_block.py index 22322e1b346..0042cbea010 100644 --- a/megatron/core/models/hybrid/hybrid_block.py +++ b/megatron/core/models/hybrid/hybrid_block.py @@ -5,6 +5,7 @@ # This source code is licensed under the Apache license found in the # LICENSE file in the root directory of this source tree. +import copy from contextlib import nullcontext from dataclasses import dataclass from typing import Optional, Tuple, Union @@ -15,7 +16,7 @@ from megatron.core.dist_checkpointing.mapping import ShardedStateDict from megatron.core.dist_checkpointing.utils import replace_prefix_for_sharding from megatron.core.enums import Fp8Recipe -from megatron.core.extensions.transformer_engine import TENorm +from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear, TENorm from megatron.core.fp4_utils import get_fp4_context from megatron.core.fp8_utils import get_fp8_context from megatron.core.inference.contexts import BaseInferenceContext @@ -28,6 +29,7 @@ from megatron.core.transformer.cuda_graphs import annotate_first_last_layer from megatron.core.transformer.identity_op import IdentityOp from megatron.core.transformer.module import MegatronModule +from megatron.core.transformer.multi_latent_attention import FusedMLASelfAttention from megatron.core.transformer.spec_utils import ModuleSpec, build_module from megatron.core.transformer.transformer_layer import TransformerLayer from megatron.core.transformer.utils import sharded_state_dict_default @@ -44,6 +46,7 @@ class HybridStackSubmodules: gdn_layer: Union[ModuleSpec, type] = IdentityOp attention_layer: Union[ModuleSpec, type] = IdentityOp dsa_layer: Union[ModuleSpec, type] = IdentityOp + mla_layer: Union[ModuleSpec, type] = IdentityOp mlp_layer: Union[ModuleSpec, type] = IdentityOp moe_layer: Union[ModuleSpec, type] = IdentityOp mtp_block_spec: Optional[ModuleSpec] = None @@ -114,6 +117,9 @@ def __init__( ) self.layer_type_list = layer_type_list + if getattr(self.config, "mla_down_proj_fusion", False): + submodules = self._fuse_mla_down_proj(submodules) + # Build layers from the pre-selected segment self.layers = nn.ModuleList() for i, layer_type in enumerate(self.layer_type_list): @@ -156,6 +162,16 @@ def __init__( pp_layer_offset=pp_layer_offset, name=(name + f".layers.{i}") if name is not None else None, ) + elif layer_type == LayerSymbols.MLA: + layer = build_module( + submodules.mla_layer, + config=self.config, + layer_number=layer_number, + pg_collection=pg_collection, + is_mtp_layer=is_mtp_layer, + add_layer_offset=False, + pp_layer_offset=pp_layer_offset, + ) elif layer_type == LayerSymbols.MLP: layer = build_module( submodules.mlp_layer, @@ -202,6 +218,26 @@ def __init__( eps=self.config.layernorm_epsilon, ) + def _fuse_mla_down_proj(self, submodules: HybridStackSubmodules) -> HybridStackSubmodules: + # Avoid modifying the original object so users don't get surprised about their `submodules` + # being modified underneath them. + submodules = copy.deepcopy(submodules) + mla_spec = submodules.mla_layer + # We always fuse the input layernorm because Hybrid always uses TransformerEngine. + mla_spec.submodules.input_layernorm = IdentityOp + mla_spec.submodules.self_attention.module = FusedMLASelfAttention + mla_spec.submodules.self_attention.submodules.linear_qkv_down_proj = ( + TELayerNormColumnParallelLinear + ) + mla_spec.submodules.self_attention.submodules.linear_q_down_proj = None + mla_spec.submodules.self_attention.submodules.linear_kv_down_proj = None + mla_spec.submodules.sharded_state_dict_keys_map = { + "self_attention.linear_q_down_proj.layer_norm_": "input_layernorm.", + "self_attention.linear_kv_down_proj.layer_norm_": "input_layernorm.", + "self_attention.linear_qkv_down_proj.layer_norm_": "input_layernorm.", + } + return submodules + def set_input_tensor(self, input_tensor: Tensor): """Set input tensor to be used instead of forward()'s input. diff --git a/megatron/core/models/hybrid/hybrid_layer_allocation.py b/megatron/core/models/hybrid/hybrid_layer_allocation.py index 67103fe67f1..83a6163b88d 100644 --- a/megatron/core/models/hybrid/hybrid_layer_allocation.py +++ b/megatron/core/models/hybrid/hybrid_layer_allocation.py @@ -18,11 +18,12 @@ class Symbols: GDN = 'G' ATTENTION = "*" DS_ATTENTION = "D" + MLA = "+" MLP = "-" MOE = 'E' PIPE = '|' MTP_SEPARATOR = "/" - VALID_LAYERS = {MAMBA, GDN, ATTENTION, DS_ATTENTION, MLP, MOE} + VALID_LAYERS = {MAMBA, GDN, ATTENTION, DS_ATTENTION, MLA, MLP, MOE} @classmethod def name_sorted_valid_layer_symbols(cls) -> list[str]: @@ -293,7 +294,7 @@ def _validate_pattern(pattern: str, pattern_name: str, allow_pipe: bool = False) ) # Disallow Attention + MLA/DSA hybridity. - if Symbols.ATTENTION in pattern and Symbols.DS_ATTENTION in pattern: + if Symbols.ATTENTION in pattern and (Symbols.DS_ATTENTION in pattern or Symbols.MLA in pattern): raise ValueError("Not supported to have both Attention and MLA/DSA in one model") @@ -321,7 +322,7 @@ def validate_segment_layers(segment: str) -> List[str]: ) # Disallow Attention + MLA/DSA hybridity. - if Symbols.ATTENTION in segment and Symbols.DS_ATTENTION in segment: + if Symbols.ATTENTION in segment and (Symbols.DS_ATTENTION in segment or Symbols.MLA in segment): raise ValueError("Not supported to have both Attention and MLA/DSA in one model") return layer_type_list diff --git a/megatron/core/models/hybrid/hybrid_layer_specs.py b/megatron/core/models/hybrid/hybrid_layer_specs.py index e1624293b5a..03fef58159f 100755 --- a/megatron/core/models/hybrid/hybrid_layer_specs.py +++ b/megatron/core/models/hybrid/hybrid_layer_specs.py @@ -169,6 +169,28 @@ self_attn_bda=get_bias_dropout_add, ), ), + mla_layer=ModuleSpec( + module=TransformerLayer, + submodules=TransformerLayerSubmodules( + input_layernorm=TENorm, + self_attention=ModuleSpec( + module=MLASelfAttention, + params={"attn_mask_type": AttnMaskType.causal}, + submodules=MLASelfAttentionSubmodules( + linear_q_proj=TEColumnParallelLinear, + linear_q_down_proj=TELinear, + linear_q_up_proj=TEColumnParallelLinear, + linear_kv_down_proj=TELinear, + linear_kv_up_proj=TEColumnParallelLinear, + core_attention=TEDotProductAttention, + linear_proj=TERowParallelLinear, + q_layernorm=IdentityOp, + kv_layernorm=IdentityOp, + ), + ), + self_attn_bda=get_bias_dropout_add, + ), + ), # Started with spec from gpt_layer_specs.py # Using the TE spec because we had problems getting the non-TE spec # working @@ -264,6 +286,28 @@ self_attn_bda=get_bias_dropout_add, ), ), + mla_layer=ModuleSpec( + module=TransformerLayer, + submodules=TransformerLayerSubmodules( + input_layernorm=TENorm, + self_attention=ModuleSpec( + module=MLASelfAttention, + params={"attn_mask_type": AttnMaskType.causal}, + submodules=MLASelfAttentionSubmodules( + linear_q_proj=TEColumnParallelLinear, + linear_q_down_proj=TELinear, + linear_q_up_proj=TEColumnParallelLinear, + linear_kv_down_proj=TELinear, + linear_kv_up_proj=TEColumnParallelLinear, + core_attention=TEDotProductAttention, + linear_proj=InferenceRowParallelLinear, + q_layernorm=IdentityOp, + kv_layernorm=IdentityOp, + ), + ), + self_attn_bda=get_bias_dropout_add, + ), + ), # Started with spec from gpt_layer_specs.py # Using the TE spec because we had problems getting the non-TE spec # working diff --git a/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py b/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py index fccf674d785..e0b6af7aa7f 100644 --- a/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py +++ b/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py @@ -35,6 +35,7 @@ ) from megatron.core.transformer.attention import Attention from megatron.core.transformer.enums import AttnMaskType +from megatron.core.transformer.mla_qk_norm_config import QKNormConfigResolver from megatron.core.transformer.spec_utils import ModuleSpec, build_module from megatron.core.transformer.transformer_config import MLATransformerConfig from megatron.core.utils import deprecate_inference_params, get_pg_size, is_te_min_version @@ -162,6 +163,10 @@ def __init__( name=name, ) + # Resolve which classes to use for Q and KV linear up projections and norms, based on + # QK-norm selection. + layer_classes = QKNormConfigResolver(self.config, submodules).resolve() + assert not config.add_bias_linear, "add_bias_linear is not supported for AbsorbedMLA" assert not ( config.tensor_model_parallel_size > 1 and not config.sequence_parallel @@ -260,7 +265,7 @@ def __init__( if self.config.q_lora_rank is None: # Not projecting query self.linear_q_proj = build_module( - submodules.linear_q_proj, + layer_classes["linear_q_proj"], self.config.hidden_size, self.config.num_attention_heads * self.q_head_dim, config=self.config, @@ -306,7 +311,7 @@ def __init__( ) self.linear_q_up_proj = build_module( - submodules.linear_q_up_proj, + layer_classes["linear_q_up_proj"], self.config.q_lora_rank, self.config.num_attention_heads * self.q_head_dim, config=self.config, @@ -353,7 +358,7 @@ def __init__( ) self.linear_kv_up_proj = build_module( - submodules.linear_kv_up_proj, + layer_classes["linear_kv_up_proj"], self.config.kv_lora_rank, self.config.num_attention_heads * (self.config.qk_head_dim + self.config.v_head_dim), config=self.config, @@ -369,14 +374,14 @@ def __init__( if self.config.q_lora_rank is not None: self.q_layernorm = build_module( - submodules.q_layernorm, + layer_classes["q_layernorm"], hidden_size=self.config.q_lora_rank, config=self.config, eps=self.config.layernorm_epsilon, ) self.kv_layernorm = build_module( - submodules.kv_layernorm, + layer_classes["kv_layernorm"], hidden_size=self.config.kv_lora_rank, config=self.config, eps=self.config.layernorm_epsilon, diff --git a/megatron/core/transformer/mla_qk_norm_config.py b/megatron/core/transformer/mla_qk_norm_config.py new file mode 100644 index 00000000000..e14066a105d --- /dev/null +++ b/megatron/core/transformer/mla_qk_norm_config.py @@ -0,0 +1,293 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +""" +Resolve MLA and DSA Q/KV norm configuration from a layer specification. +""" + +from typing import NoReturn + +from megatron.core.models.backends import get_backend +from megatron.core.transformer.identity_op import IdentityOp +from megatron.core.transformer.spec_utils import ModuleSpec +from megatron.core.transformer.torch_norm import LayerNormBuilder +from megatron.core.transformer.transformer_config import MLATransformerConfig + +__all__ = [] + +_QKNormResolvedConfig = dict[str, ModuleSpec | type | LayerNormBuilder] + + +class QKNormConfigResolver: + """Validate and resolve Q/KV norm placement for MLA and DSA. + + Q/KV norm can be represented either by a standalone norm module or by + a fused norm+linear projection. MLA can use the fused form; DSA cannot + because it needs the normalized Q/KV values outside the projection. + + Constraints: + - `qk_l2_norm` is unsupported for MLA/DSA. + - A standalone Q norm is only usable when `q_lora_rank` is set. + - Explicit norm modules cannot be paired with fused norm+linear projections. + - Disabled QK norm rejects both explicit norms and fused norm+linear projections. + - DSA with QK norm requires non-fused projections and standalone Q/KV norms. + """ + + def __init__(self, config: MLATransformerConfig, submodules) -> None: + """Capture the configuration, requested modules, and backend implementations.""" + self.config = config + self.submodules = submodules + self.has_q_lora = config.q_lora_rank is not None + self.is_dsa = config.experimental_attention_variant == "dsa" + self.variant_str = "DSA" if self.is_dsa else "MLA" + + backend = get_backend(config.transformer_impl) + self.qk_norm_impl = backend.layer_norm( + rms_norm=config.normalization == "RMSNorm", for_qk=True + ) + self.linear_impl = backend.column_parallel_linear() + self.fused_norm_linear_impl = backend.column_parallel_layer_norm_linear() + + def resolve(self) -> _QKNormResolvedConfig: + """Validate the specification and return the modules to instantiate. + + Returns: + The Q/KV norms and projections after applying the MLA or DSA constraints. + + Raises: + ValueError: If the requested norm placement is unsupported or conflicting. + """ + if self.config.qk_l2_norm: + raise ValueError(f"qk_l2_norm is not supported with {self.variant_str}.") + + self._reject_common_spec_conflicts() + if not self.config.qk_layernorm: + return self._resolve_disabled_qk_layernorm() + if self.is_dsa: + return self._resolve_dsa_qk_layernorm() + return self._resolve_mla_qk_layernorm() + + def _resolve_disabled_qk_layernorm(self) -> _QKNormResolvedConfig: + """Resolve projections when Q/KV normalization is disabled. + + Explicit norm modules and fused norm-linear projections are rejected because + they would still introduce Q/KV normalization. + """ + linear_q_proj_cls = IdentityOp + linear_q_up_proj_cls = IdentityOp + + if self.has_q_lora: + self._reject_disabled_norm( + self.submodules.linear_q_up_proj, + self.submodules.q_layernorm, + "linear_q_up_proj", + "q_layernorm", + ) + linear_q_up_proj_cls = self.submodules.linear_q_up_proj or self.linear_impl + else: + if self._is_fused_norm_linear(self.submodules.linear_q_proj): + raise ValueError( + f"spec sets linear_q_proj={self.submodules.linear_q_proj}, but " + "qk_layernorm/qk_l2_norm are supposed to be disabled" + ) + linear_q_proj_cls = self.submodules.linear_q_proj or self.linear_impl + + self._reject_disabled_norm( + self.submodules.linear_kv_up_proj, + self.submodules.kv_layernorm, + "linear_kv_up_proj", + "kv_layernorm", + ) + return self._result( + linear_q_proj=linear_q_proj_cls, + linear_q_up_proj=linear_q_up_proj_cls, + linear_kv_up_proj=self.submodules.linear_kv_up_proj or self.linear_impl, + q_layernorm=IdentityOp, + kv_layernorm=IdentityOp, + ) + + def _resolve_dsa_qk_layernorm(self) -> _QKNormResolvedConfig: + """Resolve DSA's standalone Q/KV norms and non-fused projections. + + DSA consumes the normalized Q/KV values outside the projection, so it cannot + use fused norm-linear projections. + """ + if not self.has_q_lora: + raise ValueError( + "`qk_layernorm=True` with `q_lora_rank is None` is not supported for DSA " + "because DSA cannot fuse Q norm into `linear_q_proj`." + ) + + return self._result( + linear_q_proj=IdentityOp, + linear_q_up_proj=self._dsa_linear_or_default( + self.submodules.linear_q_up_proj, "linear_q_up_proj" + ), + linear_kv_up_proj=self._dsa_linear_or_default( + self.submodules.linear_kv_up_proj, "linear_kv_up_proj" + ), + q_layernorm=self._default_if_trivial(self.submodules.q_layernorm, self.qk_norm_impl), + kv_layernorm=self._default_if_trivial(self.submodules.kv_layernorm, self.qk_norm_impl), + ) + + def _resolve_mla_qk_layernorm(self) -> _QKNormResolvedConfig: + """Resolve MLA norms, fusing them into projections when no norm is explicit.""" + q_norm_cls = self.submodules.q_layernorm or IdentityOp + linear_q_proj_cls = IdentityOp + linear_q_up_proj_cls = IdentityOp + + if self.has_q_lora: + if self._is_trivial(q_norm_cls): + linear_q_up_proj_cls = self._mla_fused_linear_or_default( + self.submodules.linear_q_up_proj, "linear_q_up_proj" + ) + else: + linear_q_up_proj_cls = self._non_fused_or_default( + self.submodules.linear_q_up_proj, "linear_q_up_proj" + ) + else: + linear_q_proj_cls = self._mla_fused_linear_or_default( + self.submodules.linear_q_proj, "linear_q_proj" + ) + + kv_norm_cls = self.submodules.kv_layernorm or IdentityOp + if self._is_trivial(kv_norm_cls): + linear_kv_up_proj_cls = self._mla_fused_linear_or_default( + self.submodules.linear_kv_up_proj, "linear_kv_up_proj" + ) + else: + linear_kv_up_proj_cls = self._non_fused_or_default( + self.submodules.linear_kv_up_proj, "linear_kv_up_proj" + ) + + return self._result( + linear_q_proj=linear_q_proj_cls, + linear_q_up_proj=linear_q_up_proj_cls, + linear_kv_up_proj=linear_kv_up_proj_cls, + q_layernorm=q_norm_cls, + kv_layernorm=kv_norm_cls, + ) + + def _reject_common_spec_conflicts(self) -> None: + """Reject conflicts that apply regardless of the selected attention variant.""" + if not self.has_q_lora and not self._is_trivial(self.submodules.q_layernorm): + self._raise_unused_q_norm() + if self.has_q_lora: + self._reject_explicit_norm_with_fused_linear( + self.submodules.linear_q_up_proj, + self.submodules.q_layernorm, + "linear_q_up_proj", + "q_layernorm", + ) + self._reject_explicit_norm_with_fused_linear( + self.submodules.linear_kv_up_proj, + self.submodules.kv_layernorm, + "linear_kv_up_proj", + "kv_layernorm", + ) + + def _reject_disabled_norm(self, module_spec, norm_spec, module_name, norm_name) -> None: + """Reject a norm module or fused projection when Q/KV norm is disabled.""" + if self._is_fused_norm_linear(module_spec) or not self._is_trivial(norm_spec): + raise ValueError( + f"spec sets {module_name}={module_spec} and " + f"{norm_name}={norm_spec}, but " + "qk_layernorm/qk_l2_norm are supposed to be disabled" + ) + + def _reject_explicit_norm_with_fused_linear( + self, module_spec, norm_spec, module_name, norm_name + ) -> None: + """Reject specifying the same norm both explicitly and inside a projection.""" + if not self._is_trivial(norm_spec) and self._is_fused_norm_linear(module_spec): + raise ValueError( + f"`{norm_name}={norm_spec}` is non-trivial " + f"and `{module_name}={module_spec}` is a " + f"fused norm+linear; either unset `{norm_name}` or use a " + f"linear layer without norm fusion for `{module_name}`" + ) + + def _non_fused_or_default(self, module_spec, module_name): + """Return a linear implementation, requiring it not to fuse normalization.""" + linear_cls = module_spec or self.linear_impl + self._require_linear(linear_cls, module_name) + if self._is_fused_norm_linear(linear_cls): + raise ValueError( + f"`{module_name}={module_spec}` is fused norm+linear, but a non-fused linear " + f"is required" + ) + return linear_cls + + def _dsa_linear_or_default(self, module_spec, module_name): + """Return DSA's non-fused projection implementation. + + This uses a DSA-specific diagnostic so the rejected constraint is clear. + """ + linear_cls = module_spec or self.linear_impl + self._require_linear(linear_cls, module_name) + if self._is_fused_norm_linear(linear_cls): + raise ValueError( + f"`{module_name}={module_spec}` is fused norm+linear, " + f"which is not supported for DSA." + ) + return linear_cls + + def _mla_fused_linear_or_default(self, module_spec, module_name): + """Return a fused MLA projection, using the backend default when available.""" + if self._is_fused_norm_linear(module_spec): + return module_spec + return self._require_linear(self.fused_norm_linear_impl, module_name) + + def _require_linear(self, module_spec, module_name): + """Return a configured projection or report that no viable implementation exists.""" + if module_spec is None: + raise RuntimeError( + "qk_layernorm requires TransformerEngine or " + "q_layernorm/kv_layernorm to be set in the spec " + f"to build `{module_name}`." + ) + return module_spec + + def _raise_unused_q_norm(self) -> NoReturn: + """Report an explicit Q norm that has no Q-LoRA projection to consume it.""" + help_msg = "" + if not self._is_fused_norm_linear(self.submodules.linear_q_proj): + help_msg = ( + f"Please use a fused norm+linear for " + f"`linear_q_proj={self.submodules.linear_q_proj}` if " + f"you intend to have a Q-norm." + ) + raise ValueError( + f"`q_layernorm={self.submodules.q_layernorm}` is non-trivial, " + f"but `q_lora_rank is None`, meaning it will not be used." + f"{help_msg}" + ) + + def _is_fused_norm_linear(self, module_spec) -> bool: + """Return whether a module specification selects the backend fused projection.""" + module_cls = module_spec.module if isinstance(module_spec, ModuleSpec) else module_spec + return self.fused_norm_linear_impl is not None and module_cls is self.fused_norm_linear_impl + + @staticmethod + def _is_trivial(module_spec) -> bool: + """Return whether a norm slot is unset or explicitly an identity operation.""" + return module_spec in (None, IdentityOp) + + @classmethod + def _default_if_trivial(cls, module_spec, default): + """Replace an unset or identity specification with the supplied default.""" + if cls._is_trivial(module_spec): + return default + return module_spec + + @staticmethod + def _result( + *, linear_q_proj, linear_q_up_proj, linear_kv_up_proj, q_layernorm, kv_layernorm + ) -> _QKNormResolvedConfig: + """Package the resolved Q/KV norms and projections in the caller's schema.""" + return dict( + linear_q_proj=linear_q_proj, + linear_q_up_proj=linear_q_up_proj, + linear_kv_up_proj=linear_kv_up_proj, + q_layernorm=q_layernorm, + kv_layernorm=kv_layernorm, + ) diff --git a/megatron/core/transformer/multi_latent_attention.py b/megatron/core/transformer/multi_latent_attention.py index 202034986db..50e11151dcd 100644 --- a/megatron/core/transformer/multi_latent_attention.py +++ b/megatron/core/transformer/multi_latent_attention.py @@ -36,6 +36,7 @@ ) from megatron.core.transformer.attention import Attention, LinearProjBuilder from megatron.core.transformer.enums import AttnMaskType +from megatron.core.transformer.mla_qk_norm_config import QKNormConfigResolver from megatron.core.transformer.spec_utils import ModuleSpec, build_module from megatron.core.transformer.torch_norm import LayerNormBuilder from megatron.core.transformer.transformer_config import MLATransformerConfig @@ -499,10 +500,14 @@ def __init__( name=name, ) + # Resolve which classes to use for Q and KV linear up projections and norms, based on + # QK-norm selection. + layer_classes = self._resolve_qk_norm_config(submodules) + if self.config.q_lora_rank is None: # Not projecting query self.linear_q_proj = build_module( - submodules.linear_q_proj, + layer_classes["linear_q_proj"], self.config.hidden_size, self.config.num_attention_heads * self.q_head_dim, config=self.config, @@ -549,7 +554,7 @@ def __init__( ) self.linear_q_up_proj = build_module( - submodules.linear_q_up_proj, + layer_classes["linear_q_up_proj"], self.config.q_lora_rank, self.config.num_attention_heads * self.q_head_dim, config=self.config, @@ -596,7 +601,7 @@ def __init__( ) self.linear_kv_up_proj = build_module( - submodules.linear_kv_up_proj, + layer_classes["linear_kv_up_proj"], self.config.kv_lora_rank, self.config.num_attention_heads * (self.config.qk_head_dim + self.config.v_head_dim), config=self.config, @@ -611,18 +616,24 @@ def __init__( ) if self.config.q_lora_rank is not None: - self.q_layernorm = submodules.q_layernorm( + self.q_layernorm = layer_classes["q_layernorm"]( hidden_size=self.config.q_lora_rank, config=self.config, eps=self.config.layernorm_epsilon, ) - self.kv_layernorm = submodules.kv_layernorm( + self.kv_layernorm = layer_classes["kv_layernorm"]( hidden_size=self.config.kv_lora_rank, config=self.config, eps=self.config.layernorm_epsilon, ) + def _resolve_qk_norm_config( + self, submodules + ) -> dict[str, ModuleSpec | type | LayerNormBuilder]: + """Resolve which Q/KV norm and up-projection implementations to build.""" + return QKNormConfigResolver(self.config, submodules).resolve() + def _qkv_down_projection(self, hidden_states): """Unfused q/kv down projection path.""" if self.config.q_lora_rank is not None: @@ -1250,6 +1261,9 @@ def __init__( "FusedMLASelfAttention requires q_lora_rank to be set; " "fallback to MLASelfAttention for q_lora_rank=None." ) + # Resolve which linear class to use for Q and KV up projections, + # based on QK-norm selection. + layer_classes = self._resolve_qk_norm_config(submodules) qkv_down_proj_kwargs = {} if submodules.linear_qkv_down_proj in [TELinear]: @@ -1285,7 +1299,7 @@ def __init__( ) self.linear_q_up_proj = build_module( - submodules.linear_q_up_proj, + layer_classes["linear_q_up_proj"], self.config.q_lora_rank, self.config.num_attention_heads * self.q_head_dim, config=self.config, @@ -1300,7 +1314,7 @@ def __init__( ) self.linear_kv_up_proj = build_module( - submodules.linear_kv_up_proj, + layer_classes["linear_kv_up_proj"], self.config.kv_lora_rank, self.config.num_attention_heads * (self.config.qk_head_dim + self.config.v_head_dim), config=self.config, @@ -1314,12 +1328,12 @@ def __init__( name=(name + ".linear_kv_up_proj") if name is not None else None, ) - self.q_layernorm = submodules.q_layernorm( + self.q_layernorm = layer_classes["q_layernorm"]( hidden_size=self.config.q_lora_rank, config=self.config, eps=self.config.layernorm_epsilon, ) - self.kv_layernorm = submodules.kv_layernorm( + self.kv_layernorm = layer_classes["kv_layernorm"]( hidden_size=self.config.kv_lora_rank, config=self.config, eps=self.config.layernorm_epsilon, diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index fccfda3d199..f38d02671e6 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -861,7 +861,10 @@ def validate_args(args, defaults={}): ) # Infer use of MLA from unified pattern - if args.hybrid_layer_pattern and Symbols.DS_ATTENTION in args.hybrid_layer_pattern: + if args.hybrid_layer_pattern and ( + Symbols.MLA in args.hybrid_layer_pattern + or Symbols.DS_ATTENTION in args.hybrid_layer_pattern + ): args.multi_latent_attention = True # === End of hybrid layer pattern: deprecation handling and validation === diff --git a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py index 3151b42d22d..01bda68b4ca 100644 --- a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py @@ -453,7 +453,12 @@ def test_mtp_layer_overlap(self, dispatcher_type, flex_backend, fp8_flag): Verifies all-to-all overlap optimization in MTP layer produces the same results as the reference implementation. """ - extra_kwargs = {"mtp_num_layers": 1, "mtp_loss_scaling_factor": 1.1} + qk_layernorm = True + extra_kwargs = { + "mtp_num_layers": 1, + "mtp_loss_scaling_factor": 1.1, + "qk_layernorm": qk_layernorm, + } apply_flex_backend_kwargs(extra_kwargs, dispatcher_type, flex_backend) if fp8_flag is not None: extra_kwargs["fp8_recipe"] = fp8_flag[1] @@ -466,7 +471,7 @@ def test_mtp_layer_overlap(self, dispatcher_type, flex_backend, fp8_flag): transformer_layer_spec = get_gpt_layer_with_transformer_engine_spec( num_experts=16, moe_grouped_gemm=True, - qk_layernorm=True, + qk_layernorm=qk_layernorm, multi_latent_attention=True, ) mtp_block_spec = get_gpt_mtp_block_spec(config, transformer_layer_spec, True) diff --git a/tests/unit_tests/distributed/test_finalize_model_grads.py b/tests/unit_tests/distributed/test_finalize_model_grads.py index 80d143a89a3..372f8d0d293 100644 --- a/tests/unit_tests/distributed/test_finalize_model_grads.py +++ b/tests/unit_tests/distributed/test_finalize_model_grads.py @@ -220,6 +220,7 @@ def test_update_router_qb_beta_skips_eval(self): class TestAllReduceLNGrads: def init_model(self, share_embeddings_and_output_weights: bool = False): + qk_layernorm = True self.transformer_config = TransformerConfig( num_layers=2, hidden_size=12, @@ -227,13 +228,15 @@ def init_model(self, share_embeddings_and_output_weights: bool = False): use_cpu_initialization=True, tensor_model_parallel_size=self.tp_size, pipeline_model_parallel_size=self.pp_size, - qk_layernorm=True, + qk_layernorm=qk_layernorm, pipeline_dtype=torch.float32, ) self.model = GPTModel( config=self.transformer_config, - transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec(qk_layernorm=True), + transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec( + qk_layernorm=qk_layernorm + ), vocab_size=100, max_sequence_length=4, share_embeddings_and_output_weights=share_embeddings_and_output_weights, diff --git a/tests/unit_tests/models/test_hybrid_model.py b/tests/unit_tests/models/test_hybrid_model.py index ffc9fe41e99..95bcaa2d7d0 100644 --- a/tests/unit_tests/models/test_hybrid_model.py +++ b/tests/unit_tests/models/test_hybrid_model.py @@ -1,9 +1,12 @@ # Copyright (c) 2024-2026, NVIDIA CORPORATION. All rights reserved. +import dataclasses +import functools import os from datetime import timedelta from itertools import accumulate from types import SimpleNamespace +from unittest.mock import patch import pytest import torch @@ -22,12 +25,75 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel, _hybrid_logging_pg_kwargs from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer import TransformerConfig +from megatron.core.transformer import MLATransformerConfig, TransformerConfig from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.module import Float16Module from megatron.core.utils import divide, is_fa_min_version, is_torch_min_version from tests.unit_tests.test_utilities import Utils +try: + from fast_hadamard_transform import hadamard_transform as _hadamard_transform + + _HAVE_HADAMARD = True +except ImportError: + _HAVE_HADAMARD = False + _hadamard_transform = None + + +def _mock_hadamard_transform(x: torch.Tensor, scale: float = 1.0) -> torch.Tensor: + """Identity-with-scale stand-in for `fast_hadamard_transform.hadamard_transform`. + + Mirrors the helper in `tests/unit_tests/transformer/experimental_attention_variant/ + test_attention_variant_dsa.py` so that DSA forward tests run in containers that + don't ship the upstream library. + """ + return x * scale + + +def _is_dataclass_instance(value): + return dataclasses.is_dataclass(value) and not isinstance(value, type) + + +def _assert_equal_with_partial_contents(left, right, path="root"): + """Assert recursive equality while comparing `partial` objects structurally.""" + if isinstance(left, functools.partial) or isinstance(right, functools.partial): + assert isinstance(left, functools.partial), f"{path}: left is not `partial`" + assert isinstance(right, functools.partial), f"{path}: right is not `partial`" + _assert_equal_with_partial_contents(left.func, right.func, f"{path}.func") + _assert_equal_with_partial_contents(left.args, right.args, f"{path}.args") + _assert_equal_with_partial_contents( + left.keywords or {}, right.keywords or {}, f"{path}.keywords" + ) + return + + if _is_dataclass_instance(left) or _is_dataclass_instance(right): + assert _is_dataclass_instance(left), f"{path}: left is not a dataclass" + assert _is_dataclass_instance(right), f"{path}: right is not a dataclass" + assert type(left) is type(right), f"{path}: dataclass types differ" + for field in dataclasses.fields(left): + if field.compare: + _assert_equal_with_partial_contents( + getattr(left, field.name), getattr(right, field.name), f"{path}.{field.name}" + ) + return + + if isinstance(left, dict) or isinstance(right, dict): + assert isinstance(left, dict), f"{path}: left is not a dict" + assert isinstance(right, dict), f"{path}: right is not a dict" + assert left.keys() == right.keys(), f"{path}: dict keys differ" + for key in left: + _assert_equal_with_partial_contents(left[key], right[key], f"{path}[{key!r}]") + return + + if isinstance(left, (list, tuple)) or isinstance(right, (list, tuple)): + assert type(left) is type(right), f"{path}: sequence types differ" + assert len(left) == len(right), f"{path}: sequence lengths differ" + for index, (left_item, right_item) in enumerate(zip(left, right)): + _assert_equal_with_partial_contents(left_item, right_item, f"{path}[{index}]") + return + + assert left == right, f"{path}: values differ" + def test_hybrid_logging_process_groups_are_paired(): tp_group = object() @@ -329,6 +395,12 @@ def test_layer_numbers(self): class TestHybridQKLayernorm: + # Subclasses override these to retarget the same tests at MLA's + # `mla_layer.kv_layernorm` or DSA's `dsa_layer.kv_layernorm`. The base class + # exercises the SelfAttention path with `attention_layer.k_layernorm`. + _attention_layer_attr = 'attention_layer' + _k_norm_attr = 'k_layernorm' + def setup_method(self, method): Utils.initialize_model_parallel(1, 1) model_parallel_cuda_manual_seed(123) @@ -336,7 +408,9 @@ def setup_method(self, method): def teardown_method(self, method): Utils.destroy_model_parallel() - def _build_model(self, **config_overrides): + def _build_model(self, spec=None, **config_overrides): + if spec is None: + spec = hybrid_stack_spec config = TransformerConfig( num_layers=3, hidden_size=256, @@ -346,26 +420,32 @@ def _build_model(self, **config_overrides): ) return HybridModel( config=config, - hybrid_stack_spec=hybrid_stack_spec, + hybrid_stack_spec=spec, vocab_size=100, max_sequence_length=4, hybrid_layer_pattern="M*-", ) def _get_attention_layer(self, model): - """Return the SelfAttention submodule from the attention layer.""" + """Return the self-attention submodule that owns a `q_layernorm`.""" for layer in model.decoder.layers: if hasattr(layer, 'self_attention') and hasattr(layer.self_attention, 'q_layernorm'): return layer.self_attention return None - def test_no_qk_norm_by_default(self): - """Without qk_layernorm, attention has no q/k layernorm.""" + def _get_k_norm(self, attn): + return getattr(attn, self._k_norm_attr) + + def test_trivial_qk_norm_by_default(self): + """Without qk_layernorm, attention has trivial q/k layernorm.""" + from megatron.core.transformer.identity_op import IdentityOp + model = self._build_model() attn = self._get_attention_layer(model) assert attn is not None - assert attn.q_layernorm is None - assert attn.k_layernorm is None + assert attn.q_layernorm is None or isinstance(attn.q_layernorm, IdentityOp) + k_norm = self._get_k_norm(attn) + assert k_norm is None or isinstance(k_norm, IdentityOp) def test_qk_layernorm_from_config(self): """config.qk_layernorm=True creates q/k layernorm even with static spec.""" @@ -375,7 +455,7 @@ def test_qk_layernorm_from_config(self): # TENorm is a factory (__new__ returns a TE LayerNorm/RMSNorm), so we # verify the norm was created rather than checking for a specific type. assert attn.q_layernorm is not None - assert attn.k_layernorm is not None + assert self._get_k_norm(attn) is not None def test_qk_l2_norm_from_config(self): """config.qk_l2_norm=True creates L2Norm q/k layernorm.""" @@ -385,57 +465,649 @@ def test_qk_l2_norm_from_config(self): attn = self._get_attention_layer(model) assert attn is not None assert isinstance(attn.q_layernorm, L2Norm) - assert isinstance(attn.k_layernorm, L2Norm) + assert isinstance(self._get_k_norm(attn), L2Norm) def test_spec_provided_norm_not_overwritten(self): """When the spec already provides q/k layernorm, config doesn't override it.""" import copy - from megatron.core.extensions.transformer_engine import ( - TEDotProductAttention, - TELayerNormColumnParallelLinear, - TERowParallelLinear, - ) - from megatron.core.transformer.attention import SelfAttention, SelfAttentionSubmodules - from megatron.core.transformer.enums import AttnMaskType from megatron.core.transformer.identity_op import IdentityOp - from megatron.core.transformer.spec_utils import ModuleSpec - from megatron.core.transformer.transformer_layer import ( - TransformerLayer, - TransformerLayerSubmodules, - ) - # Build a spec that explicitly sets q/k layernorm to IdentityOp + # Build a spec that explicitly sets q/k layernorm to IdentityOp on the + # attention layer that this subclass exercises. spec = copy.deepcopy(hybrid_stack_spec) - spec.submodules.attention_layer.submodules.self_attention.submodules.q_layernorm = ( - IdentityOp + attn_submodules = getattr( + spec.submodules, self._attention_layer_attr + ).submodules.self_attention.submodules + attn_submodules.q_layernorm = IdentityOp + setattr(attn_submodules, self._k_norm_attr, IdentityOp) + + model = self._build_model(spec=spec, qk_layernorm=True) + attn = self._get_attention_layer(model) + assert attn is not None + assert isinstance(attn.q_layernorm, IdentityOp) + assert isinstance(self._get_k_norm(attn), IdentityOp) + + def test_forward_with_qk_layernorm(self): + """HybridModel forward pass works with qk_layernorm enabled.""" + model = self._build_model(qk_layernorm=True) + model.cuda() + + sequence_length = 4 + micro_batch_size = 2 + data = list(range(sequence_length)) + input_ids = torch.tensor(data, dtype=torch.int64).repeat((micro_batch_size, 1)).cuda() + position_ids = torch.tensor(data, dtype=torch.int64).repeat((micro_batch_size, 1)).cuda() + attention_mask = torch.ones( + (micro_batch_size, 1, sequence_length, sequence_length), dtype=bool + ).cuda() + + logits = model.forward( + input_ids=input_ids, position_ids=position_ids, attention_mask=attention_mask + ) + + assert logits.shape[0] == micro_batch_size + assert logits.shape[1] == sequence_length + assert logits.shape[2] == 100 + + +class TestHybridMLAQKLayernorm(TestHybridQKLayernorm): + """Tests QK norm configuration of HybridModel with MLA.""" + + _attention_layer_attr = 'mla_layer' + _k_norm_attr = 'kv_layernorm' + + def _build_model(self, spec=None, **config_overrides): + if spec is None: + spec = hybrid_stack_spec + config = MLATransformerConfig( + num_layers=3, + hidden_size=256, + num_attention_heads=4, + use_cpu_initialization=True, + **config_overrides, ) - spec.submodules.attention_layer.submodules.self_attention.submodules.k_layernorm = ( - IdentityOp + return HybridModel( + config=config, + hybrid_stack_spec=spec, + vocab_size=100, + max_sequence_length=4, + hybrid_layer_pattern="M+-", ) - config = TransformerConfig( + def test_qk_l2_norm_from_config(self): + with pytest.raises(ValueError, match="qk_l2_norm is not supported"): + super().test_qk_l2_norm_from_config() + + +class TestHybridDSAQKLayernorm(TestHybridQKLayernorm): + """Tests QK norm configuration of HybridModel with DSA.""" + + _attention_layer_attr = 'dsa_layer' + _k_norm_attr = 'kv_layernorm' + + @pytest.fixture(autouse=True) + def _patch_hadamard_if_needed(self): + if not _HAVE_HADAMARD: + with patch( + 'megatron.core.transformer.experimental_attention_variant.dsa.hadamard_transform', + _mock_hadamard_transform, + ): + yield + else: + yield + + def test_spec_provided_norm_not_overwritten(self): + # DSA cannot fuse the QK norm into the up-projection, so a trivial + # `IdentityOp` spec is auto-promoted to `TENorm` when `qk_layernorm=True`. + # Finer-grained spec-respect behavior is covered by TestDSAQKNormResolution. + pytest.skip("DSA auto-promotes IdentityOp to TENorm; covered by TestDSAQKNormResolution.") + + def _build_model(self, spec=None, **config_overrides): + if spec is None: + spec = hybrid_stack_spec + config_kwargs = dict( num_layers=3, hidden_size=256, num_attention_heads=4, use_cpu_initialization=True, - qk_layernorm=True, + add_bias_linear=False, + # AbsorbedMLASelfAttention forwards `x` and `qr` to the DSA core attention; without + # this, the DSA core attention's forward fails on missing positional arguments. + experimental_attention_variant="dsa", + # DSA-specific settings; defaults are None and DSAIndexer requires them. + dsa_indexer_n_heads=8, + dsa_indexer_head_dim=64, + dsa_indexer_topk=32, + # The indexer-loss path runs in training mode and multiplies by this coefficient; + # leaving it at the default `None` raises `TypeError: ... 'Tensor' and 'NoneType'`. + dsa_indexer_loss_coeff=1.0, + # DSA's `rotate_activation` (Hadamard rotation) only supports bf16 input. + bf16=True, + params_dtype=torch.bfloat16, ) - model = HybridModel( + config_kwargs.update(config_overrides) + config = MLATransformerConfig(**config_kwargs) + return HybridModel( config=config, hybrid_stack_spec=spec, vocab_size=100, max_sequence_length=4, - hybrid_layer_pattern="M*-", + hybrid_layer_pattern="MD-", ) - attn = self._get_attention_layer(model) + + def test_qk_l2_norm_from_config(self): + with pytest.raises(ValueError, match="qk_l2_norm is not supported"): + super().test_qk_l2_norm_from_config() + + +class _MLAQKNormTestBase: + """Common machinery for MLA/DSA QK-norm spec tests. + + Subclasses override `experimental_attention_variant` and + `hybrid_layer_pattern` to target the MLA vs. DSA code path. + """ + + experimental_attention_variant = None + hybrid_layer_pattern = "M+-" + mla_layer_attr = "mla_layer" + + def setup_method(self, method): + Utils.initialize_model_parallel(1, 1) + model_parallel_cuda_manual_seed(123) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def _make_spec(self, **submodule_overrides): + """Return a copy of `hybrid_stack_spec` with MLA/DSA submodule overrides.""" + import copy + + spec = copy.deepcopy(hybrid_stack_spec) + mla_submodules = getattr( + spec.submodules, self.mla_layer_attr + ).submodules.self_attention.submodules + for key, value in submodule_overrides.items(): + setattr(mla_submodules, key, value) + return spec + + def _build_model(self, spec=None, **config_overrides): + if spec is None: + spec = hybrid_stack_spec + config_kwargs = dict( + num_layers=3, hidden_size=256, num_attention_heads=4, use_cpu_initialization=True + ) + if self.experimental_attention_variant is not None: + config_kwargs["experimental_attention_variant"] = self.experimental_attention_variant + if self.experimental_attention_variant == "dsa": + # Must not be True for DSA. + config_kwargs.setdefault("add_bias_linear", False) + # DSAIndexer requires these; their config defaults are None. + config_kwargs.setdefault("dsa_indexer_n_heads", 8) + config_kwargs.setdefault("dsa_indexer_head_dim", 64) + config_kwargs.setdefault("dsa_indexer_topk", 32) + + config_kwargs.update(config_overrides) + config = MLATransformerConfig(**config_kwargs) + return HybridModel( + config=config, + hybrid_stack_spec=spec, + vocab_size=100, + max_sequence_length=4, + hybrid_layer_pattern=self.hybrid_layer_pattern, + ) + + def _get_mla_attention(self, model): + """Return the attention submodule for the selected MLA variant, or None.""" + if self.experimental_attention_variant == "dsa": + from megatron.core.transformer.experimental_attention_variant.absorbed_mla import ( + AbsorbedMLASelfAttention, + ) + + attention_cls = AbsorbedMLASelfAttention + else: + from megatron.core.transformer.multi_latent_attention import MLASelfAttention + + attention_cls = MLASelfAttention + + for layer in model.decoder.layers: + if hasattr(layer, 'self_attention') and isinstance(layer.self_attention, attention_cls): + return layer.self_attention + return None + + +class TestMLAQKNormSpecValidation(_MLAQKNormTestBase): + """Tests QK norm spec validation in `MLASelfAttention`. + + These errors guard against silently ignoring a configured norm or + double-applying one through a fused norm+linear. + """ + + experimental_attention_variant = None + hybrid_layer_pattern = "M+-" + mla_layer_attr = "mla_layer" + + def test_q_norm_without_q_lora_rank_raises(self): + """When `q_lora_rank is None`, a non-trivial `q_layernorm` would + never be reached and must error out. + """ + from megatron.core.extensions.transformer_engine import TENorm + + spec = self._make_spec(q_layernorm=TENorm) + with pytest.raises(ValueError, match=r"q_lora_rank is None"): + self._build_model(spec=spec, q_lora_rank=None) + + def test_q_norm_without_q_lora_rank_hint_for_non_fused_linear(self): + """Error message hints at fused linear when `linear_q_proj` is non-fused.""" + from megatron.core.extensions.transformer_engine import TENorm + + spec = self._make_spec(q_layernorm=TENorm) + with pytest.raises(ValueError, match=r"fused norm\+linear for"): + self._build_model(spec=spec, q_lora_rank=None) + + def test_fused_linear_q_up_with_q_norm_raises(self): + """Non-trivial `q_layernorm` combined with a fused `linear_q_up_proj` + would apply the norm twice. + """ + from megatron.core.extensions.transformer_engine import ( + TELayerNormColumnParallelLinear, + TENorm, + ) + + spec = self._make_spec(q_layernorm=TENorm, linear_q_up_proj=TELayerNormColumnParallelLinear) + with pytest.raises(ValueError, match=r"fused norm\+linear"): + self._build_model(spec=spec) + + def test_fused_linear_kv_up_with_kv_norm_raises(self): + """Non-trivial `kv_layernorm` combined with a fused `linear_kv_up_proj` + would apply the norm twice. + """ + from megatron.core.extensions.transformer_engine import ( + TELayerNormColumnParallelLinear, + TENorm, + ) + + spec = self._make_spec( + kv_layernorm=TENorm, linear_kv_up_proj=TELayerNormColumnParallelLinear + ) + with pytest.raises(ValueError, match=r"fused norm\+linear"): + self._build_model(spec=spec) + + +class TestMLAQKNormResolution(_MLAQKNormTestBase): + """Tests `_resolve_qk_norm_config` for MLA. + + Covers fusion auto-selection, spec overrides, and the "disabled"-path + guards that reject fused/explicit norms when `qk_layernorm` is off. + """ + + experimental_attention_variant = None + hybrid_layer_pattern = "M+-" + mla_layer_attr = "mla_layer" + + def test_qk_layernorm_fuses_kv_up_by_default(self): + """With default (trivial) `kv_layernorm`, enabling `qk_layernorm` + auto-selects the fused `TELayerNormColumnParallelLinear` for KV up. + """ + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + from megatron.core.transformer.identity_op import IdentityOp + + model = self._build_model(qk_layernorm=True) + attn = self._get_mla_attention(model) assert attn is not None - assert isinstance(attn.q_layernorm, IdentityOp) - assert isinstance(attn.k_layernorm, IdentityOp) + assert isinstance(attn.linear_kv_up_proj, TELayerNormColumnParallelLinear) + assert isinstance(attn.kv_layernorm, IdentityOp) + + def test_spec_q_norm_disables_q_up_fusion(self): + """A non-trivial `q_layernorm` from the spec must force a non-fused + `linear_q_up_proj` so the norm isn't applied on top of a fused one. + """ + from megatron.core.extensions.transformer_engine import ( + TEColumnParallelLinear, + TELayerNormColumnParallelLinear, + TENorm, + ) + + spec = self._make_spec(q_layernorm=TENorm) + model = self._build_model(spec=spec, qk_layernorm=True) + attn = self._get_mla_attention(model) + assert attn is not None + assert isinstance(attn.linear_q_up_proj, TEColumnParallelLinear) + assert not isinstance(attn.linear_q_up_proj, TELayerNormColumnParallelLinear) + # The spec's norm is actually used; it's not reset to IdentityOp. + assert attn.q_layernorm is not None + from megatron.core.transformer.identity_op import IdentityOp + + assert not isinstance(attn.q_layernorm, IdentityOp) + + def test_spec_kv_norm_disables_kv_up_fusion(self): + """Mirror of `test_spec_q_norm_disables_q_up_fusion` for KV.""" + from megatron.core.extensions.transformer_engine import ( + TEColumnParallelLinear, + TELayerNormColumnParallelLinear, + TENorm, + ) + + spec = self._make_spec(kv_layernorm=TENorm) + model = self._build_model(spec=spec, qk_layernorm=True) + attn = self._get_mla_attention(model) + assert attn is not None + assert isinstance(attn.linear_kv_up_proj, TEColumnParallelLinear) + assert not isinstance(attn.linear_kv_up_proj, TELayerNormColumnParallelLinear) + from megatron.core.transformer.identity_op import IdentityOp + + assert not isinstance(attn.kv_layernorm, IdentityOp) + + def test_disabled_qk_layernorm_rejects_fused_linear_q_up(self): + """When `qk_layernorm` is off, spec must not force fused linear_q_up_proj.""" + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + + spec = self._make_spec(linear_q_up_proj=TELayerNormColumnParallelLinear) + with pytest.raises(ValueError, match=r"supposed to be disabled"): + self._build_model(spec=spec) + + def test_disabled_qk_layernorm_rejects_fused_linear_kv_up(self): + """When `qk_layernorm` is off, spec must not force fused linear_kv_up_proj.""" + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + + spec = self._make_spec(linear_kv_up_proj=TELayerNormColumnParallelLinear) + with pytest.raises(ValueError, match=r"supposed to be disabled"): + self._build_model(spec=spec) + + def test_disabled_qk_layernorm_rejects_spec_norms(self): + """When `qk_layernorm` is off, spec must not carry explicit q/kv layernorms.""" + from megatron.core.extensions.transformer_engine import TENorm + + for overrides in ( + {"q_layernorm": TENorm}, + {"kv_layernorm": TENorm}, + {"q_layernorm": TENorm, "kv_layernorm": TENorm}, + ): + spec = self._make_spec(**overrides) + with pytest.raises(ValueError, match=r"supposed to be disabled"): + self._build_model(spec=spec) + + +class TestDSAQKNormResolution(_MLAQKNormTestBase): + """Tests `_resolve_qk_norm_config` for DSA. + + DSA requires non-fused Q/KV up projections and explicit norms; + the fused optimization valid for MLA must be rejected here. + """ + + experimental_attention_variant = "dsa" + hybrid_layer_pattern = "MD-" + mla_layer_attr = "dsa_layer" + + def test_qk_layernorm_uses_unfused_linear_and_te_norm(self): + """With default spec, DSA + `qk_layernorm=True` uses non-fused + `TEColumnParallelLinear` and `TENorm` for Q/KV. + """ + from megatron.core.extensions.transformer_engine import ( + TEColumnParallelLinear, + TELayerNormColumnParallelLinear, + ) + from megatron.core.transformer.identity_op import IdentityOp - def test_forward_with_qk_layernorm(self): - """HybridModel forward pass works with qk_layernorm enabled.""" model = self._build_model(qk_layernorm=True) + attn = self._get_mla_attention(model) + assert attn is not None + assert isinstance(attn.linear_q_up_proj, TEColumnParallelLinear) + assert not isinstance(attn.linear_q_up_proj, TELayerNormColumnParallelLinear) + assert isinstance(attn.linear_kv_up_proj, TEColumnParallelLinear) + assert not isinstance(attn.linear_kv_up_proj, TELayerNormColumnParallelLinear) + assert not isinstance(attn.q_layernorm, IdentityOp) + assert not isinstance(attn.kv_layernorm, IdentityOp) + + def test_qk_layernorm_without_q_lora_rank_raises(self): + """DSA cannot apply Q norm when `q_lora_rank is None`.""" + with pytest.raises(ValueError, match=r"q_lora_rank is None.*not supported for DSA"): + self._build_model(qk_layernorm=True, q_lora_rank=None) + + def test_qk_layernorm_rejects_fused_linear_q_up(self): + """DSA does not support the fused norm+linear optimization.""" + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + + spec = self._make_spec(linear_q_up_proj=TELayerNormColumnParallelLinear) + with pytest.raises(ValueError, match=r"not supported for DSA"): + self._build_model(spec=spec, qk_layernorm=True) + + def test_qk_layernorm_without_q_lora_rejects_fused_linear_q(self): + """DSA does not support fused `linear_q_proj` when `q_lora_rank=None`.""" + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + + spec = self._make_spec(linear_q_proj=TELayerNormColumnParallelLinear) + with pytest.raises(ValueError, match=r"not supported for DSA"): + self._build_model(spec=spec, qk_layernorm=True, q_lora_rank=None) + + def test_disabled_qk_layernorm_rejects_fused_linear_kv_up(self): + """When `qk_layernorm` is off, spec must not force fused linear_kv_up_proj.""" + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + + spec = self._make_spec(linear_kv_up_proj=TELayerNormColumnParallelLinear) + with pytest.raises(ValueError, match=r"supposed to be disabled"): + self._build_model(spec=spec) + + def test_disabled_qk_layernorm_rejects_spec_norms(self): + """When `qk_layernorm` is off, spec must not carry explicit q/kv layernorms.""" + from megatron.core.extensions.transformer_engine import TENorm + + for overrides in ( + {"q_layernorm": TENorm}, + {"kv_layernorm": TENorm}, + {"q_layernorm": TENorm, "kv_layernorm": TENorm}, + ): + spec = self._make_spec(**overrides) + with pytest.raises(ValueError, match=r"supposed to be disabled"): + self._build_model(spec=spec) + + +class TestMLADownProjFusion: + """Tests `HybridStack._fuse_mla_down_proj`. + + The method rewrites the MLA `ModuleSpec` in place on a deep-copied + `HybridStackSubmodules` when `config.mla_down_proj_fusion=True`, swapping + the self-attention module to `FusedMLASelfAttention` and collapsing the + separate q/kv down projections into a single fused `linear_qkv_down_proj` + that also absorbs the input layernorm. + """ + + def setup_method(self, method): + Utils.initialize_model_parallel(1, 1) + model_parallel_cuda_manual_seed(123) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def _fresh_submodules(self): + """Return a deep copy of `hybrid_stack_spec.submodules` so tests don't + share state through `hybrid_stack_spec`. + """ + import copy + + return copy.deepcopy(hybrid_stack_spec.submodules) + + def _call_fuse(self, submodules, *, mla_down_proj_fusion): + """Invoke `_fuse_mla_down_proj` as an unbound method with a minimal + stub for `self`. The method only reads `self.config`, so we can avoid + constructing a full `HybridStack`. + """ + from megatron.core.models.hybrid.hybrid_block import HybridStack + + stub = SimpleNamespace(config=SimpleNamespace(mla_down_proj_fusion=mla_down_proj_fusion)) + # Mimic the call-site check in `HybridStack.__init__`. + if getattr(stub.config, "mla_down_proj_fusion", False): + submodules = HybridStack._fuse_mla_down_proj(stub, submodules) + return submodules + + def _build_model(self, pattern="M+-", **config_overrides): + config_kwargs = dict( + num_layers=3, hidden_size=256, num_attention_heads=4, use_cpu_initialization=True + ) + config_kwargs.update(config_overrides) + config = MLATransformerConfig(**config_kwargs) + return HybridModel( + config=config, + hybrid_stack_spec=hybrid_stack_spec, + vocab_size=100, + max_sequence_length=4, + hybrid_layer_pattern=pattern, + ) + + def _get_layer_with_mla(self, model): + """Return the layer whose self-attention is an `MLASelfAttention` + (which includes its `FusedMLASelfAttention` subclass). + """ + from megatron.core.transformer.multi_latent_attention import MLASelfAttention + + for layer in model.decoder.layers: + if hasattr(layer, 'self_attention') and isinstance( + layer.self_attention, MLASelfAttention + ): + return layer + return None + + def test_disabled_returns_spec_unchanged(self): + """Flag off: method returns the same object, no copying or rewriting.""" + submodules = self._fresh_submodules() + result = self._call_fuse(submodules, mla_down_proj_fusion=False) + assert result is submodules + + def test_enabled_rewrites_mla_spec(self): + """Flag on: MLA spec is swapped to the fused module and fused linear.""" + from megatron.core.extensions.transformer_engine import TELayerNormColumnParallelLinear + from megatron.core.transformer.identity_op import IdentityOp + from megatron.core.transformer.multi_latent_attention import FusedMLASelfAttention + + submodules = self._fresh_submodules() + result = self._call_fuse(submodules, mla_down_proj_fusion=True) + + mla_spec = result.mla_layer + assert mla_spec.submodules.input_layernorm is IdentityOp + assert mla_spec.submodules.self_attention.module is FusedMLASelfAttention + + attn_submodules = mla_spec.submodules.self_attention.submodules + assert attn_submodules.linear_qkv_down_proj is TELayerNormColumnParallelLinear + assert attn_submodules.linear_q_down_proj is None + assert attn_submodules.linear_kv_down_proj is None + + def test_enabled_sets_sharded_state_dict_keys_map(self): + """The keys map is written on the MLA layer submodules for checkpoint + compatibility with pre-fusion checkpoints. + """ + submodules = self._fresh_submodules() + result = self._call_fuse(submodules, mla_down_proj_fusion=True) + + keys_map = result.mla_layer.submodules.sharded_state_dict_keys_map + assert keys_map == { + "self_attention.linear_q_down_proj.layer_norm_": "input_layernorm.", + "self_attention.linear_kv_down_proj.layer_norm_": "input_layernorm.", + "self_attention.linear_qkv_down_proj.layer_norm_": "input_layernorm.", + } + + def test_enabled_deep_copies_input_submodules(self): + """The caller's submodules object must not be mutated – the method + deep-copies before rewriting, so callers can safely reuse their spec. + """ + from megatron.core.transformer.multi_latent_attention import ( + FusedMLASelfAttention, + MLASelfAttention, + ) + + submodules = self._fresh_submodules() + original_mla_module = submodules.mla_layer.submodules.self_attention.module + original_q_down_proj = ( + submodules.mla_layer.submodules.self_attention.submodules.linear_q_down_proj + ) + assert original_mla_module is MLASelfAttention # sanity check of baseline + + result = self._call_fuse(submodules, mla_down_proj_fusion=True) + + # Original is unchanged. + assert submodules.mla_layer.submodules.self_attention.module is original_mla_module + assert ( + submodules.mla_layer.submodules.self_attention.submodules.linear_q_down_proj + is original_q_down_proj + ) + # And result is a different object than the input. + assert result is not submodules + assert result.mla_layer is not submodules.mla_layer + # Plus the fused module only shows up on the returned copy. + assert result.mla_layer.submodules.self_attention.module is FusedMLASelfAttention + + def test_enabled_leaves_dsa_layer_alone(self): + """MLA fusion must not rewrite the absorbed DSA attention specification.""" + from megatron.core.transformer.experimental_attention_variant.absorbed_mla import ( + AbsorbedMLASelfAttention, + ) + from megatron.core.transformer.multi_latent_attention import FusedMLASelfAttention + + submodules = self._fresh_submodules() + result = self._call_fuse(submodules, mla_down_proj_fusion=True) + + assert result.dsa_layer.submodules.self_attention.module is AbsorbedMLASelfAttention + assert result.dsa_layer.submodules.self_attention.module is not FusedMLASelfAttention + # DSA's down projections must remain non-`None` (they're still used + # via the unfused path). + assert result.dsa_layer.submodules.self_attention.submodules.linear_q_down_proj is not None + assert result.dsa_layer.submodules.self_attention.submodules.linear_kv_down_proj is not None + + def test_enabled_leaves_non_mla_layers_alone(self): + """Unrelated layer specs (mamba, attention, mlp) must survive unchanged.""" + submodules = self._fresh_submodules() + original_mamba = submodules.mamba_layer + original_attention = submodules.attention_layer + original_mlp = submodules.mlp_layer + + result = self._call_fuse(submodules, mla_down_proj_fusion=True) + + _assert_equal_with_partial_contents(result.mamba_layer, original_mamba) + _assert_equal_with_partial_contents(result.attention_layer, original_attention) + _assert_equal_with_partial_contents(result.mlp_layer, original_mlp) + + def test_model_uses_fused_mla_when_enabled(self): + """Integration: a full HybridModel built with the flag uses + `FusedMLASelfAttention`. + """ + from megatron.core.transformer.multi_latent_attention import FusedMLASelfAttention + + model = self._build_model(mla_down_proj_fusion=True) + layer = self._get_layer_with_mla(model) + assert layer is not None + assert isinstance(layer.self_attention, FusedMLASelfAttention) + # And the fused down projection is present on the attention module. + assert hasattr(layer.self_attention, "linear_qkv_down_proj") + + def test_model_uses_unfused_mla_when_disabled(self): + """Integration: with the flag off, MLA layers use the standard + `MLASelfAttention` (never the fused subclass). + """ + from megatron.core.transformer.multi_latent_attention import ( + FusedMLASelfAttention, + MLASelfAttention, + ) + + model = self._build_model(mla_down_proj_fusion=False) + layer = self._get_layer_with_mla(model) + assert layer is not None + assert isinstance(layer.self_attention, MLASelfAttention) + assert not isinstance(layer.self_attention, FusedMLASelfAttention) + + def test_enabled_replaces_input_layernorm_with_identity(self): + """Integration: because the fused down-proj absorbs the input + layernorm, the transformer layer's own `input_layernorm` must be + `IdentityOp`. + """ + from megatron.core.transformer.identity_op import IdentityOp + + model = self._build_model(mla_down_proj_fusion=True) + layer = self._get_layer_with_mla(model) + assert layer is not None + assert isinstance(layer.input_layernorm, IdentityOp) + + def test_forward_with_fused_mla(self): + """Integration: forward pass works with `mla_down_proj_fusion=True`.""" + model = self._build_model(mla_down_proj_fusion=True) model.cuda() sequence_length = 4 diff --git a/tests/unit_tests/ssm/test_hybrid_block.py b/tests/unit_tests/ssm/test_hybrid_block.py index f59a424d5c5..5d3c33264f4 100644 --- a/tests/unit_tests/ssm/test_hybrid_block.py +++ b/tests/unit_tests/ssm/test_hybrid_block.py @@ -3,6 +3,7 @@ import pytest import torch +from megatron.core.extensions.transformer_engine import TEDotProductAttention from megatron.core.models.hybrid.hybrid_block import HybridStack from megatron.core.models.hybrid.hybrid_layer_allocation import Symbols, validate_segment_layers from megatron.core.models.hybrid.hybrid_layer_specs import hybrid_stack_spec @@ -17,6 +18,7 @@ ) from megatron.core.transformer.experimental_attention_variant.dsa import DSAttention from megatron.core.transformer.mlp import MLP +from megatron.core.transformer.multi_latent_attention import MLASelfAttention from megatron.core.transformer.transformer_config import MLATransformerConfig from megatron.core.transformer.transformer_layer import TransformerLayer from tests.unit_tests.test_utilities import Utils @@ -52,7 +54,7 @@ def get_hybrid_block(self, layer_pattern, **config_kwargs): pg_collection=self.get_pg_collection(), ) - def get_dsa_mamba_block(self, layer_pattern): + def get_dsa_hybrid_block(self, layer_pattern): layer_type_list = validate_segment_layers(layer_pattern) transformer_config = MLATransformerConfig( hidden_size=256, # The Mamba layer places several constraints on this @@ -85,6 +87,35 @@ def get_dsa_mamba_block(self, layer_pattern): pg_collection=self.get_pg_collection(), ) + def get_mla_hybrid_block(self, layer_pattern): + layer_type_list = validate_segment_layers(layer_pattern) + transformer_config = MLATransformerConfig( + hidden_size=256, # The Mamba layer places several constraints on this + # Need to specify num_attention_heads and num_layers or TransformerConfig + # will generate errors. + num_layers=len(layer_type_list), + num_attention_heads=16, + use_cpu_initialization=True, + bf16=True, + params_dtype=torch.bfloat16, + q_lora_rank=64, + kv_lora_rank=64, + qk_head_dim=64, + qk_pos_emb_head_dim=32, + v_head_dim=64, + rope_type='rope', + rotary_base=10000, + rotary_percent=1.0, + ) + modules = hybrid_stack_spec.submodules + return HybridStack( + transformer_config, + modules, + layer_type_list=layer_type_list, + pp_layer_offset=0, + pg_collection=self.get_pg_collection(), + ) + def teardown_method(self, method): Utils.destroy_model_parallel() @@ -213,7 +244,7 @@ def test_layer_types(self): assert isinstance(layers[2].mlp, MLP) def test_invalid_layer_types_cause_failure(self): - invalid_symbol = '+' + invalid_symbol = 'X' assert invalid_symbol not in Symbols.VALID_LAYERS # sanity check. layer_pattern = Symbols.MAMBA + Symbols.ATTENTION + Symbols.MLP + invalid_symbol # validate_segment_layers() in hybrid_layer_allocation.py throws a ValueError. @@ -271,7 +302,7 @@ def test_gdn_gpu_forward(self): def test_dsa_layer_types(self): """D symbol creates a TransformerLayer with absorbed MLA and DSA core attention.""" layer_pattern = Symbols.MAMBA + Symbols.DS_ATTENTION + Symbols.MAMBA - block = self.get_dsa_mamba_block(layer_pattern) + block = self.get_dsa_hybrid_block(layer_pattern) layers = block.layers assert isinstance(layers[0], MambaLayer) assert isinstance(layers[1], TransformerLayer) @@ -283,4 +314,22 @@ def test_mixed_attention_and_dsa_layer_types(self): """* and D in the same block fail.""" layer_pattern = Symbols.MAMBA + Symbols.ATTENTION + Symbols.DS_ATTENTION + Symbols.MAMBA with pytest.raises(ValueError): - block = self.get_dsa_mamba_block(layer_pattern) + block = self.get_dsa_hybrid_block(layer_pattern) + + def test_mla_layer_types(self): + """+ symbol creates a TransformerLayer with MLASelfAttention but + standard (non-DSA) core attention.""" + layer_pattern = Symbols.MAMBA + Symbols.MLA + Symbols.MAMBA + block = self.get_mla_hybrid_block(layer_pattern) + layers = block.layers + assert isinstance(layers[0], MambaLayer) + assert isinstance(layers[1], TransformerLayer) + assert isinstance(layers[1].self_attention, MLASelfAttention) + assert isinstance(layers[1].self_attention.core_attention, TEDotProductAttention) + assert isinstance(layers[2], MambaLayer) + + def test_mixed_attention_and_mla_layer_types(self): + """* and + in the same block fail (same reason as * and D).""" + layer_pattern = Symbols.MAMBA + Symbols.ATTENTION + Symbols.MLA + Symbols.MAMBA + with pytest.raises(ValueError): + block = self.get_mla_hybrid_block(layer_pattern) diff --git a/tests/unit_tests/ssm/test_hybrid_layer_allocation.py b/tests/unit_tests/ssm/test_hybrid_layer_allocation.py index faa553216da..8b4c181ee30 100644 --- a/tests/unit_tests/ssm/test_hybrid_layer_allocation.py +++ b/tests/unit_tests/ssm/test_hybrid_layer_allocation.py @@ -78,6 +78,7 @@ def test_valid_patterns(self): ("GGG*GGG*", ['G', 'G', 'G', '*', 'G', 'G', 'G', '*']), ("GEGEGE*E", ['G', 'E', 'G', 'E', 'G', 'E', '*', 'E']), ("MDMD", ['M', 'D', 'M', 'D']), + ("M+M+", ['M', '+', 'M', '+']), ] for pattern, expected in test_cases: result = validate_segment_layers(pattern) @@ -101,6 +102,11 @@ def test_invalid_symbols_cause_failure(self): with pytest.raises(ValueError): # Not allowed to have both standard Attention and MLA/DSA validate_segment_layers("MDM*-") + with pytest.raises(ValueError): + # Not allowed to have both standard Attention and MLA (same reason + # as DSA: * uses the model-level rotary_pos_emb while + uses MLA's + # own decoupled RoPE). + validate_segment_layers("M+M*-") @pytest.mark.internal @@ -163,6 +169,8 @@ def test_main_pattern_only(self): ("GEGEGE*E", "GEGEGE*E"), ("MDMD", "MDMD"), ("DM", "DM"), + ("M+M+", "M+M+"), + ("+M", "+M"), ] for pattern, expected_main in test_cases: result = parse_hybrid_pattern(pattern) @@ -287,6 +295,8 @@ def test_complex_patterns(self): ("GEGEGE*E/GG/GG", "GEGEGE*E", "GG", 2), # DSA in main pattern with MTP ("MDMD/MD/MD", "MDMD", "MD", 2), + # MLA in main pattern with MTP + ("M+M+/M+/M+", "M+M+", "M+", 2), ] for pattern, expected_main, expected_mtp, expected_depths in test_cases: result = parse_hybrid_pattern(pattern) @@ -305,21 +315,63 @@ def test_dataclass_equality(self): class TestGetHybridLayerCounts: def test_simple_pattern(self): - assert get_hybrid_layer_counts("M*M*") == {'*': 2, 'D': 0, 'G': 0, 'M': 2, '-': 0, 'E': 0} + assert get_hybrid_layer_counts("M*M*") == { + '*': 2, + 'D': 0, + 'G': 0, + 'M': 2, + '+': 0, + '-': 0, + 'E': 0, + } def test_all_layer_types(self): # Not allowed to have both standard Attention and MLA/DSA, so we do separate asserts. - assert get_hybrid_layer_counts("MG*-E") == {'*': 1, 'D': 0, 'G': 1, 'M': 1, '-': 1, 'E': 1} - assert get_hybrid_layer_counts("MGD-E") == {'*': 0, 'D': 1, 'G': 1, 'M': 1, '-': 1, 'E': 1} + assert get_hybrid_layer_counts("MG*-E") == { + '*': 1, + 'D': 0, + 'G': 1, + 'M': 1, + '+': 0, + '-': 1, + 'E': 1, + } + assert get_hybrid_layer_counts("MGD-E") == { + '*': 0, + 'D': 1, + 'G': 1, + 'M': 1, + '+': 0, + '-': 1, + 'E': 1, + } + assert get_hybrid_layer_counts("MG+-E") == { + '*': 0, + 'D': 0, + 'G': 1, + 'M': 1, + '+': 1, + '-': 1, + 'E': 1, + } def test_with_pipes(self): # Pipes should be skipped in counting - assert get_hybrid_layer_counts("M*|M*") == {'*': 2, 'D': 0, 'G': 0, 'M': 2, '-': 0, 'E': 0} + assert get_hybrid_layer_counts("M*|M*") == { + '*': 2, + 'D': 0, + 'G': 0, + 'M': 2, + '+': 0, + '-': 0, + 'E': 0, + } assert get_hybrid_layer_counts("M-M-|M-M*-") == { '*': 1, 'D': 0, 'G': 0, 'M': 4, + '+': 0, '-': 4, 'E': 0, } @@ -331,6 +383,7 @@ def test_with_mtp(self): 'D': 0, 'G': 0, 'M': 6, + '+': 0, '-': 0, 'E': 0, } @@ -343,12 +396,21 @@ def test_with_pipes_and_mtp(self): 'D': 0, 'G': 0, 'M': 8, + '+': 0, '-': 4, 'E': 0, } def test_moe_pattern(self): - assert get_hybrid_layer_counts("MEME") == {'*': 0, 'D': 0, 'G': 0, 'M': 2, '-': 0, 'E': 2} + assert get_hybrid_layer_counts("MEME") == { + '*': 0, + 'D': 0, + 'G': 0, + 'M': 2, + '+': 0, + '-': 0, + 'E': 2, + } def test_mtp_with_attention(self): # MTP pattern "*M" repeated 3 depths -> 3 attn + 3 mamba from MTP @@ -357,22 +419,66 @@ def test_mtp_with_attention(self): 'D': 0, 'G': 0, 'M': 7, + '+': 0, '-': 0, 'E': 0, } def test_gdn_pattern(self): - assert get_hybrid_layer_counts("GMGM") == {'*': 0, 'D': 0, 'G': 2, 'M': 2, '-': 0, 'E': 0} + assert get_hybrid_layer_counts("GMGM") == { + '*': 0, + 'D': 0, + 'G': 2, + 'M': 2, + '+': 0, + '-': 0, + 'E': 0, + } def test_gdn_hybrid_pattern(self): # GDN + Mamba + Attention - assert get_hybrid_layer_counts("G*GM*") == {'*': 2, 'D': 0, 'G': 2, 'M': 1, '-': 0, 'E': 0} + assert get_hybrid_layer_counts("G*GM*") == { + '*': 2, + 'D': 0, + 'G': 2, + 'M': 1, + '+': 0, + '-': 0, + 'E': 0, + } def test_dsa_pattern(self): - assert get_hybrid_layer_counts("DMDM") == {'*': 0, 'D': 2, 'G': 0, 'M': 2, '-': 0, 'E': 0} + assert get_hybrid_layer_counts("DMDM") == { + '*': 0, + 'D': 2, + 'G': 0, + 'M': 2, + '+': 0, + '-': 0, + 'E': 0, + } + + def test_mla_pattern(self): + assert get_hybrid_layer_counts("+M+M") == { + '*': 0, + 'D': 0, + 'G': 0, + 'M': 2, + '+': 2, + '-': 0, + 'E': 0, + } def test_empty_pattern(self): - assert get_hybrid_layer_counts("") == {'*': 0, 'D': 0, 'G': 0, 'M': 0, '-': 0, 'E': 0} + assert get_hybrid_layer_counts("") == { + '*': 0, + 'D': 0, + 'G': 0, + 'M': 0, + '+': 0, + '-': 0, + 'E': 0, + } @pytest.mark.internal @@ -655,7 +761,7 @@ def test_standard_layer_types(self): """Standard symbols each produce a single-entry map at local index 0.""" maps = get_layer_maps_from_layer_type_list(["*", "M", "-", "E"]) # We always get all symbols returned, not only those contained in the pattern. - assert len(maps) == 6 + assert len(maps) == 7 attention_map, mamba_map, mlp_map, moe_map = operator.itemgetter( Symbols.ATTENTION, Symbols.MAMBA, Symbols.MLP, Symbols.MOE )(maps) @@ -698,3 +804,39 @@ def test_all_mamba(self): assert mamba_map == {0: 0, 1: 1, 2: 2} assert mlp_map == {} assert moe_map == {} + + def test_mla(self): + """+ (MLA) layers are mapped independently of other attention types.""" + maps = get_layer_maps_from_layer_type_list(["+", "M", "+", "M"]) + attention_map, dsa_map, mamba_map, mla_map, mlp_map, moe_map = operator.itemgetter( + Symbols.ATTENTION, + Symbols.DS_ATTENTION, + Symbols.MAMBA, + Symbols.MLA, + Symbols.MLP, + Symbols.MOE, + )(maps) + assert attention_map == {} + assert dsa_map == {} + assert mla_map == {0: 0, 2: 1} + assert mamba_map == {1: 0, 3: 1} + assert mlp_map == {} + assert moe_map == {} + + def test_mixed_dsa_and_mla(self): + """D and + can coexist (both are MLA-based and use decoupled RoPE).""" + maps = get_layer_maps_from_layer_type_list(["D", "+", "M", "-"]) + attention_map, dsa_map, mamba_map, mla_map, mlp_map, moe_map = operator.itemgetter( + Symbols.ATTENTION, + Symbols.DS_ATTENTION, + Symbols.MAMBA, + Symbols.MLA, + Symbols.MLP, + Symbols.MOE, + )(maps) + assert attention_map == {} + assert dsa_map == {0: 0} + assert mla_map == {1: 0} + assert mamba_map == {2: 0} + assert mlp_map == {3: 0} + assert moe_map == {} diff --git a/tests/unit_tests/transformer/experimental_attention_variant/test_absorbed_mla.py b/tests/unit_tests/transformer/experimental_attention_variant/test_absorbed_mla.py index 1b81fe73399..fc1778f649f 100644 --- a/tests/unit_tests/transformer/experimental_attention_variant/test_absorbed_mla.py +++ b/tests/unit_tests/transformer/experimental_attention_variant/test_absorbed_mla.py @@ -125,7 +125,7 @@ def _forward_thd(self, q, k, v, packed_seq_params): def get_mock_mla_config( - tensor_model_parallel_size: int, context_parallel_size: int + tensor_model_parallel_size: int, context_parallel_size: int, qk_layernorm: bool ) -> MLATransformerConfig: """Create test config with all attributes used in MLA.""" return MLATransformerConfig( @@ -142,6 +142,7 @@ def get_mock_mla_config( params_dtype=torch.bfloat16, layernorm_epsilon=1e-5, normalization="RMSNorm", + qk_layernorm=qk_layernorm, layernorm_zero_centered_gamma=False, expert_model_parallel_size=1, tensor_model_parallel_size=tensor_model_parallel_size, @@ -399,15 +400,18 @@ def test_functionality(tp_cp: List[int], qkv_format: str, down_proj_use_column_p model_parallel_cuda_manual_seed(123) # Create model - config = get_mock_mla_config(tensor_model_parallel_size=tp_size, context_parallel_size=cp_size) + qk_layernorm = True + config = get_mock_mla_config( + tensor_model_parallel_size=tp_size, context_parallel_size=cp_size, qk_layernorm=qk_layernorm + ) absorbed_submodules = get_absorbed_mla_submodules( down_proj_use_column_parallel=down_proj_use_column_parallel, - qk_layernorm=True, + qk_layernorm=qk_layernorm, rms_norm=True, ) standard_submodules = get_mla_submodules( down_proj_use_column_parallel=down_proj_use_column_parallel, - qk_layernorm=True, + qk_layernorm=qk_layernorm, rms_norm=True, ) absorbed_mla = AbsorbedMLASelfAttention( diff --git a/tests/unit_tests/transformer/test_submodule_callables.py b/tests/unit_tests/transformer/test_submodule_callables.py index 42ba73bc92e..3b111db1548 100644 --- a/tests/unit_tests/transformer/test_submodule_callables.py +++ b/tests/unit_tests/transformer/test_submodule_callables.py @@ -199,9 +199,11 @@ def test_1f1b_overlap(self, dispatcher_type, grouped_gemm, permute_fusion): expert_model_parallel_size=2, virtual_pipeline_model_parallel_size=2, ) + qk_layernorm = True extra_kwargs = { "moe_token_dispatcher_type": dispatcher_type, "moe_permute_fusion": permute_fusion, + "qk_layernorm": qk_layernorm, } if dispatcher_type == "flex": extra_kwargs["moe_flex_dispatcher_backend"] = get_valid_flex_dispatcher_backend() @@ -211,7 +213,7 @@ def test_1f1b_overlap(self, dispatcher_type, grouped_gemm, permute_fusion): transformer_layer_submodules = get_gpt_layer_with_transformer_engine_submodules( num_experts=8, moe_grouped_gemm=grouped_gemm, - qk_layernorm=True, + qk_layernorm=qk_layernorm, multi_latent_attention=True, ) model = TransformerLayer(config, transformer_layer_submodules) From 4f72cc0d512231def7ad30fea0e0f63c3294f067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Fri, 24 Jul 2026 01:14:07 +0200 Subject: [PATCH 092/290] docs(skills): clarify container::lts is the older LTS PyTorch base (#6008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig Co-authored-by: Claude Opus 4.8 (1M context) --- skills/mcore-build-and-dependency/SKILL.md | 28 +++++++++++++++------- skills/mcore-cicd/SKILL.md | 8 ++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/skills/mcore-build-and-dependency/SKILL.md b/skills/mcore-build-and-dependency/SKILL.md index 3e681150f02..80c4d3472b4 100644 --- a/skills/mcore-build-and-dependency/SKILL.md +++ b/skills/mcore-build-and-dependency/SKILL.md @@ -23,6 +23,10 @@ up front before the longer workflow: - Default `dev` uses `docker/.ngc_version.dev` and the `dev` uv group; `lts` uses `docker/.ngc_version.lts` and the `lts` uv group. The `container::lts` PR label selects the LTS path; otherwise CI uses `dev`. +- **`lts` is opt-in only when the user explicitly asks for it.** It is the older + long-term-support base, not a routine second lane — never attach + `container::lts`, build the LTS image, or run the `lts` uv group on your own + initiative, not even for a container or dependency change. - Install commands inside the container: `uv sync --locked --group dev --group test`, `uv sync --locked --only-group linting`, or `uv sync --locked --group lts --group test`. @@ -51,12 +55,16 @@ dependency. ## dev vs lts Two image variants exist, each with its own Dockerfile, selected by the -`container::lts` PR label: +`container::lts` PR label. The defining difference is the **base container**: +`dev` tracks the latest NGC PyTorch release, while `lts` ("long-term support") +pins the previous, still-supported NGC PyTorch/CUDA release. `container::lts` +exists to verify a change still works on that older base — the dependency +differences below follow from it, they are not the point. | Variant | Base image pin | Dockerfile | Where deps live | When used | |---------|---------------|------------|-----------------|-----------| -| **`dev`** | `docker/.ngc_version.dev` | `docker/Dockerfile.ci.dev` | `pyproject.toml` `dev` extra (uv-resolved) | Default — CI, local development, most PRs | -| **`lts`** | `docker/.ngc_version.lts` | `docker/Dockerfile.ci.lts` | `docker/lts/requirements.txt` (pinned, sourced from main's `uv.lock` at AUT-479) | Stability testing; excludes ModelOpt and other bleeding-edge extras | +| **`dev`** | `docker/.ngc_version.dev` (latest NGC release) | `docker/Dockerfile.ci.dev` | `pyproject.toml` `dev` extra (uv-resolved) | Default — CI, local development, most PRs | +| **`lts`** | `docker/.ngc_version.lts` (older long-term-support release) | `docker/Dockerfile.ci.lts` | `docker/lts/requirements.txt` (pinned, sourced from main's `uv.lock` at AUT-479) | Backward-compat lane — verify the change still runs on the older NGC base; extras not carried on it (ModelOpt, the CUDA-13 TransformerEngine build) are dropped | > LTS deps used to live in `[project.optional-dependencies].lts` in > `pyproject.toml`. They were moved into `docker/lts/requirements.txt` so @@ -64,11 +72,15 @@ Two image variants exist, each with its own Dockerfile, selected by the > with the LTS pin set. To bump an LTS dependency, edit the version in > `docker/lts/requirements.txt` and rebuild `docker/Dockerfile.ci.lts`. -**Use `dev` for everything unless you have a specific reason to test `lts`.** -CI runs `dev` by default; attach `container::lts` to a PR only when verifying -compatibility with the stable stack (e.g. a dependency upgrade that must not -break LTS users). The `@pytest.mark.flaky_in_dev` marker skips tests in the -`dev` environment; `@pytest.mark.flaky` skips them in `lts`. +**Use `dev` for everything. `lts` is off-limits unless the user explicitly asks +for it.** CI runs `dev` by default, and that is the only variant you touch on +your own initiative. Treat `container::lts` as a high barrier, not a fallback: do +**not** attach the label, build `docker/Dockerfile.ci.lts`, or run the `lts` uv +group unless the user has explicitly requested LTS validation — not even for a +container or dependency change. When they do ask, `container::lts` verifies the +change still works on the older long-term-support PyTorch/CUDA base that LTS +users run. The `@pytest.mark.flaky_in_dev` marker skips tests in the `dev` +environment; `@pytest.mark.flaky` skips them in `lts`. --- diff --git a/skills/mcore-cicd/SKILL.md b/skills/mcore-cicd/SKILL.md index 60e2749b20f..df7efab03a6 100644 --- a/skills/mcore-cicd/SKILL.md +++ b/skills/mcore-cicd/SKILL.md @@ -19,7 +19,9 @@ For PR-label or trigger questions, lead with the exact values: - `Run tests`: `scope=mr-github`, `n_repeat=1`, `lightweight=true`. - `Run functional tests`: `scope=mr-github`, `n_repeat=5`, `lightweight=false`. - `container::lts` only switches the container image path to LTS and combines - with any scope label. + with any scope label. **Opt-in only — attach it solely when the user + explicitly asks for LTS validation; never add it on your own initiative, even + for a container or dependency change.** - `Run MBridge tests` additionally triggers the MBridge L1 suite. - ⚠️ **WARNING — destructive remote write.** `tools/trigger_internal_ci.py` **force-pushes the current branch** to the internal GitLab remote as @@ -74,7 +76,7 @@ The CI pipeline reads PR labels to decide test scope, n_repeat, and container im | Label | Effect | |-------|--------| -| **`container::lts`** | Use the LTS base image instead of `dev` (combinable with any scope label) | +| **`container::lts`** | Build on the older long-term-support NGC PyTorch base instead of `dev`'s latest — a backward-compat check, not a different test set (combinable with any scope label) | | **`Run MBridge tests`** | Also triggers the MBridge L1 test suite | ### Which label to attach when opening a PR @@ -88,7 +90,7 @@ The CI pipeline reads PR labels to decide test scope, n_repeat, and container im | **Re-enabling a disabled test** (scope `-broken` → active) | `Run functional tests` | | Non-numerical library code (logging, error handling, CLI flags, refactors) | `Run tests` | | Could affect training numerics (model arch, attention, optimizer, distributed, MoE routing) | `Run functional tests` | -| Container or dependency changes (`docker/`, `pyproject.toml`, `uv.lock`) | `Run tests` + `container::lts` | +| Container or dependency changes (`docker/`, `pyproject.toml`, `uv.lock`) | `Run tests` (add `container::lts` **only if the user explicitly asks** to validate LTS) | | Touches MBridge integration | add `Run MBridge tests` | **Rule of thumb:** default to `Run tests`. Always use `Run functional tests` when the PR adds new test cases (golden values must be generated) or when the change could plausibly shift loss curves. From ff603601630797924ae174a7057cf1e57405ed2a Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Fri, 24 Jul 2026 02:12:34 +0200 Subject: [PATCH 093/290] test(hybrid): AUT-971 quarantine Nemotron QAD functional test (#6013) Signed-off-by: svcnemo-autobot --- tests/test_utils/recipes/h100/mamba.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_utils/recipes/h100/mamba.yaml b/tests/test_utils/recipes/h100/mamba.yaml index d0e0aba156a..77668e5d6ca 100644 --- a/tests/test_utils/recipes/h100/mamba.yaml +++ b/tests/test_utils/recipes/h100/mamba.yaml @@ -99,6 +99,7 @@ products: - test_case: [hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G] products: + # Disabled while the deterministic total-loss mismatch is investigated. - environment: [dev] - scope: [mr, mr-github] + scope: [mr-broken, mr-github-broken] platforms: [dgx_h100] From 81770cb015eab05785ecd540ba929d1400a52f67 Mon Sep 17 00:00:00 2001 From: HaochenYuan <106647990+HaochenYuan@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:01:33 +0800 Subject: [PATCH 094/290] [main] add thd sequence packing dispatcher support for main (#5008) Signed-off-by: HaochenYuan --- megatron/core/transformer/moe/fused_a2a.py | 4 ++ .../core/transformer/moe/token_dispatcher.py | 51 +++++++++++++++++-- .../core/transformer/transformer_config.py | 7 +++ .../models/test_hybrid_moe_model.py | 1 + .../transformer/moe/test_token_dispatcher.py | 46 +++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/megatron/core/transformer/moe/fused_a2a.py b/megatron/core/transformer/moe/fused_a2a.py index c281c51b4fb..09e50c9f4ad 100644 --- a/megatron/core/transformer/moe/fused_a2a.py +++ b/megatron/core/transformer/moe/fused_a2a.py @@ -277,6 +277,10 @@ def set_deepep_num_sms(num_sms): _hybrid_ep_buffer = None +# HybridEP dispatch/combine kernels use 64-token chunks for their public APIs. +HYBRIDEP_TOKEN_ALIGNMENT = 64 + + def init_hybrid_ep_buffer( group: torch.distributed.ProcessGroup, hidden_dim: int, diff --git a/megatron/core/transformer/moe/token_dispatcher.py b/megatron/core/transformer/moe/token_dispatcher.py index 4c4b65679c3..683450a4a28 100644 --- a/megatron/core/transformer/moe/token_dispatcher.py +++ b/megatron/core/transformer/moe/token_dispatcher.py @@ -19,6 +19,7 @@ ) from megatron.core.transformer.enums import CudaGraphModule from megatron.core.transformer.moe.fused_a2a import ( + HYBRIDEP_TOKEN_ALIGNMENT, ensure_nccl_ep_bootstrapped, fused_combine, fused_dispatch, @@ -1039,11 +1040,40 @@ def __init__( self.moe_expert_rank_capacity_factor = self.config.moe_expert_rank_capacity_factor self.over_budget = torch.zeros(1, dtype=torch.bool, device='cuda') + # HybridEP dispatch expects equal per-rank input sizes. When requested, + # variable token counts are padded to the group-wide max and trimmed in combine. + self._original_num_tokens: Optional[int] = None + self._padded_num_tokens: Optional[int] = None def setup_metadata(self, routing_map: torch.Tensor, probs: torch.Tensor): num_tokens = routing_map.shape[0] - self.routing_map = routing_map.reshape(num_tokens, self.num_experts) - self.token_probs = probs.reshape(num_tokens, self.num_experts) + self._original_num_tokens = num_tokens + + padded_num_tokens = num_tokens + if self.config.moe_hybridep_pad_uneven_dispatch_inputs: + # Use the actual tp_ep max so all ranks in the MoE communication + # group pass the same token count to HybridEP. + max_num_tokens_across_ep = torch.tensor( + [num_tokens], device=routing_map.device, dtype=torch.long + ) + torch.distributed.all_reduce( + max_num_tokens_across_ep, op=torch.distributed.ReduceOp.MAX, group=self.group + ) + padded_num_tokens = int(max_num_tokens_across_ep.item()) + padded_num_tokens += -padded_num_tokens % HYBRIDEP_TOKEN_ALIGNMENT + self._padded_num_tokens = padded_num_tokens + + routing_map = routing_map.reshape(num_tokens, self.num_experts) + probs = probs.reshape(num_tokens, self.num_experts) + if padded_num_tokens > num_tokens: + pad_rows = padded_num_tokens - num_tokens + routing_map = torch.cat( + [routing_map, routing_map.new_zeros((pad_rows, self.num_experts))], dim=0 + ) + probs = torch.cat([probs, probs.new_zeros((pad_rows, self.num_experts))], dim=0) + + self.routing_map = routing_map + self.token_probs = probs if self.moe_expert_rank_capacity_factor is not None: pad_multiple = get_align_size_for_quantization(self.config) @@ -1051,7 +1081,7 @@ def setup_metadata(self, routing_map: torch.Tensor, probs: torch.Tensor): # budget). Tokens above this budget are dropped inside HybridEP; dispatch then # sets overflow_flag on the handle (accumulated in over_budget in dispatch()). budget = int( - routing_map.shape[0] + padded_num_tokens * self.config.moe_router_topk * self.moe_expert_rank_capacity_factor ) @@ -1062,7 +1092,7 @@ def setup_metadata(self, routing_map: torch.Tensor, probs: torch.Tensor): # in dispatch) and does not drop tokens or report overflow. # Compute the capacity for each expert at the drop_and_pad mode if self.drop_and_pad: - num_out_tokens = num_tokens * self.config.moe_router_topk + num_out_tokens = padded_num_tokens * self.config.moe_router_topk # Drop and pad the input to capacity. self.capacity = get_capacity( num_tokens=num_out_tokens, @@ -1091,6 +1121,11 @@ def dispatch( self.token_probs = self.token_probs.float() # downcast or upcast if self.config.fp8 or self.config.fp4: self.pad_multiple = get_align_size_for_quantization(self.config) + if self._padded_num_tokens is not None and hidden_states.shape[0] < self._padded_num_tokens: + pad_rows = self._padded_num_tokens - hidden_states.shape[0] + hidden_states = torch.cat( + [hidden_states, hidden_states.new_zeros((pad_rows, hidden_states.shape[-1]))], dim=0 + ) dispatched_hidden, self.dispatched_probs, _, tokens_per_expert, self.handle = ( hybrid_ep_dispatch( x=hidden_states, @@ -1137,12 +1172,20 @@ def combine( pad_multiple=self.pad_multiple, fused=self.config.moe_permute_fusion_into_hybridep, ) + if ( + self._padded_num_tokens is not None + and self._original_num_tokens is not None + and hidden_states.shape[0] > self._original_num_tokens + ): + hidden_states = hidden_states[: self._original_num_tokens] # Release the used handle/num_permuted_tokens which could change in each iteration. # For drop_and_pad mode, we don't need to reset the num_permuted_tokens and # num_dispatched_tokens, because their values never change. self.handle = None if not self.drop_and_pad: self.num_permuted_tokens = None + self._original_num_tokens = None + self._padded_num_tokens = None return hidden_states def get_permuted_hidden_states_by_experts(self, hidden_states: torch.Tensor) -> torch.Tensor: diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 3e5531fadbd..ac08b69751d 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -875,6 +875,13 @@ class TransformerConfig(ModelParallelConfig): moe_permute_fusion_into_hybridep: bool = False """Fuse token rearrangement ops during token dispatching for HybridEP.""" + moe_hybridep_pad_uneven_dispatch_inputs: bool = False + """Pad uneven HybridEP dispatch inputs to the group maximum before dispatch. + Enable when local HybridEP input token counts can differ across ranks, for example + with dynamically packed THD inputs. Leave disabled when dispatcher inputs are + already padded to equal token counts. + """ + moe_per_layer_logging: bool = False """Enable per-layer logging for MoE, currently supports auxiliary loss and z loss.""" diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index a9f688fe2e0..5648d72532c 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -338,6 +338,7 @@ "use_transformer_engine_op_fuser": False, "moe_single_grouped_weight": False, "moe_single_grouped_bias": False, + "moe_hybridep_pad_uneven_dispatch_inputs": False, } # Fields to ignore entirely (ephemeral, environment-specific, very large). SKIP_FIELDS = set() diff --git a/tests/unit_tests/transformer/moe/test_token_dispatcher.py b/tests/unit_tests/transformer/moe/test_token_dispatcher.py index 20839310ce2..a558cd2dc2d 100644 --- a/tests/unit_tests/transformer/moe/test_token_dispatcher.py +++ b/tests/unit_tests/transformer/moe/test_token_dispatcher.py @@ -7,8 +7,10 @@ from megatron.core import config, parallel_state from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_submodules +from megatron.core.transformer.moe.fused_a2a import HYBRIDEP_TOKEN_ALIGNMENT, reset_hybrid_ep_buffer from megatron.core.transformer.moe.moe_layer import MoELayer, MoESubmodules from megatron.core.transformer.moe.moe_utils import get_capacity +from megatron.core.transformer.moe.token_dispatcher import _HybridEPManager from megatron.core.transformer.spec_utils import get_submodules from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.typed_torch import apply_module @@ -426,6 +428,49 @@ def is_nccl_ep_available(): return HAVE_TE_EP +def test_hybridep_pad_uneven_dispatch_inputs_metadata(monkeypatch): + manager = _HybridEPManager.__new__(_HybridEPManager) + manager.group = object() + manager.num_local_experts = 2 + manager.num_experts = 4 + manager.config = TransformerConfig( + num_layers=1, + hidden_size=16, + num_attention_heads=4, + num_moe_experts=4, + moe_router_topk=2, + moe_hybridep_pad_uneven_dispatch_inputs=True, + ) + manager.moe_expert_rank_capacity_factor = None + manager.drop_and_pad = False + + local_num_tokens = 17 + max_num_tokens_across_ep = 70 + padded_num_tokens = ( + max_num_tokens_across_ep + -max_num_tokens_across_ep % HYBRIDEP_TOKEN_ALIGNMENT + ) + routing_map = torch.ones((local_num_tokens, manager.num_experts), dtype=torch.bool) + probs = torch.ones((local_num_tokens, manager.num_experts), dtype=torch.float32) + + def fake_all_reduce(tensor, op=None, group=None): + assert op == torch.distributed.ReduceOp.MAX + assert group is manager.group + tensor.fill_(max_num_tokens_across_ep) + + monkeypatch.setattr(torch.distributed, "all_reduce", fake_all_reduce) + + manager.setup_metadata(routing_map, probs) + + assert manager._original_num_tokens == local_num_tokens + assert manager._padded_num_tokens == padded_num_tokens + assert manager.routing_map.shape == (padded_num_tokens, manager.num_experts) + assert manager.token_probs.shape == (padded_num_tokens, manager.num_experts) + torch.testing.assert_close(manager.routing_map[:local_num_tokens], routing_map) + torch.testing.assert_close(manager.token_probs[:local_num_tokens], probs) + assert not manager.routing_map[local_num_tokens:].any() + assert not manager.token_probs[local_num_tokens:].any() + + @pytest.mark.skipif( not is_deep_ep_available() and not is_hybrid_ep_available(), reason="Deep EP and Hybrid EP are not available", @@ -435,6 +480,7 @@ def setup_method(self, method): pass def teardown_method(self, method): + reset_hybrid_ep_buffer() Utils.destroy_model_parallel() @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") From a69bbb09647f2d6cd43321249599f0e5b22654af Mon Sep 17 00:00:00 2001 From: Yashaswi Karnati <144376261+yashaswikarnati@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:28:59 -0700 Subject: [PATCH 095/290] Add encoder prefetch for heterogeneous MIMO training (#5833) Signed-off-by: ykarnati --- examples/mimo/pretrain_mimo.py | 101 ++-- .../run_hetero_nemotron_20l_mock_train.sh | 2 - examples/mimo/training/batch.py | 35 ++ examples/mimo/training/data.py | 7 +- examples/mimo/training/encoder_prefetch.py | 412 +++++++++++++++ examples/mimo/training/step.py | 57 +- megatron/core/models/mimo/submodules/base.py | 13 +- .../models/mimo/test_mimo_encoder_prefetch.py | 500 ++++++++++++++++++ .../models/mimo/test_mimo_forward_step.py | 3 +- .../models/mimo/test_mimo_mock_data.py | 2 + 10 files changed, 1060 insertions(+), 72 deletions(-) create mode 100644 examples/mimo/training/batch.py create mode 100644 examples/mimo/training/encoder_prefetch.py create mode 100644 tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py diff --git a/examples/mimo/pretrain_mimo.py b/examples/mimo/pretrain_mimo.py index 4f304958f88..ee521b188a5 100644 --- a/examples/mimo/pretrain_mimo.py +++ b/examples/mimo/pretrain_mimo.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse +from functools import partial from examples.mimo.model_providers import resolve_provider from examples.mimo.model_providers.nemotron_moe_vlm import add_model_provider_args @@ -16,9 +17,16 @@ from examples.mimo.training.builder import MimoBuildConfig from examples.mimo.training.data import add_mock_data_args, build_train_valid_test_data_loaders from examples.mimo.training.distributed import initialize_distributed, shutdown_distributed +from examples.mimo.training.encoder_prefetch import ( + EncoderPrefetchLoader, + add_encoder_prefetch_args, + prefetch_frozen_features, + validate_encoder_prefetch_args, +) from examples.mimo.training.step import mimo_forward_step from examples.mimo.training.topology import create_topology from megatron.core.enums import ModelType +from megatron.core.utils import unwrap_model from megatron.training.argument_utils import pretrain_cfg_container_from_args from megatron.training.arguments import parse_args, validate_args from megatron.training.global_vars import set_global_variables @@ -31,6 +39,7 @@ def extra_args_provider(parser: argparse.ArgumentParser) -> argparse.ArgumentPar parser = add_model_provider_args(parser) parser = add_hetero_grid_args(parser) parser = add_mock_data_args(parser) + parser = add_encoder_prefetch_args(parser) return parser @@ -54,6 +63,7 @@ def _parse_and_validate() -> argparse.Namespace: args.world_size = physical_world_size if not args.use_distributed_optimizer: raise ValueError("heterogeneous MIMO training requires --use-distributed-optimizer") + validate_encoder_prefetch_args(args) if getattr(args, "padded_vocab_size", None) is None: args.padded_vocab_size = calculate_padded_vocab_size( @@ -68,43 +78,74 @@ def main() -> None: set_global_variables(args, build_tokenizer=False) provider = resolve_provider(args) - topology = None - try: - initialize_distributed() - # The grid/rank-layout args model a single encoder region; the builder itself is - # generic over any number of encoder grids in the topology. - encoder_name = provider.encoder_module_names[0] if provider.encoder_module_names else None - specs = build_module_grid_specs(args, args.world_size, encoder_name) - topology = create_topology(specs) + prefetch_loader = None + initialize_distributed() + # The grid/rank-layout args model a single encoder region; the builder itself is + # generic over any number of encoder grids in the topology. + encoder_name = provider.encoder_module_names[0] if provider.encoder_module_names else None + specs = build_module_grid_specs(args, args.world_size, encoder_name) + topology = create_topology(specs) - communicator = provider.build_communicator(args, topology) + communicator = provider.build_communicator(args, topology) - loaders = build_train_valid_test_data_loaders(args, topology) - iterators = tuple(iter(loader) if loader is not None else None for loader in loaders) + if args.mimo_encoder_prefetch and len(provider.encoder_module_names) != 1: + raise ValueError("encoder prefetch requires exactly one encoder") + + # Encoder prefetch runs encoder forward while producing batches, so it needs the built + # rank-local encoder instance. Capture the wrapped model here so the data provider can + # later extract that encoder and bind it to the prefetch worker. + captured_model = {} + hooks = [] + if args.mimo_encoder_prefetch: + + def capture_model(models): + captured_model["model"] = models[0] + return models - model_cfg = MimoBuildConfig(_topology=topology) - cfg = pretrain_cfg_container_from_args(args, model_cfg) + hooks.append(capture_model) + model_cfg = MimoBuildConfig(_topology=topology, post_wrap_hooks=hooks) + cfg = pretrain_cfg_container_from_args(args, model_cfg) - def train_valid_test_data_provider(_train_val_test_num_samples): + def train_valid_test_data_provider(_train_val_test_num_samples): + nonlocal prefetch_loader + loaders = build_train_valid_test_data_loaders(args, topology) + iterators = tuple(iter(loader) if loader is not None else None for loader in loaders) + if not args.mimo_encoder_prefetch or loaders[0] is None: return iterators - train_valid_test_data_provider.is_distributed = True - pretrain( - cfg, - train_valid_test_data_provider, - ModelType.encoder_or_decoder, - mimo_forward_step, - model_provider=None, - skip_model_parallel_init=True, - p2p_communicator=communicator, - pg_collection=topology.schedule_pg_collection, + mimo_model = unwrap_model(captured_model["model"]) + if not mimo_model.role.has_modality_modules: + return iterators + if prefetch_loader is not None: + raise RuntimeError("encoder prefetch loader was already built") + + encoder_module = unwrap_model(mimo_model.modality_submodules[encoder_name]) + prefetch_loader = EncoderPrefetchLoader( + source=iter(loaders[0]), + encoder_name=encoder_name, + feature_producer=partial(prefetch_frozen_features, encoder_module), + depth=args.mimo_encoder_prefetch_depth, + debug=args.mimo_encoder_prefetch_debug, ) - finally: - try: - if topology is not None: - topology.destroy() - finally: - shutdown_distributed() + prefetch_loader.start() + return (prefetch_loader, *iterators[1:]) + + train_valid_test_data_provider.is_distributed = True + pretrain( + cfg, + train_valid_test_data_provider, + ModelType.encoder_or_decoder, + mimo_forward_step, + model_provider=None, + skip_model_parallel_init=True, + p2p_communicator=communicator, + pg_collection=topology.schedule_pg_collection, + ) + + if prefetch_loader is not None: + prefetch_loader.close() + topology.destroy() + shutdown_distributed() if __name__ == "__main__": diff --git a/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh b/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh index a5c759c8e6c..2ac83bf49a7 100755 --- a/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh +++ b/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh @@ -4,8 +4,6 @@ set -euo pipefail -export CUDA_DEVICE_MAX_CONNECTIONS=1 - TRAIN_ITERS=${TRAIN_ITERS:-20} NUM_MICROBATCHES=${NUM_MICROBATCHES:-4} EVAL_INTERVAL=${EVAL_INTERVAL:-1} diff --git a/examples/mimo/training/batch.py b/examples/mimo/training/batch.py new file mode 100644 index 00000000000..e82af416fae --- /dev/null +++ b/examples/mimo/training/batch.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Batch tensor utilities for MIMO training.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import fields + +import torch + +from megatron.core.packed_seq_params import PackedSeqParams + + +def map_batch_tensors(value, transform: Callable[[torch.Tensor], torch.Tensor]): + """Apply a transform to tensor leaves, including PackedSeqParams fields.""" + if isinstance(value, torch.Tensor): + return transform(value) + if isinstance(value, dict): + return {key: map_batch_tensors(item, transform) for key, item in value.items()} + if isinstance(value, list): + return [map_batch_tensors(item, transform) for item in value] + if isinstance(value, tuple): + return tuple(map_batch_tensors(item, transform) for item in value) + if isinstance(value, PackedSeqParams): + for field in fields(value): + item = getattr(value, field.name) + if isinstance(item, torch.Tensor): + setattr(value, field.name, transform(item)) + return value + + +def move_batch_to_cuda(value): + """Move tensor leaves, including PackedSeqParams tensor fields, to CUDA.""" + return map_batch_tensors(value, lambda tensor: tensor.cuda(non_blocking=True)) diff --git a/examples/mimo/training/data.py b/examples/mimo/training/data.py index 5e1dc187651..9139711662b 100644 --- a/examples/mimo/training/data.py +++ b/examples/mimo/training/data.py @@ -227,7 +227,12 @@ def _build_mock_vlm_dataloader( num_image_tiles=num_image_tiles, ) return DataLoader( - dataset, batch_size=batch_size, shuffle=False, num_workers=0, collate_fn=_collate_mock_batch + dataset, + batch_size=batch_size, + shuffle=False, + num_workers=0, + collate_fn=_collate_mock_batch, + pin_memory=True, ) diff --git a/examples/mimo/training/encoder_prefetch.py b/examples/mimo/training/encoder_prefetch.py new file mode 100644 index 00000000000..c8eb53ddbd1 --- /dev/null +++ b/examples/mimo/training/encoder_prefetch.py @@ -0,0 +1,412 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Bounded read-ahead for a frozen MIMO encoder.""" + +from __future__ import annotations + +import argparse +import logging +import threading +import time +from collections import deque +from collections.abc import Callable + +import torch + +from examples.mimo.training.batch import move_batch_to_cuda + +PREFETCHED_FEATURES_KEY = "_mimo_prefetched_encoder_features" +PROJECTION_TIMER_KEY = "_mimo_encoder_prefetch_projection_timer" + +logger = logging.getLogger(__name__) +_debug_logger = logger.getChild("debug") + + +def add_encoder_prefetch_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + group = parser.add_argument_group("mimo encoder prefetch") + group.add_argument( + "--mimo-encoder-prefetch", + action="store_true", + help="Prefetch completed features from a frozen encoder on encoder ranks.", + ) + group.add_argument( + "--mimo-encoder-prefetch-depth", + type=int, + default=2, + help="Target number of completed encoder-feature batches kept ready.", + ) + group.add_argument( + "--mimo-encoder-prefetch-debug", + action="store_true", + help="Log per-batch encoder-prefetch timing and queue diagnostics.", + ) + return parser + + +def validate_encoder_prefetch_args(args) -> None: + if not args.mimo_encoder_prefetch: + return + if not args.freeze_vit: + raise ValueError("encoder prefetch requires --freeze-vit") + if args.freeze_projection: + raise ValueError("encoder prefetch requires a trainable projection") + for field, label in ( + ("encoder_tp", "TP"), + ("encoder_cp", "CP"), + ("encoder_pp", "PP"), + ("encoder_ep", "EP"), + ): + if getattr(args, field, 1) != 1: + raise ValueError(f"encoder prefetch requires encoder {label}=1") + if args.mimo_encoder_prefetch_depth <= 0: + raise ValueError("encoder prefetch depth must be positive") + if args.rerun_mode != "disabled": + raise ValueError("encoder prefetch does not support rerun modes") + + +def prefetch_frozen_features( + module: torch.nn.Module, encoder_inputs: dict[str, object] +) -> torch.Tensor: + with torch.no_grad(): + return module.combine_embeddings(module.encode(encoder_inputs)) + + +def _record_feature_streams(features: dict[str, torch.Tensor], stream: torch.cuda.Stream) -> None: + for tensor in features.values(): + if tensor.is_cuda: + tensor.record_stream(stream) + + +def _log_producer_debug( + batch_id: int, + data_fetch_ms: float, + encode_start: torch.cuda.Event, + encode_end: torch.cuda.Event, +) -> None: + _debug_logger.info( + "encoder-prefetch-debug producer batch=%d data_fetch_ms=%.3f encode_ms=%.3f", + batch_id, + data_fetch_ms, + encode_start.elapsed_time(encode_end), + ) + + +def _log_consumer_debug( + batch_id: int, ready_at_request: int, depth: int, claimed_pending: bool, wait_start: float +) -> None: + _debug_logger.info( + "encoder-prefetch-debug consumer batch=%d ready_at_request=%d/%d " + "claimed_pending=%d pop_wait_ms=%.3f", + batch_id, + ready_at_request, + depth, + claimed_pending, + (time.perf_counter() - wait_start) * 1000, + ) + + +def _log_encoder_wait_debug( + batch_id: int, start_event: torch.cuda.Event, end_event: torch.cuda.Event +) -> None: + _debug_logger.info( + "encoder-prefetch-debug consumer-wait batch=%d encoder_wait_ms=%.3f", + batch_id, + start_event.elapsed_time(end_event), + ) + + +def _log_projection_debug( + batch_id: int, start_event: torch.cuda.Event, end_event: torch.cuda.Event +) -> None: + _debug_logger.info( + "encoder-prefetch-debug projection batch=%d projection_ms=%.3f", + batch_id, + start_event.elapsed_time(end_event), + ) + + +class _ProjectionTimer: + def __init__(self, loader: EncoderPrefetchLoader, batch_id: int) -> None: + self._loader = loader + self._batch_id = batch_id + self._start_event = torch.cuda.Event(enable_timing=True) + self._end_event = torch.cuda.Event(enable_timing=True) + + def __enter__(self): + self._start_event.record(torch.cuda.current_stream()) + return self + + def __exit__(self, _exc_type, _exc_value, _traceback) -> None: + self._end_event.record(torch.cuda.current_stream()) + self._loader._queue_projection_timing(self._batch_id, self._start_event, self._end_event) + + +class EncoderPrefetchLoader: + """Keep completed encoder features ready without delaying projection.""" + + def __init__( + self, + *, + source, + encoder_name: str, + feature_producer: Callable[[dict[str, object]], torch.Tensor], + depth: int, + stream: torch.cuda.Stream | None = None, + worker_join_timeout_s: float = 30.0, + debug: bool = False, + ) -> None: + """Initialize a bounded encoder-feature prefetch pipeline.""" + if depth <= 0: + raise ValueError("encoder prefetch depth must be positive") + if worker_join_timeout_s <= 0: + raise ValueError("worker_join_timeout_s must be positive") + self._source = iter(source) + self._encoder_name = encoder_name + self._feature_producer = feature_producer + self._depth = depth + self._stream = stream + self._worker_join_timeout_s = worker_join_timeout_s + self._debug = debug + if debug: + _debug_logger.setLevel(logging.INFO) + self._condition = threading.Condition() + self._ready: deque[dict[str, object]] = deque() + self._pending: tuple[dict[str, object], torch.cuda.Event] | None = None + self._encoder_wait_timings: deque[tuple[int, torch.cuda.Event, torch.cuda.Event]] = deque() + self._projection_timings: deque[tuple[int, torch.cuda.Event, torch.cuda.Event]] = deque() + self._produced_batches = 0 + self._consumed_batches = 0 + self._producer_error: BaseException | None = None + self._source_exhausted = False + self._stop = False + self._worker: threading.Thread | None = None + self._device: int | None = None + self._closed = False + + def __iter__(self): + """Return this loader as its own iterator.""" + return self + + def start(self) -> None: + """Initialize CUDA stream state and start the producer thread.""" + with self._condition: + if self._worker is not None: + raise RuntimeError("encoder prefetch loader is already started") + if self._closed: + raise RuntimeError("cannot start a closed encoder prefetch loader") + self._device = torch.cuda.current_device() + if self._stream is None: + self._stream = torch.cuda.Stream() + setup_event = torch.cuda.Event() + setup_event.record(torch.cuda.current_stream()) + self._stream.wait_event(setup_event) + self._worker = threading.Thread( + target=self._producer_main, name=f"mimo-{self._encoder_name}-prefetch", daemon=True + ) + self._worker.start() + + def _producer_main(self) -> None: + """Fetch, encode, and publish batches until exhaustion or shutdown.""" + staged_batch = None + while True: + with self._condition: + # Refill when the completed-feature FIFO has room, or wake for shutdown. + self._condition.wait_for(lambda: self._stop or len(self._ready) < self._depth) + if self._stop: + return + + source_exhausted = False + source_error = None + data_fetch_ms = 0.0 + try: + if staged_batch is None: + batch = next(self._source) + else: + batch = staged_batch + staged_batch = None + item, completion_event, encode_start = self._enqueue_batch(batch) + + with self._condition: + if not self._stop: + self._pending = (item, completion_event) + self._condition.notify_all() + + with self._condition: + should_read_ahead = not self._stop + if should_read_ahead: + # Stage one CPU batch while this GPU encode runs; enqueue it later. + # This advances the source by one batch beyond the trained cursor. + data_fetch_start = time.perf_counter() if self._debug else 0.0 + try: + staged_batch = next(self._source) + except StopIteration: + source_exhausted = True + except BaseException as error: + source_error = error + if self._debug: + data_fetch_ms = (time.perf_counter() - data_fetch_start) * 1000 + completion_event.synchronize() + except StopIteration: + with self._condition: + self._source_exhausted = True + self._condition.notify_all() + return + except BaseException as error: + with self._condition: + if not self._stop: + self._producer_error = error + self._pending = None + self._condition.notify_all() + return + + with self._condition: + if self._stop: + return + if self._pending is not None: + self._ready.append(self._pending[0]) + self._pending = None + batch_id = self._produced_batches + self._produced_batches += 1 + if source_exhausted: + self._source_exhausted = True + if source_error is not None: + self._producer_error = source_error + self._condition.notify_all() + terminate = source_exhausted or source_error is not None + if self._debug: + assert encode_start is not None + _log_producer_debug(batch_id, data_fetch_ms, encode_start, completion_event) + self._drain_encoder_wait_timings() + self._drain_projection_timings() + if terminate: + return + + def _enqueue_batch( + self, batch: dict[str, object] + ) -> tuple[dict[str, object], torch.cuda.Event, torch.cuda.Event | None]: + """Move encoder inputs to CUDA and enqueue their feature computation.""" + if not isinstance(batch, dict): + raise TypeError("encoder prefetch source must return a batch dictionary") + modality_inputs = batch.get("modality_inputs") + if not isinstance(modality_inputs, dict) or self._encoder_name not in modality_inputs: + raise ValueError(f"batch has no inputs for encoder {self._encoder_name!r}") + + with torch.cuda.device(self._device), torch.cuda.stream(self._stream): + # Encoder ranks intentionally retain only fields consumed by their forward step. + output_batch = {"input_ids": batch["input_ids"]} + encoder_inputs = move_batch_to_cuda(modality_inputs[self._encoder_name]) + encode_start = torch.cuda.Event(enable_timing=True) if self._debug else None + if encode_start is not None: + encode_start.record(self._stream) + encoded = self._feature_producer(encoder_inputs) + if not isinstance(encoded, torch.Tensor): + raise TypeError("feature_producer must return one combined tensor") + output_batch[PREFETCHED_FEATURES_KEY] = {self._encoder_name: encoded} + completion_event = torch.cuda.Event(enable_timing=self._debug) + completion_event.record(self._stream) + return output_batch, completion_event, encode_start + + def __next__(self) -> dict[str, object]: + """Return the next feature batch, waiting on a pending encode if needed.""" + if self._worker is None: + raise RuntimeError("encoder prefetch loader must be started before use") + with self._condition: + ready_at_request = len(self._ready) + wait_start = time.perf_counter() if self._debug else 0.0 + self._condition.wait_for( + lambda: self._stop + or self._producer_error is not None + or self._ready + or self._pending is not None + or (self._source_exhausted and not self._ready and self._pending is None) + ) + if self._stop: + raise StopIteration + completion_event = None + if self._ready: + item = self._ready.popleft() + batch_id = self._consumed_batches + self._consumed_batches += 1 + self._condition.notify_all() + elif self._pending is not None: + item, completion_event = self._pending + self._pending = None + batch_id = self._consumed_batches + self._consumed_batches += 1 + self._condition.notify_all() + elif self._producer_error is not None: + raise RuntimeError("encoder prefetch producer failed") from self._producer_error + else: + raise StopIteration + + current_stream = torch.cuda.current_stream() + if completion_event is not None: + wait_start_event = torch.cuda.Event(enable_timing=True) if self._debug else None + wait_end_event = torch.cuda.Event(enable_timing=True) if self._debug else None + if wait_start_event is not None: + wait_start_event.record(current_stream) + current_stream.wait_event(completion_event) + if wait_end_event is not None: + wait_end_event.record(current_stream) + self._queue_encoder_wait_timing(batch_id, wait_start_event, wait_end_event) + _record_feature_streams(item[PREFETCHED_FEATURES_KEY], current_stream) + if self._debug: + item[PROJECTION_TIMER_KEY] = _ProjectionTimer(self, batch_id) + _log_consumer_debug( + batch_id, ready_at_request, self._depth, completion_event is not None, wait_start + ) + return item + + def _queue_encoder_wait_timing( + self, batch_id: int, start_event: torch.cuda.Event, end_event: torch.cuda.Event + ) -> None: + """Queue a consumer encoder-wait timing for asynchronous logging.""" + with self._condition: + self._encoder_wait_timings.append((batch_id, start_event, end_event)) + + def _drain_encoder_wait_timings(self) -> None: + """Log completed consumer encoder-wait timings without synchronizing.""" + ready = [] + with self._condition: + while self._encoder_wait_timings and self._encoder_wait_timings[0][2].query(): + ready.append(self._encoder_wait_timings.popleft()) + for batch_id, start_event, end_event in ready: + _log_encoder_wait_debug(batch_id, start_event, end_event) + + def _queue_projection_timing( + self, batch_id: int, start_event: torch.cuda.Event, end_event: torch.cuda.Event + ) -> None: + """Queue a projection timing for asynchronous logging.""" + with self._condition: + self._projection_timings.append((batch_id, start_event, end_event)) + + def _drain_projection_timings(self) -> None: + """Log completed projection timings without synchronizing.""" + ready = [] + with self._condition: + while self._projection_timings and self._projection_timings[0][2].query(): + ready.append(self._projection_timings.popleft()) + for batch_id, start_event, end_event in ready: + _log_projection_debug(batch_id, start_event, end_event) + + def close(self) -> None: + """Stop the producer and discard buffered batches.""" + with self._condition: + if self._closed: + return + self._closed = True + self._stop = True + self._ready.clear() + self._pending = None + self._condition.notify_all() + worker = self._worker + if worker is not None: + worker.join(timeout=self._worker_join_timeout_s) + if worker.is_alive(): + logger.warning( + "encoder prefetch worker did not stop within %.2f seconds", + self._worker_join_timeout_s, + ) + if self._debug: + self._drain_encoder_wait_timings() + self._drain_projection_timings() diff --git a/examples/mimo/training/step.py b/examples/mimo/training/step.py index ad28ba54189..c9fa387817e 100644 --- a/examples/mimo/training/step.py +++ b/examples/mimo/training/step.py @@ -4,11 +4,13 @@ from __future__ import annotations +from contextlib import nullcontext from functools import partial import torch -from megatron.core.packed_seq_params import PackedSeqParams +from examples.mimo.training.batch import move_batch_to_cuda +from examples.mimo.training.encoder_prefetch import PREFETCHED_FEATURES_KEY, PROJECTION_TIMER_KEY def loss_func(output_tensor: torch.Tensor, *, loss_mask: torch.Tensor): @@ -37,39 +39,28 @@ def loss_func(output_tensor: torch.Tensor, *, loss_mask: torch.Tensor): def mimo_forward_step(data_iterator, model): - """Run a MIMO microbatch for the pipeline schedule. + """Run a raw-input or prefetched-feature MIMO microbatch for the pipeline schedule. On the last pipeline stage, the schedule passes ``output_tensor`` to the returned loss closure. """ batch = next(data_iterator) if data_iterator is not None else {"input_ids": None} - batch = move_batch_to_cuda(batch) - - output_tensor, loss_mask = model(**batch) - return output_tensor, partial(loss_func, loss_mask=loss_mask) - - -def move_batch_to_cuda(value): - """Move tensor leaves, including PackedSeqParams tensor fields, to CUDA.""" - if isinstance(value, torch.Tensor): - return value.cuda(non_blocking=True) - if isinstance(value, dict): - return {key: move_batch_to_cuda(item) for key, item in value.items()} - if isinstance(value, list): - return [move_batch_to_cuda(item) for item in value] - if isinstance(value, tuple): - return tuple(move_batch_to_cuda(item) for item in value) - - if isinstance(value, PackedSeqParams): - for attr in ( - "cu_seqlens_q", - "cu_seqlens_kv", - "cu_seqlens_q_padded", - "cu_seqlens_kv_padded", - "max_seqlen_q", - "max_seqlen_kv", - ): - sub = getattr(value, attr, None) - if isinstance(sub, torch.Tensor) and not sub.is_cuda: - setattr(value, attr, sub.cuda(non_blocking=True)) - return value - return value + prefetched = batch.pop(PREFETCHED_FEATURES_KEY, None) + projection_timer = batch.pop(PROJECTION_TIMER_KEY, None) + + if prefetched is None: + if projection_timer is not None: + raise RuntimeError("encoder prefetch timer has no prefetched features") + batch = move_batch_to_cuda(batch) + output_tensor, loss_mask = model(**batch) + return output_tensor, partial(loss_func, loss_mask=loss_mask) + + if batch.get("modality_inputs"): + raise ValueError("prefetched features cannot be combined with raw modality inputs") + + projection_context = projection_timer if projection_timer is not None else nullcontext() + with projection_context: + output_tensor = model._forward_encoders( + batch.get("input_ids"), modality_inputs=None, input_tensors=prefetched + ) + # Encoder ranks never evaluate the language-model loss closure. + return output_tensor, partial(loss_func, loss_mask=None) diff --git a/megatron/core/models/mimo/submodules/base.py b/megatron/core/models/mimo/submodules/base.py index f05ecc6b15c..ac7bf64c063 100644 --- a/megatron/core/models/mimo/submodules/base.py +++ b/megatron/core/models/mimo/submodules/base.py @@ -310,13 +310,18 @@ def forward( Dictionary containing encoder-specific inputs. Keys should match encoder names. Used when is_first_stage=True. hidden_states (Optional[torch.Tensor]): - Hidden states from previous pipeline stage. Used when is_first_stage=False. + Already-combined encoder features. When supplied, bypasses encoding on any stage. Returns: Optional[torch.Tensor]: Processed and projected embeddings tensor, or None if no embeddings were produced. """ - if self.is_first_stage: + if encoder_inputs is not None and hidden_states is not None: + raise ValueError("encoder_inputs and hidden_states are mutually exclusive") + + if hidden_states is not None: + combined = hidden_states + elif self.is_first_stage: if encoder_inputs is None: return None embeddings = self.encode(encoder_inputs) @@ -324,9 +329,7 @@ def forward( return None combined = self.combine_embeddings(embeddings) else: - if hidden_states is None: - return None - combined = hidden_states + return None if self.is_last_stage: return self.project_embeddings([combined], is_input=True) diff --git a/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py b/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py new file mode 100644 index 00000000000..a53c682e2fb --- /dev/null +++ b/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py @@ -0,0 +1,500 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from __future__ import annotations + +import threading +import time +from contextlib import nullcontext +from types import SimpleNamespace + +import pytest +import torch +from torch import nn + +from examples.mimo.training import encoder_prefetch +from examples.mimo.training import step as mimo_step +from examples.mimo.training.encoder_prefetch import ( + PREFETCHED_FEATURES_KEY, + PROJECTION_TIMER_KEY, + EncoderPrefetchLoader, + prefetch_frozen_features, + validate_encoder_prefetch_args, +) +from megatron.core.models.mimo.submodules.vision import VisionModalitySubmodules + +ENCODER = "clip_encoder" + + +def _args(**overrides): + values = { + "mimo_encoder_prefetch": True, + "mimo_encoder_prefetch_depth": 2, + "freeze_vit": True, + "freeze_projection": False, + "encoder_tp": 1, + "rerun_mode": "disabled", + } + values.update(overrides) + return SimpleNamespace(**values) + + +@pytest.mark.parametrize( + ("field", "value", "match"), + [ + ("freeze_vit", False, "freeze-vit"), + ("freeze_projection", True, "trainable projection"), + ("encoder_tp", 2, "TP=1"), + ("encoder_cp", 2, "CP=1"), + ("encoder_pp", 2, "PP=1"), + ("encoder_ep", 2, "EP=1"), + ("mimo_encoder_prefetch_depth", 0, "positive"), + ("rerun_mode", "validate_results", "rerun"), + ], +) +def test_prefetch_validation(field, value, match): + with pytest.raises(ValueError, match=match): + validate_encoder_prefetch_args(_args(**{field: value})) + + +class _LinearEncoder(nn.Module): + def __init__(self): + super().__init__() + self.linear = nn.Linear(4, 4, bias=False) + + def forward(self, *, x): + return self.linear(x) + + +def test_prefetch_skips_backbone_autograd_but_keeps_projection_gradients(): + torch.manual_seed(123) + submodule = VisionModalitySubmodules( + encoders={"radio": _LinearEncoder()}, input_projections=[nn.Linear(4, 4, bias=False)] + ) + submodule.encoders.requires_grad_(False) + inputs = {"radio": {"x": torch.randn(2, 3, 4)}} + + expected = submodule(encoder_inputs=inputs) + features = prefetch_frozen_features(submodule, inputs) + actual = submodule(hidden_states=features) + + torch.testing.assert_close(actual, expected) + actual.sum().backward() + assert all(parameter.grad is None for parameter in submodule.encoders.parameters()) + assert all(parameter.grad is not None for parameter in submodule.input_projections.parameters()) + with pytest.raises(ValueError, match="mutually exclusive"): + submodule(encoder_inputs=inputs, hidden_states=features) + + +class _FakeEvent: + def __init__(self): + self.recorded_on = None + self.record_calls = 0 + self.synchronized = False + + def record(self, stream=None): + self.recorded_on = stream + self.record_calls += 1 + + def synchronize(self): + self.synchronized = True + + def query(self): + return self.record_calls > 0 + + def elapsed_time(self, _end_event): + return 1.25 + + +class _FakeStream: + def __init__(self): + self.waited_events = [] + self.synchronize_calls = 0 + + def wait_event(self, event): + self.waited_events.append(event) + + def synchronize(self): + self.synchronize_calls += 1 + + +@pytest.fixture +def fake_cuda(monkeypatch): + current = _FakeStream() + producer = _FakeStream() + events = [] + + def make_event(**_kwargs): + event = _FakeEvent() + events.append(event) + return event + + monkeypatch.setattr(torch.cuda, "current_device", lambda: 0) + monkeypatch.setattr(torch.cuda, "device", lambda _device: nullcontext()) + monkeypatch.setattr(torch.cuda, "current_stream", lambda: current) + monkeypatch.setattr(torch.cuda, "Event", make_event) + monkeypatch.setattr(torch.cuda, "stream", lambda _stream: nullcontext()) + monkeypatch.setattr(encoder_prefetch, "move_batch_to_cuda", lambda value: value) + return SimpleNamespace(current=current, producer=producer, events=events) + + +class _Source: + def __init__(self, count): + self.count = count + self.position = 0 + self.condition = threading.Condition() + + def __iter__(self): + return self + + def __next__(self): + with self.condition: + if self.position == self.count: + raise StopIteration + sequence = self.position + self.position += 1 + self.condition.notify_all() + return { + "input_ids": torch.tensor(sequence), + "modality_inputs": {ENCODER: {"radio": {"x": torch.tensor(sequence)}}}, + } + + +def _wait_until(predicate): + deadline = time.monotonic() + 2 + while not predicate(): + assert time.monotonic() < deadline + time.sleep(0.005) + + +@pytest.mark.parametrize("depth", (1, 2, 4)) +def test_depth_is_completed_batches_and_refills_after_pop(fake_cuda, depth): + source = _Source(8) + produced = [] + + def produce(inputs): + produced.append(int(inputs["radio"]["x"].item())) + return torch.tensor(produced[-1]) + + loader = EncoderPrefetchLoader( + source=source, + encoder_name=ENCODER, + feature_producer=produce, + depth=depth, + stream=fake_cuda.producer, + ) + loader.start() + _wait_until(lambda: len(loader._ready) == depth) + + first = next(loader) + _wait_until(lambda: len(loader._ready) == depth) + + assert first[PREFETCHED_FEATURES_KEY][ENCODER].item() == 0 + assert PROJECTION_TIMER_KEY not in first + assert len(fake_cuda.producer.waited_events) == 1 + assert fake_cuda.current.waited_events == [] + loader.close() + + +def test_pending_encode_can_be_claimed_while_cpu_read_ahead_blocks(fake_cuda, caplog): + caplog.set_level("INFO", logger=f"{encoder_prefetch.__name__}.debug") + read_ahead_started = threading.Event() + release_read_ahead = threading.Event() + + class _BlockingSource(_Source): + def __next__(self): + if self.position == 1: + read_ahead_started.set() + release_read_ahead.wait(timeout=2) + return super().__next__() + + loader = EncoderPrefetchLoader( + source=_BlockingSource(2), + encoder_name=ENCODER, + feature_producer=lambda inputs: torch.tensor(inputs["radio"]["x"].item()), + depth=1, + stream=fake_cuda.producer, + debug=True, + ) + loader.start() + assert read_ahead_started.wait(timeout=1) + + try: + assert len(loader._ready) == 0 + completion_event = loader._pending[1] + batch = next(loader) + assert batch[PREFETCHED_FEATURES_KEY][ENCODER].item() == 0 + assert fake_cuda.current.waited_events[-1] is completion_event + assert loader._pending is None + finally: + release_read_ahead.set() + loader.close() + + assert "consumer-wait batch=0 encoder_wait_ms=1.250" in caplog.text + assert "claimed_pending=1" in caplog.text + + +def test_cpu_read_ahead_overlaps_encode_without_enqueuing_another_batch(fake_cuda): + class _ObservedSource(_Source): + def __next__(self): + if self.position == 1: + assert not fake_cuda.events[-1].synchronized + return super().__next__() + + source = _ObservedSource(2) + produced = [] + + def produce(inputs): + produced.append(inputs["radio"]["x"].item()) + return torch.tensor(produced[-1]) + + loader = EncoderPrefetchLoader( + source=source, + encoder_name=ENCODER, + feature_producer=produce, + depth=1, + stream=fake_cuda.producer, + ) + loader.start() + _wait_until(lambda: len(loader._ready) == 1) + + assert source.position == 2 + assert produced == [0] + + for expected in (0, 1): + batch = next(loader) + assert batch[PREFETCHED_FEATURES_KEY][ENCODER].item() == expected + with pytest.raises(StopIteration): + next(loader) + loader.close() + + +def test_prefetch_keeps_input_ids_on_cpu_path(fake_cuda, monkeypatch): + input_ids = torch.tensor([[511, 1]]) + encoder_inputs = {"radio": {"x": torch.tensor(0)}} + moved = [] + + def record_move(value): + moved.append(value) + return value + + monkeypatch.setattr(encoder_prefetch, "move_batch_to_cuda", record_move) + loader = EncoderPrefetchLoader( + source=[{"input_ids": input_ids, "modality_inputs": {ENCODER: encoder_inputs}}], + encoder_name=ENCODER, + feature_producer=lambda _inputs: torch.tensor(0), + depth=1, + stream=fake_cuda.producer, + ) + loader.start() + _wait_until(lambda: len(loader._ready) == 1) + + batch = next(loader) + loader.close() + + assert batch["input_ids"] is input_ids + assert len(moved) == 1 + assert moved[0] is encoder_inputs + + +def test_debug_logs_prefetch_timing_and_queue_state(fake_cuda, caplog): + module_logger_level = encoder_prefetch.logger.level + caplog.set_level("INFO", logger=f"{encoder_prefetch.__name__}.debug") + loader = EncoderPrefetchLoader( + source=_Source(2), + encoder_name=ENCODER, + feature_producer=lambda inputs: torch.tensor(inputs["radio"]["x"].item()), + depth=1, + stream=fake_cuda.producer, + debug=True, + ) + loader.start() + _wait_until(lambda: len(loader._ready) == 1) + + first = next(loader) + with first.pop(PROJECTION_TIMER_KEY): + pass + _wait_until(lambda: len(loader._ready) == 1) + second = next(loader) + with second.pop(PROJECTION_TIMER_KEY): + pass + with pytest.raises(StopIteration): + next(loader) + loader.close() + + assert encoder_prefetch.logger.level == module_logger_level + assert "encoder-prefetch-debug consumer batch=0 ready_at_request=1/1" in caplog.text + assert "encoder-prefetch-debug producer batch=1" in caplog.text + assert "encoder-prefetch-debug projection batch=0 projection_ms=1.250" in caplog.text + + +def test_producer_failure_is_terminal_and_preserves_ready_fifo(fake_cuda): + class _FailingSource(_Source): + def __next__(self): + if self.position == 2: + raise ValueError("boom") + return super().__next__() + + loader = EncoderPrefetchLoader( + source=_FailingSource(8), + encoder_name=ENCODER, + feature_producer=lambda inputs: torch.tensor(inputs["radio"]["x"].item()), + depth=2, + stream=fake_cuda.producer, + ) + loader.start() + _wait_until(lambda: len(loader._ready) == 2) + + for expected in (0, 1): + batch = next(loader) + assert batch[PREFETCHED_FEATURES_KEY][ENCODER].item() == expected + with pytest.raises(RuntimeError, match="producer failed") as exc_info: + next(loader) + assert isinstance(exc_info.value.__cause__, ValueError) + loader.close() + + +def test_close_does_not_raise_when_worker_is_stuck(fake_cuda, caplog): + entered = threading.Event() + release = threading.Event() + + def produce(_inputs): + entered.set() + release.wait(timeout=2) + return torch.tensor(1) + + loader = EncoderPrefetchLoader( + source=_Source(1), + encoder_name=ENCODER, + feature_producer=produce, + depth=1, + stream=fake_cuda.producer, + worker_join_timeout_s=0.01, + ) + loader.start() + assert entered.wait(timeout=1) + + loader.close() + + assert "worker did not stop" in caplog.text + assert fake_cuda.producer.synchronize_calls == 0 + release.set() + assert loader._worker is not None + loader._worker.join(timeout=1) + assert not loader._worker.is_alive() + + +def test_forward_step_projects_prefetched_features_inside_debug_timer(monkeypatch): + events = [] + + class _Lease: + def __enter__(self): + events.append("enter") + + def __exit__(self, *_args): + events.append("exit") + + class _Model: + role = SimpleNamespace(modality_module_names=(ENCODER,)) + + def _forward_encoders(self, input_ids, modality_inputs, input_tensors): + assert modality_inputs is None + events.append("project") + return input_tensors + + features = {ENCODER: torch.ones(2, 4)} + batch = { + "input_ids": torch.tensor([[511]]), + PREFETCHED_FEATURES_KEY: features, + PROJECTION_TIMER_KEY: _Lease(), + } + monkeypatch.setattr( + mimo_step, + "move_batch_to_cuda", + lambda _value: pytest.fail("prefetched batches are already CUDA-resident"), + ) + + output, _ = mimo_step.mimo_forward_step(iter([batch]), _Model()) + + assert output is features + assert events == ["enter", "project", "exit"] + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA") +def test_real_cuda_pending_handoff_waits_for_encode(): + read_ahead_started = threading.Event() + release_read_ahead = threading.Event() + backbone = nn.Linear(4, 4, bias=False, device="cuda") + + class _BlockingSource: + def __init__(self): + self.position = 0 + + def __iter__(self): + return self + + def __next__(self): + if self.position == 1: + read_ahead_started.set() + release_read_ahead.wait(timeout=2) + raise StopIteration + self.position += 1 + return { + "input_ids": torch.tensor([[511]]), + "modality_inputs": {ENCODER: {"radio": {"x": torch.ones(32, 4)}}}, + } + + def produce(inputs): + with torch.no_grad(): + return backbone(inputs["radio"]["x"]) + + loader = EncoderPrefetchLoader( + source=_BlockingSource(), encoder_name=ENCODER, feature_producer=produce, depth=1 + ) + loader.start() + assert read_ahead_started.wait(timeout=1) + + try: + assert len(loader._ready) == 0 + batch = next(loader) + expected = backbone(torch.ones(32, 4, device="cuda")) + torch.testing.assert_close(batch[PREFETCHED_FEATURES_KEY][ENCODER], expected) + finally: + release_read_ahead.set() + loader.close() + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA") +def test_real_cuda_handoff_and_projection_gradients(): + backbone = nn.Linear(4, 4, bias=False, device="cuda") + projection = nn.Linear(4, 1, bias=False, device="cuda") + backbone.requires_grad_(False) + source = [ + { + "input_ids": torch.tensor([[sequence]]), + "modality_inputs": {ENCODER: {"radio": {"x": torch.full((32, 4), float(sequence))}}}, + } + for sequence in range(8) + ] + + def produce(inputs): + with torch.no_grad(): + return backbone(inputs["radio"]["x"]) + + loader = EncoderPrefetchLoader( + source=source, encoder_name=ENCODER, feature_producer=produce, depth=2 + ) + loader.start() + losses = [] + for sequence in range(8): + batch = next(loader) + assert not batch["input_ids"].is_cuda + features = batch[PREFETCHED_FEATURES_KEY][ENCODER] + reference = backbone(torch.full((32, 4), float(sequence), device="cuda")) + torch.testing.assert_close(features, reference) + losses.append(projection(features).sum()) + torch.stack(losses).sum().backward() + loader.close() + + assert projection.weight.grad is not None + assert torch.isfinite(projection.weight.grad).all() + assert all(parameter.grad is None for parameter in backbone.parameters()) diff --git a/tests/unit_tests/models/mimo/test_mimo_forward_step.py b/tests/unit_tests/models/mimo/test_mimo_forward_step.py index d6f470f8a82..21bbd94be0a 100644 --- a/tests/unit_tests/models/mimo/test_mimo_forward_step.py +++ b/tests/unit_tests/models/mimo/test_mimo_forward_step.py @@ -7,7 +7,8 @@ import pytest import torch -from examples.mimo.training.step import loss_func, move_batch_to_cuda +from examples.mimo.training.batch import move_batch_to_cuda +from examples.mimo.training.step import loss_func from megatron.core.packed_seq_params import PackedSeqParams diff --git a/tests/unit_tests/models/mimo/test_mimo_mock_data.py b/tests/unit_tests/models/mimo/test_mimo_mock_data.py index a227a329b4f..6ff70d318ac 100644 --- a/tests/unit_tests/models/mimo/test_mimo_mock_data.py +++ b/tests/unit_tests/models/mimo/test_mimo_mock_data.py @@ -105,6 +105,7 @@ def test_data_adapter_builds_independent_role_specific_loaders(adapter): _args(), _topology(language_rank=True) ) assert all(loader.batch_size == 2 for loader in language_loaders) + assert all(loader.pin_memory for loader in language_loaders) assert len({id(loader.dataset) for loader in language_loaders}) == 3 assert len({loader.dataset.seed for loader in language_loaders}) == 3 language_batch = next(iter(language_loaders[0])) @@ -115,6 +116,7 @@ def test_data_adapter_builds_independent_role_specific_loaders(adapter): _args(), _topology(encoder_rank=True, language_rank=False) ) assert all(loader.batch_size == 4 for loader in encoder_loaders) + assert all(loader.pin_memory for loader in encoder_loaders) encoder_batch = next(iter(encoder_loaders[0])) assert encoder_batch["input_ids"].shape == (4, 8) encoder_inputs = encoder_batch["modality_inputs"][RADIO_ENCODER_MODULE_NAME][ From 0a7f37255c8b57b7d2bd48ac147c4e44c5fa4298 Mon Sep 17 00:00:00 2001 From: Yashaswi Karnati <144376261+yashaswikarnati@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:32:20 -0700 Subject: [PATCH 096/290] Use explicit process groups for dataloader checkpoints (#5988) Signed-off-by: ykarnati --- megatron/training/checkpointing.py | 68 ++++++++++++++++++----- tests/unit_tests/test_checkpointing.py | 75 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 25951c6abf4..ccc7ececfd7 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -577,8 +577,15 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati checkpoint_name = get_checkpoint_name(save_dir, iteration, release=release, pipeline_parallel=pipeline_parallel, tensor_rank=tensor_rank, pipeline_rank=pipeline_rank, expert_parallel=expert_parallel, expert_rank=expert_rank, return_base_dir=return_base_dir) - # Save dataloader state if the dataloader supports it (currently only Megatron Energon). - maybe_save_dataloader_state(train_data_iterator, iteration, getattr(args, "dataloader_save", None)) + # Save dataloader state if the external dataloader supports it. + maybe_save_dataloader_state( + train_data_iterator, + iteration, + getattr(args, "dataloader_save", None), + tp_group=tp_group, + pp_group=pp_group, + dp_group=dp_group, + ) # Save distributed optimizer's custom parameter state. if ( @@ -999,12 +1006,19 @@ def remove_iter_ckpts(_iter_ckpts): remove_iter_ckpts(rm_iter_ckpts) -def maybe_save_dataloader_state(train_iterator, iteration, dataloader_save_path): +def maybe_save_dataloader_state( + train_iterator, + iteration, + dataloader_save_path, + *, + tp_group=None, + pp_group=None, + dp_group=None, +): """Saves dataloader state if the dataloader supports it. - Currently, this is only used by Megatron Energon dataloader (multimodal) to store its state at a - specific iteration. The Megatron built-in dataloader (text-only) creates index files upfront - to track its state. + External dataloaders use this to store state at a specific iteration. The Megatron built-in + dataloader creates index files upfront to track its state. If the provided dataloader has `save_state` method, then it is called to save the state. Otherwise, no state is saved. @@ -1013,6 +1027,9 @@ def maybe_save_dataloader_state(train_iterator, iteration, dataloader_save_path) train_iterator (iterable): Train dataloader. iteration (int): Current iteration. dataloader_save_path (str): Path where the dataloader state is saved. + tp_group (ProcessGroup): Tensor-parallel group, or MPU fallback when unset. + pp_group (ProcessGroup): Pipeline-parallel group, or MPU fallback when unset. + dp_group (ProcessGroup): Data-parallel group, or MPU fallback when unset. """ # If no dataloader or saving path is provided, exit early, otherwise, raise an error. if train_iterator is None or dataloader_save_path is None or dataloader_save_path == "": @@ -1023,25 +1040,48 @@ def maybe_save_dataloader_state(train_iterator, iteration, dataloader_save_path) raise RuntimeError(f"Could not find a save_state for the train_iterator of type {type(train_iterator)}") # Save dataloader state for each data parallel rank only once. - first_rank = mpu.is_pipeline_first_stage(ignore_virtual=True) and mpu.get_tensor_model_parallel_rank() == 0 + first_rank = ( + get_pg_rank(pp_group) == 0 + if pp_group is not None + else mpu.is_pipeline_first_stage(ignore_virtual=True) + ) and ( + get_pg_rank(tp_group) == 0 + if tp_group is not None + else mpu.get_tensor_model_parallel_rank() == 0 + ) if not first_rank: return - dp_rank = mpu.get_data_parallel_rank() + dp_rank = get_pg_rank(dp_group) if dp_group is not None else mpu.get_data_parallel_rank() + train_dataloader_state_dict = train_iterator.iterable.save_state() if dp_rank == 0: print(f"saving dataloader checkpoint at iteration {iteration} to {dataloader_save_path}") - train_dataloader_state_dict = train_iterator.iterable.save_state() data_state_save_path = get_checkpoint_name( - dataloader_save_path, iteration, - basename=f'train_dataloader_dprank{dp_rank:03d}.pt' + dataloader_save_path, + iteration, + pipeline_parallel=( + get_pg_size(pp_group) > 1 + if pp_group is not None + else mpu.get_pipeline_model_parallel_world_size() > 1 + ), + # Dataloader state is sharded only by DP rank. Keep it in the canonical TP0/PP0 directory. + tensor_rank=0, + pipeline_rank=0, + expert_parallel=False, + expert_rank=0, + basename=f'train_dataloader_dprank{dp_rank:03d}.pt', ) - torch.distributed.barrier(group=mpu.get_data_parallel_group()) + data_parallel_group = dp_group if dp_group is not None else mpu.get_data_parallel_group() + torch.distributed.barrier(group=data_parallel_group) - if mpu.get_data_parallel_rank() == 0: + if dp_rank == 0: ensure_directory_exists(data_state_save_path) - torch.distributed.barrier(group=mpu.get_data_parallel_group()) + torch.distributed.barrier(group=data_parallel_group) + + if train_dataloader_state_dict is None: + return dataloader_save_dict = {} dataloader_save_dict['dataloader_state_dict'] = train_dataloader_state_dict diff --git a/tests/unit_tests/test_checkpointing.py b/tests/unit_tests/test_checkpointing.py index 9b62c26d674..2e717e4424f 100644 --- a/tests/unit_tests/test_checkpointing.py +++ b/tests/unit_tests/test_checkpointing.py @@ -25,6 +25,7 @@ _load_base_checkpoint, get_checkpoint_tracker_filename, load_checkpoint, + maybe_save_dataloader_state, read_metadata, save_checkpoint, ) @@ -74,6 +75,80 @@ def sharded_state_dict(self, *args, metadata: Optional[dict] = None, **kwargs): return self.state_dict() +def test_maybe_save_dataloader_state_uses_explicit_process_groups(tmp_path): + """Dataloader checkpoints use the supplied module groups and canonical model-parallel path.""" + groups = { + "tp": SimpleNamespace(rank=0, size=2), + "pp": SimpleNamespace(rank=0, size=2), + "dp": SimpleNamespace(rank=3, size=4), + } + barriers = [] + saved = [] + iterator = SimpleNamespace( + iterable=SimpleNamespace(save_state=lambda: {"global_sequence_id": 16}) + ) + + with ( + mock.patch( + "megatron.training.checkpointing.get_pg_rank", side_effect=lambda group: group.rank + ), + mock.patch( + "megatron.training.checkpointing.get_pg_size", side_effect=lambda group: group.size + ), + mock.patch( + "megatron.training.checkpointing.torch.distributed.barrier", + side_effect=lambda group: barriers.append(group), + ), + mock.patch( + "megatron.training.checkpointing.torch.save", + side_effect=lambda state, path: saved.append((state, path)), + ), + ): + maybe_save_dataloader_state( + iterator, + 2, + tmp_path, + tp_group=groups["tp"], + pp_group=groups["pp"], + dp_group=groups["dp"], + ) + + assert barriers == [groups["dp"], groups["dp"]] + assert saved[0][0] == {"dataloader_state_dict": {"global_sequence_id": 16}} + assert saved[0][1] == str( + tmp_path / "iter_0000002" / "mp_rank_00_000" / "train_dataloader_dprank003.pt" + ) + + +def test_maybe_save_dataloader_state_skips_empty_state_after_barriers(tmp_path): + """Ranks without dataloader state participate in barriers but do not write a file.""" + group = SimpleNamespace(rank=0, size=1) + iterator = SimpleNamespace(iterable=SimpleNamespace(save_state=lambda: None)) + barriers = [] + + with ( + mock.patch( + "megatron.training.checkpointing.get_pg_rank", + side_effect=lambda process_group: process_group.rank, + ), + mock.patch( + "megatron.training.checkpointing.get_pg_size", + side_effect=lambda process_group: process_group.size, + ), + mock.patch( + "megatron.training.checkpointing.torch.distributed.barrier", + side_effect=lambda group: barriers.append(group), + ), + mock.patch("megatron.training.checkpointing.torch.save") as save, + ): + maybe_save_dataloader_state( + iterator, 2, tmp_path, tp_group=group, pp_group=group, dp_group=group + ) + + assert barriers == [group, group] + save.assert_not_called() + + def create_checkpoint(load_path, ckpt_format): """Setup a dummy checkpoint directory.""" iteration = 123 From 4f657171b150510cafc48f429673fb1b2c4c098d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=84=8D=F0=9D=95=A0=F0=9D=95=9D=F0=9D=95=9D=F0=9D=95=A0?= =?UTF-8?q?=F0=9D=95=A8=20=F0=9D=95=84=F0=9D=95=92=F0=9D=95=9F?= Date: Fri, 24 Jul 2026 06:12:19 -0700 Subject: [PATCH 097/290] [2/2] Add TileLang fused DSA kernels support with THD and CP & Clean up (#5049) Signed-off-by: Hollow Man --- .../dsa_cudnn_kernels.py | 69 +- .../dsa_layout.py | 90 ++ .../dsa_masking.py | 32 + .../dsa_tilelang_kernels.py | 142 ++ .../ops/indexer.py | 131 ++ .../ops/sparse_mla.py | 105 ++ .../ops/tilelang_dsa.py | 908 ++++++++++++ .../ops/tilelang_indexer_bwd.py | 234 +++ .../ops/tilelang_indexer_fwd.py | 224 +++ .../ops/tilelang_indexer_loss.py | 344 +++++ .../ops/tilelang_sparse_mla_bwd.py | 529 +++++++ .../ops/tilelang_sparse_mla_fwd.py | 310 ++++ .../ops/tilelang_utils.py | 101 ++ .../test_dsa_tilelang_kernels.py | 1284 +++++++++++++++++ 14 files changed, 4454 insertions(+), 49 deletions(-) create mode 100644 megatron/core/transformer/experimental_attention_variant/dsa_tilelang_kernels.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/indexer.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/sparse_mla.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_dsa.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_bwd.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_fwd.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_loss.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_bwd.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_fwd.py create mode 100644 megatron/core/transformer/experimental_attention_variant/ops/tilelang_utils.py create mode 100644 tests/unit_tests/transformer/experimental_attention_variant/test_dsa_tilelang_kernels.py diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py b/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py index e26c46030aa..640db5c91ec 100644 --- a/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py +++ b/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py @@ -725,42 +725,15 @@ def _indexer_topk_multi_packed_cp_thd( raise RuntimeError("packed CP cuDNN THD indexer requires positive maximum sequence lengths") segment_divisor = 2 * cp_size - if sk % segment_divisor != 0: - raise RuntimeError(f"packed CP key length must be divisible by {segment_divisor}, got {sk}") - device = q_bshd.device - cu_q = packed_cu_seqlens_q.to(device=device, dtype=torch.int64).contiguous() - cu_k = packed_cu_seqlens_k.to(device=device, dtype=torch.int64).contiguous() - q_lengths = cu_q[1:] - cu_q[:-1] - k_lengths = cu_k[1:] - cu_k[:-1] - q_half = q_lengths // segment_divisor - k_half = k_lengths // segment_divisor - segment_q_lengths = torch.stack((q_half, q_half), dim=1).reshape(-1) - segment_k_lengths = torch.stack( - ((cp_rank + 1) * k_half, k_lengths - cp_rank * k_half), dim=1 - ).reshape(-1) - - zero_i32 = torch.zeros(1, dtype=torch.int32, device=device) - segment_cu_q = torch.cat( - (zero_i32, segment_q_lengths.cumsum(dim=0, dtype=torch.int32)) - ).contiguous() - segment_cu_k = torch.cat( - (zero_i32, segment_k_lengths.cumsum(dim=0, dtype=torch.int32)) - ).contiguous() - - segment_key_starts = cu_k[:-1].repeat_interleave(2) - total_segment_k = sk + sk // segment_divisor - segment_ids = torch.repeat_interleave( - torch.arange(segment_k_lengths.numel(), device=device), - segment_k_lengths, - output_size=total_segment_k, - ) - segment_offsets = torch.arange(total_segment_k, device=device, dtype=torch.int64) - segment_offsets -= torch.repeat_interleave( - segment_cu_k[:-1].to(dtype=torch.int64), segment_k_lengths, output_size=total_segment_k + layout = dsa_layout.build_packed_cp_indexer_layout( + packed_cu_seqlens_q.to(device=device), + packed_cu_seqlens_k.to(device=device), + cp_size=cp_size, + cp_rank=cp_rank, + key_size=sk, ) - source_indices = segment_key_starts.index_select(0, segment_ids) + segment_offsets - segmented_k = k_bshd[0].index_select(0, source_indices).contiguous() + segmented_k = k_bshd[0].index_select(0, layout.source_indices).contiguous() max_segment_q = packed_max_seqlen_q // segment_divisor max_k_half = packed_max_seqlen_k // segment_divisor @@ -771,8 +744,8 @@ def _indexer_topk_multi_packed_cp_thd( w_bsh[0], ratio=_INDEXER_RATIO, sm_scale=_INDEXER_SOFTMAX_SCALE, - cu_seqlens_q=segment_cu_q, - cu_seqlens_k=segment_cu_k, + cu_seqlens_q=layout.segment_cu_q.to(dtype=torch.int32), + cu_seqlens_k=layout.segment_cu_k.to(dtype=torch.int32), max_seqlen_q=max_segment_q, max_seqlen_k=max_segment_k, )["scores"] @@ -1043,11 +1016,8 @@ def _sort_valid_topk_indices_by_index(topk_indices: Tensor, topk_length: Tensor, """Canonicalize consumed top-K indices while keeping ignored suffix slots invalid.""" positions = _trailing_positions(topk_indices) valid = positions < topk_length.unsqueeze(-1) - sort_key = torch.where(valid, topk_indices, torch.full_like(topk_indices, sk)) - order = sort_key.argsort(dim=-1) - sorted_indices = torch.gather(topk_indices, dim=-1, index=order) - sorted_valid = torch.gather(valid.expand_as(topk_indices), dim=-1, index=order) - return sorted_indices.masked_fill(~sorted_valid, -1).contiguous() + sorted_indices, _ = dsa_masking.sort_topk_by_index(topk_indices, valid, sk=sk) + return sorted_indices def _sort_valid_topk_indices_and_scores_by_index( @@ -1056,14 +1026,15 @@ def _sort_valid_topk_indices_and_scores_by_index( """Sort valid top-K indices and keep the selected score payload aligned.""" positions = _trailing_positions(topk_indices) valid = positions < topk_length.unsqueeze(-1) - sort_key = torch.where(valid, topk_indices, torch.full_like(topk_indices, sk)) - order = sort_key.argsort(dim=-1) - sorted_indices = torch.gather(topk_indices, dim=-1, index=order) - sorted_scores = torch.gather(topk_scores, dim=-1, index=order) - sorted_valid = torch.gather(valid.expand_as(topk_indices), dim=-1, index=order) - sorted_indices = sorted_indices.masked_fill(~sorted_valid, -1) - sorted_scores = sorted_scores.masked_fill(~sorted_valid, torch.finfo(torch.float32).min) - return sorted_indices.contiguous(), sorted_scores.contiguous() + sorted_indices, sorted_scores = dsa_masking.sort_topk_by_index( + topk_indices, + valid, + sk=sk, + topk_scores=topk_scores, + invalid_score=torch.finfo(torch.float32).min, + ) + assert sorted_scores is not None + return sorted_indices, sorted_scores def _prepare_attention_topk_indices(topk_indices: Tensor, sk: int) -> Tuple[Tensor, Tensor]: diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_layout.py b/megatron/core/transformer/experimental_attention_variant/dsa_layout.py index eec5a570a44..080ee2dc8ea 100644 --- a/megatron/core/transformer/experimental_attention_variant/dsa_layout.py +++ b/megatron/core/transformer/experimental_attention_variant/dsa_layout.py @@ -2,6 +2,7 @@ """Layout helpers for DeepSeek sparse attention.""" +from dataclasses import dataclass from typing import Optional, Tuple import torch @@ -10,6 +11,8 @@ from megatron.core.utils import get_pg_size __all__ = [ + "PackedCPIndexerLayout", + "build_packed_cp_indexer_layout", "build_packed_allgather_cp_local_positions", "build_packed_allgather_cp_query_positions_and_key_reorder", "build_zigzag_allgather_cp_key_reorder", @@ -22,6 +25,93 @@ ] +@dataclass(frozen=True) +class PackedCPIndexerLayout: + """Segment metadata shared by packed-CP DSA indexer backends.""" + + segment_q_lengths: torch.Tensor + segment_k_lengths: torch.Tensor + segment_cu_q: torch.Tensor + segment_cu_k: torch.Tensor + segment_key_starts: torch.Tensor + source_indices: torch.Tensor + + +def build_packed_cp_indexer_layout( + cu_seqlens_q: torch.Tensor, + cu_seqlens_kv: torch.Tensor, + *, + cp_size: int, + cp_rank: int, + key_size: int, + local_key_layout: bool = False, +) -> PackedCPIndexerLayout: + """Build packed-CP front/back segment metadata for fused DSA indexers. + + ``local_key_layout`` describes the single-sequence optimization where the + key tensor contains only this CP rank's local front/back chunks. Otherwise, + ``key_size`` is the globally ordered packed key length. + """ + if cp_size <= 1 or not 0 <= cp_rank < cp_size: + raise RuntimeError("packed CP indexer layout requires a valid CP rank and cp_size > 1") + if cu_seqlens_q.shape != cu_seqlens_kv.shape or cu_seqlens_q.numel() < 2: + raise RuntimeError("packed CP indexer layout requires matching non-empty q/k cu_seqlens") + + device = cu_seqlens_q.device + cu_q = cu_seqlens_q.to(device=device, dtype=torch.int64).contiguous() + cu_k = cu_seqlens_kv.to(device=device, dtype=torch.int64).contiguous() + segment_divisor = 2 * cp_size + + if local_key_layout: + if cu_q.numel() != 2 or key_size % 2 != 0: + raise RuntimeError( + "local-key packed CP indexer layout requires one sequence and even key rows" + ) + half = key_size // 2 + segment_q_lengths = torch.full((2,), half, dtype=torch.int64, device=device) + segment_k_lengths = torch.tensor((half, key_size), dtype=torch.int64, device=device) + segment_key_starts = torch.zeros(2, dtype=torch.int64, device=device) + total_segment_k = key_size + half + else: + if key_size % segment_divisor != 0: + raise RuntimeError( + f"packed CP key length must be divisible by {segment_divisor}, got {key_size}" + ) + q_lengths = cu_q[1:] - cu_q[:-1] + k_lengths = cu_k[1:] - cu_k[:-1] + q_half = q_lengths // segment_divisor + k_half = k_lengths // segment_divisor + segment_q_lengths = torch.stack((q_half, q_half), dim=1).reshape(-1) + segment_k_lengths = torch.stack( + ((cp_rank + 1) * k_half, k_lengths - cp_rank * k_half), dim=1 + ).reshape(-1) + segment_key_starts = cu_k[:-1].repeat_interleave(2) + total_segment_k = key_size + key_size // segment_divisor + + zero = torch.zeros(1, dtype=torch.int64, device=device) + segment_cu_q = torch.cat((zero, segment_q_lengths.cumsum(dim=0))).contiguous() + segment_cu_k = torch.cat((zero, segment_k_lengths.cumsum(dim=0))).contiguous() + + segment_ids = torch.repeat_interleave( + torch.arange(segment_k_lengths.numel(), device=device), + segment_k_lengths, + output_size=total_segment_k, + ) + segment_offsets = torch.arange(total_segment_k, device=device, dtype=torch.int64) + segment_offsets -= torch.repeat_interleave( + segment_cu_k[:-1], segment_k_lengths, output_size=total_segment_k + ) + source_indices = segment_key_starts.index_select(0, segment_ids) + segment_offsets + return PackedCPIndexerLayout( + segment_q_lengths=segment_q_lengths, + segment_k_lengths=segment_k_lengths, + segment_cu_q=segment_cu_q, + segment_cu_k=segment_cu_k, + segment_key_starts=segment_key_starts, + source_indices=source_indices, + ) + + def normalize_cp_comm_type(cp_comm_type: Optional[str]) -> str: """Normalize CP communication type to a canonical lowercase form.""" if cp_comm_type is None: diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_masking.py b/megatron/core/transformer/experimental_attention_variant/dsa_masking.py index 98126e63798..4c178b7dcea 100644 --- a/megatron/core/transformer/experimental_attention_variant/dsa_masking.py +++ b/megatron/core/transformer/experimental_attention_variant/dsa_masking.py @@ -29,6 +29,7 @@ "prepare_additive_mask", "prepare_sparse_mask_context", "scatter_topk_into_index_mask", + "sort_topk_by_index", ] @@ -98,6 +99,37 @@ def build_valid_mask_from_starts_ends( ) +def sort_topk_by_index( + topk_indices: torch.Tensor, + valid_mask: torch.Tensor, + *, + sk: int, + topk_scores: Optional[torch.Tensor] = None, + invalid_score: float = float("-inf"), +) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """Sort valid top-k slots by key index while preserving aligned scores. + + Backends define validity explicitly: TileLang uses ``index >= 0`` sentinels, + while cuDNN consumes a compact prefix described by ``topk_length``. + """ + if valid_mask.dtype != torch.bool or valid_mask.shape != topk_indices.shape: + raise ValueError("valid_mask must be boolean and match topk_indices") + if topk_scores is not None and topk_scores.shape != topk_indices.shape: + raise ValueError("topk_scores must match topk_indices") + + sort_key = torch.where(valid_mask, topk_indices, torch.full_like(topk_indices, sk)) + order = sort_key.argsort(dim=-1) + sorted_valid = torch.gather(valid_mask, dim=-1, index=order) + sorted_indices = torch.gather(topk_indices, dim=-1, index=order) + sorted_indices = sorted_indices.masked_fill(~sorted_valid, -1).contiguous() + if topk_scores is None: + return sorted_indices, None + + sorted_scores = torch.gather(topk_scores, dim=-1, index=order) + sorted_scores = sorted_scores.masked_fill(~sorted_valid, invalid_score).contiguous() + return sorted_indices, sorted_scores + + def apply_starts_ends_mask_to_scores( scores: torch.Tensor, starts: torch.Tensor, ends: torch.Tensor, key_positions: torch.Tensor ) -> torch.Tensor: diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_tilelang_kernels.py b/megatron/core/transformer/experimental_attention_variant/dsa_tilelang_kernels.py new file mode 100644 index 00000000000..1d89e43e196 --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/dsa_tilelang_kernels.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""TileLang backend hooks for optional fused DeepSeek sparse attention kernels.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Tuple + +import torch + +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.transformer.experimental_attention_variant.ops import tilelang_dsa + +if TYPE_CHECKING: + from megatron.core.packed_seq_params import PackedSeqParams + from megatron.core.transformer.transformer_config import TransformerConfig + + +def run_fused_qk_topk( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + index_topk: int, + starts: torch.Tensor, + ends: torch.Tensor, + block_size: int, + use_relu: bool = True, + use_local_indexer_varlen: bool = False, + single_packed_thd_sequence: bool = False, + local_packed_cp_rank: int = 0, + local_packed_cp_query_start: int = 0, + local_packed_cp_query_len: Optional[int] = None, + packed_seq_params: Optional[PackedSeqParams] = None, + cp_size: int = 1, +) -> Optional[Tuple[torch.Tensor, Optional[torch.Tensor]]]: + """Adapt TileLang's indices-only result to the shared backend hook contract.""" + topk_indices = tilelang_dsa.run_fused_qk_topk( + q, + k, + weights, + index_topk, + starts, + ends, + block_size, + use_relu, + use_local_indexer_varlen=use_local_indexer_varlen, + single_packed_thd_sequence=single_packed_thd_sequence, + local_packed_cp_rank=local_packed_cp_rank, + local_packed_cp_query_start=local_packed_cp_query_start, + local_packed_cp_query_len=local_packed_cp_query_len, + packed_seq_params=packed_seq_params, + cp_size=cp_size, + ) + if topk_indices is None: + return None + return topk_indices, None + + +def run_fused_qk_topk_with_loss( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + index_topk: int, + starts: torch.Tensor, + ends: torch.Tensor, + block_size: int, + query: torch.Tensor, + key: torch.Tensor, + softmax_scale: float, + loss_coeff: float, + pg_collection: ProcessGroupCollection, + query_valid_rows: Optional[torch.Tensor] = None, + calculate_per_token_loss: bool = False, + use_relu: bool = True, + config: Optional["TransformerConfig"] = None, + use_local_indexer_varlen: bool = False, + single_packed_thd_sequence: bool = False, + local_packed_cp_rank: int = 0, + local_packed_cp_query_start: int = 0, + local_packed_cp_query_len: Optional[int] = None, + packed_seq_params: Optional[PackedSeqParams] = None, + cp_size: int = 1, +) -> Optional[Tuple[torch.Tensor, Optional[torch.Tensor], torch.Tensor]]: + """Run fused TileLang indexer and sparse indexer loss.""" + del config + result = tilelang_dsa.run_fused_qk_topk_with_loss( + q=q, + k=k, + weights=weights, + index_topk=index_topk, + starts=starts, + ends=ends, + block_size=block_size, + query=query, + key=key, + softmax_scale=softmax_scale, + loss_coeff=loss_coeff, + pg_collection=pg_collection, + query_valid_rows=query_valid_rows, + calculate_per_token_loss=calculate_per_token_loss, + use_relu=use_relu, + use_local_indexer_varlen=use_local_indexer_varlen, + single_packed_thd_sequence=single_packed_thd_sequence, + local_packed_cp_rank=local_packed_cp_rank, + local_packed_cp_query_start=local_packed_cp_query_start, + local_packed_cp_query_len=local_packed_cp_query_len, + packed_seq_params=packed_seq_params, + cp_size=cp_size, + ) + if result is None: + return None + topk_indices, indexer_loss = result + return topk_indices, None, indexer_loss + + +def run_fused_absorbed_sparse_attention( + query: torch.Tensor, + key: torch.Tensor, + topk_indices: torch.Tensor, + softmax_scale: float, + v_channels: int, + topk_length: Optional[torch.Tensor] = None, +) -> Optional[torch.Tensor]: + """Run fused TileLang SparseMLA for absorbed DSA sparse attention.""" + if topk_length is not None: + if topk_indices.ndim != 3 or topk_length.shape != topk_indices.shape[:-1]: + return None + positions = torch.arange(topk_indices.size(-1), device=topk_indices.device) + valid = positions < topk_length.to(dtype=torch.int64, device=topk_indices.device).unsqueeze( + -1 + ) + topk_indices = topk_indices.masked_fill(~valid, -1) + return tilelang_dsa.run_fused_absorbed_sparse_attention( + query, key, topk_indices, softmax_scale, v_channels + ) + + +__all__ = [ + "run_fused_absorbed_sparse_attention", + "run_fused_qk_topk", + "run_fused_qk_topk_with_loss", +] diff --git a/megatron/core/transformer/experimental_attention_variant/ops/indexer.py b/megatron/core/transformer/experimental_attention_variant/ops/indexer.py new file mode 100644 index 00000000000..2f59feb0776 --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/indexer.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import torch + +from .tilelang_indexer_bwd import HAVE_TILELANG as HAVE_TILELANG_INDEXER_BWD +from .tilelang_indexer_bwd import indexer_bwd_interface +from .tilelang_indexer_fwd import HAVE_TILELANG as HAVE_TILELANG_INDEXER_FWD +from .tilelang_indexer_fwd import indexer_fwd_interface + +HAVE_TILELANG_INDEXER = HAVE_TILELANG_INDEXER_BWD and HAVE_TILELANG_INDEXER_FWD + + +def pytorch_extract_topk_scores(logits, topk_indices, dim=-1): + """Gather top-k logits and mask invalid (-1) entries with -inf.""" + if logits.size(dim) == 0: + return torch.full( + topk_indices.shape, float("-inf"), dtype=logits.dtype, device=logits.device + ) + valid_mask = (topk_indices >= 0) & (topk_indices < logits.size(dim)) + safe_indices = topk_indices.clamp(min=0, max=logits.size(dim) - 1).to(torch.int64) + scores = torch.gather(logits, dim=dim, index=safe_indices) + scores = torch.where(valid_mask, scores, float("-inf")) + return scores + + +def _select_topk_from_logits( + logits: torch.Tensor, topk: int, mask_invalid: bool = True +) -> tuple[torch.Tensor, torch.Tensor]: + """Select top-k scores and int32 indices from indexer logits.""" + effective_topk = min(topk, logits.size(-1)) + if effective_topk > 0: + topk_scores, topk_indices = torch.topk(logits, effective_topk, dim=-1, sorted=False) + topk_indices = topk_indices.to(torch.int32) + if mask_invalid: + topk_indices = topk_indices.masked_fill(topk_scores == -torch.inf, -1) + return topk_scores, topk_indices + + empty_shape = logits.shape[:-1] + (0,) + topk_scores = torch.empty(empty_shape, dtype=logits.dtype, device=logits.device) + topk_indices = torch.empty(empty_shape, dtype=torch.int32, device=logits.device) + return topk_scores, topk_indices + + +class IndexerFunction(torch.autograd.Function): # pragma: no cover + """Autograd wrapper for fused tilelang indexer forward/backward.""" + + @staticmethod + def forward( + ctx, + index_q: torch.Tensor, + index_k: torch.Tensor, + weights: torch.Tensor, + cu_seqlen_ks: torch.Tensor, + cu_seqlen_ke: torch.Tensor, + topk: int, + topk_indices: torch.Tensor | None = None, + use_relu: bool = True, + ): + """Run fused indexer forward and optionally select top-k indices.""" + logits = indexer_fwd_interface( + index_q, + index_k, + weights, + cu_seqlen_ks, + cu_seqlen_ke, + clean_logits=True, + use_relu=use_relu, + ) + if topk_indices is None: + index_score, topk_indices = _select_topk_from_logits(logits, topk) + else: + index_score = pytorch_extract_topk_scores(logits, topk_indices) + + ctx.save_for_backward(index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke, topk_indices) + ctx.use_relu = use_relu + return index_score, topk_indices + + @staticmethod + def backward(ctx, grad_scores, grad_indices): + """Propagate gradients through fused indexer outputs.""" + index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke, topk_indices = ctx.saved_tensors + grad_q, grad_w, grad_k = indexer_bwd_interface( + index_q, weights, index_k, topk_indices, grad_scores, use_relu=ctx.use_relu + ) + return grad_q, grad_k, grad_w, None, None, None, None, None + + +def lighting_indexer( # pragma: no cover + index_q: torch.Tensor, + index_k: torch.Tensor, + weights: torch.Tensor, + cu_seqlen_ks: torch.Tensor, + cu_seqlen_ke: torch.Tensor, + topk: int, + topk_indices: torch.Tensor | None = None, + use_relu: bool = True, +): + """Compute indexer top-k scores/indices via the custom autograd function.""" + return IndexerFunction.apply( + index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke, topk, topk_indices, use_relu + ) + + +def lighting_indexer_indices( # pragma: no cover + index_q: torch.Tensor, + index_k: torch.Tensor, + weights: torch.Tensor, + cu_seqlen_ks: torch.Tensor, + cu_seqlen_ke: torch.Tensor, + topk: int, + use_relu: bool = True, +): + """Compute TileLang indexer top-k indices without score/autograd bookkeeping.""" + with torch.no_grad(): + logits = indexer_fwd_interface( + index_q, + index_k, + weights, + cu_seqlen_ks, + cu_seqlen_ke, + clean_logits=True, + use_relu=use_relu, + ) + _, topk_indices = _select_topk_from_logits(logits, topk, mask_invalid=False) + return topk_indices + + +if not HAVE_TILELANG_INDEXER: + IndexerFunction = None + lighting_indexer = None + lighting_indexer_indices = None diff --git a/megatron/core/transformer/experimental_attention_variant/ops/sparse_mla.py b/megatron/core/transformer/experimental_attention_variant/ops/sparse_mla.py new file mode 100644 index 00000000000..76976ebe0aa --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/sparse_mla.py @@ -0,0 +1,105 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import torch + +from .tilelang_sparse_mla_bwd import HAVE_TILELANG as HAVE_TILELANG_SPARSE_MLA_BWD +from .tilelang_sparse_mla_bwd import sparse_mla_bwd, sparse_mla_delta +from .tilelang_sparse_mla_fwd import HAVE_TILELANG as HAVE_TILELANG_SPARSE_MLA_FWD +from .tilelang_sparse_mla_fwd import sparse_mla_fwd_interface + +HAVE_TILELANG_SPARSE_MLA = HAVE_TILELANG_SPARSE_MLA_BWD and HAVE_TILELANG_SPARSE_MLA_FWD + + +def _canonicalize_batch_stride(tensor: torch.Tensor) -> torch.Tensor: + """Normalize a size-one batch stride without copying tensor data.""" + tensor = tensor.contiguous() + if tensor.ndim == 4 and tensor.size(0) == 1: + tensor = tensor.squeeze(0).unsqueeze(0) + return tensor + + +def _valid_head_mask(indices, num_heads): + valid_groups = indices.ge(0).any(dim=-1) + kv_group = valid_groups.size(-1) + if kv_group == num_heads: + return valid_groups + if num_heads % kv_group != 0: + raise RuntimeError( + f"SparseMLA heads must be divisible by kv_group, got heads={num_heads}, " + f"kv_group={kv_group}" + ) + return valid_groups.repeat_interleave(num_heads // kv_group, dim=-1) + + +def _zero_invalid_heads(tensor, valid_heads): + zero = torch.zeros((), dtype=tensor.dtype, device=tensor.device) + return torch.where(valid_heads.unsqueeze(-1), tensor, zero) + + +class SparseMLA(torch.autograd.Function): # pragma: no cover + """Autograd wrapper around tilelang sparse-MLA forward/backward kernels.""" + + @staticmethod + def forward(ctx, q, kv, indices, scaling): + """ + Args: + q: Query tensor (seq_len, heads, dim_plus_tail_dim) or + (batch, seq_len, heads, dim_plus_tail_dim) + kv: Key-Value tensor (seq_len_kv, kv_group, dim_plus_tail_dim) or + (batch, seq_len_kv, kv_group, dim_plus_tail_dim) + indices: Sparse indices tensor (seq_len, kv_group, topk) or + (batch, seq_len, kv_group, topk) + + Returns: + out: Output tensor (seq_len, heads, dim) or (batch, seq_len, heads, dim) + """ + indices = _canonicalize_batch_stride(indices) + q = _canonicalize_batch_stride(q) + kv = _canonicalize_batch_stride(kv) + ctx.scaling = scaling + valid_heads = _valid_head_mask(indices, q.size(-2)) + tl_out, tl_lse = sparse_mla_fwd_interface(q, kv, indices, sm_scale=scaling) + tl_out = _zero_invalid_heads(tl_out, valid_heads) + lse_zero = torch.zeros((), dtype=tl_lse.dtype, device=tl_lse.device) + tl_lse = torch.where(valid_heads, tl_lse, lse_zero) + + # Do not save tl_out/tl_lse: backward recomputes them just long enough to form + # delta and run the kernel. Saved inputs still go through autograd's saved-tensor + # hooks/offload path and retain_graph can recompute these tensors again. + ctx.save_for_backward(q, kv, indices, valid_heads) + + return tl_out, tl_lse + + @staticmethod + def backward(ctx, grad_output, grad_lse): + """ + Args: + grad_output: Gradient of the loss with respect to output + + Returns: + Gradients for q, kv, and indices (None for indices) + """ + q, kv, indices, valid_heads = ctx.saved_tensors + scaling = ctx.scaling + grad_output = grad_output.contiguous() + grad_output = _zero_invalid_heads(grad_output, valid_heads) + with torch.no_grad(): + tl_out, tl_lse = sparse_mla_fwd_interface(q, kv, indices, sm_scale=scaling) + tl_out = _zero_invalid_heads(tl_out, valid_heads) + lse_zero = torch.zeros((), dtype=tl_lse.dtype, device=tl_lse.device) + tl_lse = torch.where(valid_heads, tl_lse, lse_zero) + delta = sparse_mla_delta(tl_out, grad_output) + del tl_out + + tl_dq, tl_dkv = sparse_mla_bwd( + q, kv, None, grad_output, indices, tl_lse, sm_scale=scaling, delta=delta + ) + tl_dq = _zero_invalid_heads(tl_dq, valid_heads) + del tl_lse + + # Return gradients for each input (None for indices as it's not differentiable) + return tl_dq, tl_dkv, None, None + + +if not HAVE_TILELANG_SPARSE_MLA: + SparseMLA = None diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_dsa.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_dsa.py new file mode 100644 index 00000000000..bedcdb4ca1f --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_dsa.py @@ -0,0 +1,908 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""TileLang-backed DSA hook implementations. + +This module keeps TileLang-specific batching, chunking, and sparse-KL streaming +out of the backend-neutral DSA control flow in ``dsa.py``. +""" + +from collections import OrderedDict +from typing import TYPE_CHECKING, Optional, Tuple + +import torch + +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.transformer.experimental_attention_variant import ( + dsa_indexer_loss, + dsa_layout, + dsa_masking, +) +from megatron.core.utils import get_pg_size + +if TYPE_CHECKING: + from megatron.core.packed_seq_params import PackedSeqParams + +try: + from megatron.core.transformer.experimental_attention_variant.ops.indexer import ( + lighting_indexer, + lighting_indexer_indices, + ) + from megatron.core.transformer.experimental_attention_variant.ops.tilelang_indexer_bwd import ( + is_supported_indexer_bwd_head_count, + ) +except (ImportError, OSError): + is_supported_indexer_bwd_head_count = None + lighting_indexer = None + lighting_indexer_indices = None + +try: + from megatron.core.transformer.experimental_attention_variant.ops.sparse_mla import SparseMLA +except (ImportError, OSError): + SparseMLA = None + +try: + from megatron.core.transformer.experimental_attention_variant.ops.tilelang_indexer_loss import ( + SparseIndexerKLLoss, + sparse_indexer_target_interface, + ) +except (ImportError, OSError): + SparseIndexerKLLoss = None + sparse_indexer_target_interface = None + + +# Reusable no-grad scratch buffers keyed by (name, shape, dtype, device). +_DSA_SCRATCH_CACHE_MAX_ENTRIES = 128 +_DSA_SCRATCH_CACHE_MAX_BYTES = 512 * 1024 * 1024 +_DSA_SCRATCH_CACHE = OrderedDict() +_DSA_SCRATCH_CACHE_TOTAL_BYTES = 0 + + +def _scratch_buffer_bytes(buf: torch.Tensor) -> int: + return buf.numel() * buf.element_size() + + +def _is_supported_sparse_mla_head_count(heads: int, kv_group: int = 1) -> bool: + """Return whether TileLang SparseMLA supports this query/KV head grouping. + + The forward and backward kernels pad ``head_kv`` to ``max(next_power_of_2(head_kv), 16)`` + and index the unpadded head dimension by that padded count with no head-dim bound, so they + only stay in bounds when no padding occurs. That requires ``head_kv`` (= ``heads // + kv_group``) to be a power of two and at least 16; any other value (e.g. 48, 192, or < 16) + must fall back to the unfused path rather than read/write past the real head count. + """ + if kv_group <= 0 or heads % kv_group != 0: + return False + head_kv = heads // kv_group + return head_kv >= 16 and (head_kv & (head_kv - 1)) == 0 + + +def _all_bfloat16(*tensors: torch.Tensor) -> bool: + return all(tensor.dtype == torch.bfloat16 for tensor in tensors) + + +def _evict_scratch_cache_if_needed() -> None: + """Bound scratch cache growth by LRU eviction.""" + global _DSA_SCRATCH_CACHE_TOTAL_BYTES + while ( + len(_DSA_SCRATCH_CACHE) > _DSA_SCRATCH_CACHE_MAX_ENTRIES + or _DSA_SCRATCH_CACHE_TOTAL_BYTES > _DSA_SCRATCH_CACHE_MAX_BYTES + ): + _, buf = _DSA_SCRATCH_CACHE.popitem(last=False) + _DSA_SCRATCH_CACHE_TOTAL_BYTES -= _scratch_buffer_bytes(buf) + + +def _get_scratch_buffer( + name: str, shape: Tuple[int, ...], dtype: torch.dtype, device: torch.device +) -> torch.Tensor: + """Get a reusable scratch tensor for temporary no-grad workspaces.""" + global _DSA_SCRATCH_CACHE_TOTAL_BYTES + key = (name, shape, dtype, device) + buf = _DSA_SCRATCH_CACHE.pop(key, None) + if buf is not None: + _DSA_SCRATCH_CACHE_TOTAL_BYTES -= _scratch_buffer_bytes(buf) + else: + buf = torch.empty(shape, dtype=dtype, device=device) + _DSA_SCRATCH_CACHE[key] = buf + _DSA_SCRATCH_CACHE_TOTAL_BYTES += _scratch_buffer_bytes(buf) + _evict_scratch_cache_if_needed() + return buf + + +def _topk_valid_mask( + topk_indices: torch.Tensor, starts: torch.Tensor, ends: torch.Tensor +) -> torch.Tensor: + """Compute the row-wise [start, end) validity mask for fused indexer outputs.""" + starts_for_cmp = starts.to(device=topk_indices.device, dtype=topk_indices.dtype).unsqueeze(-1) + ends_for_cmp = ends.to(device=topk_indices.device, dtype=topk_indices.dtype).unsqueeze(-1) + return (topk_indices >= starts_for_cmp) & (topk_indices < ends_for_cmp) + + +def _sanitize_fused_topk_indices( + topk_indices: torch.Tensor, starts: torch.Tensor, ends: torch.Tensor +) -> torch.Tensor: + """Mask fused indexer outputs in place and return the validity mask.""" + valid = _topk_valid_mask(topk_indices, starts, ends) + topk_indices.masked_fill_(~valid, -1) + return valid + + +def _sanitize_fused_topk_outputs( + topk_indices: torch.Tensor, + starts: torch.Tensor, + ends: torch.Tensor, + topk_scores: Optional[torch.Tensor] = None, +) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """Mask fused indexer outputs and optional scores to row-wise key bounds.""" + valid = _topk_valid_mask(topk_indices, starts, ends) + sanitized_indices = topk_indices.masked_fill(~valid, -1) + if topk_scores is not None: + topk_scores = topk_scores.masked_fill(~valid, float("-inf")) + return sanitized_indices, topk_scores + + +def _build_packed_cp_indexer_inputs( + index_k: torch.Tensor, + starts: torch.Tensor, + ends: torch.Tensor, + *, + packed_seq_params: "PackedSeqParams", + cp_size: int, + cp_rank: int, + single_packed_thd_sequence: bool, + local_query_start: int, + local_query_len: int, +) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Pack CP front/back key prefixes and translate query bounds to the packed key space.""" + if cp_size <= 1 or not 0 <= cp_rank < cp_size: + raise RuntimeError("packed CP TileLang indexer requires a valid CP rank and cp_size > 1") + if local_query_start < 0 or local_query_start + starts.numel() > local_query_len: + raise RuntimeError( + "packed CP TileLang indexer received an invalid local query slice: " + f"start={local_query_start}, rows={starts.numel()}, local_rows={local_query_len}" + ) + + cu_q, cu_k = dsa_layout.get_packed_qk_cu_seqlens(packed_seq_params) + device = index_k.device + sk = index_k.size(0) + layout = dsa_layout.build_packed_cp_indexer_layout( + cu_q.to(device=device), + cu_k.to(device=device), + cp_size=cp_size, + cp_rank=cp_rank, + key_size=sk, + local_key_layout=single_packed_thd_sequence and sk == local_query_len, + ) + segmented_k = index_k.index_select(0, layout.source_indices).contiguous() + + segment_ids_q = torch.repeat_interleave( + torch.arange(layout.segment_q_lengths.numel(), device=device), + layout.segment_q_lengths, + output_size=local_query_len, + ) + row_start = local_query_start + row_end = row_start + starts.numel() + row_segment_ids = segment_ids_q[row_start:row_end] + row_segment_starts = layout.segment_cu_k[:-1].index_select(0, row_segment_ids) + row_segment_ends = row_segment_starts + layout.segment_k_lengths.index_select( + 0, row_segment_ids + ) + row_global_starts = layout.segment_key_starts.index_select(0, row_segment_ids) + + local_starts = row_segment_starts + starts.to(torch.int64) - row_global_starts + local_ends = row_segment_starts + ends.to(torch.int64) - row_global_starts + local_starts = torch.maximum(local_starts, row_segment_starts) + local_starts = torch.minimum(local_starts, row_segment_ends) + local_ends = torch.maximum(local_ends, local_starts) + local_ends = torch.minimum(local_ends, row_segment_ends) + return ( + segmented_k, + local_starts.to(torch.int32).contiguous(), + local_ends.to(torch.int32).contiguous(), + layout.source_indices, + ) + + +def _remap_segmented_topk_indices( + topk_indices: torch.Tensor, source_indices: torch.Tensor +) -> torch.Tensor: + """Map valid indices from a segmented key tensor back to the original packed key tensor.""" + valid = topk_indices >= 0 + safe_indices = topk_indices.clamp(min=0).reshape(-1).to(torch.int64) + global_indices = source_indices.index_select(0, safe_indices).view_as(topk_indices) + return torch.where(valid, global_indices.to(topk_indices.dtype), topk_indices) + + +def fused_qk_topk_lighting( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + index_topk: int, + starts: torch.Tensor, + ends: torch.Tensor, + block_size: int, + use_relu: bool = True, + use_local_indexer_varlen: bool = False, + single_packed_thd_sequence: bool = False, + local_packed_cp_rank: int = 0, + local_packed_cp_query_start: int = 0, + local_packed_cp_query_len: Optional[int] = None, + packed_seq_params: Optional["PackedSeqParams"] = None, + cp_size: int = 1, +) -> Optional[torch.Tensor]: + """Run fused TileLang indexer and return top-k indices [b, sq, topk].""" + if lighting_indexer_indices is None: + return None + if q.ndim != 4 or k.ndim != 3 or weights.ndim != 3: + return None + if not _all_bfloat16(q, k): + return None + + sq, b = q.size(0), q.size(1) + if k.size(1) != b or weights.size(1) != b: + return None + starts = starts.contiguous() + ends = ends.contiguous() + + topk_k = min(index_topk, k.size(0)) + topk_out = torch.empty((b, sq, topk_k), dtype=torch.int32, device=q.device) + for bi in range(b): + index_q = q[:, bi].contiguous() + index_k = k[:, bi].contiguous() + index_w = weights[:, bi].float().contiguous() + local_starts = starts + local_ends = ends + source_indices = None + if b == 1 and use_local_indexer_varlen and packed_seq_params is not None and cp_size > 1: + local_query_len = ( + local_packed_cp_query_len if local_packed_cp_query_len is not None else sq + ) + index_k, local_starts, local_ends, source_indices = _build_packed_cp_indexer_inputs( + index_k, + starts, + ends, + packed_seq_params=packed_seq_params, + cp_size=cp_size, + cp_rank=local_packed_cp_rank, + single_packed_thd_sequence=single_packed_thd_sequence, + local_query_start=local_packed_cp_query_start, + local_query_len=local_query_len, + ) + for start in range(0, sq, block_size): + end = min(start + block_size, sq) + topk_indices = lighting_indexer_indices( + index_q[start:end], + index_k, + index_w[start:end], + local_starts[start:end], + local_ends[start:end], + topk_k, + use_relu=use_relu, + ) + _sanitize_fused_topk_indices( + topk_indices, starts=local_starts[start:end], ends=local_ends[start:end] + ) + if source_indices is not None: + topk_indices = _remap_segmented_topk_indices(topk_indices, source_indices) + topk_out[bi, start:end].copy_(topk_indices) + + return topk_out + + +@torch.no_grad() +def _compute_topk_target_chunk_sum( + *, + query_h: torch.Tensor, + key_shared: Optional[torch.Tensor], + key_per_head: Optional[torch.Tensor], + s0: int, + s1: int, + idx_seq: torch.Tensor, + valid_seq: torch.Tensor, + softmax_scale: float, + head_chunk_size: int, + topk_chunk_size: int, + sk: int, + hn: int, +) -> torch.Tensor: + """Compute unnormalized target probability mass on top-k support for one sequence chunk.""" + s_len = s1 - s0 + topk = idx_seq.size(-1) + device = query_h.device + np = query_h.size(0) + + attn_chunk_sum = _get_scratch_buffer("kl_attn_chunk_sum", (s_len, topk), torch.float32, device) + attn_chunk_sum.zero_() + + for h0 in range(0, np, head_chunk_size): + h1 = min(h0 + head_chunk_size, np) + h_chunk = h1 - h0 + q_chunk = query_h[h0:h1, s0:s1, :] + q_chunk_float = q_chunk.float() + + if key_shared is None: + key_chunk = key_per_head[h0:h1] + flat_keys = key_chunk.reshape(h_chunk * sk, hn) + head_offsets = ( + torch.arange(h_chunk, device=device, dtype=torch.int64).view(-1, 1, 1) * sk + ) + else: + flat_keys = None + head_offsets = None + + # Two-pass online softmax over top-k chunks: + # 1) compute row-wise max and denominator; 2) recompute and accumulate probabilities. + # These accumulators are rebound to fresh tensors each top-k chunk, so they + # cannot reuse a scratch buffer in place. + running_max = torch.full( + (h_chunk, s_len), float("-inf"), dtype=torch.float32, device=device + ) + running_denom = torch.zeros((h_chunk, s_len), dtype=torch.float32, device=device) + + def _chunk_logits(idx_topk, valid_topk_chunk, k_len): + if key_shared is not None: + key_sel = key_shared.index_select(0, idx_topk.reshape(-1)).view(s_len, k_len, hn) + logits = torch.einsum("hsd,skd->hsk", q_chunk_float, key_sel.float()) + else: + flat_idx = idx_topk.unsqueeze(0) + head_offsets + key_sel = flat_keys.index_select(0, flat_idx.reshape(-1)).view( + h_chunk, s_len, k_len, hn + ) + logits = (q_chunk_float.unsqueeze(2) * key_sel.float()).sum(dim=-1) + logits = logits * softmax_scale + return logits.masked_fill(~valid_topk_chunk.unsqueeze(0), float("-inf")) + + for t0 in range(0, topk, topk_chunk_size): + t1 = min(t0 + topk_chunk_size, topk) + logits = _chunk_logits(idx_seq[:, t0:t1], valid_seq[:, t0:t1], t1 - t0) + chunk_max = logits.max(dim=-1).values + new_running_max = torch.maximum(running_max, chunk_max) + max_for_exp = torch.where( + torch.isfinite(new_running_max), new_running_max, torch.zeros_like(new_running_max) + ) + alpha = torch.exp(running_max - max_for_exp) + p_chunk = torch.exp(logits - max_for_exp.unsqueeze(-1)) + running_denom = running_denom * alpha + p_chunk.sum(dim=-1) + running_max = new_running_max + + stable_max = torch.where( + torch.isfinite(running_max), running_max, torch.zeros_like(running_max) + ) + inverse_denom = running_denom.clamp_min(1e-10).reciprocal() + for t0 in range(0, topk, topk_chunk_size): + t1 = min(t0 + topk_chunk_size, topk) + logits = _chunk_logits(idx_seq[:, t0:t1], valid_seq[:, t0:t1], t1 - t0) + probs = torch.exp(logits - stable_max.unsqueeze(-1)) * inverse_denom.unsqueeze(-1) + attn_chunk_sum[:, t0:t1] += probs.sum(dim=0) + + return attn_chunk_sum + + +def _compute_sparse_topk_kl_chunk( + target_chunk: torch.Tensor, index_logits_chunk: torch.Tensor, valid_seq: torch.Tensor +) -> torch.Tensor: + """Compute KL(target || index) sum for one [s_chunk, topk] chunk.""" + index_logits_chunk = index_logits_chunk.to(dtype=torch.float32, device=target_chunk.device) + target_chunk = target_chunk.to(dtype=torch.float32, device=index_logits_chunk.device) + with torch.no_grad(): + index_log_scores_chunk = dsa_masking.masked_log_softmax( + index_logits_chunk.detach(), valid_seq, dim=-1 + ) + index_scores_chunk = index_log_scores_chunk.exp().masked_fill(~valid_seq, 0.0) + kl_value = dsa_indexer_loss.indexer_kl_sum(target_chunk, index_log_scores_chunk, valid_seq) + grad_logits = (index_scores_chunk - target_chunk).masked_fill(~valid_seq, 0.0) + index_logits_for_grad = index_logits_chunk.masked_fill(~valid_seq, 0.0) + grad_surrogate = (index_logits_for_grad * grad_logits).sum() + return grad_surrogate + (kl_value - grad_surrogate).detach() + + +def _can_use_fused_sparse_indexer_target( + query: torch.Tensor, key: Optional[torch.Tensor], topk_indices: torch.Tensor +) -> bool: + """Return whether the fused TileLang target kernel supports these tensors.""" + return ( + sparse_indexer_target_interface is not None + and key is not None + and query.is_cuda + and key.is_cuda + and topk_indices.is_cuda + and query.ndim == 3 + and key.ndim == 2 + and query.dtype == torch.bfloat16 + and key.dtype == torch.bfloat16 + and query.size(-1) == key.size(-1) + and query.size(-1) % 16 == 0 + and topk_indices.ndim == 2 + and topk_indices.size(-1) % 64 == 0 + ) + + +def _can_use_fused_sparse_indexer_kl( + target: torch.Tensor, index_logits: torch.Tensor, valid_mask: torch.Tensor +) -> bool: + """Return whether the fused TileLang KL/score-gradient kernel supports these tensors.""" + return ( + SparseIndexerKLLoss is not None + and target.is_cuda + and index_logits.is_cuda + and valid_mask.is_cuda + and target.dtype == torch.float32 + and index_logits.dtype == torch.float32 + and valid_mask.dtype == torch.bool + and target.shape == index_logits.shape == valid_mask.shape + and target.ndim == 2 + and target.size(-1) % 256 == 0 + ) + + +def _canonicalize_topk_scores_for_tp_reduce( + topk_indices: torch.Tensor, topk_scores: torch.Tensor, *, sk: int +) -> Tuple[torch.Tensor, torch.Tensor]: + """Sort selected top-k slots by key index before slot-wise TP reductions.""" + valid = topk_indices >= 0 + topk_indices, topk_scores = dsa_masking.sort_topk_by_index( + topk_indices, valid, sk=sk, topk_scores=topk_scores + ) + assert topk_scores is not None + return topk_indices, topk_scores + + +def _accumulate_topk_kl_chunk( + *, + target_chunk: torch.Tensor, + index_logits_chunk: torch.Tensor, + valid_seq: torch.Tensor, + kl_sum: torch.Tensor, +) -> torch.Tensor: + """Normalize one target chunk and accumulate its sparse KL contribution.""" + if _can_use_fused_sparse_indexer_kl(target_chunk, index_logits_chunk, valid_seq): + return kl_sum + SparseIndexerKLLoss.apply( + target_chunk.contiguous(), index_logits_chunk.contiguous(), valid_seq.contiguous() + ) + normalized_target = dsa_indexer_loss.normalize_indexer_target_(target_chunk) + return kl_sum + _compute_sparse_topk_kl_chunk( + target_chunk=normalized_target, index_logits_chunk=index_logits_chunk, valid_seq=valid_seq + ) + + +def _stage_topk_target_chunk( + target_chunk: torch.Tensor, + *, + slot_prefix: str, + slot: int, + device: torch.device, + tp_group: torch.distributed.ProcessGroup, + tp_size: int, +) -> Tuple[torch.Tensor, Optional[torch.distributed.Work]]: + """Copy chunk into scratch slot and optionally launch async TP all-reduce.""" + target_chunk_work = _get_scratch_buffer( + f"{slot_prefix}_slot{slot}", tuple(target_chunk.shape), torch.float32, device + ) + target_chunk_work.copy_(target_chunk) + if tp_size > 1: + handle = torch.distributed.all_reduce(target_chunk_work, group=tp_group, async_op=True) + else: + handle = None + return target_chunk_work, handle + + +def _consume_pending_topk_kl_chunk( + *, + pending_handle: Optional[torch.distributed.Work], + pending_target_chunk: Optional[torch.Tensor], + pending_index_logits: Optional[torch.Tensor], + pending_valid_seq: Optional[torch.Tensor], + kl_sum: torch.Tensor, +) -> torch.Tensor: + """Finalize one pending chunk and accumulate its KL contribution into ``kl_sum``.""" + if pending_target_chunk is None: + return kl_sum + if pending_handle is not None: + pending_handle.wait() + return _accumulate_topk_kl_chunk( + target_chunk=pending_target_chunk, + index_logits_chunk=pending_index_logits, + valid_seq=pending_valid_seq, + kl_sum=kl_sum, + ) + + +def _enqueue_topk_kl_chunk( + *, + target_chunk: torch.Tensor, + index_logits_chunk: torch.Tensor, + valid_seq: torch.Tensor, + slot_prefix: str, + chunk_id: int, + device: torch.device, + tp_group: torch.distributed.ProcessGroup, + tp_size: int, + pending_handle: Optional[torch.distributed.Work], + pending_target_chunk: Optional[torch.Tensor], + pending_index_logits: Optional[torch.Tensor], + pending_valid_seq: Optional[torch.Tensor], + kl_sum: torch.Tensor, +) -> Tuple[ + torch.Tensor, + int, + Optional[torch.distributed.Work], + Optional[torch.Tensor], + Optional[torch.Tensor], + Optional[torch.Tensor], +]: + """Stage a new KL chunk, consume previous pending chunk, and update pending state.""" + slot = chunk_id & 1 + target_chunk_work, current_handle = _stage_topk_target_chunk( + target_chunk, + slot_prefix=slot_prefix, + slot=slot, + device=device, + tp_group=tp_group, + tp_size=tp_size, + ) + kl_sum = _consume_pending_topk_kl_chunk( + pending_handle=pending_handle, + pending_target_chunk=pending_target_chunk, + pending_index_logits=pending_index_logits, + pending_valid_seq=pending_valid_seq, + kl_sum=kl_sum, + ) + return (kl_sum, chunk_id + 1, current_handle, target_chunk_work, index_logits_chunk, valid_seq) + + +def fused_qk_topk_lighting_with_streaming_sparse_kl( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + index_topk: int, + starts: torch.Tensor, + ends: torch.Tensor, + block_size: int, + query: torch.Tensor, + key: torch.Tensor, + softmax_scale: float, + loss_coeff: float, + pg_collection: ProcessGroupCollection, + query_valid_rows: Optional[torch.Tensor] = None, + calculate_per_token_loss: bool = False, + seq_chunk_size: int = 512, + head_chunk_size: int = 16, + topk_chunk_size: int = 1024, + use_relu: bool = True, + use_local_indexer_varlen: bool = False, + single_packed_thd_sequence: bool = False, + local_packed_cp_rank: int = 0, + local_packed_cp_query_start: int = 0, + local_packed_cp_query_len: Optional[int] = None, + packed_seq_params: Optional["PackedSeqParams"] = None, + cp_size: int = 1, +) -> Optional[Tuple[torch.Tensor, torch.Tensor]]: + """Run the fused TileLang indexer with streaming sparse KL accumulation. + + The objective matches ``compute_dsa_indexer_loss`` on the selected top-k support. TileLang + streams query/head/top-k chunks and overlaps TP target reduction to avoid materializing dense + scores; its custom gradient surrogate supplies the same log-softmax gradient for fused indexer + logits. Target normalization, KL evaluation, and token reduction use the shared backend-neutral + helpers in ``dsa_indexer_loss``. + """ + if lighting_indexer is None: + return None + if q.ndim != 4 or k.ndim != 3 or weights.ndim != 3: + return None + if not _all_bfloat16(q, k): + return None + if is_supported_indexer_bwd_head_count is None or not is_supported_indexer_bwd_head_count( + q.size(2) + ): + return None + + query, _ = dsa_layout.ensure_sbhd(query, "query") + key, _ = dsa_layout.ensure_sbhd(key, "key") + sq, b = q.size(0), q.size(1) + sq_q, b_q, np, hn = query.size() + sk, b_k, nk, hk = key.size() + if k.size(1) != b or weights.size(1) != b: + return None + if sq_q != sq or b_q != b or b_k != b or hk != hn: + return None + if nk != 1 and nk != np: + return None + query_valid_rows = dsa_masking.normalize_query_valid_rows( + query_valid_rows, b=b, sq=sq, device=query.device + ) + + starts = starts.contiguous() + ends = ends.contiguous() + + topk_out = None + kl_sum = torch.zeros((), dtype=torch.float32, device=q.device) + tp_size = get_pg_size(pg_collection.tp) + pending_handle = None + pending_target_chunk = None + pending_index_logits = None + pending_valid_seq = None + chunk_id = 0 + for bi in range(b): + query_h = query[:, bi].permute(1, 0, 2).contiguous() + if nk == 1: + key_shared = key[:, bi, 0].contiguous() + key_per_head = None + else: + key_shared = None + key_per_head = key[:, bi].permute(1, 0, 2).contiguous() + + index_q = q[:, bi].contiguous() + index_k = k[:, bi].contiguous() + index_w = weights[:, bi].float().contiguous() + local_starts = starts + local_ends = ends + source_indices = None + if b == 1 and use_local_indexer_varlen and packed_seq_params is not None and cp_size > 1: + local_query_len = ( + local_packed_cp_query_len if local_packed_cp_query_len is not None else sq + ) + index_k, local_starts, local_ends, source_indices = _build_packed_cp_indexer_inputs( + index_k, + starts, + ends, + packed_seq_params=packed_seq_params, + cp_size=cp_size, + cp_rank=local_packed_cp_rank, + single_packed_thd_sequence=single_packed_thd_sequence, + local_query_start=local_packed_cp_query_start, + local_query_len=local_query_len, + ) + + for start in range(0, sq, block_size): + end = min(start + block_size, sq) + topk_scores, topk_indices = lighting_indexer( + index_q[start:end], + index_k, + index_w[start:end], + local_starts[start:end], + local_ends[start:end], + min(index_topk, k.size(0)), + topk_indices=None, + use_relu=use_relu, + ) + topk_indices, topk_scores = _sanitize_fused_topk_outputs( + topk_indices=topk_indices, + starts=local_starts[start:end], + ends=local_ends[start:end], + topk_scores=topk_scores, + ) + if source_indices is not None: + topk_indices = _remap_segmented_topk_indices(topk_indices, source_indices) + if tp_size > 1: + topk_indices, topk_scores = _canonicalize_topk_scores_for_tp_reduce( + topk_indices, topk_scores, sk=sk + ) + + if topk_out is None: + topk_out = torch.empty( + (b, sq, topk_indices.size(-1)), + dtype=topk_indices.dtype, + device=topk_indices.device, + ) + topk_out[bi, start:end].copy_(topk_indices) + + s_len = end - start + for rel_start in range(0, s_len, seq_chunk_size): + rel_end = min(rel_start + seq_chunk_size, s_len) + abs_start = start + rel_start + abs_end = start + rel_end + + idx_seq_raw = topk_indices[rel_start:rel_end].to(device=query.device) + valid_seq = idx_seq_raw >= 0 + if query_valid_rows is not None: + row_valid = query_valid_rows[bi, abs_start:abs_end] + valid_seq = valid_seq & row_valid.unsqueeze(-1) + loss_topk_indices = idx_seq_raw.masked_fill(~valid_seq, -1).contiguous() + query_chunk = query[abs_start:abs_end, bi].contiguous() + if _can_use_fused_sparse_indexer_target(query_chunk, key_shared, loss_topk_indices): + target_chunk = sparse_indexer_target_interface( + query_chunk, key_shared, loss_topk_indices, softmax_scale + ) + else: + target_chunk = _compute_topk_target_chunk_sum( + query_h=query_h, + key_shared=key_shared, + key_per_head=key_per_head, + s0=abs_start, + s1=abs_end, + idx_seq=idx_seq_raw.clamp(min=0).to(torch.int64), + valid_seq=valid_seq, + softmax_scale=softmax_scale, + head_chunk_size=head_chunk_size, + topk_chunk_size=topk_chunk_size, + sk=sk, + hn=hn, + ) + index_logits_chunk = topk_scores[rel_start:rel_end] + ( + kl_sum, + chunk_id, + pending_handle, + pending_target_chunk, + pending_index_logits, + pending_valid_seq, + ) = _enqueue_topk_kl_chunk( + target_chunk=target_chunk, + index_logits_chunk=index_logits_chunk, + valid_seq=valid_seq, + slot_prefix="stream_kl_target", + chunk_id=chunk_id, + device=query.device, + tp_group=pg_collection.tp, + tp_size=tp_size, + pending_handle=pending_handle, + pending_target_chunk=pending_target_chunk, + pending_index_logits=pending_index_logits, + pending_valid_seq=pending_valid_seq, + kl_sum=kl_sum, + ) + kl_sum = _consume_pending_topk_kl_chunk( + pending_handle=pending_handle, + pending_target_chunk=pending_target_chunk, + pending_index_logits=pending_index_logits, + pending_valid_seq=pending_valid_seq, + kl_sum=kl_sum, + ) + + if topk_out is None: + return None + valid_row_count = query_valid_rows.sum() if query_valid_rows is not None else None + kl_div = dsa_indexer_loss.reduce_indexer_kl_sum( + kl_sum, + num_rows=b * sq, + calculate_per_token_loss=calculate_per_token_loss, + valid_row_count=valid_row_count, + ) + return topk_out, kl_div * loss_coeff + + +def fused_sparse_mla_absorbed( + query: torch.Tensor, + key: torch.Tensor, + topk_indices: torch.Tensor, + softmax_scale: float, + v_channels: int, +) -> Optional[torch.Tensor]: + """Run fused SparseMLA kernel for absorbed-MLA path.""" + if SparseMLA is None: + return None + + if query.ndim != 4 or key.ndim != 4 or topk_indices.ndim != 3: + return None + if not _all_bfloat16(query, key): + return None + if key.size(2) != 1: + return None + if query.size(1) != key.size(1) or topk_indices.size(0) != query.size(1): + return None + if topk_indices.size(1) != query.size(0): + return None + if query.size(-1) != key.size(-1): + return None + if query.size(-1) != 576 or v_channels != 512: + # Current copied TileLang kernels are specialized for GLM5/DeepSeek V3.2 absorbed dims. + return None + query_heads = query.size(2) + if query_heads <= 0: + return None + kernel_heads = max(query_heads, 16) + if not _is_supported_sparse_mla_head_count(kernel_heads, kv_group=key.size(2)): + return None + if topk_indices.size(-1) % 64 != 0: + return None + + query_bshd = query.permute(1, 0, 2, 3).contiguous() + if kernel_heads != query_heads: + # SparseMLA uses a minimum 16-head tile without head bounds. Pad the caller + # tensor so small TP shards stay in bounds, then discard those heads below. + query_bshd = torch.nn.functional.pad(query_bshd, (0, 0, 0, kernel_heads - query_heads)) + key_bshd = key.permute(1, 0, 2, 3).contiguous() + indices_bsgk = topk_indices.unsqueeze(2).to(torch.int32).contiguous() + out, _ = SparseMLA.apply(query_bshd, key_bshd, indices_bsgk, softmax_scale) + if out.ndim != 4 or out.size(2) != kernel_heads or out.size(-1) != v_channels: + return None + out = out[:, :, :query_heads] + return out.permute(1, 0, 2, 3).contiguous() + + +def run_fused_qk_topk( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + index_topk: int, + starts: torch.Tensor, + ends: torch.Tensor, + block_size: int, + use_relu: bool = True, + use_local_indexer_varlen: bool = False, + single_packed_thd_sequence: bool = False, + local_packed_cp_rank: int = 0, + local_packed_cp_query_start: int = 0, + local_packed_cp_query_len: Optional[int] = None, + packed_seq_params: Optional["PackedSeqParams"] = None, + cp_size: int = 1, +) -> Optional[torch.Tensor]: + """Optional fused indexer hook backed by TileLang.""" + return fused_qk_topk_lighting( + q, + k, + weights, + index_topk, + starts, + ends, + block_size, + use_relu, + use_local_indexer_varlen=use_local_indexer_varlen, + single_packed_thd_sequence=single_packed_thd_sequence, + local_packed_cp_rank=local_packed_cp_rank, + local_packed_cp_query_start=local_packed_cp_query_start, + local_packed_cp_query_len=local_packed_cp_query_len, + packed_seq_params=packed_seq_params, + cp_size=cp_size, + ) + + +def run_fused_qk_topk_with_loss( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + index_topk: int, + starts: torch.Tensor, + ends: torch.Tensor, + block_size: int, + query: torch.Tensor, + key: torch.Tensor, + softmax_scale: float, + loss_coeff: float, + pg_collection: ProcessGroupCollection, + query_valid_rows: Optional[torch.Tensor] = None, + calculate_per_token_loss: bool = False, + use_relu: bool = True, + use_local_indexer_varlen: bool = False, + single_packed_thd_sequence: bool = False, + local_packed_cp_rank: int = 0, + local_packed_cp_query_start: int = 0, + local_packed_cp_query_len: Optional[int] = None, + packed_seq_params: Optional["PackedSeqParams"] = None, + cp_size: int = 1, +) -> Optional[Tuple[torch.Tensor, torch.Tensor]]: + """Optional fused indexer+loss hook backed by TileLang.""" + return fused_qk_topk_lighting_with_streaming_sparse_kl( + q=q, + k=k, + weights=weights, + index_topk=index_topk, + starts=starts, + ends=ends, + block_size=block_size, + query=query, + key=key, + softmax_scale=softmax_scale, + loss_coeff=loss_coeff, + pg_collection=pg_collection, + query_valid_rows=query_valid_rows, + calculate_per_token_loss=calculate_per_token_loss, + use_relu=use_relu, + use_local_indexer_varlen=use_local_indexer_varlen, + single_packed_thd_sequence=single_packed_thd_sequence, + local_packed_cp_rank=local_packed_cp_rank, + local_packed_cp_query_start=local_packed_cp_query_start, + local_packed_cp_query_len=local_packed_cp_query_len, + packed_seq_params=packed_seq_params, + cp_size=cp_size, + ) + + +def run_fused_absorbed_sparse_attention( + query: torch.Tensor, + key: torch.Tensor, + topk_indices: torch.Tensor, + softmax_scale: float, + v_channels: int, +) -> Optional[torch.Tensor]: + """Optional fused sparse-attention hook backed by TileLang.""" + return fused_sparse_mla_absorbed(query, key, topk_indices, softmax_scale, v_channels) diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_bwd.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_bwd.py new file mode 100644 index 00000000000..33c5627728b --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_bwd.py @@ -0,0 +1,234 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# ruff: noqa +# Adapted from: +# https://github.com/tile-ai/tilelang/blob/4956b5835fa554af6c03d4a6289cad44bf310869/ +# examples/dsa_sparse_finetune/indexer_bwd.py +import threading +from collections import OrderedDict + +import torch + +from .tilelang_utils import ( + HAVE_TILELANG, + T, + _get_cached_kernel, + _next_power_of_two, + _round_up, + require_tilelang, +) +from .tilelang_utils import tilelang as tl +from .tilelang_utils import tilelang_jit + +BF16 = T.bfloat16 if HAVE_TILELANG else None +FP32 = T.float32 if HAVE_TILELANG else None +INT32 = T.int32 if HAVE_TILELANG else None +_tilelang_indexer_bwd_kernel_cache = OrderedDict() +_tilelang_indexer_bwd_cache_lock = threading.Lock() + +if HAVE_TILELANG: + pass_configs = { + tl.PassConfigKey.TL_DISABLE_TMA_LOWER: True, + tl.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, + } +else: + pass_configs = {} + + +def _canonical_topk(topk: int, block_i: int = 32) -> int: + return _round_up(_next_power_of_two(topk), block_i) + + +def is_supported_indexer_bwd_head_count(heads: int) -> bool: + """Return whether TileLang indexer backward supports this indexer head count.""" + return heads <= 64 and heads % 8 == 0 + + +def _get_indexer_bwd_kernel(heads: int, dim: int, topk: int, use_relu: bool = True): + num_threads = 32 if heads < 16 else 128 + return _get_cached_kernel( + _tilelang_indexer_bwd_kernel_cache, + _tilelang_indexer_bwd_cache_lock, + (heads, dim, topk, use_relu), + lambda: tl_indexer_bwd_impl(heads, dim, topk, num_threads=num_threads, use_relu=use_relu), + ) + + +@tilelang_jit(pass_configs=pass_configs) +def tl_indexer_bwd_impl( # pragma: no cover + heads: int, + dim: int, + topk: int, + block_I: int = 32, + num_stages: int = 0, + num_threads: int = 128, + use_relu: bool = True, +): + """Build tilelang backward kernel for sparse indexer.""" + require_tilelang() + assert num_stages == 0 + assert topk == tl.math.next_power_of_2(topk) + assert topk % block_I == 0 + assert heads <= 64 and heads % 8 == 0 + seq_len = T.symbolic("seq_len") + q_seq_len = T.symbolic("q_seq_len") + + dtype: str = BF16 + accum_dtype: str = FP32 + index_q_shape = [q_seq_len, heads, dim] + weights_shape = [q_seq_len, heads] + index_k_shape = [seq_len, dim] + shape_p = [q_seq_len, topk] + topk_indices_shape = [q_seq_len, topk] + + pad_heads = heads + if heads < 16: + pad_heads = 16 + + @T.prim_func + def tl_indexer_bwd_kernel( + IndexQ: T.Tensor(index_q_shape, dtype), + IndexK: T.Tensor(index_k_shape, dtype), + Weights: T.Tensor(weights_shape, FP32), + TopkIndices: T.Tensor(topk_indices_shape, INT32), + OGrad: T.Tensor(shape_p, FP32), + dIndexQ: T.Tensor(index_q_shape, dtype), + dWeights: T.Tensor(weights_shape, FP32), + dIndexK: T.Tensor(index_k_shape, FP32), + ): + + with T.Kernel(q_seq_len, threads=num_threads) as (bx): + index_q_shared = T.alloc_shared([pad_heads, dim], dtype=FP32) + weights_shared = T.alloc_shared([pad_heads], dtype=FP32) + index_k_shared = T.alloc_shared([block_I, dim], dtype=FP32) + indices_shared = T.alloc_shared([block_I], dtype=INT32) + d_index_q_frag = T.alloc_fragment([pad_heads, dim], dtype=accum_dtype) + d_weights_frag = T.alloc_fragment([pad_heads], dtype=accum_dtype) + d_index_k_frag = T.alloc_fragment([block_I, dim], dtype=accum_dtype) + logits = T.alloc_fragment((block_I, pad_heads), dtype=accum_dtype) + _logits = T.alloc_shared((block_I, pad_heads), dtype=accum_dtype) + grad = T.alloc_shared([block_I], dtype=FP32) + + num_blocks = T.ceildiv(topk, block_I) + for i, j in T.Parallel(pad_heads, dim): + index_q_shared[i, j] = T.if_then_else(i < heads, IndexQ[bx, i, j], 0) + for i in T.Parallel(heads): + weights_shared[i] = Weights[bx, i] + + T.fill(d_index_q_frag, 0) + T.fill(d_weights_frag, 0) + + for bi_i in T.serial(num_blocks): + for i in T.Parallel(block_I): + if bi_i * block_I + i < topk: + indices_shared[i] = TopkIndices[bx, bi_i * block_I + i] + grad[i] = OGrad[bx, bi_i * block_I + i] + + T.sync_threads() + for i, j in T.Parallel(block_I, dim): + index_k_shared[i, j] = T.if_then_else( + indices_shared[i] > -1 and indices_shared[i] < seq_len, + IndexK[indices_shared[i], j], + 0, + ) + + T.sync_threads() + T.gemm( + index_k_shared, + index_q_shared, + logits, + transpose_A=False, + transpose_B=True, + clear_accum=True, + ) + d_weights_i = T.alloc_fragment((block_I, pad_heads), accum_dtype) + for i, j in T.Parallel(block_I, heads): + d_weights_i[i, j] = grad[i] * ( + T.max(logits[i, j], 0) if use_relu else logits[i, j] + ) + T.reduce_sum(d_weights_i, d_weights_frag, dim=0, clear=False) + + for i, j in T.Parallel(block_I, pad_heads): + _logits[i, j] = T.if_then_else( + (logits[i, j] > 0 if use_relu else True) and j < heads, + grad[i] * weights_shared[j], + 0, + ) + T.sync_threads() + T.gemm( + _logits, + index_k_shared, + d_index_q_frag, + transpose_A=True, + transpose_B=False, + clear_accum=False, + ) + + T.gemm( + _logits, + index_q_shared, + d_index_k_frag, + transpose_A=False, + transpose_B=False, + clear_accum=True, + ) + + for i, j in T.Parallel(block_I, dim): + if indices_shared[i] > -1 and indices_shared[i] < seq_len: + T.atomic_add(dIndexK[indices_shared[i], j], d_index_k_frag[i, j]) + + T.copy(d_index_q_frag[:heads, :], dIndexQ[bx, :, :]) + T.copy(d_weights_frag[:heads], dWeights[bx, :]) + + return tl_indexer_bwd_kernel + + +def indexer_bwd_interface( # pragma: no cover + index_q: torch.Tensor, + weights: torch.Tensor, + index_k: torch.Tensor, + topk_indices: torch.Tensor, + grad_scores: torch.Tensor, + use_relu: bool = True, +): + """Run indexer backward kernel and return gradients for q/w/k.""" + require_tilelang() + _, head_num, head_dim = index_q.shape + k_top = int(topk_indices.shape[1]) + assert k_top > 0, "topk must be positive" + padded_topk = _canonical_topk(k_top) + + if padded_topk != k_top: + padded_indices = torch.full( + (topk_indices.size(0), padded_topk), + -1, + dtype=topk_indices.dtype, + device=topk_indices.device, + ) + padded_indices[:, :k_top].copy_(topk_indices) + topk_indices = padded_indices + + padded_grad_scores = torch.zeros( + (grad_scores.size(0), padded_topk), dtype=grad_scores.dtype, device=grad_scores.device + ) + padded_grad_scores[:, :k_top].copy_(grad_scores) + grad_scores = padded_grad_scores + + grad_scores = grad_scores.contiguous() + weights_kernel = weights.to(dtype=torch.float32).contiguous() + grad_q = torch.empty_like(index_q) + grad_w = torch.empty_like(weights, dtype=torch.float32) + grad_k = torch.zeros_like(index_k, dtype=torch.float32) + + bwd_kernel = _get_indexer_bwd_kernel(head_num, head_dim, padded_topk, use_relu=use_relu) + bwd_kernel( + index_q.contiguous(), + index_k.contiguous(), + weights_kernel, + topk_indices.contiguous(), + grad_scores, + grad_q, + grad_w, + grad_k, + ) + + return grad_q, grad_w, grad_k.to(index_k.dtype) diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_fwd.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_fwd.py new file mode 100644 index 00000000000..ffa45ccdf90 --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_fwd.py @@ -0,0 +1,224 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# ruff: noqa +# Adapted from: +# https://github.com/tile-ai/tilelang/blob/4956b5835fa554af6c03d4a6289cad44bf310869/ +# examples/deepseek_v32/fp8_lighting_indexer.py +import threading +from collections import OrderedDict + +import torch + +from .tilelang_utils import ( + HAVE_TILELANG, + T, + _get_cached_kernel, + require_tilelang, + tilelang, + tilelang_jit, +) + +_tilelang_indexer_fwd_kernel_cache = OrderedDict() +_tilelang_indexer_clean_logits_kernel_cache = OrderedDict() +_tilelang_indexer_fwd_cache_lock = threading.Lock() + + +def _get_clean_logits_kernel(threads: int = 512, block_K: int = 4096): + return _get_cached_kernel( + _tilelang_indexer_clean_logits_kernel_cache, + _tilelang_indexer_fwd_cache_lock, + (threads, block_K), + lambda: clean_logits_(threads=threads, block_K=block_K), + ) + + +def _get_indexer_fwd_kernel( + heads: int, + index_dim: int, + block_N: int = 256, + num_stages: int = 3, + threads: int = 512, + use_relu: bool = True, +): + return _get_cached_kernel( + _tilelang_indexer_fwd_kernel_cache, + _tilelang_indexer_fwd_cache_lock, + (heads, index_dim, block_N, num_stages, threads, use_relu), + lambda: tl_indexer_fwd_impl( + heads=heads, + index_dim=index_dim, + block_N=block_N, + num_stages=num_stages, + threads=threads, + use_relu=use_relu, + ), + ) + + +_TL_INDEXER_FWD_PASS_CONFIGS = ( + {tilelang.PassConfigKey.TL_ENABLE_FAST_MATH: True} if HAVE_TILELANG else {} +) + + +@tilelang_jit(pass_configs=_TL_INDEXER_FWD_PASS_CONFIGS) +def tl_indexer_fwd_impl( # pragma: no cover + heads, index_dim, block_N=256, num_stages=3, threads=512, block_Q=None, use_relu=True +): + """Build tilelang forward kernel for sparse indexer logits.""" + require_tilelang() + assert heads > 0 + if block_Q is None: + block_Q = max(1, 128 // heads) + dtype = T.bfloat16 + accum_dtype = T.float32 + index_dtype = T.int32 + + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + index_q_shape = [seq_len * heads, index_dim] + index_k_shape = [seq_len_kv, index_dim] + logits_shape = [seq_len, seq_len_kv] + + @T.prim_func + def tl_indexer_fwd_kernel( + IndexQ: T.Tensor(index_q_shape, dtype), # type: ignore + IndexK: T.Tensor(index_k_shape, dtype), # type: ignore + Logits: T.Tensor(logits_shape, accum_dtype), # type: ignore + Weights: T.Tensor([seq_len, heads], accum_dtype), # type: ignore + CuSeqLenKS: T.Tensor([seq_len], index_dtype), # type: ignore + CuSeqLenKE: T.Tensor([seq_len], index_dtype), # type: ignore + ): + with T.Kernel(T.ceildiv(seq_len, block_Q), threads=threads) as bx: + index_q_shared = T.alloc_shared([block_Q * heads, index_dim], dtype) + index_k_shared = T.alloc_shared([block_N, index_dim], dtype) + s = T.alloc_fragment([block_N, block_Q * heads], accum_dtype) + s_reshaped = T.reshape(s, (block_N, block_Q, heads)) + logits_shared = T.alloc_shared([block_N, block_Q], accum_dtype) + weights = T.alloc_fragment([block_Q, heads], accum_dtype) + + seq_len_i = bx * block_Q + + cu_k_s_min = T.alloc_var(index_dtype) + cu_k_e_max = T.alloc_var(index_dtype) + + cu_k_s_min = 2147483647 + cu_k_e_max = -2147483648 + + for bq_i in T.serial(block_Q): + q_idx = seq_len_i + bq_i + if q_idx < seq_len: + k_s = T.max(T.min(CuSeqLenKS[q_idx], seq_len_kv), 0) + cu_k_s_min = T.min(cu_k_s_min, k_s) + for bq_i in T.serial(block_Q): + q_idx = seq_len_i + bq_i + if q_idx < seq_len: + k_e = T.max(T.min(CuSeqLenKE[q_idx], seq_len_kv), 0) + cu_k_e_max = T.max(cu_k_e_max, k_e) + + # Clamp bounds to [0, seq_len_kv] and normalize empty rows. + cu_k_s_min = T.max(cu_k_s_min, 0) + cu_k_s_min = T.min(cu_k_s_min, seq_len_kv) + cu_k_e_max = T.max(cu_k_e_max, 0) + cu_k_e_max = T.min(cu_k_e_max, seq_len_kv) + if cu_k_e_max < cu_k_s_min: + cu_k_e_max = cu_k_s_min + + for bq_i, h_i, d_i in T.Parallel(block_Q, heads, index_dim): + q_idx = seq_len_i + bq_i + index_q_shared[bq_i * heads + h_i, d_i] = T.if_then_else( + q_idx < seq_len, IndexQ[q_idx * heads + h_i, d_i], 0 + ) + for bq_i, h_i in T.Parallel(block_Q, heads): + q_idx = seq_len_i + bq_i + weights[bq_i, h_i] = T.if_then_else(q_idx < seq_len, Weights[q_idx, h_i], 0) + + for nbn_i in T.Pipelined( + T.ceildiv(cu_k_e_max - cu_k_s_min, block_N), num_stages=num_stages + ): + for bn_i, d_i in T.Parallel(block_N, index_dim): + k_idx = cu_k_s_min + nbn_i * block_N + bn_i + index_k_shared[bn_i, d_i] = T.if_then_else( + k_idx >= 0 and k_idx < cu_k_e_max, IndexK[k_idx, d_i], 0 + ) + + T.gemm( + index_k_shared, + index_q_shared, + s, + transpose_B=True, + clear_accum=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + for bn_i, bq_i, h_i in T.Parallel(block_N, block_Q, heads): + s_reshaped[bn_i, bq_i, h_i] = ( + T.max(s_reshaped[bn_i, bq_i, h_i], 0) + if use_relu + else s_reshaped[bn_i, bq_i, h_i] + ) * weights[bq_i, h_i] + + T.reduce_sum(s_reshaped, logits_shared, dim=-1, clear=True) + + # Keep this write deterministic to satisfy data-race verification. + for bq_i in T.serial(block_Q): + q_idx = seq_len_i + bq_i + if q_idx < seq_len: + for bn_i in T.serial(block_N): + k_idx = cu_k_s_min + nbn_i * block_N + bn_i + if k_idx >= 0 and k_idx < cu_k_e_max: + Logits[q_idx, k_idx] = logits_shared[bn_i, bq_i] + + return tl_indexer_fwd_kernel + + +@tilelang_jit +def clean_logits_(threads: int = 512, block_K: int = 4096): # pragma: no cover + """Build kernel that masks out invalid key ranges in logits.""" + require_tilelang() + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + dtype = T.float + indices_dtype = T.int32 + + @T.prim_func + def clean_logits_kernel( + Logits: T.Tensor([seq_len, seq_len_kv], dtype), # type: ignore + CuSeqLenKS: T.Tensor([seq_len], indices_dtype), # type: ignore + CuSeqLenKE: T.Tensor([seq_len], indices_dtype), # type: ignore + ): + with T.Kernel(seq_len, threads=threads) as bx: + tx = T.thread_binding(0, threads, thread="threadIdx.x") + cu_k_s = CuSeqLenKS[bx] + cu_k_e = CuSeqLenKE[bx] + + for n_i in T.Pipelined(T.ceildiv(seq_len_kv, block_K)): + for k_i in T.serial(block_K // threads): + idx = n_i * block_K + k_i * threads + tx + if idx < seq_len_kv and (idx < cu_k_s or idx >= cu_k_e): + Logits[bx, idx] = -T.infinity(dtype) + + return clean_logits_kernel + + +def indexer_fwd_interface( # pragma: no cover + q, kv, weights, cu_seqlen_ks, cu_seqlen_ke, clean_logits=True, use_relu=True +): + """Run indexer forward kernel and optionally clean logits by row bounds.""" + require_tilelang() + seq_len, heads, index_dim = q.shape + seq_len_kv = kv.shape[0] + weights = weights.to(dtype=torch.float32).contiguous() + + tl_indexer_fwd_kernel = _get_indexer_fwd_kernel( + heads=heads, index_dim=index_dim, use_relu=use_relu + ) + logits = torch.empty([seq_len, seq_len_kv], device=q.device, dtype=torch.float32) + tl_indexer_fwd_kernel( + q.view(seq_len * heads, index_dim), kv, logits, weights, cu_seqlen_ks, cu_seqlen_ke + ) + + if clean_logits: + clean_logits_kernel = _get_clean_logits_kernel() + clean_logits_kernel(logits, cu_seqlen_ks, cu_seqlen_ke) + return logits diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_loss.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_loss.py new file mode 100644 index 00000000000..3bfbfd70c2a --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_indexer_loss.py @@ -0,0 +1,344 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""TileLang kernels for the sparse DSA indexer KL target and score gradient.""" + +import threading +from collections import OrderedDict + +import torch + +from .tilelang_utils import ( + HAVE_TILELANG, + T, + _get_cached_kernel, + _normalize_sm_scale, + require_tilelang, + tilelang, + tilelang_jit, +) + +_target_kernel_cache = OrderedDict() +_kl_kernel_cache = OrderedDict() +_kernel_cache_lock = threading.Lock() + +_PASS_CONFIGS = {tilelang.PassConfigKey.TL_ENABLE_FAST_MATH: True} if HAVE_TILELANG else {} + + +def _get_target_kernel( + heads: int, + dim: int, + topk: int, + softmax_scale: float, + block_h: int = 32, + block_i: int = 64, + num_stages: int = 2, + threads: int = 256, +): + scale = _normalize_sm_scale(softmax_scale) + key = (heads, dim, topk, scale, block_h, block_i, num_stages, threads) + return _get_cached_kernel( + _target_kernel_cache, + _kernel_cache_lock, + key, + lambda: sparse_indexer_target( + heads=heads, + dim=dim, + topk=topk, + softmax_scale=scale, + block_h=block_h, + block_i=block_i, + num_stages=num_stages, + threads=threads, + ), + ) + + +def _get_kl_kernel(topk: int, block_i: int = 256, threads: int = 256): + key = (topk, block_i, threads) + return _get_cached_kernel( + _kl_kernel_cache, + _kernel_cache_lock, + key, + lambda: sparse_indexer_kl(topk=topk, block_i=block_i, threads=threads), + ) + + +@tilelang_jit(out_idx=[-1], pass_configs=_PASS_CONFIGS) +def sparse_indexer_target( # pragma: no cover + heads: int, + dim: int, + topk: int, + softmax_scale: float, + block_h: int = 32, + block_i: int = 64, + num_stages: int = 2, + threads: int = 256, +): + """Build a kernel that sums selected-key attention probabilities over local heads.""" + require_tilelang() + assert heads > 0 + assert dim % 16 == 0 + assert topk % block_i == 0 + + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + dtype = T.bfloat16 + accum_dtype = T.float32 + index_dtype = T.int32 + num_tiles = tilelang.cdiv(topk, block_i) + num_head_tiles = tilelang.cdiv(heads, block_h) + scale_log2 = softmax_scale * 1.4426950408889634 + + @T.prim_func + def main( + Query: T.Tensor([seq_len, heads, dim], dtype), # type: ignore + Key: T.Tensor([seq_len_kv, dim], dtype), # type: ignore + Indices: T.Tensor([seq_len, topk], index_dtype), # type: ignore + Target: T.Tensor([seq_len, topk], accum_dtype), # type: ignore + ): + with T.Kernel(seq_len, threads=threads) as row: + query_shared = T.alloc_shared([block_h, dim], dtype) + key_shared = T.alloc_shared([block_i, dim], dtype) + scores = T.alloc_fragment([block_h, block_i], accum_dtype) + probabilities = T.alloc_fragment([block_h, block_i], accum_dtype) + target_tile = T.alloc_fragment([block_i], accum_dtype) + valid = T.alloc_fragment([block_i], "bool") + row_max = T.alloc_fragment([block_h], accum_dtype) + previous_max = T.alloc_fragment([block_h], accum_dtype) + tile_max = T.alloc_fragment([block_h], accum_dtype) + row_sum = T.alloc_fragment([block_h], accum_dtype) + tile_sum = T.alloc_fragment([block_h], accum_dtype) + alpha = T.alloc_fragment([block_h], accum_dtype) + + for item in T.Parallel(topk): + Target[row, item] = 0 + + for head_tile in T.serial(num_head_tiles): + for head, d in T.Parallel(block_h, dim): + head_index = head_tile * block_h + head + query_shared[head, d] = T.if_then_else( + head_index < heads, Query[row, head_index, d], 0 + ) + T.fill(row_max, -(2**30)) + T.fill(row_sum, 0) + + for tile in T.Pipelined(num_tiles, num_stages=num_stages): + for item in T.Parallel(block_i): + index = Indices[row, tile * block_i + item] + valid[item] = index >= 0 and index < seq_len_kv + for item, d in T.Parallel(block_i, dim): + index = Indices[row, tile * block_i + item] + safe_index = T.max(T.min(index, seq_len_kv - 1), 0) + key_shared[item, d] = T.if_then_else(valid[item], Key[safe_index, d], 0) + T.gemm( + query_shared, + key_shared, + scores, + transpose_B=True, + clear_accum=True, + policy=T.GemmWarpPolicy.FullRow, + ) + for head, item in T.Parallel(block_h, block_i): + scores[head, item] = T.if_then_else( + valid[item] and head_tile * block_h + head < heads, + scores[head, item], + -T.infinity(accum_dtype), + ) + T.copy(row_max, previous_max) + T.reduce_max(scores, tile_max, dim=1, clear=True) + for head in T.Parallel(block_h): + row_max[head] = T.max(previous_max[head], tile_max[head]) + alpha[head] = T.exp2((previous_max[head] - row_max[head]) * scale_log2) + for head, item in T.Parallel(block_h, block_i): + probabilities[head, item] = T.if_then_else( + valid[item] and head_tile * block_h + head < heads, + T.exp2((scores[head, item] - row_max[head]) * scale_log2), + 0, + ) + T.reduce_sum(probabilities, tile_sum, dim=1, clear=True) + for head in T.Parallel(block_h): + row_sum[head] = row_sum[head] * alpha[head] + tile_sum[head] + + for tile in T.Pipelined(num_tiles, num_stages=num_stages): + for item in T.Parallel(block_i): + index = Indices[row, tile * block_i + item] + valid[item] = index >= 0 and index < seq_len_kv + for item, d in T.Parallel(block_i, dim): + index = Indices[row, tile * block_i + item] + safe_index = T.max(T.min(index, seq_len_kv - 1), 0) + key_shared[item, d] = T.if_then_else(valid[item], Key[safe_index, d], 0) + T.gemm( + query_shared, + key_shared, + scores, + transpose_B=True, + clear_accum=True, + policy=T.GemmWarpPolicy.FullRow, + ) + for head, item in T.Parallel(block_h, block_i): + scores[head, item] = T.if_then_else( + valid[item] and head_tile * block_h + head < heads, + scores[head, item], + -T.infinity(accum_dtype), + ) + probabilities[head, item] = T.if_then_else( + valid[item] + and head_tile * block_h + head < heads + and row_sum[head] > 0, + T.exp2((scores[head, item] - row_max[head]) * scale_log2) + / row_sum[head], + 0, + ) + T.reduce_sum(probabilities, target_tile, dim=0, clear=True) + for item in T.Parallel(block_i): + Target[row, tile * block_i + item] += target_tile[item] + + return main + + +@tilelang_jit(out_idx=[-2, -1], pass_configs=_PASS_CONFIGS) +def sparse_indexer_kl(topk: int, block_i: int = 256, threads: int = 256): # pragma: no cover + """Build a kernel that computes sparse KL row sums and gradients for indexer logits.""" + require_tilelang() + assert topk % block_i == 0 + + seq_len = T.dynamic("seq_len") + accum_dtype = T.float32 + num_tiles = tilelang.cdiv(topk, block_i) + log2_e = 1.4426950408889634 + ln_2 = 0.6931471805599453 + eps = 1.0e-10 + + @T.prim_func + def main( + Target: T.Tensor([seq_len, topk], accum_dtype), # type: ignore + IndexLogits: T.Tensor([seq_len, topk], accum_dtype), # type: ignore + ValidMask: T.Tensor([seq_len, topk], "bool"), # type: ignore + GradLogits: T.Tensor([seq_len, topk], accum_dtype), # type: ignore + KLRows: T.Tensor([seq_len], accum_dtype), # type: ignore + ): + with T.Kernel(seq_len, threads=threads) as row: + logits = T.alloc_fragment([1, block_i], accum_dtype) + target = T.alloc_fragment([1, block_i], accum_dtype) + probabilities = T.alloc_fragment([1, block_i], accum_dtype) + kl_terms = T.alloc_fragment([1, block_i], accum_dtype) + valid = T.alloc_fragment([block_i], "bool") + row_max = T.alloc_fragment([1], accum_dtype) + previous_max = T.alloc_fragment([1], accum_dtype) + tile_max = T.alloc_fragment([1], accum_dtype) + row_sum = T.alloc_fragment([1], accum_dtype) + tile_sum = T.alloc_fragment([1], accum_dtype) + target_sum = T.alloc_fragment([1], accum_dtype) + target_tile_sum = T.alloc_fragment([1], accum_dtype) + kl_sum = T.alloc_fragment([1], accum_dtype) + kl_tile_sum = T.alloc_fragment([1], accum_dtype) + + T.fill(row_max, -(2**30)) + T.fill(row_sum, 0) + T.fill(target_sum, 0) + T.fill(kl_sum, 0) + + for tile in T.serial(num_tiles): + for item in T.Parallel(block_i): + valid[item] = ValidMask[row, tile * block_i + item] + logits[0, item] = T.if_then_else( + valid[item], + IndexLogits[row, tile * block_i + item], + -T.infinity(accum_dtype), + ) + target[0, item] = T.if_then_else( + valid[item], Target[row, tile * block_i + item], 0 + ) + T.copy(row_max, previous_max) + T.reduce_max(logits, tile_max, dim=1, clear=True) + row_max[0] = T.max(previous_max[0], tile_max[0]) + for item in T.Parallel(block_i): + probabilities[0, item] = T.if_then_else( + valid[item], T.exp2((logits[0, item] - row_max[0]) * log2_e), 0 + ) + T.reduce_sum(probabilities, tile_sum, dim=1, clear=True) + row_sum[0] = ( + row_sum[0] * T.exp2((previous_max[0] - row_max[0]) * log2_e) + tile_sum[0] + ) + T.reduce_sum(target, target_tile_sum, dim=1, clear=True) + target_sum[0] += target_tile_sum[0] + + for tile in T.serial(num_tiles): + for item in T.Parallel(block_i): + valid[item] = ValidMask[row, tile * block_i + item] + logits[0, item] = T.if_then_else( + valid[item], + IndexLogits[row, tile * block_i + item], + -T.infinity(accum_dtype), + ) + target[0, item] = T.if_then_else( + valid[item] and target_sum[0] > 0, + Target[row, tile * block_i + item] / target_sum[0], + 0, + ) + probabilities[0, item] = T.if_then_else( + valid[item] and row_sum[0] > 0, + T.exp2((logits[0, item] - row_max[0]) * log2_e) / row_sum[0], + 0, + ) + GradLogits[row, tile * block_i + item] = T.if_then_else( + valid[item], probabilities[0, item] - target[0, item], 0 + ) + kl_terms[0, item] = T.if_then_else( + valid[item] and target[0, item] > 0, + target[0, item] + * ( + T.log2(T.max(target[0, item], eps)) * ln_2 + - (logits[0, item] - row_max[0]) + + T.log2(T.max(row_sum[0], eps)) * ln_2 + ), + 0, + ) + T.reduce_sum(kl_terms, kl_tile_sum, dim=1, clear=True) + kl_sum[0] += kl_tile_sum[0] + + KLRows[row] = kl_sum[0] + + return main + + +def sparse_indexer_target_interface( + query: torch.Tensor, key: torch.Tensor, topk_indices: torch.Tensor, softmax_scale: float +) -> torch.Tensor: + """Compute the local-head sparse attention target on selected top-k keys.""" + require_tilelang() + seq_len, heads, dim = query.shape + topk = topk_indices.size(1) + kernel = _get_target_kernel(heads, dim, topk, softmax_scale) + return kernel(query, key, topk_indices) + + +def sparse_indexer_kl_interface( + target: torch.Tensor, index_logits: torch.Tensor, valid_mask: torch.Tensor +) -> tuple[torch.Tensor, torch.Tensor]: + """Compute unscaled indexer KL sum and its exact gradient with respect to logits.""" + require_tilelang() + kernel = _get_kl_kernel(valid_mask.size(1)) + grad_logits, kl_rows = kernel(target, index_logits, valid_mask) + return kl_rows.sum(), grad_logits + + +class SparseIndexerKLLoss(torch.autograd.Function): # pragma: no cover + """Autograd bridge from fused sparse KL score gradients to the TileLang indexer.""" + + @staticmethod + def forward(ctx, target, index_logits, valid_mask): + """Compute the sparse indexer KL loss and save its logits gradient.""" + kl_sum, grad_logits = sparse_indexer_kl_interface(target, index_logits, valid_mask) + ctx.save_for_backward(grad_logits) + return kl_sum + + @staticmethod + def backward(ctx, grad_output): + """Scale the saved index-logits gradient for the backward pass.""" + (grad_logits,) = ctx.saved_tensors + return None, grad_logits * grad_output, None + + +if not HAVE_TILELANG: + SparseIndexerKLLoss = None diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_bwd.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_bwd.py new file mode 100644 index 00000000000..1cccec8339b --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_bwd.py @@ -0,0 +1,529 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# ruff: noqa +# Adapted from: +# https://github.com/tile-ai/tilelang/blob/4ff81c7d40803d269569e157e847623e84553f78/ +# examples/deepseek_v32/sparse_mla_bwd.py +import threading +from collections import OrderedDict + +import torch + +from .tilelang_utils import ( + HAVE_TILELANG, + T, + _env_int, + _get_cached_kernel, + _normalize_sm_scale, + _round_up, + require_tilelang, + tilelang, + tilelang_jit, +) + +_SPARSE_MLA_BWD_BLOCK_SIZE = 32 +_tilelang_sparse_mla_preprocess_kernel_cache = OrderedDict() +_tilelang_sparse_mla_bwd_kernel_cache = OrderedDict() +_tilelang_sparse_mla_postprocess_kernel_cache = OrderedDict() +_tilelang_sparse_mla_bwd_cache_lock = threading.Lock() + + +def _get_preprocess_kernel(H: int, D: int): + return _get_cached_kernel( + _tilelang_sparse_mla_preprocess_kernel_cache, + _tilelang_sparse_mla_bwd_cache_lock, + (H, D), + lambda: preprocess(H, D), + ) + + +def _normalize_block_h(block_h: int) -> int: + if block_h >= 64: + return 64 + if block_h >= 32: + return 32 + return 16 + + +def _get_bwd_kernel( + H: int, D: int, D_tail: int, topk: int, kv_group: int, sm_scale, max_block_h: int +): + max_block_h = _normalize_block_h(max_block_h) + key = (H, D, D_tail, topk, kv_group, _normalize_sm_scale(sm_scale), max_block_h) + return _get_cached_kernel( + _tilelang_sparse_mla_bwd_kernel_cache, + _tilelang_sparse_mla_bwd_cache_lock, + key, + lambda: bwd(H, D, D_tail, topk, kv_group, sm_scale, max_block_h=max_block_h), + ) + + +def _get_postprocess_kernel(D: int, D_tail: int, kv_group: int): + return _get_cached_kernel( + _tilelang_sparse_mla_postprocess_kernel_cache, + _tilelang_sparse_mla_bwd_cache_lock, + (D, D_tail, kv_group), + lambda: postprocess(D, D_tail, kv_group), + ) + + +@tilelang_jit(out_idx=[-1]) +def preprocess( # pragma: no cover + H, + D, + block_ND=32, + num_stages=5, + dtype=T.bfloat16 if HAVE_TILELANG else None, + accum_dtype=T.float32 if HAVE_TILELANG else None, +): + """Build preprocessing kernel that computes Delta = sum(O * dO) per row/head.""" + require_tilelang() + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + batch = T.dynamic("batch") + seq_len = T.dynamic("seq_len") + shape = [batch, seq_len, H, D] + + @T.prim_func + def preprocess_kernel( + O: T.Tensor(shape, dtype), + dO: T.Tensor(shape, dtype), + Delta: T.Tensor([batch, seq_len, H], accum_dtype), + ): + with T.Kernel(H, T.ceildiv(seq_len, block_ND), batch) as (bx, by, bz): + o = T.alloc_fragment([block_ND, block_ND], accum_dtype) + do = T.alloc_fragment([block_ND, block_ND], accum_dtype) + delta = T.alloc_fragment([block_ND], accum_dtype) + acc = T.alloc_fragment([block_ND, block_ND], accum_dtype) + T.clear(acc) + for k in T.Pipelined(T.ceildiv(D, block_ND), num_stages=num_stages): + T.copy( + O[ + bz, + by * block_ND : (by + 1) * block_ND, + bx, + k * block_ND : (k + 1) * block_ND, + ], + o, + ) + T.copy( + dO[ + bz, + by * block_ND : (by + 1) * block_ND, + bx, + k * block_ND : (k + 1) * block_ND, + ], + do, + ) + for i, j in T.Parallel(block_ND, block_ND): + acc[i, j] += o[i, j] * do[i, j] + T.reduce_sum(acc, delta, 1) + T.copy(delta, Delta[bz, by * block_ND : (by + 1) * block_ND, bx]) + + return preprocess_kernel + + +@tilelang_jit(out_idx=[-1]) +def postprocess( # pragma: no cover + D, + D_tail, + kv_group=1, + block_N=64, + threads=128, + dtype=T.bfloat16 if HAVE_TILELANG else None, + accum_dtype=T.float32 if HAVE_TILELANG else None, +): + """Build postprocess kernel that casts/exports accumulated dKV.""" + require_tilelang() + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + batch = T.dynamic("batch") + seq_len_kv = T.dynamic("seq_len_kv") + dkv_shape = [batch, seq_len_kv, kv_group, D + D_tail] + + @T.prim_func + def postprocess_kernel( + dKV: T.Tensor(dkv_shape, accum_dtype), dKV_out: T.Tensor(dkv_shape, dtype) + ): + with T.Kernel(T.ceildiv(seq_len_kv, block_N), kv_group, batch, threads=threads) as ( + bx, + by, + bz, + ): + T.copy( + dKV[bz, bx * block_N : (bx + 1) * block_N, by, :], + dKV_out[bz, bx * block_N : (bx + 1) * block_N, by, :], + ) + + return postprocess_kernel + + +_SPARSE_MLA_BWD_PASS_CONFIGS = ( + { + tilelang.PassConfigKey.TL_DISABLE_TMA_LOWER: True, + tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, + tilelang.PassConfigKey.TL_ENABLE_AGGRESSIVE_SHARED_MEMORY_MERGE: True, + } + if HAVE_TILELANG + else {} +) + + +@tilelang_jit(out_idx=[-2], pass_configs=_SPARSE_MLA_BWD_PASS_CONFIGS) +def bwd( # pragma: no cover + H, + D, + D_tail, + topk, + kv_group=1, + sm_scale=None, + block_size=32, + max_block_h=32, + num_stages=2, + threads=128, + indices_dtype=T.int32 if HAVE_TILELANG else None, + dtype=T.bfloat16 if HAVE_TILELANG else None, + accum_dtype=T.float32 if HAVE_TILELANG else None, +): + """Build sparse-MLA backward kernel.""" + require_tilelang() + assert ( + topk % block_size == 0 + ), "otherwise will load some index=0 thus causing wrong kv to be loaded" + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + assert indices_dtype == T.int32 + + if sm_scale is None: + sm_scale = (D + D_tail) ** (-0.5) + sm_scale_mul_reciprocal_log2 = sm_scale * 1.44269504 # log2(e) + + batch = T.dynamic("batch") + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + H_kv = H // kv_group + q_shape = [batch, seq_len, H, D + D_tail] + k_shape = [batch, seq_len_kv, kv_group, D + D_tail] + o_shape = [batch, seq_len, H, D] + indices_shape = [batch, seq_len, kv_group, topk] + delta_shape = [batch, seq_len, H] + lse_shape = [batch, seq_len, H] + assert indices_dtype == T.int32 + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + + H = H_kv + padded_H = max(tilelang.math.next_power_of_2(H_kv), 16) + block_H = min(_normalize_block_h(max_block_h), padded_H) + assert padded_H % block_H == 0 + NH = padded_H // block_H + BS = block_size + NS = tilelang.cdiv(topk, block_size) + + split_store = 2 + + @T.prim_func + def sparse_mla_bwd_kernel( + Q: T.Tensor(q_shape, dtype), + KV: T.Tensor(k_shape, dtype), + dO: T.Tensor(o_shape, dtype), + Indices: T.Tensor(indices_shape, indices_dtype), + Lse: T.Tensor(lse_shape, accum_dtype), + Delta: T.Tensor(delta_shape, accum_dtype), + dQ: T.Tensor(q_shape, dtype), + dKV: T.Tensor(k_shape, accum_dtype), + ): + with T.Kernel(seq_len, batch, kv_group * NH, threads=threads) as (s_i, by, bz): + Q_shared = T.alloc_shared([block_H, D], dtype) + Q_tail_shared = T.alloc_shared([block_H, D_tail], dtype) + KV_shared = T.alloc_shared([BS, D], dtype) + KV_tail_shared = T.alloc_shared([BS, D_tail], dtype) + dO_shared = T.alloc_shared([block_H, D], dtype) + mask = T.alloc_fragment([BS], "bool") + + P_shared_cast = T.alloc_shared([block_H, BS], dtype) + dP_shared_cast = T.alloc_shared([block_H, BS], dtype) + dQ_shared = T.alloc_shared([block_H, D], dtype) + dQ_tail_shared = T.alloc_shared([block_H, D_tail], dtype) + + acc_p = T.alloc_fragment([block_H, BS], accum_dtype) + acc_dp = T.alloc_fragment([block_H, BS], accum_dtype) + acc_dq = T.alloc_fragment([block_H, D], accum_dtype) + acc_dq_tail = T.alloc_fragment([block_H, D_tail], accum_dtype) + acc_dkv = T.alloc_fragment([BS, D], accum_dtype) + acc_dkv_tail = T.alloc_fragment([BS, D_tail], accum_dtype) + acc_dkv_shared = T.alloc_shared([BS // split_store, D], accum_dtype) + acc_dkv_tail_shared = T.alloc_shared([BS // split_store, D_tail], accum_dtype) + + T.copy(Q[by, s_i, bz * block_H : (bz + 1) * block_H, :D], Q_shared) + T.copy(Q[by, s_i, bz * block_H : (bz + 1) * block_H, D:], Q_tail_shared) + T.copy(dO[by, s_i, bz * block_H : (bz + 1) * block_H, :D], dO_shared) + + T.clear(acc_dq) + T.clear(acc_dq_tail) + + # Process each block of indices + for i_i in T.Pipelined(NS, num_stages=num_stages): + # Check which indices are valid + for bi_i in T.Parallel(BS): + # Changed here for thd + mask[bi_i] = Indices[by, s_i, bz // NH, i_i * BS + bi_i] != -1 + + # Compute attention scores + for h_i, bi_i in T.Parallel(block_H, BS): + acc_p[h_i, bi_i] = T.if_then_else(mask[bi_i], 0, -T.infinity(acc_p.dtype)) + + # Load KV, V for this block of indices + for bi_i, d_i in T.Parallel(BS, D): + idx = Indices[by, s_i, bz // NH, i_i * BS + bi_i] + safe_idx = T.max(idx, 0) + KV_shared[bi_i, d_i] = KV[by, safe_idx, bz // NH, d_i] + + T.gemm( + Q_shared, KV_shared, acc_p, transpose_B=True, policy=T.GemmWarpPolicy.FullCol + ) + + for bi_i, d_i in T.Parallel(BS, D_tail): + idx = Indices[by, s_i, bz // NH, i_i * BS + bi_i] + safe_idx = T.max(idx, 0) + KV_tail_shared[bi_i, d_i] = KV[by, safe_idx, bz // NH, D + d_i] + T.gemm( + Q_tail_shared, + KV_tail_shared, + acc_p, + transpose_B=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + for h_i, bi_i in T.Parallel(block_H, BS): + acc_p[h_i, bi_i] = T.exp2( + acc_p[h_i, bi_i] * sm_scale_mul_reciprocal_log2 + - Lse[by, s_i, bz * block_H + h_i] + ) + + T.copy(acc_p, P_shared_cast) + + T.gemm( + dO_shared, + KV_shared, + acc_dp, + transpose_B=True, + policy=T.GemmWarpPolicy.FullCol, + clear_accum=True, + ) + + for h_i, bi_i in T.Parallel(block_H, BS): + acc_dp[h_i, bi_i] = ( + acc_p[h_i, bi_i] + * (acc_dp[h_i, bi_i] - Delta[by, s_i, bz * block_H + h_i]) + * sm_scale + ) + + T.copy(acc_dp, dP_shared_cast) + T.gemm(dP_shared_cast, KV_shared, acc_dq, policy=T.GemmWarpPolicy.FullCol) + T.gemm(dP_shared_cast, KV_tail_shared, acc_dq_tail, policy=T.GemmWarpPolicy.FullCol) + + T.gemm( + dP_shared_cast, + Q_shared, + acc_dkv, + transpose_A=True, + policy=T.GemmWarpPolicy.FullCol, + clear_accum=True, + ) + T.gemm( + P_shared_cast, + dO_shared, + acc_dkv, + transpose_A=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + T.clear(acc_dkv_tail) + T.gemm( + dP_shared_cast, + Q_tail_shared, + acc_dkv_tail, + transpose_A=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + for s in range(split_store): + for bi_i, d_i in T.Parallel(BS, D): + if bi_i < BS // split_store: + acc_dkv_shared[bi_i, d_i] = acc_dkv[bi_i + s * (BS // split_store), d_i] + + for bi_i, d_i in T.Parallel(BS, D_tail): + if bi_i < BS // split_store: + acc_dkv_tail_shared[bi_i, d_i] = acc_dkv_tail[ + bi_i + s * (BS // split_store), d_i + ] + + for bi_i, d_i in T.Parallel(BS // split_store, D // 4): + idx = Indices[by, s_i, bz // NH, i_i * BS + bi_i + s * (BS // split_store)] + if idx >= 0: + T.atomic_addx4( + dKV[by, idx, bz // NH, d_i * 4], acc_dkv_shared[bi_i, d_i * 4] + ) + + # Atomically update dKV, dKV_tail tensors + for bi_i, d_i in T.Parallel(BS // split_store, D_tail // 4): + idx = Indices[by, s_i, bz // NH, i_i * BS + bi_i + s * (BS // split_store)] + if idx >= 0: + T.atomic_addx4( + dKV[by, idx, bz // NH, D + d_i * 4], + acc_dkv_tail_shared[bi_i, d_i * 4], + ) + + # Store the accumulated dQ + T.copy(acc_dq, dQ_shared) + T.copy(acc_dq_tail, dQ_tail_shared) + + T.copy(dQ_shared, dQ[by, s_i, bz * block_H : (bz + 1) * block_H, :D]) + T.copy(dQ_tail_shared, dQ[by, s_i, bz * block_H : (bz + 1) * block_H, D:]) + + return sparse_mla_bwd_kernel + + +def _sparse_mla_delta_batched(o, do): # pragma: no cover + """Compute Delta = sum(O * dO) with safe sequence padding for TileLang tiles.""" + require_tilelang() + assert o.is_contiguous() + assert do.is_contiguous() + assert o.shape == do.shape + B, S, H, D = o.shape + + seq_len_padded = _round_up(S, _SPARSE_MLA_BWD_BLOCK_SIZE) + if seq_len_padded != S: + o_padded = torch.zeros((B, seq_len_padded, H, D), dtype=o.dtype, device=o.device) + o_padded[:, :S].copy_(o) + o = o_padded + + do_padded = torch.zeros((B, seq_len_padded, H, D), dtype=do.dtype, device=do.device) + do_padded[:, :S].copy_(do) + do = do_padded + + preprocess_kernel = _get_preprocess_kernel(H, D) + return preprocess_kernel(o, do)[:, :S].contiguous() + + +def sparse_mla_delta(o, do): # pragma: no cover + """Compute Delta = sum(O * dO) per sequence row and head.""" + squeeze_batch = o.ndim == 3 + if squeeze_batch: + o = o.unsqueeze(0) + do = do.unsqueeze(0) + delta = _sparse_mla_delta_batched(o, do) + if squeeze_batch: + delta = delta.squeeze(0) + return delta + + +def sparse_mla_bwd(q, kv, o, do, indices, lse, sm_scale=None, delta=None): # pragma: no cover + """Run sparse-MLA backward kernels and return (dq, dkv).""" + require_tilelang() + + seq_bucket = _env_int("MCORE_DSA_TILELANG_SEQ_BUCKET", 256) + topk_bucket = _env_int("MCORE_DSA_TILELANG_TOPK_BUCKET", _SPARSE_MLA_BWD_BLOCK_SIZE) + max_block_h = _env_int("MCORE_DSA_TILELANG_BWD_MAX_BLOCK_H", 32) + + squeeze_batch = q.ndim == 3 + if squeeze_batch: + q = q.unsqueeze(0) + kv = kv.unsqueeze(0) + do = do.unsqueeze(0) + indices = indices.unsqueeze(0) + lse = lse.unsqueeze(0) + if o is not None: + if squeeze_batch: + o = o.unsqueeze(0) + + assert q.is_contiguous() + assert kv.is_contiguous() + assert indices.is_contiguous() + assert lse.is_contiguous() + assert q.ndim == 4 and kv.ndim == 4 and do.ndim == 4 and indices.ndim == 4 and lse.ndim == 3 + B, S, H, dim_plus_tail_dim = q.shape + _, S_kv, kv_group, _ = kv.shape + assert kv.shape[-1] == dim_plus_tail_dim + assert kv.shape[0] == B + # This copied kernel currently assumes a fixed base value-channel dimension. + D = 512 + assert ( + dim_plus_tail_dim >= D + ), f"Invalid dimensions: dim_plus_tail_dim={dim_plus_tail_dim} is smaller than base D={D}" + + D_tail = dim_plus_tail_dim - D + topk = indices.shape[-1] + assert indices.shape == (B, S, kv_group, topk) + assert lse.shape == (B, S, H) + + seq_bucketed = _round_up(S, seq_bucket) + seq_kv_bucketed = _round_up(S_kv, seq_bucket) + topk_bucketed = _round_up(_round_up(topk, topk_bucket), _SPARSE_MLA_BWD_BLOCK_SIZE) + + if seq_bucketed != S: + q_padded = torch.zeros( + (B, seq_bucketed, H, dim_plus_tail_dim), dtype=q.dtype, device=q.device + ) + q_padded[:, :S].copy_(q) + q = q_padded + + if o is not None: + o_padded = torch.zeros((B, seq_bucketed, H, D), dtype=o.dtype, device=o.device) + o_padded[:, :S].copy_(o) + o = o_padded + + do_padded = torch.zeros((B, seq_bucketed, H, D), dtype=do.dtype, device=do.device) + do_padded[:, :S].copy_(do) + do = do_padded + + lse_padded = torch.zeros((B, seq_bucketed, H), dtype=lse.dtype, device=lse.device) + lse_padded[:, :S].copy_(lse) + lse = lse_padded + + if seq_kv_bucketed != S_kv: + kv_padded = torch.zeros( + (B, seq_kv_bucketed, kv_group, dim_plus_tail_dim), dtype=kv.dtype, device=kv.device + ) + kv_padded[:, :S_kv].copy_(kv) + kv = kv_padded + + if seq_bucketed != S or topk_bucketed != topk: + indices_padded = torch.full( + (B, seq_bucketed, kv_group, topk_bucketed), + -1, + dtype=indices.dtype, + device=indices.device, + ) + indices_padded[:, :S, :, :topk].copy_(indices) + indices = indices_padded + + if delta is not None: + if delta.ndim == 2: + delta = delta.unsqueeze(0) + if seq_bucketed != S: + delta_padded = torch.zeros((B, seq_bucketed, H), dtype=delta.dtype, device=delta.device) + delta_padded[:, :S].copy_(delta) + delta = delta_padded + + # Get kernels + bwd_kernel = _get_bwd_kernel(H, D, D_tail, topk_bucketed, kv_group, sm_scale, max_block_h) + postprocess_kernel = _get_postprocess_kernel(D, D_tail, kv_group) + + if delta is None: + if o is None: + raise ValueError("sparse_mla_bwd requires either output tensor o or precomputed delta") + delta = _sparse_mla_delta_batched(o, do) + dkv = torch.zeros_like(kv, dtype=torch.float32) + dq = bwd_kernel(q, kv, do, indices, lse, delta, dkv) + dkv = postprocess_kernel(dkv) + + dq = dq[:, :S].contiguous() + dkv = dkv[:, :S_kv].contiguous() + + if squeeze_batch: + dq = dq.squeeze(0) + dkv = dkv.squeeze(0) + + return dq, dkv diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_fwd.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_fwd.py new file mode 100644 index 00000000000..707c453d00e --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_sparse_mla_fwd.py @@ -0,0 +1,310 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# ruff: noqa +# Adapted from: +# https://github.com/tile-ai/tilelang/blob/e666d2d3cc483829c57618c9ebf2e4f4ada0819d/ +# examples/deepseek_v32/sparse_mla_fwd.py +import threading +from collections import OrderedDict + +import torch + +from .tilelang_utils import ( + HAVE_TILELANG, + T, + _env_int, + _get_cached_kernel, + _normalize_sm_scale, + _round_up, + require_tilelang, + tilelang, + tilelang_jit, +) + +_tilelang_sparse_mla_fwd_kernel_cache = OrderedDict() +_tilelang_sparse_mla_fwd_cache_lock = threading.Lock() + + +def _get_sparse_mla_fwd_kernel( + heads: int, + dim: int, + tail_dim: int, + topk: int, + kv_group: int, + sm_scale, + block_I: int, + num_stages: int, + threads: int, +): + key = ( + heads, + dim, + tail_dim, + topk, + kv_group, + _normalize_sm_scale(sm_scale), + block_I, + num_stages, + threads, + ) + return _get_cached_kernel( + _tilelang_sparse_mla_fwd_kernel_cache, + _tilelang_sparse_mla_fwd_cache_lock, + key, + lambda: sparse_mla_fwd( + heads, + dim, + tail_dim, + topk, + kv_group, + sm_scale, + block_I=block_I, + num_stages=num_stages, + threads=threads, + ), + ) + + +_SPARSE_MLA_FWD_PASS_CONFIGS = ( + { + tilelang.PassConfigKey.TL_DISABLE_TMA_LOWER: True, + tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, + } + if HAVE_TILELANG + else {} +) + + +@tilelang_jit(out_idx=[-2, -1], pass_configs=_SPARSE_MLA_FWD_PASS_CONFIGS) +def sparse_mla_fwd( # pragma: no cover + heads, dim, tail_dim, topk, kv_group=1, sm_scale=None, block_I=64, num_stages=2, threads=256 +): + """Build sparse-MLA forward kernel.""" + require_tilelang() + assert dim == tilelang.math.next_power_of_2(dim), f"dim must be a power of two, got dim={dim}" + assert tail_dim == tilelang.math.next_power_of_2( + tail_dim + ), f"tail_dim must be a power of two, got tail_dim={tail_dim}" + assert ( + topk % block_I == 0 + ), "otherwise will load some index=0 thus causing wrong kv to be loaded" + if sm_scale is None: + sm_scale = (1.0 / (dim + tail_dim)) ** 0.5 * 1.44269504 # log2(e) + else: + sm_scale = sm_scale * 1.44269504 # log2(e) + + batch = T.dynamic("batch") + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + head_kv = heads // kv_group + q_shape = [batch, seq_len, heads, dim + tail_dim] + kv_shape = [batch, seq_len_kv, kv_group, dim + tail_dim] + o_shape = [batch, seq_len, heads, dim] + indices_shape = [batch, seq_len, kv_group, topk] + lse_shape = [batch, seq_len, heads] + indices_dtype = T.int32 + dtype = T.bfloat16 + accum_dtype = T.float32 + + G = kv_group + H = head_kv + padded_H = max(tilelang.math.next_power_of_2(head_kv), 16) + if padded_H != H: + assert kv_group == 1, ( + "here we solve the H padding automatically, otherwise handle Q/Output copy with " + "your own mask (for kv_group==1, g_i*padded_H:(g_i+1)*padded_H is handled)" + ) + BI = block_I + NI = tilelang.cdiv(topk, block_I) + D = dim + D_tail = tail_dim + + if head_kv > 64: + assert head_kv % 64 == 0, "head_kv should be a multiple of 64" + REPLICATE_H = head_kv // 64 + else: + REPLICATE_H = 1 + + H_per_block = padded_H if REPLICATE_H == 1 else 64 + + @T.prim_func + def main( + Q: T.Tensor(q_shape, dtype), # type: ignore + KV: T.Tensor(kv_shape, dtype), # type: ignore + Indices: T.Tensor(indices_shape, indices_dtype), # type: ignore + Output: T.Tensor(o_shape, dtype), # type: ignore + Lse: T.Tensor(lse_shape, accum_dtype), # type: ignore + ): + with T.Kernel(seq_len * REPLICATE_H, batch, kv_group, threads=threads) as (bx, by, bz): + Q_shared = T.alloc_shared([H_per_block, D], dtype) + Q_tail_shared = T.alloc_shared([H_per_block, D_tail], dtype) + KV_shared = T.alloc_shared([BI, D], dtype) + K_tail_shared = T.alloc_shared([BI, D_tail], dtype) + O_shared = T.alloc_shared([H_per_block, D], dtype) + Lse_shared = T.alloc_shared([H_per_block], accum_dtype) + mask = T.alloc_fragment([BI], "bool") + + acc_o = T.alloc_fragment([H_per_block, D], accum_dtype) + acc_s = T.alloc_fragment([H_per_block, BI], accum_dtype) + S_shared = T.alloc_shared([H_per_block, BI], dtype) + sumexp = T.alloc_fragment([H_per_block], accum_dtype) + sumexp_i = T.alloc_fragment([H_per_block], accum_dtype) + alpha = T.alloc_fragment([H_per_block], accum_dtype) + m_i = T.alloc_fragment([H_per_block], accum_dtype) + m_i_prev = T.alloc_fragment([H_per_block], accum_dtype) + + T.fill(acc_o, 0) + T.fill(sumexp, 0) + T.fill(m_i, -(2**30)) # avoid -inf - inf to cause nan + + b_i, g_i = by, bz + s_i = bx if REPLICATE_H == 1 else (bx // REPLICATE_H) + q_i = s_i + max_kv_i = q_i + + H0 = g_i * padded_H + (0 if REPLICATE_H == 1 else (bx % REPLICATE_H) * 64) + H1 = H0 + H_per_block + + T.copy(Q[b_i, s_i, H0:H1, :D], Q_shared) + T.copy(Q[b_i, s_i, H0:H1, D:], Q_tail_shared) + + for i_i in T.Pipelined(NI, num_stages=num_stages): + for bi_i in T.Parallel(BI): + # Changed here for thd + mask[bi_i] = Indices[b_i, s_i, g_i, i_i * BI + bi_i] != -1 + + for bi_i, d_i in T.Parallel(BI, D): + idx = Indices[b_i, s_i, g_i, i_i * BI + bi_i] + safe_idx = T.max(idx, 0) + KV_shared[bi_i, d_i] = KV[b_i, safe_idx, g_i, d_i] + for bi_i, d_i in T.Parallel(BI, D_tail): + idx = Indices[b_i, s_i, g_i, i_i * BI + bi_i] + safe_idx = T.max(idx, 0) + K_tail_shared[bi_i, d_i] = KV[b_i, safe_idx, g_i, D + d_i] + + for h_i, bi_i in T.Parallel(H_per_block, BI): + acc_s[h_i, bi_i] = T.if_then_else(mask[bi_i], 0, -T.infinity(acc_s.dtype)) + T.gemm( + Q_shared, KV_shared, acc_s, transpose_B=True, policy=T.GemmWarpPolicy.FullRow + ) + T.gemm( + Q_tail_shared, + K_tail_shared, + acc_s, + transpose_B=True, + policy=T.GemmWarpPolicy.FullRow, + ) + T.copy(m_i, m_i_prev) + T.reduce_max(acc_s, m_i, dim=1, clear=False) + for h_i in T.Parallel(H_per_block): + m_i[h_i] = T.max(m_i[h_i], m_i_prev[h_i]) + for h_i in T.Parallel(H_per_block): + alpha[h_i] = T.exp2((m_i_prev[h_i] - m_i[h_i]) * sm_scale) + for h_i, bi_i in T.Parallel(H_per_block, BI): + acc_s[h_i, bi_i] = T.exp2(acc_s[h_i, bi_i] * sm_scale - m_i[h_i] * sm_scale) + # Reduce the current tile; the online softmax accumulation happens below. + T.reduce_sum(acc_s, sumexp_i, dim=1) + for h_i in T.Parallel(H_per_block): + sumexp[h_i] = sumexp[h_i] * alpha[h_i] + sumexp_i[h_i] + for h_i, d_i in T.Parallel(H_per_block, D): + acc_o[h_i, d_i] = acc_o[h_i, d_i] * alpha[h_i] + + T.copy(acc_s, S_shared) + T.gemm(S_shared, KV_shared, acc_o, policy=T.GemmWarpPolicy.FullRow) + + # Rescale. Packed THD can produce sentinel-only rows; define those rows as zero + # output/LSE instead of dividing by a zero softmax denominator. + for h_i, d_i in T.Parallel(H_per_block, D): + acc_o[h_i, d_i] = T.if_then_else(sumexp[h_i] > 0, acc_o[h_i, d_i] / sumexp[h_i], 0) + for h_i in T.Parallel(H_per_block): + sumexp[h_i] = T.if_then_else( + sumexp[h_i] > 0, T.log2(sumexp[h_i]) + m_i[h_i] * sm_scale, 0 + ) + + T.copy(acc_o, Output[b_i, s_i, H0:H1, :]) + T.copy(sumexp, Lse[b_i, s_i, H0:H1]) + + return main + + +def sparse_mla_fwd_interface( + q, kv, indices, sm_scale=None, d_v=512, block_I=64, num_stages=2, threads=256 +): + """Run sparse-MLA forward kernel and return (out, lse).""" + require_tilelang() + seq_bucket = _env_int("MCORE_DSA_TILELANG_SEQ_BUCKET", 256) + topk_bucket = _env_int("MCORE_DSA_TILELANG_TOPK_BUCKET", block_I) + + squeeze_batch = q.ndim == 3 + if squeeze_batch: + q = q.unsqueeze(0) + kv = kv.unsqueeze(0) + indices = indices.unsqueeze(0) + + assert q.is_contiguous() and kv.is_contiguous() and indices.is_contiguous() + assert q.ndim == 4 and kv.ndim == 4 and indices.ndim == 4 + batch, seq_len, heads, dim_plus_tail_dim = q.shape + _, seq_len_kv, kv_group, kv_dim = kv.shape + assert ( + kv_dim == dim_plus_tail_dim + ), "q and kv must have the same embedding dimension on the last axis" + assert ( + dim_plus_tail_dim == 576 + ), "TileLang sparse MLA fwd is currently specialized for dim_plus_tail_dim=576" + dim = d_v + assert 0 < dim <= dim_plus_tail_dim, f"d_v must be in (0, {dim_plus_tail_dim}], but got {dim}" + + assert kv.shape[-1] == dim_plus_tail_dim + tail_dim = dim_plus_tail_dim - dim + assert kv.shape[0] == batch + _, _, _, topk = indices.shape + assert indices.shape == (batch, seq_len, kv_group, topk) + + seq_len_bucketed = _round_up(seq_len, seq_bucket) + seq_len_kv_bucketed = _round_up(seq_len_kv, seq_bucket) + topk_bucketed = _round_up(_round_up(topk, topk_bucket), block_I) + + if seq_len_bucketed != seq_len: + q_padded = torch.zeros( + (batch, seq_len_bucketed, heads, dim_plus_tail_dim), dtype=q.dtype, device=q.device + ) + q_padded[:, :seq_len].copy_(q) + q = q_padded + + if seq_len_kv_bucketed != seq_len_kv: + kv_padded = torch.zeros( + (batch, seq_len_kv_bucketed, kv_group, dim_plus_tail_dim), + dtype=kv.dtype, + device=kv.device, + ) + kv_padded[:, :seq_len_kv].copy_(kv) + kv = kv_padded + + if seq_len_bucketed != seq_len or topk_bucketed != topk: + indices_padded = torch.full( + (batch, seq_len_bucketed, kv_group, topk_bucketed), + -1, + dtype=indices.dtype, + device=indices.device, + ) + indices_padded[:, :seq_len, :, :topk].copy_(indices) + indices = indices_padded + + kernel = _get_sparse_mla_fwd_kernel( + heads=heads, + dim=dim, + tail_dim=tail_dim, + topk=topk_bucketed, + kv_group=kv_group, + sm_scale=sm_scale, + block_I=block_I, + num_stages=num_stages, + threads=threads, + ) + out, lse = kernel(q, kv, indices) + out = out[:, :seq_len].contiguous() + lse = lse[:, :seq_len].contiguous() + if squeeze_batch: + out = out.squeeze(0) + lse = lse.squeeze(0) + return out, lse diff --git a/megatron/core/transformer/experimental_attention_variant/ops/tilelang_utils.py b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_utils.py new file mode 100644 index 00000000000..689436bfb59 --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/ops/tilelang_utils.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import os +from collections import OrderedDict + +import torch + +from megatron.core.utils import round_up_to_nearest_multiple + +try: + import tilelang + from tilelang import language as T # pylint: disable=unused-import + + HAVE_TILELANG = True +except (ImportError, OSError): + tilelang = None + T = None + HAVE_TILELANG = False + + +def _noop_jit(*args, **kwargs): + if len(args) == 1 and callable(args[0]) and not kwargs: + return args[0] + + def decorator(func): + return func + + return decorator + + +def tilelang_jit(*args, **kwargs): + """Return TileLang's jit decorator when available, otherwise a no-op decorator.""" + if HAVE_TILELANG: + return tilelang.jit(*args, **kwargs) + return _noop_jit(*args, **kwargs) + + +def require_tilelang(): + """Raise a clear error when a fused TileLang kernel is used without TileLang installed.""" + if not HAVE_TILELANG: + raise ImportError( + "TileLang is required to use fused DSA TileLang kernels. " + "Install tilelang or use the unfused fallback path." + ) + + +def _env_int(name: str, default: int) -> int: + """Parse a positive integer environment variable, falling back to ``default``.""" + value = os.getenv(name) + if value is None: + return default + try: + parsed = int(value) + except ValueError: + return default + return parsed if parsed > 0 else default + + +_TILELANG_KERNEL_CACHE_MAX = _env_int("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", 512) + + +def _cache_put_lru(cache: OrderedDict, key, value): + """Insert ``value`` as the most-recently-used entry, evicting oldest past the cap.""" + cache[key] = value + cache.move_to_end(key) + while len(cache) > _TILELANG_KERNEL_CACHE_MAX: + cache.popitem(last=False) + + +def _get_cached_kernel(cache: OrderedDict, lock, key, build_fn): + """Return a cached compiled kernel for ``key``, building it via ``build_fn`` on miss.""" + with lock: + kernel = cache.pop(key, None) + if kernel is None: + kernel = build_fn() + _cache_put_lru(cache, key, kernel) + return kernel + + +def _round_up(x: int, multiple: int) -> int: + if multiple <= 1: + return x + return round_up_to_nearest_multiple(x, multiple) + + +def _next_power_of_two(x: int) -> int: + if x <= 1: + return 1 + return 1 << (x - 1).bit_length() + + +def _normalize_sm_scale(sm_scale): + """Coerce a softmax scale to a stable float so it can key the kernel cache.""" + if sm_scale is None: + return None + if isinstance(sm_scale, torch.Tensor): + sm_scale = float(sm_scale.detach().item()) + else: + sm_scale = float(sm_scale) + # Avoid tiny floating-point jitter creating cache-key churn. + return round(sm_scale, 12) diff --git a/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_tilelang_kernels.py b/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_tilelang_kernels.py new file mode 100644 index 00000000000..2a9e151d4ee --- /dev/null +++ b/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_tilelang_kernels.py @@ -0,0 +1,1284 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +import math +from types import SimpleNamespace + +import pytest +import torch + +from megatron.core.packed_seq_params import PackedSeqParams +from megatron.core.transformer.experimental_attention_variant import ( + dsa_indexer_loss, + dsa_masking, + dsa_tilelang_kernels, +) +from megatron.core.transformer.experimental_attention_variant.ops import ( + indexer, + sparse_mla, + tilelang_dsa, + tilelang_indexer_bwd, + tilelang_indexer_fwd, + tilelang_indexer_loss, + tilelang_sparse_mla_bwd, + tilelang_utils, +) + + +def test_run_fused_qk_topk_forwards_to_tilelang_backend(monkeypatch): + q = torch.empty(2, 1, 3, 4) + k = torch.empty(5, 1, 4) + weights = torch.empty(2, 1, 3) + starts = torch.tensor([0, 1], dtype=torch.int32) + ends = torch.tensor([3, 5], dtype=torch.int32) + expected_indices = torch.tensor([[[2, 1], [4, 3]]], dtype=torch.int32) + call = {} + + def fake_run_fused_qk_topk( + q_arg, k_arg, weights_arg, index_topk, starts_arg, ends_arg, block_size, use_relu, **kwargs + ): + call.update( + q=q_arg, + k=k_arg, + weights=weights_arg, + index_topk=index_topk, + starts=starts_arg, + ends=ends_arg, + block_size=block_size, + use_relu=use_relu, + kwargs=kwargs, + ) + return expected_indices + + monkeypatch.setattr( + dsa_tilelang_kernels.tilelang_dsa, "run_fused_qk_topk", fake_run_fused_qk_topk + ) + + result = dsa_tilelang_kernels.run_fused_qk_topk( + q, + k, + weights, + index_topk=2, + starts=starts, + ends=ends, + block_size=8, + use_relu=False, + use_local_indexer_varlen=True, + ) + + indices, topk_length = result + assert indices is expected_indices + assert topk_length is None + assert call["q"] is q + assert call["k"] is k + assert call["weights"] is weights + assert call["index_topk"] == 2 + assert call["starts"] is starts + assert call["ends"] is ends + assert call["block_size"] == 8 + assert call["use_relu"] is False + assert call["kwargs"]["use_local_indexer_varlen"] is True + assert call["kwargs"]["cp_size"] == 1 + + +def test_run_fused_qk_topk_preserves_unavailable_backend(monkeypatch): + def fake_run_fused_qk_topk(*_args, **_kwargs): + return None + + monkeypatch.setattr( + dsa_tilelang_kernels.tilelang_dsa, "run_fused_qk_topk", fake_run_fused_qk_topk + ) + + result = dsa_tilelang_kernels.run_fused_qk_topk( + torch.empty(2, 1, 3, 4), + torch.empty(5, 1, 4), + torch.empty(2, 1, 3), + index_topk=2, + starts=torch.tensor([0, 1], dtype=torch.int32), + ends=torch.tensor([3, 5], dtype=torch.int32), + block_size=8, + ) + + assert result is None + + +def test_tilelang_packed_cp_indexer_inputs_segment_keys_and_bounds(): + packed_seq_params = PackedSeqParams( + qkv_format="thd", + cu_seqlens_q=torch.tensor([0, 8, 24], dtype=torch.int32), + cu_seqlens_kv=torch.tensor([0, 8, 24], dtype=torch.int32), + max_seqlen_q=16, + max_seqlen_kv=16, + ) + query_positions = torch.tensor([0, 1, 6, 7, 8, 9, 10, 11, 20, 21, 22, 23]) + starts = torch.tensor([0] * 4 + [8] * 8, dtype=torch.int32) + ends = (query_positions + 1).to(torch.int32) + index_k = torch.arange(24, dtype=torch.float32).view(24, 1) + + segmented_k, local_starts, local_ends, source_indices = ( + tilelang_dsa._build_packed_cp_indexer_inputs( + index_k, + starts, + ends, + packed_seq_params=packed_seq_params, + cp_size=2, + cp_rank=0, + single_packed_thd_sequence=False, + local_query_start=0, + local_query_len=12, + ) + ) + + expected_sources = torch.tensor( + [0, 1, *range(8), *range(8, 12), *range(8, 24)], dtype=torch.int64 + ) + torch.testing.assert_close(source_indices, expected_sources) + torch.testing.assert_close(segmented_k[:, 0], expected_sources.to(torch.float32)) + torch.testing.assert_close( + local_starts, torch.tensor([0, 0, 2, 2, 10, 10, 10, 10, 14, 14, 14, 14], dtype=torch.int32) + ) + torch.testing.assert_close( + local_ends, torch.tensor([1, 2, 9, 10, 11, 12, 13, 14, 27, 28, 29, 30], dtype=torch.int32) + ) + + +def test_tilelang_packed_cp_indexer_remaps_segmented_topk(monkeypatch): + packed_seq_params = PackedSeqParams( + qkv_format="thd", + cu_seqlens_q=torch.tensor([0, 8, 24], dtype=torch.int32), + cu_seqlens_kv=torch.tensor([0, 8, 24], dtype=torch.int32), + max_seqlen_q=16, + max_seqlen_kv=16, + ) + query_positions = torch.tensor([0, 1, 6, 7, 8, 9, 10, 11, 20, 21, 22, 23]) + starts = torch.tensor([0] * 4 + [8] * 8, dtype=torch.int32) + ends = (query_positions + 1).to(torch.int32) + seen = {} + + def fake_lighting_indexer_indices( + index_q, index_k, index_w, starts_arg, ends_arg, index_topk, use_relu=True + ): + del index_q, index_w, use_relu + seen["key"] = index_k[:, 0].clone() + seen["starts"] = starts_arg.clone() + seen["ends"] = ends_arg.clone() + offsets = torch.arange(index_topk, dtype=torch.int32).view(1, -1) + return ends_arg.view(-1, 1) - 1 - offsets + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer_indices", fake_lighting_indexer_indices) + topk = tilelang_dsa.fused_qk_topk_lighting( + torch.ones((12, 1, 1, 1), dtype=torch.bfloat16), + torch.arange(24, dtype=torch.bfloat16).view(24, 1, 1), + torch.ones((12, 1, 1)), + index_topk=3, + starts=starts, + ends=ends, + block_size=12, + use_local_indexer_varlen=True, + packed_seq_params=packed_seq_params, + cp_size=2, + ) + + expected = [] + for position, sequence_start in zip(query_positions.tolist(), starts.tolist()): + row = list(range(position, max(sequence_start - 1, position - 3), -1)) + expected.append(row + [-1] * (3 - len(row))) + torch.testing.assert_close(topk, torch.tensor([expected], dtype=torch.int32)) + assert seen["key"].numel() == 30 + torch.testing.assert_close( + seen["starts"], + torch.tensor([0, 0, 2, 2, 10, 10, 10, 10, 14, 14, 14, 14], dtype=torch.int32), + ) + + +def test_run_fused_qk_topk_with_loss_preserves_unavailable_backend(monkeypatch): + def fake_run_fused_qk_topk_with_loss(**kwargs): + return None + + monkeypatch.setattr( + dsa_tilelang_kernels.tilelang_dsa, + "run_fused_qk_topk_with_loss", + fake_run_fused_qk_topk_with_loss, + ) + + result = dsa_tilelang_kernels.run_fused_qk_topk_with_loss( + q=torch.empty(2, 1, 3, 4), + k=torch.empty(5, 1, 4), + weights=torch.empty(2, 1, 3), + index_topk=2, + starts=torch.tensor([0, 1], dtype=torch.int32), + ends=torch.tensor([3, 5], dtype=torch.int32), + block_size=8, + query=torch.empty(2, 1, 3, 4), + key=torch.empty(5, 1, 1, 4), + softmax_scale=0.5, + loss_coeff=0.1, + pg_collection=SimpleNamespace(), + config=SimpleNamespace(), + use_local_indexer_varlen=True, + ) + + assert result is None + + +def test_run_fused_qk_topk_with_loss_adds_empty_topk_length(monkeypatch): + q = torch.empty(2, 1, 3, 4) + k = torch.empty(5, 1, 4) + weights = torch.empty(2, 1, 3) + starts = torch.tensor([0, 1], dtype=torch.int32) + ends = torch.tensor([3, 5], dtype=torch.int32) + query = torch.empty(2, 1, 3, 4) + key = torch.empty(5, 1, 1, 4) + query_valid_rows = torch.tensor([[True, False]]) + pg_collection = SimpleNamespace() + expected_indices = torch.tensor([[[2, 1], [4, 3]]], dtype=torch.int32) + expected_loss = torch.tensor(1.25) + call = {} + + def fake_run_fused_qk_topk_with_loss(**kwargs): + call.update(kwargs) + return expected_indices, expected_loss + + monkeypatch.setattr( + dsa_tilelang_kernels.tilelang_dsa, + "run_fused_qk_topk_with_loss", + fake_run_fused_qk_topk_with_loss, + ) + + result = dsa_tilelang_kernels.run_fused_qk_topk_with_loss( + q=q, + k=k, + weights=weights, + index_topk=2, + starts=starts, + ends=ends, + block_size=8, + query=query, + key=key, + softmax_scale=0.5, + loss_coeff=0.1, + pg_collection=pg_collection, + query_valid_rows=query_valid_rows, + calculate_per_token_loss=True, + use_relu=False, + config=SimpleNamespace(), + use_local_indexer_varlen=True, + ) + + indices, topk_length, indexer_loss = result + assert indices is expected_indices + assert topk_length is None + assert indexer_loss is expected_loss + assert call["q"] is q + assert call["k"] is k + assert call["weights"] is weights + assert call["index_topk"] == 2 + assert call["starts"] is starts + assert call["ends"] is ends + assert call["block_size"] == 8 + assert call["query"] is query + assert call["key"] is key + assert call["softmax_scale"] == 0.5 + assert call["loss_coeff"] == 0.1 + assert call["pg_collection"] is pg_collection + assert call["query_valid_rows"] is query_valid_rows + assert call["calculate_per_token_loss"] is True + assert call["use_relu"] is False + + +def test_run_fused_absorbed_sparse_attention_forwards_to_tilelang_backend(monkeypatch): + query = torch.empty(2, 1, 3, 4) + key = torch.empty(5, 1, 1, 4) + topk_indices = torch.tensor([[[0, 1], [1, 99]]], dtype=torch.int32) + topk_length = torch.tensor([[2, 1]], dtype=torch.int32) + expected_output = torch.empty(2, 1, 3, 4) + call = {} + + def fake_run_fused_absorbed_sparse_attention( + query_arg, key_arg, topk_indices_arg, softmax_scale, v_channels + ): + call.update( + query=query_arg, + key=key_arg, + topk_indices=topk_indices_arg, + softmax_scale=softmax_scale, + v_channels=v_channels, + ) + return expected_output + + monkeypatch.setattr( + dsa_tilelang_kernels.tilelang_dsa, + "run_fused_absorbed_sparse_attention", + fake_run_fused_absorbed_sparse_attention, + ) + + result = dsa_tilelang_kernels.run_fused_absorbed_sparse_attention( + query, key, topk_indices, softmax_scale=0.5, v_channels=4, topk_length=topk_length + ) + + assert result is expected_output + assert call["query"] is query + assert call["key"] is key + torch.testing.assert_close( + call["topk_indices"], torch.tensor([[[0, 1], [1, -1]]], dtype=torch.int32) + ) + assert call["softmax_scale"] == 0.5 + assert call["v_channels"] == 4 + + +def test_indexer_topk_helpers_mask_invalid_entries(): + logits = torch.tensor([[1.0, 3.0, float("-inf")], [0.0, 2.0, 1.0]]) + requested_indices = torch.tensor([[1, -1, 4], [0, 2, 1]], dtype=torch.int32) + + gathered = indexer.pytorch_extract_topk_scores(logits, requested_indices) + + assert torch.equal(gathered[0], torch.tensor([3.0, float("-inf"), float("-inf")])) + assert torch.equal(gathered[1], torch.tensor([0.0, 1.0, 2.0])) + + topk_scores, topk_indices = indexer._select_topk_from_logits(logits, topk=4) + assert topk_scores.shape == (2, 3) + assert topk_indices.shape == (2, 3) + assert topk_indices.dtype == torch.int32 + assert -1 in topk_indices[0].tolist() + + empty_scores, empty_indices = indexer._select_topk_from_logits(torch.empty(2, 0), topk=3) + assert empty_scores.shape == (2, 0) + assert empty_indices.shape == (2, 0) + assert empty_indices.dtype == torch.int32 + + +def test_sparse_mla_head_mask_helpers(): + indices = torch.tensor([[[0, -1], [-1, -1]], [[1, 2], [3, -1]]], dtype=torch.int32) + + valid_heads = sparse_mla._valid_head_mask(indices, num_heads=4) + + assert torch.equal( + valid_heads, torch.tensor([[True, True, False, False], [True, True, True, True]]) + ) + + tensor = torch.arange(2 * 4 * 3, dtype=torch.float32).view(2, 4, 3) + zeroed = sparse_mla._zero_invalid_heads(tensor, valid_heads) + + assert torch.equal(zeroed[0, :2], tensor[0, :2]) + assert torch.equal(zeroed[0, 2:], torch.zeros_like(tensor[0, 2:])) + assert torch.equal(zeroed[1], tensor[1]) + + batched_indices = indices.unsqueeze(0) + batched_valid_heads = sparse_mla._valid_head_mask(batched_indices, num_heads=4) + assert torch.equal(batched_valid_heads, valid_heads.unsqueeze(0)) + + batched_tensor = tensor.unsqueeze(0) + batched_zeroed = sparse_mla._zero_invalid_heads(batched_tensor, batched_valid_heads) + assert torch.equal(batched_zeroed, zeroed.unsqueeze(0)) + + with pytest.raises(RuntimeError, match="heads must be divisible"): + sparse_mla._valid_head_mask(indices, num_heads=3) + + +def test_tilelang_dsa_sanitize_helper(): + topk_indices = torch.tensor([[0, 2, 5], [-1, 3, 4]], dtype=torch.int32) + topk_scores = torch.tensor([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) + starts = torch.tensor([1, 3], dtype=torch.int32) + ends = torch.tensor([5, 4], dtype=torch.int32) + + sanitized_indices, sanitized_scores = tilelang_dsa._sanitize_fused_topk_outputs( + topk_indices, starts, ends, topk_scores + ) + + assert torch.equal(sanitized_indices, torch.tensor([[-1, 2, -1], [-1, 3, -1]])) + assert torch.equal( + torch.isneginf(sanitized_scores), torch.tensor([[True, False, True], [True, False, True]]) + ) + + +def test_tilelang_dsa_scratch_cache_reuses_buffers(monkeypatch): + tilelang_dsa._DSA_SCRATCH_CACHE.clear() + monkeypatch.setattr(tilelang_dsa, "_DSA_SCRATCH_CACHE_TOTAL_BYTES", 0) + monkeypatch.setattr(tilelang_dsa, "_DSA_SCRATCH_CACHE_MAX_ENTRIES", 1) + monkeypatch.setattr(tilelang_dsa, "_DSA_SCRATCH_CACHE_MAX_BYTES", 1024) + + first = tilelang_dsa._get_scratch_buffer("a", (2,), torch.float32, torch.device("cpu")) + first.fill_(3.0) + reused = tilelang_dsa._get_scratch_buffer("a", (2,), torch.float32, torch.device("cpu")) + second = tilelang_dsa._get_scratch_buffer("b", (2,), torch.float32, torch.device("cpu")) + + assert reused is first + assert torch.equal(reused, torch.full((2,), 3.0)) + assert list(tilelang_dsa._DSA_SCRATCH_CACHE) == [ + ("b", (2,), torch.float32, torch.device("cpu")) + ] + assert tilelang_dsa._DSA_SCRATCH_CACHE_TOTAL_BYTES == second.numel() * second.element_size() + + +def test_tilelang_kernel_helper_caches_and_env_parsing(monkeypatch): + monkeypatch.delenv("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", raising=False) + assert tilelang_utils._env_int("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", 7) == 7 + + monkeypatch.setenv("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", "bad") + assert tilelang_utils._env_int("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", 7) == 7 + + monkeypatch.setenv("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", "-3") + assert tilelang_utils._env_int("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", 7) == 7 + + monkeypatch.setenv("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", "2") + assert tilelang_utils._env_int("MCORE_DSA_TILELANG_KERNEL_CACHE_MAX", 7) == 2 + + # Shared numeric/layout helpers now live in tilelang_utils. + assert tilelang_utils._next_power_of_two(0) == 1 + assert tilelang_utils._next_power_of_two(9) == 16 + assert tilelang_utils._round_up(9, 4) == 12 + assert tilelang_utils._round_up(9, 1) == 9 + assert tilelang_utils._normalize_sm_scale(None) is None + assert tilelang_utils._normalize_sm_scale(torch.tensor(0.5)) == 0.5 + + # Kernel-specific helpers stay with their modules. + assert tilelang_indexer_bwd._canonical_topk(33) == 64 + assert tilelang_indexer_bwd.is_supported_indexer_bwd_head_count(8) + assert tilelang_indexer_bwd.is_supported_indexer_bwd_head_count(64) + assert not tilelang_indexer_bwd.is_supported_indexer_bwd_head_count(7) + assert not tilelang_indexer_bwd.is_supported_indexer_bwd_head_count(72) + assert tilelang_sparse_mla_bwd._normalize_block_h(12) == 16 + assert tilelang_sparse_mla_bwd._normalize_block_h(40) == 32 + assert tilelang_sparse_mla_bwd._normalize_block_h(80) == 64 + assert tilelang_dsa._is_supported_sparse_mla_head_count(16) + assert tilelang_dsa._is_supported_sparse_mla_head_count(32) + assert tilelang_dsa._is_supported_sparse_mla_head_count(64) + assert tilelang_dsa._is_supported_sparse_mla_head_count(128) + assert tilelang_dsa._is_supported_sparse_mla_head_count(256, kv_group=2) + assert not tilelang_dsa._is_supported_sparse_mla_head_count(96) + assert not tilelang_dsa._is_supported_sparse_mla_head_count(96, kv_group=0) + # head_kv that is not a power of two >= 16 pads to a larger head dim in the kernels and + # would index past the real head count, so it must decline to the unfused path. + assert not tilelang_dsa._is_supported_sparse_mla_head_count(8) + assert not tilelang_dsa._is_supported_sparse_mla_head_count(48) + assert not tilelang_dsa._is_supported_sparse_mla_head_count(192) + assert not tilelang_dsa._is_supported_sparse_mla_head_count(96, kv_group=2) + + +def test_sparse_mla_canonicalizes_size_one_batch_stride_without_copy(): + tensor_sbhd = torch.empty(256, 1, 1, 4) + tensor_bshd = tensor_sbhd.permute(1, 0, 2, 3) + + assert tensor_bshd.is_contiguous() + assert tensor_bshd.stride(0) != tensor_bshd.numel() + + normalized = sparse_mla._canonicalize_batch_stride(tensor_bshd) + + assert normalized.stride(0) == normalized.numel() + assert normalized.data_ptr() == tensor_bshd.data_ptr() + + +def test_indexer_bwd_returns_grad_k_in_index_k_dtype(monkeypatch): + captured = {} + + def fake_kernel(index_q, index_k, weights, topk_indices, grad_scores, grad_q, grad_w, grad_k): + del index_q, index_k, weights, topk_indices, grad_scores + captured["grad_k_kernel_dtype"] = grad_k.dtype + grad_q.fill_(1) + grad_w.fill_(2) + grad_k.fill_(3) + + monkeypatch.setattr(tilelang_indexer_bwd, "require_tilelang", lambda: None) + monkeypatch.setattr( + tilelang_indexer_bwd, "_get_indexer_bwd_kernel", lambda *_args, **_kwargs: fake_kernel + ) + + index_q = torch.empty((2, 8, 4), dtype=torch.bfloat16) + index_k = torch.empty((3, 4), dtype=torch.bfloat16) + weights = torch.empty((2, 8), dtype=torch.float32) + topk_indices = torch.zeros((2, 1), dtype=torch.int32) + grad_scores = torch.empty((2, 1), dtype=torch.float32) + + _, _, grad_k = tilelang_indexer_bwd.indexer_bwd_interface( + index_q, weights, index_k, topk_indices, grad_scores + ) + + assert captured["grad_k_kernel_dtype"] == torch.float32 + assert grad_k.dtype == index_k.dtype + torch.testing.assert_close(grad_k.float(), torch.full_like(grad_k, 3, dtype=torch.float32)) + + +def test_sparse_mla_delta_pads_partial_sequence_tile(monkeypatch): + seq_len = 65 + padded_seq_len = 96 + heads = 2 + dim = 4 + o = torch.arange(seq_len * heads * dim, dtype=torch.float32).view(seq_len, heads, dim) + do = torch.full_like(o, 2.0) + + monkeypatch.setattr(tilelang_sparse_mla_bwd, "require_tilelang", lambda: None) + + def fake_get_preprocess_kernel(H, D): + assert H == heads + assert D == dim + + def fake_preprocess_kernel(o_arg, do_arg): + assert o_arg.shape == (1, padded_seq_len, heads, dim) + assert do_arg.shape == (1, padded_seq_len, heads, dim) + assert torch.equal(o_arg[:, :seq_len], o.unsqueeze(0)) + assert torch.equal(do_arg[:, :seq_len], do.unsqueeze(0)) + assert torch.equal(o_arg[:, seq_len:], torch.zeros_like(o_arg[:, seq_len:])) + assert torch.equal(do_arg[:, seq_len:], torch.zeros_like(do_arg[:, seq_len:])) + return torch.arange(padded_seq_len * heads, dtype=torch.float32).view( + 1, padded_seq_len, heads + ) + + return fake_preprocess_kernel + + monkeypatch.setattr( + tilelang_sparse_mla_bwd, "_get_preprocess_kernel", fake_get_preprocess_kernel + ) + + delta = tilelang_sparse_mla_bwd.sparse_mla_delta(o.contiguous(), do.contiguous()) + + assert delta.shape == (seq_len, heads) + assert delta.is_contiguous() + expected = torch.arange(padded_seq_len * heads, dtype=torch.float32).view( + padded_seq_len, heads + )[:seq_len] + torch.testing.assert_close(delta, expected) + + +def test_lighting_indexer_indices_preserves_single_head_weight_axis(monkeypatch): + seen = {} + + def fake_indexer_fwd_interface( + index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke, clean_logits, use_relu + ): + del index_q, index_k, cu_seqlen_ks, cu_seqlen_ke + seen["weights_shape"] = weights.shape + seen["clean_logits"] = clean_logits + seen["use_relu"] = use_relu + return torch.arange(6, dtype=torch.float32).view(2, 3) + + monkeypatch.setattr(indexer, "indexer_fwd_interface", fake_indexer_fwd_interface) + + topk_indices = indexer.lighting_indexer_indices( + index_q=torch.empty(2, 1, 4), + index_k=torch.empty(3, 4), + weights=torch.ones(2, 1), + cu_seqlen_ks=torch.zeros(2, dtype=torch.int32), + cu_seqlen_ke=torch.full((2,), 3, dtype=torch.int32), + topk=2, + use_relu=False, + ) + + assert seen["weights_shape"] == (2, 1) + assert seen["clean_logits"] is True + assert seen["use_relu"] is False + torch.testing.assert_close(topk_indices, torch.tensor([[2, 1], [2, 1]], dtype=torch.int32)) + + +def _skip_if_real_tilelang_indexer_unavailable(): + if not torch.cuda.is_available(): + pytest.skip("CUDA is required for TileLang indexer parity tests") + if not indexer.HAVE_TILELANG_INDEXER: + pytest.skip("TileLang indexer forward/backward kernels are unavailable") + + +def _pytorch_indexer_scores(index_q, index_k, weights, *, use_relu): + per_head_scores = torch.einsum("qhd,kd->qkh", index_q.float(), index_k.float()) + if use_relu: + per_head_scores = per_head_scores.relu() + return (per_head_scores * weights.float().unsqueeze(1)).sum(dim=-1) + + +@pytest.mark.parametrize("use_relu", [False, True]) +def test_tilelang_indexer_forward_matches_pytorch(use_relu): + _skip_if_real_tilelang_indexer_unavailable() + torch.manual_seed(1234) + + device = torch.device("cuda") + q_len, k_len, heads, dim = 5, 19, 8, 16 + index_q = (torch.randn(q_len, heads, dim, device=device) * 0.25).to(torch.bfloat16) + index_k = (torch.randn(k_len, dim, device=device) * 0.25).to(torch.bfloat16) + weights = torch.randn(q_len, heads, dtype=torch.float32, device=device) * 0.25 + starts = torch.tensor([0, 1, 3, 5, 8], dtype=torch.int32, device=device) + ends = torch.tensor([7, 10, 13, 17, 19], dtype=torch.int32, device=device) + + actual = indexer.indexer_fwd_interface( + index_q, index_k, weights, starts, ends, clean_logits=True, use_relu=use_relu + ) + expected = _pytorch_indexer_scores(index_q, index_k, weights, use_relu=use_relu) + key_positions = torch.arange(k_len, device=device) + valid = (key_positions.unsqueeze(0) >= starts.unsqueeze(1)) & ( + key_positions.unsqueeze(0) < ends.unsqueeze(1) + ) + + torch.testing.assert_close(actual[valid], expected[valid], rtol=2e-2, atol=2e-2) + assert torch.isneginf(actual[~valid]).all() + + +@pytest.mark.parametrize("use_relu", [False, True]) +def test_tilelang_indexer_backward_matches_pytorch(use_relu): + _skip_if_real_tilelang_indexer_unavailable() + torch.manual_seed(5678) + + device = torch.device("cuda") + q_len, k_len, heads, dim = 4, 32, 8, 16 + index_q = (torch.randn(q_len, heads, dim, device=device) * 0.25).to(torch.bfloat16) + index_k = (torch.randn(k_len, dim, device=device) * 0.25).to(torch.bfloat16) + weights = torch.randn(q_len, heads, dtype=torch.float32, device=device) * 0.25 + topk_indices = torch.tensor( + [ + [0, 2, 4, 6, 8, 10, -1], + [1, 3, 5, 7, 9, 11, -1], + [12, 14, 16, 18, 20, 22, -1], + [13, 15, 17, 19, 21, 23, -1], + ], + dtype=torch.int32, + device=device, + ) + grad_scores = torch.randn(topk_indices.shape, dtype=torch.float32, device=device) + grad_scores.masked_fill_(topk_indices < 0, 0.0) + + actual_grad_q, actual_grad_w, actual_grad_k = indexer.indexer_bwd_interface( + index_q, weights, index_k, topk_indices, grad_scores, use_relu=use_relu + ) + + reference_q = index_q.detach().clone().requires_grad_(True) + reference_k = index_k.detach().clone().requires_grad_(True) + reference_w = weights.detach().clone().requires_grad_(True) + reference_scores = _pytorch_indexer_scores( + reference_q, reference_k, reference_w, use_relu=use_relu + ) + valid = topk_indices >= 0 + selected_scores = reference_scores.gather(1, topk_indices.clamp_min(0).long()) + selected_scores = selected_scores.masked_fill(~valid, 0.0) + (selected_scores * grad_scores).sum().backward() + + torch.testing.assert_close(actual_grad_q, reference_q.grad, rtol=5e-2, atol=5e-2) + torch.testing.assert_close(actual_grad_w, reference_w.grad, rtol=5e-2, atol=5e-2) + torch.testing.assert_close(actual_grad_k, reference_k.grad, rtol=5e-2, atol=5e-2) + + +def test_shared_topk_sort_uses_explicit_validity_mask(): + indices = torch.tensor([[5, 1, 7, 3]], dtype=torch.int32) + scores = torch.tensor([[0.5, 0.1, 0.7, 0.3]]) + valid = torch.tensor([[True, False, True, False]]) + + sorted_indices, sorted_scores = dsa_masking.sort_topk_by_index( + indices, valid, sk=8, topk_scores=scores + ) + + torch.testing.assert_close(sorted_indices, torch.tensor([[5, 7, -1, -1]], dtype=torch.int32)) + torch.testing.assert_close(sorted_scores[:, :2], torch.tensor([[0.5, 0.7]])) + assert torch.isneginf(sorted_scores[:, 2:]).all() + + +def test_tilelang_kernel_getters_reuse_cached_builders(monkeypatch): + def make_fake_kernel(): + return lambda *_args, **_kwargs: None + + try: + monkeypatch.setattr(tilelang_utils, "_TILELANG_KERNEL_CACHE_MAX", 1) + tilelang_indexer_fwd._tilelang_indexer_fwd_kernel_cache.clear() + tilelang_indexer_fwd._tilelang_indexer_clean_logits_kernel_cache.clear() + fwd_builds = [] + clean_builds = [] + + def fake_indexer_builder(**kwargs): + fwd_builds.append(kwargs) + return make_fake_kernel() + + def fake_clean_builder(**kwargs): + clean_builds.append(kwargs) + return make_fake_kernel() + + monkeypatch.setattr(tilelang_indexer_fwd, "tl_indexer_fwd_impl", fake_indexer_builder) + monkeypatch.setattr(tilelang_indexer_fwd, "clean_logits_", fake_clean_builder) + + first = tilelang_indexer_fwd._get_indexer_fwd_kernel(2, 4) + second = tilelang_indexer_fwd._get_indexer_fwd_kernel(2, 4) + third = tilelang_indexer_fwd._get_indexer_fwd_kernel(4, 4) + clean_first = tilelang_indexer_fwd._get_clean_logits_kernel() + clean_second = tilelang_indexer_fwd._get_clean_logits_kernel() + + assert first is second + assert third is not first + assert len(fwd_builds) == 2 + assert clean_first is clean_second + assert len(clean_builds) == 1 + + tilelang_indexer_bwd._tilelang_indexer_bwd_kernel_cache.clear() + bwd_builds = [] + + def fake_bwd_builder(*args, **kwargs): + bwd_builds.append((args, kwargs)) + return make_fake_kernel() + + monkeypatch.setattr(tilelang_indexer_bwd, "tl_indexer_bwd_impl", fake_bwd_builder) + bwd_first = tilelang_indexer_bwd._get_indexer_bwd_kernel(8, 4, 32) + bwd_second = tilelang_indexer_bwd._get_indexer_bwd_kernel(8, 4, 32) + bwd_third = tilelang_indexer_bwd._get_indexer_bwd_kernel(16, 4, 32) + + assert bwd_first is bwd_second + assert bwd_third is not bwd_first + assert len(bwd_builds) == 2 + assert bwd_builds[0][1]["num_threads"] == 32 + assert bwd_builds[1][1]["num_threads"] == 128 + + tilelang_sparse_mla_bwd._tilelang_sparse_mla_preprocess_kernel_cache.clear() + tilelang_sparse_mla_bwd._tilelang_sparse_mla_bwd_kernel_cache.clear() + tilelang_sparse_mla_bwd._tilelang_sparse_mla_postprocess_kernel_cache.clear() + monkeypatch.setattr( + tilelang_sparse_mla_bwd, "preprocess", lambda *args, **kwargs: make_fake_kernel() + ) + monkeypatch.setattr( + tilelang_sparse_mla_bwd, "bwd", lambda *args, **kwargs: make_fake_kernel() + ) + monkeypatch.setattr( + tilelang_sparse_mla_bwd, "postprocess", lambda *args, **kwargs: make_fake_kernel() + ) + + preprocess_first = tilelang_sparse_mla_bwd._get_preprocess_kernel(2, 4) + preprocess_second = tilelang_sparse_mla_bwd._get_preprocess_kernel(2, 4) + sparse_bwd_first = tilelang_sparse_mla_bwd._get_bwd_kernel(2, 512, 64, 32, 1, 0.5, 80) + sparse_bwd_second = tilelang_sparse_mla_bwd._get_bwd_kernel(2, 512, 64, 32, 1, 0.5, 80) + postprocess_first = tilelang_sparse_mla_bwd._get_postprocess_kernel(512, 64, 1) + postprocess_second = tilelang_sparse_mla_bwd._get_postprocess_kernel(512, 64, 1) + + assert preprocess_first is preprocess_second + assert sparse_bwd_first is sparse_bwd_second + assert postprocess_first is postprocess_second + finally: + tilelang_indexer_fwd._tilelang_indexer_fwd_kernel_cache.clear() + tilelang_indexer_fwd._tilelang_indexer_clean_logits_kernel_cache.clear() + tilelang_indexer_bwd._tilelang_indexer_bwd_kernel_cache.clear() + tilelang_sparse_mla_bwd._tilelang_sparse_mla_preprocess_kernel_cache.clear() + tilelang_sparse_mla_bwd._tilelang_sparse_mla_bwd_kernel_cache.clear() + tilelang_sparse_mla_bwd._tilelang_sparse_mla_postprocess_kernel_cache.clear() + + +def test_tilelang_utils_noop_jit_and_require_tilelang(monkeypatch): + def fn(): + return "ok" + + monkeypatch.setattr(tilelang_utils, "HAVE_TILELANG", False) + assert tilelang_utils._noop_jit(fn) is fn + assert tilelang_utils._noop_jit()(fn) is fn + assert tilelang_utils.tilelang_jit(fn) is fn + with pytest.raises(ImportError, match="TileLang is required"): + tilelang_utils.require_tilelang() + + +def test_compute_topk_target_chunk_sum_shared_and_per_head_paths(monkeypatch): + tilelang_dsa._DSA_SCRATCH_CACHE.clear() + monkeypatch.setattr(tilelang_dsa, "_DSA_SCRATCH_CACHE_TOTAL_BYTES", 0) + + query_h = torch.tensor( + [[[1.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [1.0, -1.0]]], requires_grad=True + ) + key_shared = torch.tensor([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], requires_grad=True) + idx_seq = torch.tensor([[0, 1], [1, 2]], dtype=torch.int64) + valid_seq = torch.tensor([[True, True], [True, False]]) + + shared = tilelang_dsa._compute_topk_target_chunk_sum( + query_h=query_h, + key_shared=key_shared, + key_per_head=None, + s0=0, + s1=2, + idx_seq=idx_seq, + valid_seq=valid_seq, + softmax_scale=1.0, + head_chunk_size=1, + topk_chunk_size=1, + sk=3, + hn=2, + ) + + assert shared.shape == (2, 2) + assert not shared.requires_grad + assert torch.allclose(shared.sum(dim=-1), torch.tensor([2.0, 2.0]), atol=1e-6) + assert shared[1, 1] == 0 + + key_per_head = torch.stack((key_shared, key_shared + 1.0)).detach().requires_grad_(True) + per_head = tilelang_dsa._compute_topk_target_chunk_sum( + query_h=query_h, + key_shared=None, + key_per_head=key_per_head, + s0=0, + s1=2, + idx_seq=idx_seq, + valid_seq=valid_seq, + softmax_scale=1.0, + head_chunk_size=2, + topk_chunk_size=2, + sk=3, + hn=2, + ) + + assert per_head.shape == (2, 2) + assert not per_head.requires_grad + assert torch.allclose(per_head.sum(dim=-1), torch.tensor([2.0, 2.0]), atol=1e-6) + assert per_head[1, 1] == 0 + + +def test_tilelang_dsa_fused_hook_guard_paths(monkeypatch): + q = torch.empty(2, 1, 2, 4) + k = torch.empty(3, 1, 4) + weights = torch.empty(2, 1, 2) + starts = torch.zeros(2, dtype=torch.int32) + ends = torch.ones(2, dtype=torch.int32) + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer_indices", None) + assert tilelang_dsa.fused_qk_topk_lighting(q, k, weights, 2, starts, ends, 1) is None + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer_indices", lambda *args, **kwargs: None) + assert tilelang_dsa.fused_qk_topk_lighting(q.squeeze(1), k, weights, 2, starts, ends, 1) is None + assert tilelang_dsa.fused_qk_topk_lighting(q, k[:, :0], weights, 2, starts, ends, 1) is None + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer", None) + query = torch.empty(2, 1, 2, 4) + key = torch.empty(3, 1, 1, 4) + assert ( + tilelang_dsa.fused_qk_topk_lighting_with_streaming_sparse_kl( + q, + k, + weights, + 2, + starts, + ends, + 1, + query, + key, + 1.0, + 0.1, + SimpleNamespace(tp=SimpleNamespace(size=lambda: 1)), + ) + is None + ) + + def fail_lighting_indexer(*_args, **_kwargs): + raise AssertionError("unsupported indexer head count should fall back before TileLang") + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer", fail_lighting_indexer) + assert ( + tilelang_dsa.fused_qk_topk_lighting_with_streaming_sparse_kl( + torch.empty(2, 1, 7, 4), + k, + torch.empty(2, 1, 7), + 2, + starts, + ends, + 1, + torch.empty(2, 1, 2, 4), + key, + 1.0, + 0.1, + SimpleNamespace(tp=SimpleNamespace(size=lambda: 1)), + ) + is None + ) + + monkeypatch.setattr(tilelang_dsa, "SparseMLA", None) + topk_indices = torch.zeros(1, 2, 64, dtype=torch.int32) + assert tilelang_dsa.fused_sparse_mla_absorbed(query, key, topk_indices, 1.0, 512) is None + + class FakeSparseMLA: + @staticmethod + def apply(q_t, kv_t, idx_t, softmax_scale): + del q_t, kv_t, idx_t, softmax_scale + return torch.empty(2, 2, 128), torch.empty(2, 2) + + monkeypatch.setattr(tilelang_dsa, "SparseMLA", FakeSparseMLA) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed(query.squeeze(1), key, topk_indices, 1.0, 512) + is None + ) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed(query, key.squeeze(2), topk_indices, 1.0, 512) + is None + ) + assert tilelang_dsa.fused_sparse_mla_absorbed(query, key, topk_indices[:0], 1.0, 512) is None + assert tilelang_dsa.fused_sparse_mla_absorbed(query, key, topk_indices[:, :1], 1.0, 512) is None + assert ( + tilelang_dsa.fused_sparse_mla_absorbed(query, key[..., :3], topk_indices, 1.0, 512) is None + ) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed(query, key, topk_indices[..., :63], 1.0, 512) is None + ) + + query_supported = torch.empty(2, 1, 3, 576) + key_supported = torch.empty(2, 1, 1, 576) + topk_supported = torch.zeros(1, 2, 64, dtype=torch.int32) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed( + query_supported, key_supported, topk_supported, 1.0, 256 + ) + is None + ) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed( + query_supported, key_supported, topk_supported[..., :63], 1.0, 512 + ) + is None + ) + + class FailSparseMLA: + @staticmethod + def apply(*_args, **_kwargs): + raise AssertionError( + "unsupported SparseMLA head count should fall back before TileLang" + ) + + monkeypatch.setattr(tilelang_dsa, "SparseMLA", FailSparseMLA) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed( + torch.empty(2, 1, 96, 576), key_supported, topk_supported, 1.0, 512 + ) + is None + ) + + monkeypatch.setattr(tilelang_dsa, "SparseMLA", FakeSparseMLA) + assert ( + tilelang_dsa.fused_sparse_mla_absorbed( + query_supported, key_supported, topk_supported, 1.0, 512 + ) + is None + ) + + +def test_fused_qk_topk_lighting_sanitizes_mocked_tilelang_indices(monkeypatch): + q = torch.empty(3, 1, 2, 4, dtype=torch.bfloat16) + k = torch.empty(5, 1, 4, dtype=torch.bfloat16) + weights = torch.empty(3, 1, 2) + starts = torch.tensor([0, 2, 4], dtype=torch.int32) + ends = torch.tensor([2, 4, 5], dtype=torch.int32) + calls = [] + + def fake_lighting_indexer_indices( + index_q, index_k, index_w, starts_arg, ends_arg, index_topk, use_relu=True + ): + del index_k, index_w, index_topk + calls.append((tuple(index_q.shape), starts_arg.clone(), ends_arg.clone(), use_relu)) + return torch.stack((starts_arg, ends_arg), dim=-1).to(torch.int32) + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer_indices", fake_lighting_indexer_indices) + + topk = tilelang_dsa.fused_qk_topk_lighting( + q, k, weights, index_topk=2, starts=starts, ends=ends, block_size=2, use_relu=False + ) + + assert torch.equal(topk, torch.tensor([[[0, -1], [2, -1], [4, -1]]], dtype=torch.int32)) + assert [call[0] for call in calls] == [(2, 2, 4), (1, 2, 4)] + assert all(call[3] is False for call in calls) + + +def test_fused_sparse_mla_absorbed_batches_mocked_tilelang_outputs(monkeypatch): + class FakeSparseMLA: + @staticmethod + def apply(q_t, kv_t, idx_t, softmax_scale): + assert q_t.shape == (2, 2, 16, 576) + assert kv_t.shape == (2, 2, 1, 576) + assert idx_t.shape == (2, 2, 1, 64) + assert softmax_scale == 0.25 + batch_sums = q_t.float().sum(dim=(1, 2, 3)).to(dtype=q_t.dtype) + out = batch_sums.view(q_t.size(0), 1, 1, 1).expand( + q_t.size(0), q_t.size(1), q_t.size(2), 512 + ) + lse = torch.zeros(q_t.size(0), q_t.size(1), q_t.size(2)) + return out, lse + + monkeypatch.setattr(tilelang_dsa, "SparseMLA", FakeSparseMLA) + query = torch.zeros(2, 2, 16, 576, dtype=torch.bfloat16) + query[:, 1].fill_(1.0) + key = torch.zeros(2, 2, 1, 576, dtype=torch.bfloat16) + topk_indices = torch.zeros(2, 2, 64, dtype=torch.int32) + + output = tilelang_dsa.fused_sparse_mla_absorbed( + query, key, topk_indices, softmax_scale=0.25, v_channels=512 + ) + + assert output.shape == (2, 2, 16, 512) + assert torch.equal(output[:, 0], torch.zeros_like(output[:, 0])) + assert torch.equal(output[:, 1], torch.full_like(output[:, 1], 18432.0)) + + +def test_fused_sparse_mla_absorbed_pads_small_head_count_without_gradient_leak(monkeypatch): + class FakeSparseMLA: + @staticmethod + def apply(q_t, kv_t, idx_t, softmax_scale): + assert q_t.shape == (1, 2, 16, 576) + assert kv_t.shape == (1, 2, 1, 576) + assert idx_t.shape == (1, 2, 1, 64) + assert softmax_scale == 0.25 + assert torch.count_nonzero(q_t[:, :, 8:]) == 0 + out = q_t[..., :512] + kv_t[..., :512] + return out, torch.zeros(q_t.shape[:-1], dtype=torch.float32) + + monkeypatch.setattr(tilelang_dsa, "SparseMLA", FakeSparseMLA) + query = torch.randn(2, 1, 8, 576, dtype=torch.bfloat16, requires_grad=True) + key = torch.randn(2, 1, 1, 576, dtype=torch.bfloat16, requires_grad=True) + topk_indices = torch.zeros(1, 2, 64, dtype=torch.int32) + + output = tilelang_dsa.fused_sparse_mla_absorbed( + query, key, topk_indices, softmax_scale=0.25, v_channels=512 + ) + + assert output is not None + assert output.shape == (2, 1, 8, 512) + output.float().sum().backward() + assert torch.equal(query.grad[..., :512], torch.ones_like(query.grad[..., :512])) + assert torch.count_nonzero(query.grad[..., 512:]) == 0 + assert torch.equal(key.grad[..., :512], torch.full_like(key.grad[..., :512], 8.0)) + assert torch.count_nonzero(key.grad[..., 512:]) == 0 + + +def test_streaming_sparse_kl_path_with_mocked_tilelang_indexer(monkeypatch): + q = torch.empty(2, 1, 2, 4, dtype=torch.bfloat16) + k = torch.empty(4, 1, 4, dtype=torch.bfloat16) + weights = torch.empty(2, 1, 2) + starts = torch.tensor([0, 0], dtype=torch.int32) + ends = torch.tensor([4, 4], dtype=torch.int32) + query = torch.empty(2, 1, 2, 4, dtype=torch.bfloat16) + key = torch.empty(4, 1, 1, 4, dtype=torch.bfloat16) + query_valid_rows = torch.tensor([[True, False]]) + + def fake_lighting_indexer( + index_q, + index_k, + index_w, + starts_arg, + ends_arg, + index_topk, + topk_indices=None, + use_relu=True, + ): + del index_k, index_w, starts_arg, ends_arg, topk_indices, use_relu + topk_scores = torch.zeros(index_q.size(0), index_topk) + topk = torch.tensor([[0, 1], [2, 3]], dtype=torch.int32)[: index_q.size(0)] + return topk_scores, topk + + def fake_compute_topk_target_chunk_sum(**kwargs): + idx_seq = kwargs["idx_seq"] + return torch.ones(idx_seq.shape, dtype=torch.float32, device=idx_seq.device) + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer", fake_lighting_indexer) + monkeypatch.setattr(tilelang_dsa, "is_supported_indexer_bwd_head_count", lambda *_args: True) + monkeypatch.setattr( + tilelang_dsa, "_compute_topk_target_chunk_sum", fake_compute_topk_target_chunk_sum + ) + + topk, loss = tilelang_dsa.fused_qk_topk_lighting_with_streaming_sparse_kl( + q=q, + k=k, + weights=weights, + index_topk=2, + starts=starts, + ends=ends, + block_size=2, + query=query, + key=key, + softmax_scale=0.5, + loss_coeff=2.0, + pg_collection=SimpleNamespace(tp=SimpleNamespace(size=lambda: 1)), + query_valid_rows=query_valid_rows, + calculate_per_token_loss=False, + seq_chunk_size=1, + head_chunk_size=1, + topk_chunk_size=1, + use_relu=False, + ) + + assert torch.equal(topk, torch.tensor([[[0, 1], [2, 3]]], dtype=torch.int32)) + assert loss.item() == 0.0 + + +def test_streaming_sparse_kl_uses_fused_target_when_supported(monkeypatch): + q = torch.empty(2, 1, 2, 4, dtype=torch.bfloat16) + k = torch.empty(4, 1, 4, dtype=torch.bfloat16) + weights = torch.empty(2, 1, 2) + starts = torch.zeros(2, dtype=torch.int32) + ends = torch.full((2,), 4, dtype=torch.int32) + query = torch.empty(2, 1, 2, 4, dtype=torch.bfloat16) + key = torch.empty(4, 1, 1, 4, dtype=torch.bfloat16) + calls = [] + + def fake_lighting_indexer( + index_q, + index_k, + index_w, + starts_arg, + ends_arg, + index_topk, + topk_indices=None, + use_relu=True, + ): + del index_k, index_w, starts_arg, ends_arg, topk_indices, use_relu + topk_scores = torch.zeros(index_q.size(0), index_topk, requires_grad=True) + topk = torch.tensor([[0, 1], [2, 3]], dtype=torch.int32)[: index_q.size(0)] + return topk_scores, topk + + def fake_target(query_arg, key_arg, indices_arg, softmax_scale): + calls.append((query_arg, key_arg, indices_arg.clone(), softmax_scale)) + return torch.ones(indices_arg.shape, dtype=torch.float32) + + def fail_python_target(**_kwargs): + raise AssertionError("the PyTorch target path should not run") + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer", fake_lighting_indexer) + monkeypatch.setattr(tilelang_dsa, "is_supported_indexer_bwd_head_count", lambda *_args: True) + monkeypatch.setattr(tilelang_dsa, "_can_use_fused_sparse_indexer_target", lambda *_args: True) + monkeypatch.setattr(tilelang_dsa, "sparse_indexer_target_interface", fake_target) + monkeypatch.setattr(tilelang_dsa, "_can_use_fused_sparse_indexer_kl", lambda *_args: False) + monkeypatch.setattr(tilelang_dsa, "_compute_topk_target_chunk_sum", fail_python_target) + + topk, loss = tilelang_dsa.fused_qk_topk_lighting_with_streaming_sparse_kl( + q=q, + k=k, + weights=weights, + index_topk=2, + starts=starts, + ends=ends, + block_size=2, + query=query, + key=key, + softmax_scale=0.5, + loss_coeff=2.0, + pg_collection=SimpleNamespace(tp=SimpleNamespace(size=lambda: 1)), + seq_chunk_size=2, + ) + + assert torch.equal(topk, torch.tensor([[[0, 1], [2, 3]]], dtype=torch.int32)) + assert loss.item() == 0.0 + assert len(calls) == 1 + assert calls[0][0].shape == (2, 2, 4) + assert calls[0][1].shape == (4, 4) + assert calls[0][3] == 0.5 + + +@pytest.mark.parametrize("heads", [48, 96]) +def test_fused_sparse_indexer_target_and_kl_match_reference(heads): + if not torch.cuda.is_available(): + pytest.skip("CUDA is required for TileLang indexer-loss tests") + if not tilelang_indexer_loss.HAVE_TILELANG: + pytest.skip("TileLang indexer-loss kernels are unavailable") + + torch.manual_seed(1234 + heads) + seq_len = 2 + key_len = 256 + topk = 256 + dim = 576 + softmax_scale = dim**-0.5 + query = torch.randn(seq_len, heads, dim, device="cuda", dtype=torch.bfloat16) + key = torch.randn(key_len, dim, device="cuda", dtype=torch.bfloat16) + topk_indices = torch.arange(topk, device="cuda", dtype=torch.int32).repeat(seq_len, 1) + topk_indices[1, -16:] = -1 + valid = topk_indices >= 0 + + target = tilelang_indexer_loss.sparse_indexer_target_interface( + query, key, topk_indices, softmax_scale + ) + safe_indices = topk_indices.clamp(min=0).to(torch.int64) + selected_key = key.index_select(0, safe_indices.reshape(-1)).view(seq_len, topk, dim) + reference_scores = ( + torch.einsum("shd,skd->shk", query.float(), selected_key.float()) * softmax_scale + ) + reference_scores = reference_scores.masked_fill(~valid.unsqueeze(1), float("-inf")) + reference_target = torch.softmax(reference_scores, dim=-1).masked_fill(~valid.unsqueeze(1), 0.0) + reference_target = reference_target.sum(dim=1) + torch.testing.assert_close(target, reference_target, rtol=2e-2, atol=2e-2) + + logits = torch.randn(seq_len, topk, device="cuda", dtype=torch.float32, requires_grad=True) + loss = tilelang_indexer_loss.SparseIndexerKLLoss.apply(target, logits, valid) + loss.backward() + + normalized_target = dsa_indexer_loss.normalize_indexer_target(reference_target) + reference_log_probs = dsa_masking.masked_log_softmax(logits.detach(), valid, dim=-1) + reference_loss = dsa_indexer_loss.indexer_kl_sum(normalized_target, reference_log_probs, valid) + reference_grad = ( + reference_log_probs.exp().masked_fill(~valid, 0.0) - normalized_target + ).masked_fill(~valid, 0.0) + torch.testing.assert_close(loss, reference_loss, rtol=2e-3, atol=2e-3) + torch.testing.assert_close(logits.grad, reference_grad, rtol=2e-3, atol=2e-3) + + +@pytest.mark.parametrize("dtype", [torch.float16, torch.float32]) +def test_tilelang_ops_decline_non_bfloat16_inputs(monkeypatch, dtype): + def fail_if_called(*_args, **_kwargs): + raise AssertionError("TileLang kernel should not run for non-BF16 inputs") + + class FailSparseMLA: + @staticmethod + def apply(*args): + return fail_if_called(*args) + + monkeypatch.setattr(tilelang_dsa, "lighting_indexer_indices", fail_if_called) + monkeypatch.setattr(tilelang_dsa, "lighting_indexer", fail_if_called) + monkeypatch.setattr(tilelang_dsa, "SparseMLA", FailSparseMLA) + + q_indexer = torch.zeros((1, 1, 1, 1), dtype=dtype) + k_indexer = torch.zeros((1, 1, 1), dtype=dtype) + weights = torch.zeros((1, 1, 1), dtype=dtype) + starts = torch.tensor([0], dtype=torch.int32) + ends = torch.tensor([1], dtype=torch.int32) + query = torch.zeros((1, 1, 1, 1), dtype=dtype) + key = torch.zeros((1, 1, 1, 1), dtype=dtype) + topk_indices = torch.zeros((1, 1, 1), dtype=torch.int32) + + assert ( + tilelang_dsa.fused_qk_topk_lighting(q_indexer, k_indexer, weights, 1, starts, ends, 128) + is None + ) + assert ( + tilelang_dsa.fused_qk_topk_lighting_with_streaming_sparse_kl( + q=q_indexer, + k=k_indexer, + weights=weights, + index_topk=1, + starts=starts, + ends=ends, + block_size=128, + query=query, + key=key, + softmax_scale=1.0, + loss_coeff=0.01, + pg_collection=object(), + ) + is None + ) + assert tilelang_dsa.fused_sparse_mla_absorbed(query, key, topk_indices, 1.0, 1) is None + + +@pytest.mark.parametrize("num_heads", [8, 64]) +def test_fused_sparse_mla_absorbed_accepts_thd_sentinels(num_heads): + if not torch.cuda.is_available(): + pytest.skip("CUDA is required for TileLang SparseMLA tests") + if tilelang_dsa.SparseMLA is None: + pytest.skip("TileLang SparseMLA kernel is unavailable") + + torch.manual_seed(1234) + torch.cuda.manual_seed(1234) + + # Match the sequence bucket so the kernel receives the original B=1 tensor views. + # This exercises the canonical batch-stride handling rather than hiding it with padding. + seqlen = 256 + dim = 576 + v_channels = 512 + topk = 64 + + query = torch.randn( + (seqlen, 1, num_heads, dim), dtype=torch.bfloat16, device="cuda", requires_grad=True + ) + key = torch.randn((seqlen, 1, 1, dim), dtype=torch.bfloat16, device="cuda", requires_grad=True) + topk_indices = torch.full((1, seqlen, topk), -1, dtype=torch.int32, device="cuda") + for row in range(1, seqlen): + valid = min(row, topk) + topk_indices[0, row, :valid] = torch.arange(valid, dtype=torch.int32, device="cuda") + + output = tilelang_dsa.fused_sparse_mla_absorbed( + query, key, topk_indices, softmax_scale=1.0 / math.sqrt(dim), v_channels=v_channels + ) + + assert output is not None + assert output.shape == (seqlen, 1, num_heads, v_channels) + assert torch.isfinite(output).all() + assert output[0].abs().max() == 0 + + output.float().square().mean().backward() + assert query.grad is not None + assert key.grad is not None + assert torch.isfinite(query.grad).all() + assert torch.isfinite(key.grad).all() + assert query.grad[0].abs().max() == 0 From 6f0db6fb2bc28858f9c699343fef5bd9452550fe Mon Sep 17 00:00:00 2001 From: wdykas <73254672+wdykas@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:02:46 -0400 Subject: [PATCH 098/290] Refit: local plan building, node-add cache key, and NIXL backend (#5872) Signed-off-by: William Dykas --- megatron/core/resharding/README.md | 30 +- megatron/core/resharding/__init__.py | 10 +- .../core/resharding/copy_services/__init__.py | 9 +- .../core/resharding/copy_services/base.py | 5 + .../copy_services/nixl_copy_service.py | 277 ++++++++++++++ megatron/core/resharding/execution.py | 5 +- megatron/core/resharding/planner.py | 354 ++++++++++-------- megatron/core/resharding/refit.py | 22 +- .../resharding/test_copy_services.py | 6 + tests/unit_tests/resharding/test_execution.py | 13 +- tests/unit_tests/resharding/test_planner.py | 100 +++++ 11 files changed, 662 insertions(+), 169 deletions(-) create mode 100644 megatron/core/resharding/copy_services/nixl_copy_service.py diff --git a/megatron/core/resharding/README.md b/megatron/core/resharding/README.md index 875d82d2c95..134e9c9225c 100644 --- a/megatron/core/resharding/README.md +++ b/megatron/core/resharding/README.md @@ -10,7 +10,8 @@ inference model that may use a different parallelism layout. ``` refit.py High-level API: swap_model_weights, caching, MXFP8 auto-detection | -planner.py Centralized plan builder (rank 0 builds, scatters to all) +planner.py Local plan builder (every rank all-gathers metadata, replays + the same deterministic schedule, keeps only its own ops) | execution.py Submits send/recv ops to a CopyService, handles writebacks | @@ -79,6 +80,7 @@ swap_model_weights(None, None, "nccl", | `nccl` | GPU P2P via `batch_isend_irecv` | Intra-node / single cluster | Lowest latency; default choice | | `gloo` | CPU-staged via Gloo PG | Cross-cluster / multi-node | Higher latency; works where NCCL cross-cluster doesn't | | `nvshmem` | Pipelined NVSHMEM puts | High-throughput intra-node | Requires NVSHMEM; uses double-buffered kernel pipeline | +| `nixl` | GPU RDMA via NIXL (UCX), sender-initiated WRITE | Cross-cluster / non-collocated | Requires NIXL; transfers GPU memory directly (no host staging) | All backends detect same-rank (local) transfers via `task_id` and short-circuit them into direct `tensor.copy_()` instead of going @@ -87,15 +89,26 @@ through the network stack. ## How the Reshard Plan Works 1. Each rank extracts parameter metadata (shape, sharding, TP/EP/PP groups). -2. Metadata is gathered to rank 0 via `dist.gather_object()`. -3. Rank 0 builds a complete transfer schedule: - - For each destination param, finds the matching source param(s) by name. - - Routes to a dimension-specific planner (LCM tiling for standard TP, +2. Metadata is all-gathered so **every** rank has the full picture + (`dist.all_gather_object()`) — no rank-0 bottleneck, no scatter. +3. Every rank independently replays the **same deterministic schedule** + (`_iter_global_transfer_ops`): + - Iterate destination ranks, then each rank's destination params in gathered + order; for each destination param, find the matching source param(s) by name. + - Route to a dimension-specific planner (LCM tiling for standard TP, block-interleaved for partitioned params like Mamba `in_proj`). - - Produces `TransferOp` pairs with globally unique `task_id` values. -4. Plans are scattered back; each rank receives only its own send/recv ops. + - Assign a monotonic `task_id` per sub-op. Because the iteration order and + counter are a pure function of the gathered metadata, the send op computed + on the sender and the recv op computed on the receiver get the **same** + `task_id` without any central authority. +4. Each rank keeps only the ops where it is the sender or receiver. 5. The plan is cached so repeated refits skip steps 1-4. +The deterministic schedule stays stable when a larger roster is supplied: existing +transfers keep their `task_id`s and newly appended destination ranks receive new +ones. Live process-group membership changes and their orchestration remain future +work; this module does not currently add or remove ranks from a running group. + ## MXFP8 Transform When the target model uses `transformer_impl='inference_optimized'` with @@ -144,11 +157,12 @@ attribute with the following groups: | File | Role | |------|------| | `refit.py` | Public API, caching, MXFP8 auto-detection | -| `planner.py` | Centralized plan builder (metadata, LCM/block-interleaved planners) | +| `planner.py` | Local deterministic plan builder (metadata, LCM/block-interleaved planners) | | `execution.py` | Plan executor (send/recv submission, writeback, format conversion) | | `transforms.py` | `ReshardTransform` base class, `MXFP8ReshardTransform` | | `utils.py` | `TransferOp`, `ReshardPlan`, `ParameterMetadata`, `ShardingDescriptor` | | `copy_services/nccl_copy_service.py` | NCCL backend | | `copy_services/gloo_copy_service.py` | Gloo backend | +| `copy_services/nixl_copy_service.py` | NIXL/UCX backend | | `copy_services/nvshmem_copy_service.py` | NVSHMEM backend (delegates to `nvshmem_copy_service/`) | | `nvshmem_copy_service/` | Full NVSHMEM implementation (planning, memory, kernels, pipeline) | diff --git a/megatron/core/resharding/__init__.py b/megatron/core/resharding/__init__.py index 8c59b6ef809..7fc122c2400 100644 --- a/megatron/core/resharding/__init__.py +++ b/megatron/core/resharding/__init__.py @@ -1,6 +1,11 @@ # Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. from .execution import execute_reshard_plan -from .planner import build_centralized_reshard_plan +from .planner import ( + build_centralized_reshard_plan, + build_local_reshard_plan, + build_plan_from_rosters, + index_metadata_rosters, +) from .refit import ( clear_service_cache, get_or_create_service, @@ -12,6 +17,9 @@ __all__ = [ "build_centralized_reshard_plan", + "build_local_reshard_plan", + "build_plan_from_rosters", + "index_metadata_rosters", "execute_reshard_plan", "MXFP8ReshardTransform", "ReshardTransform", diff --git a/megatron/core/resharding/copy_services/__init__.py b/megatron/core/resharding/copy_services/__init__.py index c85ebb0ece2..18ee6afa2fe 100644 --- a/megatron/core/resharding/copy_services/__init__.py +++ b/megatron/core/resharding/copy_services/__init__.py @@ -4,6 +4,13 @@ from .base import CopyService from .gloo_copy_service import GlooCopyService from .nccl_copy_service import NCCLCopyService +from .nixl_copy_service import NixlCopyService from .nvshmem_copy_service import NVSHMEMCopyService -__all__ = ["CopyService", "GlooCopyService", "NCCLCopyService", "NVSHMEMCopyService"] +__all__ = [ + "CopyService", + "GlooCopyService", + "NCCLCopyService", + "NixlCopyService", + "NVSHMEMCopyService", +] diff --git a/megatron/core/resharding/copy_services/base.py b/megatron/core/resharding/copy_services/base.py index 00dc884767c..98404d28015 100644 --- a/megatron/core/resharding/copy_services/base.py +++ b/megatron/core/resharding/copy_services/base.py @@ -37,6 +37,11 @@ class CopyService(ABC): remote transfers simply ignore it. """ + # Most torch.distributed backends retain the executor's historical + # process-group rendezvous after run(). Backends whose run() protocol already + # establishes completion across every participating peer can opt out. + requires_process_group_barrier = True + def __init__(self, group=None): self.group = group # group.rank()/size() supports cross-cluster ProcessGroups where members diff --git a/megatron/core/resharding/copy_services/nixl_copy_service.py b/megatron/core/resharding/copy_services/nixl_copy_service.py new file mode 100644 index 00000000000..917ad6fbf33 --- /dev/null +++ b/megatron/core/resharding/copy_services/nixl_copy_service.py @@ -0,0 +1,277 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +from __future__ import annotations + +import logging +import os +import time +from typing import Dict, List, Optional, Tuple + +import torch +import torch.distributed as dist + +from .base import CopyService, RecvOp, SendOp, match_local_ops_by_task_id + +logger = logging.getLogger(__name__) + +# Transports that let UCX read/write CUDA memory. Without one of these UCX sees +# a GPU pointer as host memory and segfaults mid-transfer. +_CUDA_UCX_TRANSPORTS = ("cuda_copy", "cuda_ipc") + +# (addr, len_bytes, device_id) for a registered region. Exchanged between ranks +# so a sender can WRITE straight into a receiver's destination buffer. +_MemDesc = Tuple[int, int, int] + + +def _ensure_cuda_ucx_transports() -> None: + """Add cuda transports to UCX_TLS if it's pinned to a host-only allowlist. + + UCX reads UCX_TLS once at agent init. Deployments often set it to e.g. "tcp", + which can't touch GPU memory. Only augment a plain inclusion list; leave an + unset value (defaults to "all") or an exclusion list ("^...") alone. + """ + tls = os.environ.get("UCX_TLS") + if not tls: + return + tokens = [t.strip() for t in tls.split(",") if t.strip()] + if not tokens or tokens[0].startswith("^") or any("cuda" in t for t in tokens): + return + os.environ["UCX_TLS"] = tls + "," + ",".join(_CUDA_UCX_TRANSPORTS) + logger.warning("UCX_TLS=%r has no cuda transport; using %r", tls, os.environ["UCX_TLS"]) + + +class NixlCopyService(CopyService): + """Refit transport over NIXL (UCX/RDMA), for cross-cluster non-collocated refit. + + Each rank runs a NIXL agent. To WRITE into a peer it needs that peer's agent + metadata (connection info) plus a {task_id: (addr, len, dev)} map of the peer's + registered recv buffers. A one-time torch all-gather builds and caches this + peer table. Buffers are registered locally, not exchanged. + + Every refit after that is pure NIXL and sender-driven: a receiver signals each + source that its buffers are free ("ready"); the source waits, syncs its weights, + and issues one WRITE per receiver, each carrying a "data" notification. Those two + notifications order producer and consumer per refit, so there's no barrier and no + per-refit collective. Notifications are tagged with a per-refit sequence, so stale + ones are ignored. Same-rank transfers skip NIXL and copy directly. + + Registered buffers are assumed address-stable across refits; if a recv address + changes after setup, call clear_service_cache() to rebuild. + """ + + requires_process_group_barrier = False + + def __init__(self, group=None, agent_name: Optional[str] = None): + super().__init__(group=group) + # Object collectives (the one-time handshake) run on this group to support + # cross-world PGs. + self.group = group + + try: + from nixl._api import nixl_agent, nixl_agent_config + except ImportError as e: + raise ImportError( + "NixlCopyService requires the 'nixl' package; install it or use " + "another refit backend (nccl/gloo/nvshmem)." + ) from e + + _ensure_cuda_ucx_transports() + + # Name by group rank, which is unique across the (possibly cross-world) + # group. dist.get_rank() would collide: separate worlds each have a rank 0. + self.agent_name = agent_name or f"refit-nixl-rank-{self.rank}" + # This backend is intentionally UCX-only: refit moves CPU/CUDA tensors + # directly between ranks and does not use NIXL's storage-oriented plugins. + self.agent = nixl_agent(self.agent_name, nixl_agent_config(backends=["UCX"])) + + self.send_ops: List[SendOp] = [] + self.recv_ops: List[RecvOp] = [] + self._copy_stream = torch.cuda.Stream() + + # Peer table {group rank -> (agent_name, agent_metadata, recv_descs)}, + # populated by the first run's handshake. + self._remote_agent_names: Dict[int, str] = {} # group rank -> agent name + self._gathered: Optional[Dict[int, tuple]] = None + self._recv_descs: Optional[Dict[int, _MemDesc]] = None + # RDMA registrations, kept across refits; send and recv tracked separately + # so a changing side doesn't re-pin the other. + self._reg: Dict[str, tuple] = {} # 'send'/'recv' -> (handle, signature) + # Notifications are tagged (kind, seq). _future_notifs buffers tags for + # later refits that we're not draining yet. + self._seq = 0 + self._future_notifs: Dict[Tuple[str, int], int] = {} + + def submit_send(self, src_tensor: torch.Tensor, dest_rank: int, task_id: Optional[int] = None): + self.send_ops.append(SendOp(task_id=task_id, tensor=src_tensor, dest_rank=dest_rank)) + + def submit_recv(self, dest_tensor: torch.Tensor, src_rank: int, task_id: Optional[int] = None): + self.recv_ops.append(RecvOp(task_id=task_id, tensor=dest_tensor, src_rank=src_rank)) + + @staticmethod + def _mem_desc(tensor: torch.Tensor) -> _MemDesc: + if not tensor.is_contiguous(): + raise RuntimeError("NixlCopyService requires contiguous tensors") + dev = tensor.get_device() # -1 for host tensors; NIXL addresses DRAM as device 0 + return (tensor.data_ptr(), tensor.numel() * tensor.element_size(), dev if dev >= 0 else 0) + + def _register(self, which: str, tensors: List[torch.Tensor]) -> None: + # Re-register only when the set of regions changes. + sig = tuple((t.data_ptr(), t.numel() * t.element_size()) for t in tensors) + cached = self._reg.get(which) + if cached is not None and cached[1] == sig: + return + if cached is not None and cached[0] is not None: + self.agent.deregister_memory(cached[0]) + handle = self.agent.register_memory(tensors) if tensors else None + self._reg[which] = (handle, sig) + + def _handshake(self, recv_descs: Dict[int, _MemDesc]) -> None: + # Initial bootstrap over the torch group: share agent metadata and recv + # descriptors, connect to every peer, and cache the peer table. This is + # NIXL's one torch collective. + payload = (self.agent_name, self.agent.get_agent_metadata(), recv_descs) + gathered: List[Optional[tuple]] = [None] * self.world_size + dist.all_gather_object(gathered, payload, group=self.group) + self._gathered = {rank: entry for rank, entry in enumerate(gathered) if entry is not None} + for rank, (name, metadata, _descs) in self._gathered.items(): + if rank != self.rank: + self._remote_agent_names[rank] = self.agent.add_remote_agent(metadata) or name + self._recv_descs = recv_descs + + def _do_local_copies(self) -> None: + # Collocated (same-rank) transfers never hit the network. + local_sends = [op for op in self.send_ops if op.dest_rank == self.rank] + local_recvs = [op for op in self.recv_ops if op.src_rank == self.rank] + if not local_sends and not local_recvs: + return + pairs = match_local_ops_by_task_id(local_sends, local_recvs, "NixlCopyService", self.rank) + with torch.no_grad(), torch.cuda.stream(self._copy_stream): + for send_op, recv_op in pairs: + recv_op.tensor.copy_(send_op.tensor) + + def _plan_writes(self, remote_sends: List[SendOp]): + # Group writes by destination agent: one WRITE per receiver pushes all its + # regions at once, with local[i] landing in remote[i]. + if self._gathered is None: + raise RuntimeError("NixlCopyService: handshake has not completed") + by_dst: Dict[int, Tuple[List[torch.Tensor], List[_MemDesc]]] = {} + for op in remote_sends: + dst_entry = self._gathered.get(op.dest_rank) + if dst_entry is None: + raise RuntimeError(f"NixlCopyService: no metadata from dst rank {op.dest_rank}") + remote_desc = dst_entry[2].get(op.task_id) + if remote_desc is None: + raise RuntimeError( + f"NixlCopyService: dst rank {op.dest_rank} missing task_id {op.task_id}" + ) + local_list, remote_list = by_dst.setdefault(op.dest_rank, ([], [])) + local_list.append(op.tensor) + remote_list.append(remote_desc) + return by_dst + + @staticmethod + def _notif(kind: str, seq: int) -> bytes: + return f"{kind}{seq}".encode() + + @staticmethod + def _parse_notif(m: bytes) -> Tuple[str, int]: + return chr(m[0]), int(m[1:]) + + def _await_notifs(self, kind: str, expected: int, seq: int) -> None: + # Wait for `expected` notifications of this kind ('R' ready / 'D' data) + # tagged with this refit. Buffer any tagged otherwise — e.g. a data notif + # arriving while we're still collecting ready signals. + if expected == 0: + return + want = (kind, seq) + got = self._future_notifs.pop(want, 0) + while got < expected: + for _agent, msgs in self.agent.get_new_notifs().items(): + for m in msgs: + tag = self._parse_notif(m) + if tag == want: + got += 1 + else: + self._future_notifs[tag] = self._future_notifs.get(tag, 0) + 1 + if got < expected: + time.sleep(0) # NIXL delivers notifs on its own thread + + def run(self): + remote_sends = [op for op in self.send_ops if op.dest_rank != self.rank] + remote_recvs = [op for op in self.recv_ops if op.src_rank != self.rank] + seq = self._seq + + # Overlaps with the writes below. + self._do_local_copies() + + # Both sides of a WRITE must be registered: our send tensors (we read them) + # and our recv buffers (peers write into them). + self._register("send", [op.tensor for op in remote_sends]) + self._register("recv", [op.tensor for op in remote_recvs]) + + recv_descs = {op.task_id: self._mem_desc(op.tensor) for op in remote_recvs} + if self._gathered is None: + self._handshake(recv_descs) + elif recv_descs != self._recv_descs: + raise RuntimeError( + "NixlCopyService: recv tensor addresses changed after setup; " + "call clear_service_cache() to rebuild the handshake." + ) + + # Tell each source our destination buffers are free for this refit; this is + # what orders a source's write after we've consumed the previous one. + ready = self._notif("R", seq) + for src_rank in {op.src_rank for op in remote_recvs}: + self.agent.send_notif(self._remote_agent_names[src_rank], ready) + + # As a source: wait for every receiver's ready, then push once the weights + # are finished (a peer must not read a half-written buffer). + self._await_notifs("R", len({op.dest_rank for op in remote_sends}), seq) + if remote_sends: + torch.cuda.current_stream().synchronize() + + data = self._notif("D", seq) + handles = [] + for dst_rank, (local_tensors, remote_descs) in self._plan_writes(remote_sends).items(): + # local and remote are the same memory class (GPU->GPU for refit); the + # remote tuples carry no type, so take it from the send tensor. + mem_type = "cuda" if local_tensors[0].is_cuda else "cpu" + local_xfer = self.agent.get_xfer_descs(local_tensors) + remote_xfer = self.agent.get_xfer_descs(remote_descs, mem_type=mem_type) + handle = self.agent.initialize_xfer( + "WRITE", local_xfer, remote_xfer, self._remote_agent_names[dst_rank], notif_msg=data + ) + if self.agent.transfer(handle) == "ERR": + raise RuntimeError(f"NixlCopyService: WRITE to dst {dst_rank} failed to start") + handles.append((dst_rank, handle)) + + # NIXL's asynchronous Python API exposes transfer completion through + # check_xfer_state(); the official examples poll it until DONE. Transfers + # progress in the backend, so yield the Python thread between checks. + for dst_rank, handle in handles: + state = self.agent.check_xfer_state(handle) + while state not in ("DONE", "ERR"): + time.sleep(0) + state = self.agent.check_xfer_state(handle) + if state == "ERR": + raise RuntimeError(f"NixlCopyService: WRITE to dst {dst_rank} errored") + self.agent.release_xfer_handle(handle) + self._await_notifs("D", len({op.src_rank for op in remote_recvs}), seq) + + torch.cuda.current_stream().wait_stream(self._copy_stream) + self._seq += 1 + self.send_ops.clear() + self.recv_ops.clear() + + def close(self) -> None: + for handle, _sig in self._reg.values(): + if handle is not None: + self.agent.deregister_memory(handle) + self._reg.clear() + for name in self._remote_agent_names.values(): + try: + self.agent.remove_remote_agent(name) + except Exception: + pass + self._remote_agent_names.clear() + self._gathered = None + self._recv_descs = None diff --git a/megatron/core/resharding/execution.py b/megatron/core/resharding/execution.py index 8b06ef1cd14..ce45f124b58 100644 --- a/megatron/core/resharding/execution.py +++ b/megatron/core/resharding/execution.py @@ -64,7 +64,7 @@ def execute_reshard_plan( transform: Optional[ReshardTransform] = None, ) -> None: """ - Execute a reshard plan (from centralized controller). + Execute a reshard plan (built locally on each rank). A communication service must be provided to abstract transport. Expected service API: submit_send(tensor, dest_rank, task_id), submit_recv(tensor, src_rank, task_id), run(). @@ -195,7 +195,8 @@ def get_sendable(param_name: str, param: torch.nn.Parameter) -> torch.Tensor: logger.info(f"Executing {len(plan.send_ops)} sends + {len(plan.recv_ops)} recvs") service.run() torch.cuda.synchronize() - dist.barrier(group=group) + if service.requires_process_group_barrier: + dist.barrier(group=group) # Write back received buffers into their destination parameter slices. # diff --git a/megatron/core/resharding/planner.py b/megatron/core/resharding/planner.py index 1eda91e914b..f89f39a4a0c 100644 --- a/megatron/core/resharding/planner.py +++ b/megatron/core/resharding/planner.py @@ -3,6 +3,7 @@ import logging import math +import warnings import torch import torch.distributed as dist @@ -301,170 +302,223 @@ def _determine_source_ranks_for_dst_param( return _finalize_dp_transfers(param_name, src_metadata, dst_metadata, my_global_rank) -def build_centralized_reshard_plan( +def _iter_global_transfer_ops( + dst_param_metadata_by_rank: dict[int, dict[str, ParameterMetadata]], + src_param_metadata: dict[str, list[ParameterMetadata]], +): + """Yield the whole reshard schedule in a deterministic order. + + The iteration order (dst rank ascending, then that rank's dst params in + gathered order, then per-source sub-ops) depends only on the rosters, so + replaying this on any rank produces the same sequence and assigns the same + task_id to the same transfer. That's what lets each rank build its own + send/recv ops while sender and receiver still agree on task_id. + + Ranks are taken from the roster keys rather than range(world_size), so a + sparse or growing rank set (nodes added later) rebuilds identically. + + Yields (task_id, dst_rank, src_rank, src_slice, dst_slice, src_metadata, + dst_metadata). PP is handled implicitly: each rank contributes metadata only + for the params it owns, and any source holding the same resolved_name can + serve as sender (with DP balancing). + """ + # Shared between a send and its recv; NVSHMEM builds a schedule from it and + # local copies match on it. + next_task_id = 0 + for dst_rank in sorted(dst_param_metadata_by_rank): + dst_rank_params = dst_param_metadata_by_rank[dst_rank] + for resolved_name, dst_metadata in dst_rank_params.items(): + src_meta_list = src_param_metadata.get(resolved_name) + if not src_meta_list and resolved_name.endswith("output_layer.weight"): + # Tied embeddings: the source shares the output projection with + # the input embedding, so it has no separate output_layer.weight. + # A pp>1 destination materializes one (embedding and output land + # on different stages), e.g. pp=1 (tied) -> pp=2. Source it from + # the embedding weight (same shape + vocab/TP shard); that tensor + # then feeds both the destination embedding and output_layer. + for emb_name in ("embedding.word_embeddings.weight", "word_embeddings.weight"): + src_meta_list = src_param_metadata.get(emb_name) + if src_meta_list: + break + if not src_meta_list: + raise RuntimeError( + f"Destination parameter '{resolved_name}' on rank {dst_rank} " + "not found in source model." + ) + # Choose a representative source metadata with DP round-robin balancing. + src_metadata = select_src_metadata_balanced(src_meta_list, dst_metadata, dst_rank) + sources = _determine_source_ranks_for_dst_param( + resolved_name, src_metadata, dst_metadata, dst_rank + ) + for src_rank, src_slice, dst_slice in sources: + task_id = next_task_id + next_task_id += 1 + yield task_id, dst_rank, src_rank, src_slice, dst_slice, src_metadata, dst_metadata + + +def _extract_module_metadata( + module, owner_rank, num_experts, rank_offset, rank_list_cache +) -> list[ParameterMetadata]: + """Metadata for a module's params and persistent buffers, or [] if None. + + Persistent buffers travel too so training state (e.g. MoE router expert_bias) + refits with the weights. + """ + if module is None: + return [] + pg = getattr(module, "pg_collection", None) + if pg is None: + raise ValueError("Module must have pg_collection") + layer_prefix_map = _build_layer_module_prefix_map(module) + return [ + extract_param_metadata( + p, + name, + owner_rank, + pg, + num_experts=num_experts, + layer_module_prefix_map=layer_prefix_map, + rank_offset=rank_offset, + _rank_list_cache=rank_list_cache, + ) + for name, p in named_refit_tensors(module) + ] + + +def index_metadata_rosters(gathered_pairs: list): + """Turn a rank-ordered list of ``(src_meta, dst_meta)`` (index == rank) into the + two rosters the plan builder consumes: dst params keyed by rank, and src params + keyed by resolved_name. The list may come from the all-gather, or be reassembled + in rank order as nodes are added, before calling build_plan_from_rosters. + """ + dst_param_metadata_by_rank: dict[int, dict[str, ParameterMetadata]] = {} + src_param_metadata: dict[str, list[ParameterMetadata]] = {} + for rank_id, (src_meta_list, dst_meta_list) in enumerate(gathered_pairs): + dst_param_metadata_by_rank[rank_id] = {m.resolved_name: m for m in dst_meta_list} + for metadata in src_meta_list: + src_param_metadata.setdefault(metadata.resolved_name, []).append(metadata) + return dst_param_metadata_by_rank, src_param_metadata + + +def build_plan_from_rosters( + dst_param_metadata_by_rank: dict[int, dict[str, ParameterMetadata]], + src_param_metadata: dict[str, list[ParameterMetadata]], + my_global_rank: int, +) -> ReshardPlan: + """Replay the deterministic global schedule and keep only this rank's ops. + + Pure and collective-free, so it can be tested or reused with preassembled + rosters without touching the process group. Live membership orchestration + is intentionally outside this module. + """ + my_plan = ReshardPlan([], []) + for ( + task_id, + dst_rank, + src_rank, + src_slice, + dst_slice, + src_metadata, + dst_metadata, + ) in _iter_global_transfer_ops(dst_param_metadata_by_rank, src_param_metadata): + if dst_rank == my_global_rank: + my_plan.recv_ops.append( + TransferOp( + param_name=dst_metadata.name, + peer_rank=src_rank, + is_send=False, + my_slice=dst_slice, + peer_slice=src_slice, + task_id=task_id, + ) + ) + if src_rank == my_global_rank: + my_plan.send_ops.append( + TransferOp( + param_name=src_metadata.name, + peer_rank=dst_rank, + is_send=True, + my_slice=src_slice, + peer_slice=dst_slice, + task_id=task_id, + ) + ) + + logger.info( + f"Rank {my_global_rank}: Built plan locally - {len(my_plan.recv_ops)} recvs, " + f"{len(my_plan.send_ops)} sends" + ) + return my_plan + + +def build_local_reshard_plan( src_module: torch.nn.Module, dst_module: torch.nn.Module, - num_experts: int = None, + num_experts: int | None = None, group=None, src_rank_offset: int = 0, dst_rank_offset: int = 0, ) -> ReshardPlan: """ - Centralized planning: Rank 0 builds complete plan for all ranks, then scatters. - - Supports None for src_module and/or dst_module to enable non-collocated mode: - - src_module=None: Rank doesn't have source model (destination-only) - - dst_module=None: Rank doesn't have destination model (source-only) - - Both provided: Rank has both models (collocated mode) - - Each rank provides metadata only for the models it owns, including parallel group - membership (tensor_parallel_group_ranks, expert_parallel_group_ranks, etc.). - This metadata is sufficient for rank 0 to build correct transfer plans without - requiring dummy models. + Build this rank's reshard plan locally: all-gather the parameter metadata, + replay the global schedule (see _iter_global_transfer_ops), and keep only the + ops where this rank is the sender or receiver. No rank-0 bottleneck and no + scatter, since sender and receiver derive matching task_ids from the same + metadata. + + The metadata gather (the one collective) and the plan build are split into + index_metadata_rosters + build_plan_from_rosters, so the deterministic build + can also be tested against preassembled rosters without a process group. + + src_module/dst_module may be None for non-collocated ranks (destination-only, + source-only, or idle). Each rank contributes metadata only for the models it + owns, including its parallel-group membership. """ - # Use group.rank() instead of dist.get_rank(group) to support cross-cluster - # ProcessGroups where members have independent default PGs (same default rank). + # group.rank()/size() (not dist.get_rank(group)) support cross-cluster PGs + # whose members have independent default PGs. my_global_rank = group.rank() if group is not None else dist.get_rank() world_size = group.size() if group is not None else dist.get_world_size() - # Shared cache for deduplicating rank lists across all metadata on this - # rank. Params sharing the same TP/DP/EP/PP groups will reference one - # list object, making pickle ~75% smaller for the gather. - _rank_list_cache: dict = {} - - def _extract_metadata(module, rank_offset): - """Extract per-parameter metadata from a module, or [] if module is None. - - Includes both ``nn.Parameter`` instances and persistent buffers — the - latter so that buffers carrying training state (e.g. MoE router - ``expert_bias``) travel with the weights during refit. - """ - if module is None: - return [] - pg = getattr(module, "pg_collection", None) - if pg is None: - raise ValueError("Module must have pg_collection") - layer_prefix_map = _build_layer_module_prefix_map(module) - return [ - extract_param_metadata( - p, - name, - my_global_rank, - pg, - num_experts=num_experts, - layer_module_prefix_map=layer_prefix_map, - rank_offset=rank_offset, - _rank_list_cache=_rank_list_cache, - ) - for name, p in named_refit_tensors(module) - ] - - my_src_metadata = _extract_metadata(src_module, src_rank_offset) - my_dst_metadata = _extract_metadata(dst_module, dst_rank_offset) - - # Gather (src, dst) tuples in one collective so we pay one pickle round-trip - # instead of two. Only rank 0 needs the full picture; other ranks just need - # their own plan from the later scatter. - gathered_pairs = [None] * world_size if my_global_rank == 0 else None - dist.gather_object((my_src_metadata, my_dst_metadata), gathered_pairs, group_dst=0, group=group) + # Dedup rank lists so params sharing a group reuse one list object; shrinks + # the pickled all-gather ~75%. + rank_list_cache: dict = {} + my_src_metadata = _extract_module_metadata( + src_module, my_global_rank, num_experts, src_rank_offset, rank_list_cache + ) + my_dst_metadata = _extract_module_metadata( + dst_module, my_global_rank, num_experts, dst_rank_offset, rank_list_cache + ) - # Free local metadata — no longer needed after gather. + # One all-gather gives every rank the full (src, dst) picture, replacing the + # gather-to-0 + scatter. + gathered_pairs = [None] * world_size + dist.all_gather_object(gathered_pairs, (my_src_metadata, my_dst_metadata), group=group) del my_src_metadata, my_dst_metadata - # Parameter to metadata maps keyed by resolved_name (only populated on rank 0) - dst_param_metadata_by_rank = {} - src_param_metadata: dict[str, list[ParameterMetadata]] = {} - - if my_global_rank == 0: - for rank_id, (src_meta_list, dst_meta_list) in enumerate(gathered_pairs): - dst_param_metadata_by_rank[rank_id] = {m.resolved_name: m for m in dst_meta_list} - for metadata in src_meta_list: - src_param_metadata.setdefault(metadata.resolved_name, []).append(metadata) - - # Free the raw gathered list — data is now in the indexed dicts. - del gathered_pairs - - # Build the plan on global rank 0 and broadcast to all ranks - if my_global_rank == 0: - plans_for_all_ranks = {r: ReshardPlan([], []) for r in range(world_size)} - # Global monotonically increasing ID for non-local transfers. - # This is shared between the corresponding send/recv ops so that - # NVSHMEM can build schedule. - next_task_id = 0 - - # Pipeline-parallel (PP) "mapping" is handled implicitly. - # Each rank contributes metadata only for the parameters it actually owns - # (i.e., the module partitioning for its PP stage). When PP sizes differ - # between source and destination, we don't compute an explicit stage-to-stage - # mapping here; instead, we iterate destination ranks and plan copies for the - # parameters present on those ranks. Any source rank that has the same logical - # parameter (matched by resolved_name) can serve as a sender (with DP balancing), - # and TP slicing is applied when applicable. - for dst_rank in range(world_size): - dst_rank_params = dst_param_metadata_by_rank.get(dst_rank, {}) - for resolved_name, dst_metadata in dst_rank_params.items(): - src_meta_list = src_param_metadata.get(resolved_name) - if not src_meta_list and resolved_name.endswith("output_layer.weight"): - # Tied embeddings: the source shares the output projection with - # the input embedding, so it has no separate output_layer.weight. - # A pp>1 destination materializes one (embedding and output land - # on different stages), e.g. pp=1 (tied) -> pp=2. Source it from - # the embedding weight (same shape + vocab/TP shard); that tensor - # then feeds both the destination embedding and output_layer. - for emb_name in ("embedding.word_embeddings.weight", "word_embeddings.weight"): - src_meta_list = src_param_metadata.get(emb_name) - if src_meta_list: - break - if not src_meta_list: - raise RuntimeError( - f"Destination parameter '{resolved_name}' on rank {dst_rank} " - "not found in source model." - ) - # Choose a representative source metadata with DP round-robin balancing - src_metadata = select_src_metadata_balanced(src_meta_list, dst_metadata, dst_rank) - sources = _determine_source_ranks_for_dst_param( - resolved_name, src_metadata, dst_metadata, dst_rank - ) - for src_rank, src_slice, dst_slice in sources: - task_id = next_task_id - next_task_id += 1 - - plans_for_all_ranks[dst_rank].recv_ops.append( - TransferOp( - param_name=dst_metadata.name, - peer_rank=src_rank, - is_send=False, - my_slice=dst_slice, - peer_slice=src_slice, - task_id=task_id, - ) - ) - plans_for_all_ranks[src_rank].send_ops.append( - TransferOp( - param_name=src_metadata.name, - peer_rank=dst_rank, - is_send=True, - my_slice=src_slice, - peer_slice=dst_slice, - task_id=task_id, - ) - ) - plans_list = [plans_for_all_ranks[r] for r in range(world_size)] - - # Free planning intermediates on rank 0 before the scatter. - del plans_for_all_ranks, dst_param_metadata_by_rank, src_param_metadata - else: - plans_list = None + dst_param_metadata_by_rank, src_param_metadata = index_metadata_rosters(gathered_pairs) + del gathered_pairs + return build_plan_from_rosters(dst_param_metadata_by_rank, src_param_metadata, my_global_rank) - # Scatter: each rank receives only its own plan (not all plans). - my_plan_list = [None] - torch.distributed.scatter_object_list(my_plan_list, plans_list, group_src=0, group=group) - my_plan = my_plan_list[0] - del plans_list # Free the full list on rank 0. - logger.info( - f"Rank {my_global_rank}: Received plan - {len(my_plan.recv_ops)} recvs, " - f"{len(my_plan.send_ops)} sends" +def build_centralized_reshard_plan( + src_module: torch.nn.Module, + dst_module: torch.nn.Module, + num_experts: int | None = None, + group=None, + src_rank_offset: int = 0, + dst_rank_offset: int = 0, +) -> ReshardPlan: + """Deprecated compatibility wrapper for :func:`build_local_reshard_plan`.""" + warnings.warn( + "build_centralized_reshard_plan is deprecated; use build_local_reshard_plan instead.", + DeprecationWarning, + stacklevel=2, + ) + return build_local_reshard_plan( + src_module, + dst_module, + num_experts=num_experts, + group=group, + src_rank_offset=src_rank_offset, + dst_rank_offset=dst_rank_offset, ) - - return my_plan diff --git a/megatron/core/resharding/refit.py b/megatron/core/resharding/refit.py index 624ffec58d0..5798b48c29a 100644 --- a/megatron/core/resharding/refit.py +++ b/megatron/core/resharding/refit.py @@ -21,16 +21,17 @@ from megatron.core.models.common.language_module.language_module import LanguageModule from megatron.core.utils import unwrap_model -from . import build_centralized_reshard_plan, execute_reshard_plan +from . import build_local_reshard_plan, execute_reshard_plan from .copy_services.base import CopyService from .copy_services.gloo_copy_service import GlooCopyService from .copy_services.nccl_copy_service import NCCLCopyService +from .copy_services.nixl_copy_service import NixlCopyService from .copy_services.nvshmem_copy_service import NVSHMEMCopyService from .transforms import MXFP8ReshardTransform, ReshardTransform from .utils import invalidate_refit_tensor_cache, named_persistent_buffers # Supported refit backend names -RefitBackendName = Literal["nccl", "gloo", "nvshmem"] +RefitBackendName = Literal["nccl", "gloo", "nvshmem", "nixl"] @dataclass(frozen=True) @@ -44,6 +45,9 @@ class _PlanCacheKey: src_config: Optional[Tuple[int, int, int, int, int]] dst_config: Optional[Tuple[int, int, int, int, int]] num_experts: Optional[int] + # Adding inference nodes leaves the configs and offsets unchanged, so without + # world_size the stale pre-growth plan would be reused. + world_size: int = 0 # Rank offsets distinguish non-collocated configurations that would otherwise # share the same (rank, sizes, num_experts) tuple but route to different # global ranks. @@ -89,13 +93,16 @@ def _build_plan_cache_key( pool_index: int = 0, ) -> _PlanCacheKey: """Build cache key for reshard plan.""" - # group.rank() supports cross-cluster ProcessGroups. + # group.rank()/size() support cross-cluster ProcessGroups where members + # have independent default PGs. rank = group.rank() if group is not None else torch.distributed.get_rank() + world_size = group.size() if group is not None else torch.distributed.get_world_size() return _PlanCacheKey( rank=rank, src_config=_get_config_tuple(src_core), dst_config=_get_config_tuple(tgt_core), num_experts=num_experts, + world_size=world_size, src_rank_offset=src_rank_offset, dst_rank_offset=dst_rank_offset, pool_index=pool_index, @@ -114,7 +121,7 @@ def get_or_create_service(backend: RefitBackendName, group=None) -> CopyService: when swap_model_weights is called multiple times with the same backend. Args: - backend: Backend name ("nccl", "gloo", or "nvshmem"). + backend: Backend name ("nccl", "gloo", "nvshmem", or "nixl"). group: Optional process group for NCCL backend. """ if backend in _service_cache: @@ -126,6 +133,8 @@ def get_or_create_service(backend: RefitBackendName, group=None) -> CopyService: service = GlooCopyService(group=group) elif backend == "nvshmem": service = NVSHMEMCopyService(group=group) + elif backend == "nixl": + service = NixlCopyService(group=group) else: raise ValueError(f"Unknown backend '{backend}'") @@ -202,7 +211,8 @@ def _build_or_get_plan( """Return the cached reshard plan, building it (collectively) if not yet cached. All participating ranks must call this simultaneously when the plan is not - yet cached, because build_centralized_reshard_plan uses collective communication. + yet cached, because build_local_reshard_plan uses collective communication + (an all_gather of parameter metadata). """ global _plan_cache cache_key = _build_plan_cache_key( @@ -215,7 +225,7 @@ def _build_or_get_plan( pool_index=pool_index, ) if cache_key not in _plan_cache: - _plan_cache[cache_key] = build_centralized_reshard_plan( + _plan_cache[cache_key] = build_local_reshard_plan( src_core, tgt_core, num_experts=num_experts, diff --git a/tests/unit_tests/resharding/test_copy_services.py b/tests/unit_tests/resharding/test_copy_services.py index 0fe9a40bf60..9e255f9fd9a 100644 --- a/tests/unit_tests/resharding/test_copy_services.py +++ b/tests/unit_tests/resharding/test_copy_services.py @@ -16,6 +16,7 @@ SendOp, match_local_ops_by_task_id, ) +from megatron.core.resharding.copy_services.nixl_copy_service import NixlCopyService def _t(): @@ -164,3 +165,8 @@ def close(self): assert svc.closed is False svc.close() assert svc.closed is True + + +def test_nixl_service_skips_redundant_process_group_barrier(): + """NIXL's ready/data protocol provides its own peer completion.""" + assert NixlCopyService.requires_process_group_barrier is False diff --git a/tests/unit_tests/resharding/test_execution.py b/tests/unit_tests/resharding/test_execution.py index 70d5d14dc03..5323efab82c 100644 --- a/tests/unit_tests/resharding/test_execution.py +++ b/tests/unit_tests/resharding/test_execution.py @@ -6,7 +6,7 @@ non-collocated mode handling. Requires CUDA (uses torch.cuda.synchronize). """ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest import torch @@ -262,6 +262,17 @@ def test_transform_not_called_for_non_matching(self): class TestEdgeCases: """Test edge cases in execute_reshard_plan.""" + def test_service_can_skip_process_group_barrier(self): + """A self-synchronizing backend does not use the executor's barrier.""" + Utils.initialize_distributed() + service = MockCopyService() + service.requires_process_group_barrier = False + + with patch("megatron.core.resharding.execution.dist.barrier") as barrier: + execute_reshard_plan(ReshardPlan(send_ops=[], recv_ops=[]), None, None, service) + + barrier.assert_not_called() + def test_empty_plan(self): """Empty plan (no ops) should complete without error.""" plan = ReshardPlan(send_ops=[], recv_ops=[]) diff --git a/tests/unit_tests/resharding/test_planner.py b/tests/unit_tests/resharding/test_planner.py index 55c4019dc09..fb8fcd6e9f9 100644 --- a/tests/unit_tests/resharding/test_planner.py +++ b/tests/unit_tests/resharding/test_planner.py @@ -11,10 +11,13 @@ import pytest +import megatron.core.resharding.planner as planner from megatron.core.resharding.planner import ( _build_descriptors_for_param, _finalize_dp_transfers, _plan_tp, + build_plan_from_rosters, + index_metadata_rosters, ) from megatron.core.resharding.utils import ParameterMetadata, ShardingDescriptor @@ -360,3 +363,100 @@ def test_missing_tp_ranks(self): descs = _build_descriptors_for_param(src, dst) assert descs == [] + + +# =========================================================================== +# build_plan_from_rosters (local, deterministic planning + node-add stability) +# =========================================================================== + + +def _plan_edges(plans): + """Collect (task_id, src_rank, dst_rank) transfers from a {rank: ReshardPlan}. + + Reads them once from every send op and once from every recv op; the two sets + must be equal for the plan to be consistent (a matching, same-task_id recv for + every send). + """ + sends = {(op.task_id, r, op.peer_rank) for r, p in plans.items() for op in p.send_ops} + recvs = {(op.task_id, op.peer_rank, r) for r, p in plans.items() for op in p.recv_ops} + return sends, recvs + + +def _build_all(gathered_pairs): + """Build every rank's plan from a rank-ordered list of (src_meta, dst_meta).""" + dst_by_rank, src_by_name = index_metadata_rosters(gathered_pairs) + return {rank: build_plan_from_rosters(dst_by_rank, src_by_name, rank) for rank in dst_by_rank} + + +def _recv_sig(plan): + """Identity of a plan's recv ops (task_id + slices), for stability comparisons.""" + return [(op.task_id, op.peer_rank, op.my_slice, op.peer_slice) for op in plan.recv_ops] + + +class TestBuildPlanFromRosters: + """Local plan building replayed independently per rank.""" + + def test_task_ids_match_across_ranks(self): + """Sender and receiver, planned independently, agree on task_id per transfer. + + rank 0 sources a replicated weight; ranks 1 and 2 each receive a full copy. + """ + gathered = [ + ([_meta(owner_rank=0, tp_ranks=[0], dp_ranks=[0])], []), # rank 0: source + ([], [_meta(owner_rank=1, tp_ranks=[1], dp_ranks=[1])]), # rank 1: dest + ([], [_meta(owner_rank=2, tp_ranks=[2], dp_ranks=[2])]), # rank 2: dest + ] + plans = _build_all(gathered) + sends, recvs = _plan_edges(plans) + + # Every send has a matching recv with the same task_id, and vice versa. + assert sends == recvs + # Two transfers: 0->1 and 0->2, with distinct task_ids. + assert len(sends) == 2 + assert {(s, d) for _, s, d in sends} == {(0, 1), (0, 2)} + assert len({tid for tid, _, _ in sends}) == 2 + + def test_node_add_keeps_existing_task_ids_stable(self): + """Appending a rank rebuilds locally without renumbering existing transfers.""" + base = [ + ([_meta(owner_rank=0, tp_ranks=[0], dp_ranks=[0])], []), + ([], [_meta(owner_rank=1, tp_ranks=[1], dp_ranks=[1])]), + ([], [_meta(owner_rank=2, tp_ranks=[2], dp_ranks=[2])]), + ] + before = _build_all(base) + + # A new destination rank 3 joins; everyone replays over the grown roster. + grown = base + [([], [_meta(owner_rank=3, tp_ranks=[3], dp_ranks=[3])])] + after = _build_all(grown) + + sends_after, recvs_after = _plan_edges(after) + assert sends_after == recvs_after + # Existing receivers keep the exact same recv ops (task_id + slices). + for rank in (1, 2): + assert _recv_sig(before[rank]) == _recv_sig(after[rank]) + # The new rank added exactly one transfer with a fresh task_id. + assert len(sends_after) == 3 + assert {(s, d) for _, s, d in sends_after} == {(0, 1), (0, 2), (0, 3)} + + +def test_centralized_planner_compatibility_wrapper(monkeypatch): + """The previous public planner name warns and forwards every argument.""" + sentinel = object() + forwarded = {} + + def fake_local(src_module, dst_module, **kwargs): + forwarded["args"] = (src_module, dst_module) + forwarded["kwargs"] = kwargs + return sentinel + + monkeypatch.setattr(planner, "build_local_reshard_plan", fake_local) + with pytest.warns(DeprecationWarning, match="build_local_reshard_plan"): + result = planner.build_centralized_reshard_plan( + "src", "dst", num_experts=8, group="group", src_rank_offset=3, dst_rank_offset=7 + ) + + assert result is sentinel + assert forwarded == { + "args": ("src", "dst"), + "kwargs": {"num_experts": 8, "group": "group", "src_rank_offset": 3, "dst_rank_offset": 7}, + } From 72411e513aa48367086f9b88e444dd4b5f78e64e Mon Sep 17 00:00:00 2001 From: Cory Ye <44509866+cspades@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:49:51 -0700 Subject: [PATCH 099/290] Fix FSDP2 SwiGLU checkpointing. (#5714) Signed-off-by: Cory Ye --- .../torch_fully_sharded_data_parallel.py | 2 + megatron/core/transformer/mlp.py | 95 +++++++++++- megatron/core/transformer/moe/experts.py | 6 +- megatron/core/utils.py | 35 ++++- .../test_torch_fully_sharded_parallel.py | 138 +++++++++++++++++- 5 files changed, 260 insertions(+), 16 deletions(-) diff --git a/megatron/core/distributed/torch_fully_sharded_data_parallel.py b/megatron/core/distributed/torch_fully_sharded_data_parallel.py index 5babb6312ac..cd8aab8aba6 100644 --- a/megatron/core/distributed/torch_fully_sharded_data_parallel.py +++ b/megatron/core/distributed/torch_fully_sharded_data_parallel.py @@ -96,6 +96,8 @@ def save_custom_attrs(module): # micro-batch id, thus removing unnecessary memory stores attrs['_fp8_attrs']['transpose_invalid'] = False del attrs['_fp8_attrs']['transpose'] + # Mark this parameter as an FSDP2 parameter. + attrs["is_torch_fsdp2_param"] = True custom_attrs[name] = {k: v for k, v in attrs.items()} return custom_attrs diff --git a/megatron/core/transformer/mlp.py b/megatron/core/transformer/mlp.py index 1a578151f1e..4c84e271ad3 100644 --- a/megatron/core/transformer/mlp.py +++ b/megatron/core/transformer/mlp.py @@ -26,9 +26,15 @@ from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.transformer_config import TransformerConfig -from megatron.core.transformer.utils import cat_with_oom_fallback, sharded_state_dict_default +from megatron.core.transformer.utils import ( + cat_with_oom_fallback, + ensure_metadata_has_dp_cp_group, + sharded_state_dict_default, +) from megatron.core.typed_torch import apply_module, not_none from megatron.core.utils import ( + get_pg_rank, + get_pg_size, get_tensor_model_parallel_group_if_none, nvtx_range_pop, nvtx_range_push, @@ -355,6 +361,7 @@ def sharded_state_dict( self, prefix: str = "", sharded_offsets: tuple = (), metadata: Optional[dict] = None ) -> ShardedStateDict: """Return the sharded state dictionary of the module.""" + metadata = ensure_metadata_has_dp_cp_group(metadata) sharded_state_dict = {} singleton_local_shards = (metadata or {}).get('singleton_local_shards', False) for name, module in self._modules.items(): @@ -365,7 +372,11 @@ def sharded_state_dict( for k, v in sub_sd.items(): if k in (f"{prefix}{name}.weight", f"{prefix}{name}.bias"): sub_sd[k] = apply_swiglu_sharded_factory( - v, sharded_offsets, singleton_local_shards + v, + sharded_offsets, + singleton_local_shards, + tp_group=self.tp_group, + dp_group=metadata['dp_cp_group'], ) sharded_state_dict.update(sub_sd) return sharded_state_dict @@ -404,7 +415,11 @@ def as_mlp_submodule( # pylint: disable=missing-function-docstring def apply_swiglu_sharded_factory( - original_sh_ten, sharded_offsets, singleton_local_shards: bool = False + original_sh_ten, + sharded_offsets, + singleton_local_shards: bool = False, + tp_group: torch.distributed.ProcessGroup | None = None, + dp_group: torch.distributed.ProcessGroup | None = None, ): # We must split the tensor into 2 parts, each sharded separately. # This requires a ShardedTensorFactory which `chunk`s during saving @@ -418,15 +433,24 @@ def apply_swiglu_sharded_factory( assert ( original_sh_ten.global_offset[swiglu_shard_axis + prepend_axis_num] % local_axis_size == 0 ) - rank_offset = ( - original_sh_ten.global_offset[swiglu_shard_axis + prepend_axis_num] // local_axis_size - ) axis_frag = original_sh_ten.axis_fragmentations[swiglu_shard_axis + prepend_axis_num] + # Only FSDP2 supports torch_dist ShardedTensor. (Add other DP sharding algos here if needed.) + is_torch_fsdp2_param = getattr(original_sh_ten, "is_torch_fsdp2_param", False) + if is_torch_fsdp2_param: + assert dp_group is not None + dp_size = get_pg_size(dp_group) + is_dp_sharded = dp_size > 1 + else: + is_dp_sharded = False + @torch.no_grad() def sh_ten_build_fn( key: str, t: torch.Tensor, replica_id: ReplicaId, flattened_range: Optional[slice] ): + rank_offset = ( + original_sh_ten.global_offset[swiglu_shard_axis + prepend_axis_num] // local_axis_size + ) if singleton_local_shards: offset_w = (swiglu_shard_axis + prepend_axis_num, rank_offset, axis_frag) offset_v = (swiglu_shard_axis + prepend_axis_num, rank_offset, axis_frag) @@ -462,10 +486,67 @@ def sh_ten_build_fn( ), ] + @torch.no_grad() + def dp_sh_ten_build_fn( + key: str, t: torch.Tensor, replica_id: ReplicaId, flattened_range: Optional[slice] + ): + assert not singleton_local_shards, ( + "FSDP does not support singleton ShardedTensor for SwiGLU fused FC1. " + "Set singleton_local_shards=False, which is the default in MCore." + ) + # FSDP shards the TP-local [W; V] SwiGLU FC1 tensor over DP along dim 0. + # TP sharding produces TP-sharded pairs of W/V, followed by DP sharding! + assert tp_group is not None and dp_group is not None + tp_size = get_pg_size(tp_group) + global_axis = swiglu_shard_axis + prepend_axis_num + tp_rank = get_pg_rank(tp_group) + dp_rank = get_pg_rank(dp_group) + # Size of a TP shard for W + V. + tp_local_axis_size = original_sh_ten.global_shape[global_axis] // tp_size + assert tp_local_axis_size % 2 == 0 # W and V should be symmetrically shaped. + # Size of a TP shard for W or V. "Half" size TP-shard. + half_axis_size = tp_local_axis_size // 2 + # Check that the TP-local W or V is cleanly divisible by DP. + assert half_axis_size % local_axis_size == 0, ( + "SwiGLU FC1 FSDP ShardedTensor requires each DP shard of " + "linear_fc1 to be completely inside either the W or V half." + ) + # Number of DP shards per W or V TP-shard, and make sure + # that DP sharding spans both W and V. + shards_per_half = half_axis_size // local_axis_size + assert dp_size == 2 * shards_per_half + + # Compute if DP rank maps to W or V in [W; V]. + swiglu_half_idx, half_dp_shard_idx = divmod(dp_rank, shards_per_half) + # If W, then 0. If V, then 1. + assert swiglu_half_idx in (0, 1) + # Map [ W; V ] to this rank's shard [ {W_tpx; V_tpx}_dpy ]. + shard_rank_offset = ( + # W or V half of the [W; V] global data. + swiglu_half_idx * tp_size * shards_per_half + # TP Shard Index + + tp_rank * shards_per_half + # TP-DP Shard Index + + half_dp_shard_idx + ) + + return [ + ShardedTensor.from_rank_offsets( + key, + t, + *sharded_offsets, + (global_axis, shard_rank_offset, axis_frag), + replica_id=replica_id, + prepend_axis_num=prepend_axis_num, + ) + ] + + # Construct a ShardedTensorFactory. + sh_ten_factory_build_function = dp_sh_ten_build_fn if is_dp_sharded else sh_ten_build_fn return ShardedTensorFactory( original_sh_ten.key, original_sh_ten.data, - sh_ten_build_fn, + sh_ten_factory_build_function, cat_with_oom_fallback, original_sh_ten.replica_id, flattened_range=original_sh_ten.flattened_range, diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index 090e37c4f3e..197115ebb10 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -901,7 +901,11 @@ def sharded_state_dict( for k in (f'{name}.weight{i}', f'{name}.bias{i}'): if k in sub_sd: sub_sd[k] = apply_swiglu_sharded_factory( - sub_sd[k], new_sharded_offsets, singleton_local_shards + sub_sd[k], + new_sharded_offsets, + singleton_local_shards, + tp_group=self.tp_group, + dp_group=metadata['dp_cp_group'], ) if singleton_local_shards: replace_prefix_for_sharding(sub_sd, '', f'{prefix}experts.') diff --git a/megatron/core/utils.py b/megatron/core/utils.py index 2cc5d635f48..2e2f6184733 100644 --- a/megatron/core/utils.py +++ b/megatron/core/utils.py @@ -1008,8 +1008,11 @@ def make_tp_sharded_tensor_for_checkpoint( new_offsets.append((tp_axis + prepend_axis_num, tp_rank, tp_size)) - if HAVE_DTENSOR and isinstance(tensor, DTensor): - # TP + FSDP2 sharding + is_torch_fsdp2_param = ( + hasattr(tensor, "is_torch_fsdp2_param") and HAVE_DTENSOR and isinstance(tensor, DTensor) + ) + if is_torch_fsdp2_param: + # When using FSDP2, every DP shard is a main replica. dp_replica_id = 0 tensor = tensor._local_tensor @@ -1025,7 +1028,7 @@ def make_tp_sharded_tensor_for_checkpoint( if replica_id is None: replica_id = (0, 0, dp_replica_id) - return ShardedTensor.from_rank_offsets( + sharded_tensor = ShardedTensor.from_rank_offsets( key, tensor, *prepend_offsets, @@ -1034,6 +1037,11 @@ def make_tp_sharded_tensor_for_checkpoint( prepend_axis_num=prepend_axis_num, **kwargs, ) + if is_torch_fsdp2_param: + # Marker used downstream for FSDP2-related logic, such as TP-DP + # sharding / loading for non-trivial parameters like SwiGLU. + sharded_tensor.is_torch_fsdp2_param = is_torch_fsdp2_param + return sharded_tensor def make_sharded_tensor_for_checkpoint(tensor, key, prepend_offsets=(), replica_id=None, **kwargs): @@ -1069,16 +1077,20 @@ def make_sharded_tensor_for_checkpoint(tensor, key, prepend_offsets=(), replica_ dp_size = get_pg_size(dp_cp_group) dp_replica_id = get_pg_rank(dp_cp_group) - if HAVE_DTENSOR and isinstance(tensor, DTensor): - # FSDP2 sharding + is_torch_fsdp2_param = ( + hasattr(tensor, "is_torch_fsdp2_param") and HAVE_DTENSOR and isinstance(tensor, DTensor) + ) + if is_torch_fsdp2_param: + # When using FSDP2, every DP shard is a main replica. dp_replica_id = 0 tensor = get_full_tensor_if_necessary(tensor) + # Add FSDP sharding rank offsets. new_offsets.append((prepend_axis_num, dp_rank, dp_size)) if replica_id is None: replica_id = (0, get_pg_rank(tp_group), dp_replica_id) - return ShardedTensor.from_rank_offsets( + sharded_tensor = ShardedTensor.from_rank_offsets( key, tensor, *prepend_offsets, @@ -1087,10 +1099,19 @@ def make_sharded_tensor_for_checkpoint(tensor, key, prepend_offsets=(), replica_ prepend_axis_num=prepend_axis_num, **kwargs, ) + if is_torch_fsdp2_param: + # Marker used downstream for FSDP2-related logic, such as TP-DP + # sharding / loading for non-trivial parameters like SwiGLU. + sharded_tensor.is_torch_fsdp2_param = is_torch_fsdp2_param + return sharded_tensor def get_full_tensor_if_necessary(tensor): - """For DTensor gets full tensor if some ranks will not have a local copy""" + """ + Captures an edge case where devices out-number elements in a DTensor, + for instance when generating a ShardedTensor. Replicate the DTensor + on all ranks to avoid empty DTensors on any rank. + """ need_full_tensor = False for i in range(tensor.device_mesh.ndim): if ( diff --git a/tests/unit_tests/distributed/test_torch_fully_sharded_parallel.py b/tests/unit_tests/distributed/test_torch_fully_sharded_parallel.py index 6a50e8d1aa5..e1aa1a60b78 100644 --- a/tests/unit_tests/distributed/test_torch_fully_sharded_parallel.py +++ b/tests/unit_tests/distributed/test_torch_fully_sharded_parallel.py @@ -11,14 +11,36 @@ init_num_microbatches_calculator, unset_num_microbatches_calculator, ) +from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel import ColumnParallelLinear from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer import MegatronModule +from megatron.core.transformer.mlp import apply_swiglu_sharded_factory from megatron.core.transformer.module import Float16Module from megatron.core.transformer.transformer_config import TransformerConfig -from megatron.core.utils import init_method_normal, is_torch_min_version +from megatron.core.utils import ( + init_method_normal, + is_torch_min_version, + make_tp_sharded_tensor_for_checkpoint, +) from tests.unit_tests.test_utilities import Utils +try: + from torch.distributed import DeviceMesh + from torch.distributed.tensor import DTensor + from torch.distributed.tensor.placement_types import Shard + + HAVE_DTENSOR = True +except ImportError: + HAVE_DTENSOR = False + +try: + import einops + + HAVE_EINOPS = True +except ImportError: + HAVE_EINOPS = False + class DummyModel(MegatronModule): """Setup a few modules to test the FSDP2 constructor.""" @@ -106,3 +128,117 @@ def _is_fsdp_wrapped_module(instance): assert _is_fsdp_wrapped_module(fsdp_model.module.module.linear) assert _is_fsdp_wrapped_module(fsdp_model.module.module.column_parallel_linear) assert not _is_fsdp_wrapped_module(fsdp_model.module.module.conv) + + +@pytest.mark.skipif(not is_torch_min_version("2.4.0"), reason="FSDP2 requires PyTorch >= 2.4") +@pytest.mark.skipif(not HAVE_EINOPS, reason="einops is not available") +@pytest.mark.skipif(not HAVE_DTENSOR, reason="DTensor is not available") +def test_fsdp2_swiglu_sharded_tensor_factory(): + """ + Test construction of a TP2 DP{N} ShardedTensor for SwiGLU. + """ + # Initialize distributed with TP2. + Utils.initialize_model_parallel(tensor_model_parallel_size=2, pipeline_model_parallel_size=1) + pg_collection = ProcessGroupCollection.use_mpu_process_groups() + tp_group = pg_collection.tp + tp_size = tp_group.size() + tp_rank = tp_group.rank() + dp_cp_group = pg_collection.dp_cp + dp_cp_size = dp_cp_group.size() + dp_cp_rank = dp_cp_group.rank() + + # Create FSDP2 DTensor with DP only. (Implicitly TP-sharded before FSDP2 init.) + device_mesh = DeviceMesh.from_group(dp_cp_group, device_type="cuda", mesh_dim_names=["dp_cp"]) + toy_dtensor = DTensor.from_local( + torch.randn(2, 8), device_mesh=device_mesh, placements=(Shard(dim=0),) + ) + toy_dtensor.is_torch_fsdp2_param = True + + # Initialize TP-DP ShardedTensor. + tp_dp_sh_ten = make_tp_sharded_tensor_for_checkpoint( + toy_dtensor, + "test_fsdp2_tp_swiglu_weight", + # SwiGLU FC1 TP & FSDP2 Sharding Dim + tp_axis=0, + replica_id=None, + prepend_offsets=(), + tp_group=tp_group, + dp_cp_group=dp_cp_group, + ) + """ + Before TP2-DP4 Swizzle (TP Rank 1, DP Rank 1): + (Pdb) tp_dp_sh_ten + ShardedTensor( + local_shape=(2, 8), + global_shape=(16, 8), + # Canonical Data Offset = 2 * (tp_rank * dp_cp_size + dp_cp_rank) + # = 2 * (5) = 10 + global_offset=(10, 0), + axis_fragmentations=(8, 1), + replica_id=(0, 0, 0), + prepend_axis_num=0 + ) + """ + + # Test SwiGLU factory for FSDP2-TP. + swiglu_sh_ten_factory = apply_swiglu_sharded_factory( + tp_dp_sh_ten, + sharded_offsets=(), # Vanilla MLP. + # Fused W/V. + singleton_local_shards=False, + tp_group=tp_group, + dp_group=dp_cp_group, + ) + sh_ten_shards = swiglu_sh_ten_factory.build() + + """ + After TP2-DP4 Swizzle (TP Rank 1, DP Rank 1): + (Pdb) sh_ten_shards[0] + ShardedTensor( + local_shape=(2, 8), + global_shape=(16, 8), + global_offset=(6, 0), # W/V TP-swizzled, DP-sharded! + axis_fragmentations=(8, 1), + replica_id=(0, 0, 0), + prepend_axis_num=0 + ) + + This is a mapping from the checkpoint [W;V] rank offsets: + + W_tp0_dp0 W_tp0_dp1 W_tp1_dp0 W_tp1_dp1 V_tp0_dp2 V_tp0_dp3 V_tp1_dp2 V_tp1_dp3 + 0 1 2 3 4 5 6 7 + | + Data Offset = 3 * 2 = 6 is just the rank offset x local shape. + + to the model [ {W_tpx; V_tpx}_dpy ] rank offsets: + + W_tp0_dp0 W_tp0_dp1 V_tp0_dp2 V_tp0_dp3 W_tp1_dp0 W_tp1_dp1 V_tp1_dp2 V_tp1_dp3 + """ + + # Validate FSDP2 TP-DP sharding and swizzle. + assert getattr(tp_dp_sh_ten, "is_torch_fsdp2_param", False) + assert len(sh_ten_shards) == 1 + shard = sh_ten_shards[0] + toy_tensor_shape = toy_dtensor.to_local().shape + assert shard.axis_fragmentations[0] == tp_size * dp_cp_size + assert shard.global_shape[0] == tp_size * dp_cp_size * toy_tensor_shape[0] + # Expected global data offsets considering the parallelism ranks and tensor shape. + expected_global_rank_offsets = { + # (TP Rank, DP Rank) -> Global Data Rank Location / Offset + (0, 0): 0, + (0, 1): 1, + (0, 2): 4, + (0, 3): 5, + (1, 0): 2, + (1, 1): 3, + (1, 2): 6, + (1, 3): 7, + } + assert ( + shard.global_offset[0] + == expected_global_rank_offsets[(tp_rank, dp_cp_rank)] * toy_tensor_shape[0] + ) + + # Destroy distributed. + Utils.destroy_model_parallel() + unset_num_microbatches_calculator() From 7ee25803b3c79e39f9a9df66f3aa9ca63c0800c2 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 25 Jul 2026 00:27:14 +0000 Subject: [PATCH 100/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index 7cfe209b721..5043e800188 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "HollowMan6", "ISEEKYAN", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From 650b783826bb050eb49b29a0b8b34f1007e297c6 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Jul 2026 09:33:10 -0700 Subject: [PATCH 101/290] Prevent FlashInfer sampling from running with CUDA graphs (#5791) Signed-off-by: Keshav Santhanam Signed-off-by: Siddharth Singh Co-authored-by: Siddharth Singh --- megatron/core/inference/config.py | 15 +- megatron/core/inference/sampling/base.py | 19 ++- .../inference/sampling/flashinfer_sampling.py | 114 ++++++++----- .../core/inference/sampling/torch_sampling.py | 8 +- .../text_generation_controller.py | 82 +++++---- megatron/training/arguments.py | 10 -- megatron/training/config/inference_config.py | 6 +- .../test_inference_regular_pipeline.py | 3 +- .../golden_values_dev_dgx_h100.json | 156 +++++++++--------- .../test_text_generation_controller.py | 63 +++++++ 10 files changed, 300 insertions(+), 176 deletions(-) diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index 1ee1f64f1db..a2ae3182b9a 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -1,5 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import warnings from dataclasses import InitVar, dataclass from enum import Enum from typing import List, Literal, Optional, Tuple @@ -367,7 +368,8 @@ class InferenceConfig: """ sampling_backend: Literal['torch', 'flashinfer'] = 'torch' - """Which sampling kernels to use during inference.""" + """Which sampling kernels to use during inference. Falls back to "torch" with a warning if + "flashinfer" is requested but the package is not installed.""" async_sched_mode: AsyncScheduleMode = AsyncScheduleMode.LEGACY """Mode used to schedule dynamic batching inference work.""" @@ -435,8 +437,9 @@ def __post_init__(self, verbose: bool): if self.sampling_backend == 'flashinfer': try: import flashinfer # noqa: F401 - except ImportError as e: - raise ImportError( - "sampling_backend='flashinfer' requires the flashinfer package; " - "install it or set sampling_backend='torch'." - ) from e + except ImportError: + warnings.warn( + "sampling_backend='flashinfer' was requested but the flashinfer " + "package is not installed; falling back to sampling_backend='torch'." + ) + self.sampling_backend = 'torch' diff --git a/megatron/core/inference/sampling/base.py b/megatron/core/inference/sampling/base.py index dceebb060a8..9092e0130e0 100644 --- a/megatron/core/inference/sampling/base.py +++ b/megatron/core/inference/sampling/base.py @@ -21,6 +21,8 @@ def sample_kernel( n: int, context, *, + no_top_k: bool, + no_top_p: bool, gather_indices: Optional[Tensor] = None, token_to_request_index: Optional[Tensor] = None, eager: bool = False, @@ -32,13 +34,17 @@ def sample_kernel( logits: Logits tensor of shape `[>=n, vocab_size]`. n: Number of rows to sample. context: The active DynamicInferenceContext. + no_top_k, no_top_p: Required batch-level dispatch flags (whether NO active + request uses top-k / top-p). The caller computes them once from the + pinned CPU sampling metadata (see the controller's + `_active_requests_sampling_filter_flags`), so the kernel never has to. gather_indices: If provided, only sample from `logits[gather_indices[:n], :]`. token_to_request_index: Per-token request mapping; when set, sampling parameters are gathered per-token instead of per-request. - eager, cache_key: Consumed by `CudaGraphManager` when it wraps this kernel. + eager, cache_key: Accepted for API symmetry; ignored (no CUDA graph). Returns: - Sampled token ids of shape `[n]`. Under CUDA graph replay, this is a static buffer. + Sampled token ids of shape `[n]`. """ ... @@ -80,10 +86,19 @@ def sample_speculative( torch.arange(num_decode, num_decode + num_prefill, device=device), ] ) + # Batch-level dispatch flags, required by `sample_kernel`. Read from the same + # pinned CPU sampling metadata as the controller's filter flags (sync-free): a + # filter is absent only when NO active request uses it. + active_request_count = context.total_request_count - context.paused_request_count + md = context.active_request_metadata + no_top_k = bool((md["top_k"][:active_request_count] == 0).all()) + no_top_p = bool((md["top_p"][:active_request_count] == 0.0).all()) return self.sample_kernel( required_logits, num_tokens, context, + no_top_k=no_top_k, + no_top_p=no_top_p, gather_indices=gather_indices, token_to_request_index=token_to_request_index, eager=True, diff --git a/megatron/core/inference/sampling/flashinfer_sampling.py b/megatron/core/inference/sampling/flashinfer_sampling.py index f7b85a8836e..399135b1a64 100644 --- a/megatron/core/inference/sampling/flashinfer_sampling.py +++ b/megatron/core/inference/sampling/flashinfer_sampling.py @@ -11,32 +11,33 @@ flashinfer = None from megatron.core.inference.sampling.base import Sampling -from megatron.core.transformer.cuda_graphs import CudaGraphManager class FlashInferSampling(Sampling): - """Fused FlashInfer sampling, with optional CUDA graph capture/replay.""" + """FlashInfer sampling with per-step top-p-only / top-k-only / joint dispatch. + + Each step selects a kernel from the batch's active filters: the dedicated exact + top-p or top-k kernel when only one filter is in use, and the joint kernel only + for genuinely mixed batches. The dispatch flags are read from the pinned CPU + sampling metadata, so evaluating them costs no GPU sync. + + The sampler runs eagerly. Its kernel choice is data-dependent (it varies with + which filters the batch uses), so it cannot be captured in a CUDA graph; running + eagerly also lets the controller's seeded RNG generator advance its philox offset + normally between steps -- fresh randomness per step, reproducible from the seed. + (FlashInfer bakes the philox state into a graph as a by-value constant at capture, + so a captured sampler replays identical random numbers; see + https://www.linkedin.com/pulse/pinned-rng-drifting-crash-from-cuda-graph-chenyang-zhao-csuac/) + """ def __init__( self, vocab_size: int, rng: torch.Generator, config=None, enable_cuda_graph: bool = False ) -> None: + # `config` / `enable_cuda_graph` are accepted for factory API symmetry but + # intentionally unused: the sampler is never graphed (see class docstring). + del config, enable_cuda_graph self._vocab_size = vocab_size self._rng = rng - if enable_cuda_graph and config is not None and config.cuda_graph_impl == "local": - CudaGraphManager( - config, - self, - function_name="sample_kernel", - need_backward=False, - inline_capture=True, - ) - CudaGraphManager( - config, - self, - function_name="sample_speculative", - need_backward=False, - inline_capture=True, - ) def sample_kernel( self, @@ -44,31 +45,35 @@ def sample_kernel( n: int, context, *, + no_top_k: bool, + no_top_p: bool, gather_indices: Optional[Tensor] = None, token_to_request_index: Optional[Tensor] = None, eager: bool = False, cache_key: Any = None, ) -> Tensor: - """FlashInfer fused top-k / top-p sampling kernel. + """Sample tokens, dispatching top-p-only / top-k-only / joint by filter flags. Args: logits: Logits tensor of shape `[>=n, vocab_size]`. n: Number of rows to sample. context: The active DynamicInferenceContext. + no_top_k, no_top_p: Required batch-level dispatch flags (whether NO active + request uses top-k / top-p). The caller computes them once from the + pinned CPU sampling metadata (the controller's + `_active_requests_sampling_filter_flags`). gather_indices: When set, sample from `logits[gather_indices[:n], :]`. - token_to_request_index: When set, sampling parameters are gathered per-token - rather than per-request (used by the speculative path). - eager, cache_key: Consumed by `CudaGraphManager` when it wraps this kernel. + token_to_request_index: When set, sampling parameters are gathered + per-token rather than per-request (speculative decoding path). + eager, cache_key: Accepted for API symmetry; ignored (no CUDA graph). Returns: - Sampled token ids of shape `[n]`. Under CUDA graph replay, this is a static buffer. + Sampled token ids of shape `[n]`. """ - # CudaGraphManager consumes these args, if it exists. del eager, cache_key - # Read GPU sampling parameters from the per-step gpu_view mirror. The - # CPU source-of-truth (`active_request_metadata`) is pinned but resident - # on CPU, so reading it here would mix devices with `logits`. + # Per-row sampling params (GPU) for the kernel. gpu_view mirrors the pinned + # CPU `active_request_metadata` via the per-step coalesced H2D. gv = context.gpu_view if token_to_request_index is None: temperature = gv.temperature[:n] @@ -79,26 +84,53 @@ def sample_kernel( top_k = gv.top_k[token_to_request_index] top_p = gv.top_p[token_to_request_index] - # Clamp temperature to avoid division by 0. + # Temperature scale. `temperature` is a float32 tensor, so `bf16 logits / + # temperature` promotes `scaled` to fp32 -- the softmax / nucleus math must + # run in fp32 (a bf16 softmax over the vocab loses precision in exactly the + # tail region top-p depends on). The assert pins that guarantee. temperature = temperature.clamp(min=1e-6) if gather_indices is None: scaled = logits[:n] / temperature.unsqueeze(1) else: scaled = logits[gather_indices[:n], :] / temperature.unsqueeze(1) - probs = torch.softmax(scaled, dim=-1) - - # Sentinel values disable filtering: - # top_k=vocab_size keeps all tokens, top_p=1.0 keeps the full probability mass. - # TODO: Consider changing the disable flags in the `InferenceRequest`. - top_k_safe = top_k.masked_fill(top_k == 0, self._vocab_size) - top_p_safe = top_p.masked_fill(top_p == 0.0, 1.0) - output = torch.empty(n, device=logits.device, dtype=torch.int64) - output.copy_( - flashinfer.sampling.top_k_top_p_sampling_from_probs( - probs, top_k_safe, top_p_safe, generator=self._rng - ) - ) - return output + assert scaled.dtype == torch.float32, f"sampling math must be fp32, got {scaled.dtype}" + + # `no_top_k` / `no_top_p` are the caller-supplied batch-level dispatch flags: + # a filter is absent only when NO active request uses it. Per-row sentinels + # disable a filter for a row (top_k=vocab keeps all tokens, top_p=1.0 keeps + # the full mass). Every kernel gets `self._rng` so sampling is seeded and its + # philox offset advances per launch. + if no_top_k and no_top_p: + # No nucleus / top-k filtering: sample the full temperature-scaled + # distribution. Use FlashInfer's kernel rather than torch.multinomial: + # multinomial forces a device-to-host sync, whereas sampling_from_probs + # stays on-device and keeps the RNG's philox offset advancing per launch. + probs = torch.softmax(scaled, dim=-1) + return flashinfer.sampling.sampling_from_probs( + probs, deterministic=True, generator=self._rng + ).long() + elif no_top_k: + # Top-p only -> dedicated exact nucleus kernel. + probs = torch.softmax(scaled, dim=-1) + top_p_safe = top_p.masked_fill(top_p == 0.0, 1.0) + return flashinfer.sampling.top_p_sampling_from_probs( + probs, top_p_safe, deterministic=True, generator=self._rng + ).long() + elif no_top_p: + # Top-k only -> dedicated exact top-k kernel. + probs = torch.softmax(scaled, dim=-1) + top_k_safe = top_k.masked_fill(top_k == 0, self._vocab_size) + return flashinfer.sampling.top_k_sampling_from_probs( + probs, top_k_safe, deterministic=True, generator=self._rng + ).long() + else: + # Mixed batch (some top-k, some top-p, or requests using both) -> joint + # kernel, fed the temperature-scaled logits. + top_k_safe = top_k.masked_fill(top_k == 0, self._vocab_size) + top_p_safe = top_p.masked_fill(top_p == 0.0, 1.0) + return flashinfer.sampling.top_k_top_p_sampling_from_logits( + scaled, top_k_safe, top_p_safe, deterministic=True, generator=self._rng + ).long() def log_probs_kernel( self, logits: Tensor, temperature: Tensor, top_k: Tensor, top_p: Tensor diff --git a/megatron/core/inference/sampling/torch_sampling.py b/megatron/core/inference/sampling/torch_sampling.py index f7f6f8cb662..b4f8f1acc4b 100644 --- a/megatron/core/inference/sampling/torch_sampling.py +++ b/megatron/core/inference/sampling/torch_sampling.py @@ -144,6 +144,8 @@ def sample_kernel( n: int, context, *, + no_top_k: bool, + no_top_p: bool, gather_indices: Optional[Tensor] = None, token_to_request_index: Optional[Tensor] = None, eager: bool = False, @@ -155,6 +157,9 @@ def sample_kernel( logits: Logits tensor of shape `[>=n, vocab_size]`. n: Number of rows to sample. context: The active DynamicInferenceContext. + no_top_k, no_top_p: Batch-level dispatch flags (part of the shared kernel + contract); ignored here since the exact per-bucket sort already handles + any top-k / top-p combination. gather_indices: When set, sample from `logits[gather_indices[:n], :]`. token_to_request_index: When set, the loop dispatches per-token rather than per-request (used by the speculative path). @@ -164,8 +169,7 @@ def sample_kernel( Returns: Sampled token ids of shape `[n]`. """ - # CudaGraphManager consumes these args, if it exists. - del eager, cache_key + del eager, cache_key, no_top_k, no_top_p # Group active requests into sampling buckets by (temperature, top_k, top_p). active_request_count = context.total_request_count - context.paused_request_count diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index d265ca5fa23..99e818c141b 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -804,10 +804,13 @@ def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: Returns: Tensor: Sampled tokens of shape [num_requests]. """ + no_top_k, no_top_p = self._active_requests_sampling_filter_flags() return self._sampling.sample_kernel( logits_2d, logits_2d.shape[0], self.inference_wrapped_model.inference_context, + no_top_k=no_top_k, + no_top_p=no_top_p, eager=True, ) @@ -988,24 +991,17 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, input_ids: Tensor): context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count - # Sampling-side request counts: padded when running a captured graph. - # Verify uses the actual counts so the Triton kernels operate on the real workload. - use_graph_for_sampling = ( - self._sampling_backend == "flashinfer" - and self._enable_cuda_graph - and context.using_cuda_graph_this_step() - ) - if use_graph_for_sampling: - sample_num_decode = context.padded_batch_dimensions.decode_req_count - sample_num_prefill = context.padded_batch_dimensions.prefill_req_count - else: - sample_num_decode = context.num_decode_requests - sample_num_prefill = context.num_prefill_requests + # The FlashInfer sampler runs eagerly (never CUDA-graphed), so verify with the + # actual request counts. When the forward pass is graphed `required_logits` is + # padded to a static shape, but sampling only the actual token prefix (below) + # leaves the trailing padded rows unsampled. + sample_num_decode = context.num_decode_requests + sample_num_prefill = context.num_prefill_requests # Logit indices for tokens that need sampling. - # Padded under graph capture so the captured `gather_indices` input has a stable shape. - # Padded slots resolve to row 0; verify and prepare-next read only the actual prefix, - # so the padded-row samples produced by the captured kernel are discarded. + # `speculative_required_logit_indices()` pads to a static shape when the forward + # pass is graphed (trailing slots resolve to row 0); sampling uses the actual + # counts, so those padded slots are never sampled. nvtx_range_push("mtp-spec-decoding/verify/logit-indices") # Use pre-allocated buffer for CUDA graph compatibility. logits = self._all_logits_cuda @@ -1035,12 +1031,6 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, input_ids: Tensor): self.num_speculative_tokens, context, gather_indices=sample_gather_indices, - eager=not use_graph_for_sampling, - cache_key=( - ("sample_speculative", sample_num_decode, sample_num_prefill) - if use_graph_for_sampling - else None - ), ) nvtx_range_pop("mtp-spec-decoding/verify/sample") @@ -1113,13 +1103,9 @@ def _dynamic_step_sample_logits(self): context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count - use_graph = ( - self._sampling_backend == "flashinfer" - and self._enable_cuda_graph - and context.using_cuda_graph_this_step() - ) - # Padded count when running a captured graph (cache key buckets); actual otherwise. - n = context.padded_active_request_count if use_graph else active_request_count + # The FlashInfer sampler runs eagerly (never CUDA-graphed), so sample the + # actual active rows -- there is no captured static shape to pad up to. + n = active_request_count # When `materialize_only_last_token_logits` is true the forward pass already # selected the right rows. Otherwise we point the kernel at the per-request # last-token positions via `gather_indices`; padded slots safely fan in to row 0. @@ -1128,17 +1114,45 @@ def _dynamic_step_sample_logits(self): if context.config.materialize_only_last_token_logits else context.gpu_view.active_request_last_token_idxs ) - sampled_tokens_cuda = self._sampling.sample_kernel( + no_top_k, no_top_p = self._active_requests_sampling_filter_flags(active_request_count) + sampled_tokens = self._sampling.sample_kernel( self._all_logits_cuda.squeeze(0), n, context, gather_indices=gather_indices, - eager=not use_graph, - cache_key=("sample", n) if use_graph else None, + no_top_k=no_top_k, + no_top_p=no_top_p, ) - self._sampled_tokens_cuda[:active_request_count].copy_( - sampled_tokens_cuda[:active_request_count] + # Copy into the stable `max_requests` buffer rather than rebinding it. The spec + # (`sampled_tokens_buf`) and async-scheduling (`torch.max(out=...)`) paths both + # write into this buffer in place and rely on its address staying fixed, so this + # path must keep the same contract (see the `__init__` allocation comment). + self._sampled_tokens_cuda[:n].copy_(sampled_tokens) + + def _active_requests_sampling_filter_flags( + self, active_request_count: Optional[int] = None + ) -> Tuple[bool, bool]: + """Return ``(no_top_k, no_top_p)`` batch-level escape hatches for the active batch. + + These drive the FlashInfer sampler's dispatch (top-p-only / top-k-only / + joint) and are read from the pinned CPU sampling metadata, so they incur no + GPU sync. A filter is "absent" only when NO active request uses it. Padded + rows carry a neutral 0 and never flip a flag. + """ + context = self.inference_wrapped_model.inference_context + active_request_count = ( + context.total_request_count - context.paused_request_count + if active_request_count is None + else active_request_count ) + if active_request_count <= 0: + return True, True + + active_metadata = context.active_request_metadata + active_slice = slice(0, active_request_count) + no_top_k = bool((active_metadata["top_k"][active_slice] == 0).all()) + no_top_p = bool((active_metadata["top_p"][active_slice] == 0.0).all()) + return no_top_k, no_top_p def _dynamic_step_log_probs_bookkeeping(self) -> Tuple[bool, bool]: """Perform bookkeeping necessary to compute log probs for dynamic batching. diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index f38d02671e6..9ecc061fd2b 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1059,16 +1059,6 @@ def validate_args(args, defaults={}): ): raise ValueError("MXFP8 with inference optimized layers requires FlashInfer >= 0.6.4") - if args.inference_dynamic_batching_sampling_backend == 'flashinfer': - try: - import flashinfer # noqa: F401 - except ImportError as e: - raise ImportError( - "--inference-dynamic-batching-sampling-backend=flashinfer requires " - "the flashinfer package; install it or pass " - "--inference-dynamic-batching-sampling-backend=torch." - ) from e - if args.use_megatron_fsdp: # NOTE: The flag `use_custom_fsdp` is deprecated and will be removed in future versions. # Please use `use_megatron_fsdp` instead, as all functionality will be migrated there. diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index 5144ded2b34..5c8f34e456f 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -132,9 +132,9 @@ class InferenceSetupConfig: down to tp_size, giving a log-spaced distribution with bounded relative padding. "linear" uses varying linear strides across the range.""" - inference_dynamic_batching_sampling_backend: Literal["torch", "flashinfer"] = "torch" - """Which sampling kernels to use during inference. Falls back to "torch" with a warning if - "flashinfer" is requested but the package is not installed.""" + inference_dynamic_batching_sampling_backend: Literal["torch", "flashinfer"] = "flashinfer" + """Which sampling kernels to use during inference. Defaults to "flashinfer" and falls back to + "torch" with a warning if the flashinfer package is not installed.""" inference_dynamic_batching_async_sched_mode: Literal["legacy", "serial", "overlap"] = "legacy" """Async scheduling mode for dynamic batching. "legacy" (default) preserves the diff --git a/tests/functional_tests/python_test_utils/test_inference_regular_pipeline.py b/tests/functional_tests/python_test_utils/test_inference_regular_pipeline.py index bec93f10675..0e6e0974683 100644 --- a/tests/functional_tests/python_test_utils/test_inference_regular_pipeline.py +++ b/tests/functional_tests/python_test_utils/test_inference_regular_pipeline.py @@ -148,7 +148,8 @@ def test_inference_pipeline( # TODO: Compare liftime_prefill_token_count to groundtruth pass - for request_id, groundtruth_results in output_groundtruth.items(): + for request_id in groundtruth_request_ids: + groundtruth_results = output_groundtruth[request_id] current_results = output_current[request_id] at_least_one_test_loop = False diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/golden_values_dev_dgx_h100.json index 956754f44c1..b79d1ed4115 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/golden_values_dev_dgx_h100.json @@ -1,41 +1,41 @@ { "0": { "input_prompt": "Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies.", - "generated_text": " You are not alone. You are not alone. You are not alone. You are not alone. You are not alone. You are not alone.", + "generated_text": " You are a part of it, and you are not. You are a part of it, and you are not. You are a part of it", "generated_tokens": [ 3213, 1584, - 1605, - 9412, - 1046, - 3213, + 1261, + 1805, + 1307, + 1494, + 1044, + 1321, + 1636, 1584, 1605, - 9412, 1046, 3213, 1584, - 1605, - 9412, - 1046, - 3213, + 1261, + 1805, + 1307, + 1494, + 1044, + 1321, + 1636, 1584, 1605, - 9412, 1046, 3213, 1584, - 1605, - 9412, - 1046, - 3213, - 1584, - 1605, - 9412, - 1046 + 1261, + 1805, + 1307, + 1494 ], - "latency": 1.878054141998291, - "ttft": 0.07786321640014648, + "latency": 1.882845401763916, + "ttft": 0.0774838924407959, "cuda_graph_request_count_map": null, "step_count": 30, "top_n_logprobs": null, @@ -133,33 +133,33 @@ -3.0331737995147705, -1.9080564975738525, -2.52506947517395, - -2.325258493423462, - -1.180279016494751, - -1.1824196577072144, - -0.39788734912872314, - -1.110222578048706, - -1.5034958124160767, - -0.9765141606330872, - -0.9300433397293091, - -0.15196305513381958, - -0.2200203537940979, - -0.06051275506615639, - -0.6840062737464905, - -1.0964292287826538, - -0.17654964327812195, - -0.18547140061855316, - -0.06710249185562134, - -0.4758152365684509, - -0.6657928228378296, - -0.10342729091644287, - -0.10059614479541779, - -0.046978313475847244, - -0.410809725522995, - -0.428723007440567, - -0.06053968518972397, - -0.06518109142780304, - -0.030038274824619293, - -0.3271780014038086 + -3.010035514831543, + -0.011503085494041443, + -1.3661863803863525, + -0.7587734460830688, + -1.4640830755233765, + -1.4567692279815674, + -1.0451016426086426, + -2.3799993991851807, + -1.3697059154510498, + -1.0724036693572998, + -0.5331835150718689, + -1.8569976091384888, + -0.9731019735336304, + -0.05548504367470741, + -0.29222357273101807, + -0.2191229909658432, + -0.23294074833393097, + -0.6115235090255737, + -0.39632147550582886, + -1.1302311420440674, + -0.3844905197620392, + -0.7445144653320312, + -0.20952190458774567, + -0.29755353927612305, + -0.0599716454744339, + -0.008489826694130898, + -0.03623323515057564 ], "logprobs": [ -9.498085021972656, @@ -252,35 +252,37 @@ -3.0331737995147705, -1.9080564975738525, -2.52506947517395, - -2.325258493423462, - -1.180279016494751, - -1.1824196577072144, - -0.39788734912872314, - -1.110222578048706, - -1.5034958124160767, - -0.9765141606330872, - -0.9300433397293091, - -0.15196305513381958, - -0.2200203537940979, - -0.06051275506615639, - -0.6840062737464905, - -1.0964292287826538, - -0.17654964327812195, - -0.18547140061855316, - -0.06710249185562134, - -0.4758152365684509, - -0.6657928228378296, - -0.10342729091644287, - -0.10059614479541779, - -0.046978313475847244, - -0.410809725522995, - -0.428723007440567, - -0.06053968518972397, - -0.06518109142780304, - -0.030038274824619293, - -0.3271780014038086 + -3.010035514831543, + -0.011503085494041443, + -1.3661863803863525, + -0.7587734460830688, + -1.4640830755233765, + -1.4567692279815674, + -1.0451016426086426, + -2.3799993991851807, + -1.3697059154510498, + -1.0724036693572998, + -0.5331835150718689, + -1.8569976091384888, + -0.9731019735336304, + -0.05548504367470741, + -0.29222357273101807, + -0.2191229909658432, + -0.23294074833393097, + -0.6115235090255737, + -0.39632147550582886, + -1.1302311420440674, + -0.3844905197620392, + -0.7445144653320312, + -0.20952190458774567, + -0.29755353927612305, + -0.0599716454744339, + -0.008489826694130898, + -0.03623323515057564 ] }, - "mem-max-allocated-bytes": 53350692864, - "lifetime_prefill_token_count": 88 + "mem-max-allocated-bytes": 48828598272, + "lifetime_prefill_token_count": 88, + "async_sched_step_count": 0, + "async_sched_compaction_step_count": 0 } \ No newline at end of file diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index f0939fa0ad9..63912327d86 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1071,6 +1071,69 @@ def test_sample_from_dynamic_logits( sampled_logits >= expected_min_values ), f"The sampled logits should all be greater than {expected_min_values} but its {sampled_logits}" + def test_dynamic_sampling_keeps_sampled_tokens_buffer_full_capacity(self): + """`_sampled_tokens_cuda` is a single `max_requests` buffer written in place by + every sampling path. The non-speculative path (`_dynamic_step_sample_logits`) + writes its `active_request_count` prefix, and the async-scheduling path + (`_run_async_sched_sample`) writes its own prefix via `torch.max(out=...)`. The + buffer must retain its full capacity across successive steps regardless of each + step's active count, so a later step with more active requests than an earlier + one still has an in-bounds destination. + + Drive a small-batch non-speculative sample followed by a larger-batch async + sample through the same buffer, and confirm the buffer keeps its capacity and + identity and the larger async write lands correctly. + """ + self.setup_model( + torch.float32, batch_size=8, static=False, materialize_only_last_token_logits=True + ) + context = self.text_generation_controller.inference_wrapped_model.inference_context + controller = self.text_generation_controller + + capacity = controller._sampled_tokens_cuda.numel() + buffer_ptr = controller._sampled_tokens_cuda.data_ptr() + small_count, large_count = 2, 5 + assert large_count < capacity + + # Non-speculative sample over a small active batch. top_k == 1 makes the torch + # backend short-circuit to argmax, so the sampled token is deterministic. + context.active_request_metadata["temperature"][:small_count].fill_(1.0) + context.active_request_metadata["top_k"][:small_count].fill_(1) + context.active_request_metadata["top_p"][:small_count].fill_(0.0) + context.padded_active_token_count = small_count + context.request_query_lengths = torch.ones(small_count, dtype=torch.int32, device="cuda") + context.paused_request_count = 0 + context.total_request_count = small_count + context.num_prefill_requests = 0 + context.pad_active_slices() + + small_expected = torch.tensor([3, 4], device="cuda") + small_logits = torch.zeros(1, small_count, self.vocab_size, device="cuda") + for row, col in enumerate(small_expected.tolist()): + small_logits[0, row, col] = 10.0 + controller._all_logits_cuda = small_logits + controller._dynamic_step_sample_logits() + + assert controller._sampled_tokens_cuda.numel() == capacity + assert controller._sampled_tokens_cuda.data_ptr() == buffer_ptr + assert torch.equal(controller._sampled_tokens_cuda[:small_count], small_expected) + + # Async-scheduling sample over a larger active batch through the same buffer; + # its `torch.max(out=...)` destination is `_sampled_tokens_cuda[:large_count]`. + context.total_request_count = large_count + context.paused_request_count = 0 + large_expected = torch.tensor([0, 1, 2, 3, 4], device="cuda") + large_logits = torch.zeros(1, large_count, self.vocab_size, device="cuda") + for row, col in enumerate(large_expected.tolist()): + large_logits[0, row, col] = 10.0 + controller._all_logits_cuda = large_logits + + sampled = controller._run_async_sched_sample() + + assert sampled.data_ptr() == controller._sampled_tokens_cuda.data_ptr() + assert controller._sampled_tokens_cuda.numel() == capacity + assert torch.equal(sampled, large_expected) + @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) @pytest.mark.parametrize( "symmetric_ar_type", From 2d754e65bfe619ffd4fb3a1a87cb1416a4d155d5 Mon Sep 17 00:00:00 2001 From: Yashaswi Karnati <144376261+yashaswikarnati@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:00:53 -0700 Subject: [PATCH 102/290] Enable DDP communication overlap for MIMO training (#5979) Signed-off-by: ykarnati --- .../run_hetero_nemotron_20l_mock_train.sh | 4 +- examples/mimo/training/args.py | 15 ++++ examples/mimo/training/grad_sync.py | 9 ++ examples/mimo/training/runtime.py | 4 +- megatron/core/models/mimo/model/base.py | 46 ++++++++++ megatron/core/models/mimo/optimizer.py | 5 ++ megatron/training/training.py | 28 +++++- .../models/mimo/test_mimo_1f1b_schedule.py | 30 ++----- .../mimo/test_mimo_colocated_correctness.py | 8 +- .../models/mimo/test_mimo_grad_sync.py | 28 ++++++ .../models/mimo/test_mimo_hetero_grid_args.py | 19 +++++ .../models/mimo/test_mimo_hetero_runtime.py | 43 +++++++++- .../mimo/test_mimo_overlap_lifecycle.py | 85 +++++++++++++++++++ 13 files changed, 288 insertions(+), 36 deletions(-) create mode 100644 tests/unit_tests/models/mimo/test_mimo_overlap_lifecycle.py diff --git a/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh b/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh index 2ac83bf49a7..787df50fef8 100755 --- a/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh +++ b/examples/mimo/scripts/run_hetero_nemotron_20l_mock_train.sh @@ -94,7 +94,9 @@ uv run --extra ssm python -m torch.distributed.run \ --adam-beta2 0.95 \ --clip-grad 1.0 \ --use-distributed-optimizer \ - --ddp-bucket-size 0 \ + --overlap-grad-reduce \ + --overlap-param-gather \ + --encoder-ddp-overlap \ --train-iters "${TRAIN_ITERS}" \ --eval-interval "${EVAL_INTERVAL}" \ --eval-iters "${EVAL_ITERS}" \ diff --git a/examples/mimo/training/args.py b/examples/mimo/training/args.py index e1d9e2116f7..e62bc31967b 100644 --- a/examples/mimo/training/args.py +++ b/examples/mimo/training/args.py @@ -48,6 +48,14 @@ def add_hetero_grid_args(parser: argparse.ArgumentParser) -> argparse.ArgumentPa "requires --llm-offset 0 so the language grid covers WORLD_SIZE." ), ) + grid.add_argument( + "--encoder-ddp-overlap", + action="store_true", + help=( + "Apply the global grad-reduce and param-gather overlap settings to encoder DDP. " + "Requires every encoder DP rank to execute encoder backward on every microbatch." + ), + ) return parser @@ -56,6 +64,11 @@ def validate_hetero_grid_args(args: argparse.Namespace, world_size: int) -> tupl if args.llm_cp != 1: raise ValueError("hetero MIMO training currently supports CP=1 only") + if getattr(args, "encoder_ddp_overlap", False) and not getattr( + args, "overlap_grad_reduce", False + ): + raise ValueError("--encoder-ddp-overlap requires --overlap-grad-reduce") + # MoE expert count must divide evenly across the language grid's expert parallelism. num_experts = _num_experts(args) if num_experts and num_experts % args.llm_ep != 0: @@ -66,6 +79,8 @@ def validate_hetero_grid_args(args: argparse.Namespace, world_size: int) -> tupl llm_size = args.llm_tp * args.llm_cp * args.llm_pp * args.llm_dp if args.llm_only: + if getattr(args, "encoder_ddp_overlap", False): + raise ValueError("--encoder-ddp-overlap cannot be used with --llm-only") if args.llm_offset != 0: raise ValueError( "--llm-only requires --llm-offset 0 so language ranks cover WORLD_SIZE" diff --git a/examples/mimo/training/grad_sync.py b/examples/mimo/training/grad_sync.py index 9ac6a495aa5..2a06a4b8188 100644 --- a/examples/mimo/training/grad_sync.py +++ b/examples/mimo/training/grad_sync.py @@ -191,3 +191,12 @@ def finalize_grads_func(_model_list, num_tokens, force_all_reduce=False, **_kwar # The schedule always calls grad_scale_func with a Tensor loss; the per-token # mean is applied in finalize_grads_func, so no extra scaling is needed here. mimo_model.config.grad_scale_func = lambda loss: loss + + if getattr(args, "overlap_grad_reduce", False): + assert mimo_model.config.no_sync_func is None, ( + "MIMO overlap owns config.no_sync_func; a second synchronization context " + "cannot be composed safely" + ) + mimo_model.config.no_sync_func = mimo_model.no_sync + if getattr(args, "align_grad_reduce", False): + mimo_model.config.grad_sync_func = mimo_model.start_grad_sync diff --git a/examples/mimo/training/runtime.py b/examples/mimo/training/runtime.py index 3aca8391ac2..6a5235aba13 100644 --- a/examples/mimo/training/runtime.py +++ b/examples/mimo/training/runtime.py @@ -123,7 +123,9 @@ def wrap_active_modules_with_ddp( [submodule], enc_config, topology.module_pgs[name], - ddp_config=_ddp_config_from_args(args, enable_overlap=False), + ddp_config=_ddp_config_from_args( + args, enable_overlap=getattr(args, "encoder_ddp_overlap", False) + ), data_parallel_random_init=data_parallel_random_init, mixed_precision_wrapper=_EncoderFloat16Module, )[0] diff --git a/megatron/core/models/mimo/model/base.py b/megatron/core/models/mimo/model/base.py index 7485df0787f..b226b5c1e4b 100644 --- a/megatron/core/models/mimo/model/base.py +++ b/megatron/core/models/mimo/model/base.py @@ -2,6 +2,7 @@ import logging import warnings +from contextlib import ExitStack, contextmanager from typing import Any, Dict, Optional, Tuple import torch @@ -292,6 +293,51 @@ def _active_submodules(self): if submodule is not None: yield submodule + def _active_ddp_modules(self): + """Yield this rank's active DDP-wrapped submodules.""" + for module in self._active_submodules(): + if isinstance(module, DistributedDataParallel): + yield module + + @contextmanager + def no_sync(self): + """Disable grad-ready registration on overlapped inner DDP modules.""" + with ExitStack() as stack: + for module in self._active_ddp_modules(): + if module.ddp_config.overlap_grad_reduce: + stack.enter_context(module.no_sync()) + yield + + def enable_forward_pre_hook(self): + """Enable parameter-gather pre-hooks on overlapped inner DDP modules.""" + for module in self._active_ddp_modules(): + if module.ddp_config.overlap_param_gather: + module.enable_forward_pre_hook() + + def disable_forward_pre_hook(self, param_sync: bool = True): + """Disable parameter-gather pre-hooks on overlapped inner DDP modules.""" + for module in self._active_ddp_modules(): + if module.ddp_config.overlap_param_gather: + module.disable_forward_pre_hook(param_sync=param_sync) + + def start_param_sync(self, *unused, force_sync: bool = False, force_dispatch: bool = False): + """Start parameter synchronization on overlapped inner DDP modules.""" + for module in self._active_ddp_modules(): + if module.ddp_config.overlap_param_gather: + module.start_param_sync(force_sync=force_sync, force_dispatch=force_dispatch) + + def start_grad_sync(self, *unused): + """Start gradient synchronization on overlapped inner DDP modules.""" + for module in self._active_ddp_modules(): + if module.ddp_config.overlap_grad_reduce: + module.start_grad_sync() + + def free_overlap_buffers(self): + """Release parameter-gather buffers owned by overlapped inner DDP modules.""" + for module in self._active_ddp_modules(): + if module.ddp_config.overlap_param_gather: + module.free_overlap_buffers() + def zero_grad_buffer(self): """Zero each active submodule's DDP grad buffer.""" for module in self._active_submodules(): diff --git a/megatron/core/models/mimo/optimizer.py b/megatron/core/models/mimo/optimizer.py index 821c3b93065..598f6c883af 100644 --- a/megatron/core/models/mimo/optimizer.py +++ b/megatron/core/models/mimo/optimizer.py @@ -123,6 +123,11 @@ def zero_grad(self, set_to_none: bool = True): for opt in self._active_optimizers: opt.zero_grad(set_to_none) + def prepare_model_params_for_param_sync(self) -> None: + """Stage parameters for explicit synchronization in all active module optimizers.""" + for opt in self._active_optimizers: + opt.prepare_model_params_for_param_sync() + def get_loss_scale(self) -> torch.Tensor: """Return the loss scale tensor from the first active optimizer.""" if self._active_optimizers: diff --git a/megatron/training/training.py b/megatron/training/training.py index 0e4c1ea6a23..44e24acd4ae 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -2922,9 +2922,29 @@ def compute_throughputs_and_append_to_progress_log(iteration, num_floating_point ) +def _assert_param_gather_overlap_model(model_chunk): + """Assert that a model chunk implements the parameter-gather overlap lifecycle.""" + # MimoModel is a composite wrapper rather than a DDP instance, but delegates this + # interface to its active inner DDP modules. + required_methods = ( + 'enable_forward_pre_hook', + 'disable_forward_pre_hook', + 'start_param_sync', + ) + missing_methods = [ + method_name + for method_name in required_methods + if not callable(getattr(model_chunk, method_name, None)) + ] + assert not missing_methods, ( + f'{type(model_chunk).__name__} does not support parameter-gather overlap; ' + f'missing callable methods: {", ".join(missing_methods)}' + ) + + def enable_forward_pre_hook(model_chunks): for model_chunk in model_chunks: - assert isinstance(model_chunk, DDP) + _assert_param_gather_overlap_model(model_chunk) model_chunk.enable_forward_pre_hook() @@ -2932,15 +2952,15 @@ def disable_forward_pre_hook(model_chunks, optimizer=None, param_sync=True): if param_sync and optimizer is not None: optimizer.prepare_model_params_for_param_sync() for model_chunk in model_chunks: - assert isinstance(model_chunk, DDP) + _assert_param_gather_overlap_model(model_chunk) model_chunk.disable_forward_pre_hook(param_sync=param_sync) -def force_param_sync(model_chunks: list[DDP], optimizer=None) -> None: +def force_param_sync(model_chunks, optimizer=None) -> None: if optimizer is not None: optimizer.prepare_model_params_for_param_sync() for model_chunk in model_chunks: - assert isinstance(model_chunk, DDP) + _assert_param_gather_overlap_model(model_chunk) model_chunk.start_param_sync(force_sync=True) diff --git a/tests/unit_tests/models/mimo/test_mimo_1f1b_schedule.py b/tests/unit_tests/models/mimo/test_mimo_1f1b_schedule.py index bbc665bc44d..56d8db19735 100644 --- a/tests/unit_tests/models/mimo/test_mimo_1f1b_schedule.py +++ b/tests/unit_tests/models/mimo/test_mimo_1f1b_schedule.py @@ -7,7 +7,6 @@ """ import logging -from contextlib import ExitStack, contextmanager from functools import partial from types import SimpleNamespace @@ -61,27 +60,6 @@ _embedding_pg_cache: dict = {} -def build_no_sync_func(mimo_model): - """Build a no_sync_func that stacks DDP no_sync over each sub-module. - - Shared by 1F1B pipeline tests and colocated-correctness tests — both need - DDP's gradient sync disabled during microbatches and resumed via the - schedule's finalize_grads_func. - """ - - @contextmanager - def no_sync_func(): - with ExitStack() as stack: - if mimo_model.language_model is not None: - stack.enter_context(mimo_model.language_model.no_sync()) - for submodule in mimo_model.modality_submodules.values(): - if submodule is not None: - stack.enter_context(submodule.no_sync()) - yield - - return no_sync_func - - def create_hypercomm_grid(offset=0, tp=1, cp=1, pp=1, dp=1): """Create a HyperCommGrid (base view) plus a dense expert view, matching the topology builder. @@ -623,8 +601,6 @@ def run_mimo_1f1b_test( per_token_loss=True, ) - mimo_model.config.no_sync_func = build_no_sync_func(mimo_model) - # Use the production grad-sync hook (finalize per module over its own groups + # cross-grid N_global per-token mean) for every config. grad_sync_topology = SimpleNamespace( @@ -634,7 +610,11 @@ def run_mimo_1f1b_test( **{name: vision_pg for name in mimo_model.modality_submodules}, }, ) - configure_grad_sync(SimpleNamespace(), mimo_model, grad_sync_topology) + configure_grad_sync( + SimpleNamespace(overlap_grad_reduce=True, align_grad_reduce=False), + mimo_model, + grad_sync_topology, + ) # Create optimizer opt_config = OptimizerConfig( diff --git a/tests/unit_tests/models/mimo/test_mimo_colocated_correctness.py b/tests/unit_tests/models/mimo/test_mimo_colocated_correctness.py index 747b66a815a..251a4228740 100644 --- a/tests/unit_tests/models/mimo/test_mimo_colocated_correctness.py +++ b/tests/unit_tests/models/mimo/test_mimo_colocated_correctness.py @@ -68,7 +68,6 @@ from megatron.core.transformer.enums import ModelType from megatron.core.utils import unwrap_model from tests.unit_tests.models.mimo.test_mimo_1f1b_schedule import ( - build_no_sync_func, create_all_embedding_groups, create_hypercomm_grid, destroy_all_grids, @@ -167,7 +166,7 @@ def _set_deterministic_env(): def _wire_training_hooks(mimo_model, module_to_grid_map, language_pg, vision_pg): - """Attach no_sync plus the production grad-sync hooks to a MimoModel. + """Attach the production no-sync and grad-sync hooks to a MimoModel. Delegates the finalize/grad-scale wiring to ``configure_grad_sync`` (the real examples/mimo path), so this test's dp1-reference assertions validate that @@ -175,7 +174,6 @@ def _wire_training_hooks(mimo_model, module_to_grid_map, language_pg, vision_pg) mean: all-reduce ``total_num_tokens`` over the LLM DP group to get ``N_global``, finalize each submodule over its own group, then ``scale_gradients(1/N_global)``. """ - mimo_model.config.no_sync_func = build_no_sync_func(mimo_model) topology = SimpleNamespace( grids=module_to_grid_map, module_pgs={ @@ -183,7 +181,9 @@ def _wire_training_hooks(mimo_model, module_to_grid_map, language_pg, vision_pg) **{name: vision_pg for name in mimo_model.modality_submodules}, }, ) - configure_grad_sync(SimpleNamespace(), mimo_model, topology) + configure_grad_sync( + SimpleNamespace(overlap_grad_reduce=True, align_grad_reduce=False), mimo_model, topology + ) def _generate_and_broadcast_global_batches( diff --git a/tests/unit_tests/models/mimo/test_mimo_grad_sync.py b/tests/unit_tests/models/mimo/test_mimo_grad_sync.py index 33eaa88e907..81f1be17e13 100644 --- a/tests/unit_tests/models/mimo/test_mimo_grad_sync.py +++ b/tests/unit_tests/models/mimo/test_mimo_grad_sync.py @@ -16,9 +16,11 @@ from examples.mimo.training.grad_sync import ( _vision_participation_count, + configure_grad_sync, mark_modality_participation, reset_modality_participation, ) +from megatron.core.models.mimo.config.role import MIMO_LANGUAGE_MODULE_KEY from tests.unit_tests.models.mimo.test_mimo_1f1b_schedule import ( create_hypercomm_grid, destroy_all_grids, @@ -26,6 +28,32 @@ from tests.unit_tests.test_utilities import Utils +def test_configure_grad_sync_installs_production_overlap_hooks(): + def no_sync(): + return None + + def start_grad_sync(*_unused): + return None + + config = SimpleNamespace(no_sync_func=None, grad_sync_func=None) + model = SimpleNamespace( + config=config, + no_sync=no_sync, + start_grad_sync=start_grad_sync, + language_model=None, + modality_submodules={}, + ) + language_grid = SimpleNamespace(get_rank_enum=lambda _name: [[0]]) + topology = SimpleNamespace(grids={MIMO_LANGUAGE_MODULE_KEY: language_grid}, module_pgs={}) + + configure_grad_sync( + SimpleNamespace(overlap_grad_reduce=True, align_grad_reduce=True), model, topology + ) + + assert config.no_sync_func is no_sync + assert config.grad_sync_func is start_grad_sync + + class TestVisionParticipation: @classmethod def setup_class(cls): diff --git a/tests/unit_tests/models/mimo/test_mimo_hetero_grid_args.py b/tests/unit_tests/models/mimo/test_mimo_hetero_grid_args.py index 7429e87434e..fb923c2b37d 100644 --- a/tests/unit_tests/models/mimo/test_mimo_hetero_grid_args.py +++ b/tests/unit_tests/models/mimo/test_mimo_hetero_grid_args.py @@ -103,12 +103,31 @@ def test_llm_cp_must_be_one(): validate_hetero_grid_args(args, WORLD_SIZE_8) +def test_encoder_overlap_requires_grad_reduce(): + args = _layout_8gpu_20l(encoder_ddp_overlap=True, overlap_grad_reduce=False) + with pytest.raises(ValueError, match="requires --overlap-grad-reduce"): + validate_hetero_grid_args(args, WORLD_SIZE_8) + + +def test_encoder_overlap_accepts_uniform_participation_opt_in(): + args = _layout_8gpu_20l(encoder_ddp_overlap=True, overlap_grad_reduce=True) + assert validate_hetero_grid_args(args, WORLD_SIZE_8) == (4, 4) + + def test_llm_only_requires_offset_zero(): args = _layout_8gpu_20l(llm_only=True, llm_offset=4) with pytest.raises(ValueError, match="--llm-only requires --llm-offset 0"): validate_hetero_grid_args(args, WORLD_SIZE_8) +def test_llm_only_rejects_encoder_overlap(): + args = _layout_8gpu_20l( + llm_only=True, llm_offset=0, llm_ep=2, encoder_ddp_overlap=True, overlap_grad_reduce=True + ) + with pytest.raises(ValueError, match="cannot be used with --llm-only"): + validate_hetero_grid_args(args, 4) + + def test_llm_only_covers_world(): # llm tp2/pp1/dp2 = 4 ranks at offset 0; world_size 4 -> covers exactly, no encoder spec. args = _layout_8gpu_20l(llm_only=True, llm_offset=0, llm_ep=2, num_experts=128) diff --git a/tests/unit_tests/models/mimo/test_mimo_hetero_runtime.py b/tests/unit_tests/models/mimo/test_mimo_hetero_runtime.py index f7ce0f14778..7e383b64d65 100644 --- a/tests/unit_tests/models/mimo/test_mimo_hetero_runtime.py +++ b/tests/unit_tests/models/mimo/test_mimo_hetero_runtime.py @@ -8,7 +8,11 @@ import pytest import torch -from examples.mimo.training.runtime import configure_module_rng, wrap_active_modules_with_ddp +from examples.mimo.training.runtime import ( + _ddp_config_from_args, + configure_module_rng, + wrap_active_modules_with_ddp, +) from examples.mimo.training.topology import ModuleGridSpec, create_topology from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig from megatron.core.enums import ModelType @@ -65,6 +69,43 @@ def _build_unwrapped_mimo_model(topo, bf16=False): return mimo_model +def test_ddp_overlap_config_is_selected_per_module_role(): + args = _args(overlap_grad_reduce=True, overlap_param_gather=True) + + enabled = _ddp_config_from_args(args, enable_overlap=True) + disabled = _ddp_config_from_args(args, enable_overlap=False) + + assert enabled.overlap_grad_reduce + assert enabled.overlap_param_gather + assert not disabled.overlap_grad_reduce + assert not disabled.overlap_param_gather + + +def test_encoder_overlap_opt_in_reaches_encoder_ddp_config(mocker): + encoder = mocker.MagicMock() + wrapped_encoder = mocker.MagicMock() + mimo_model = SimpleNamespace(language_model=None, modality_submodules={ENCODER: encoder}) + topology = SimpleNamespace(module_pgs={ENCODER: mocker.MagicMock()}) + prepare = mocker.patch( + "examples.mimo.training.runtime.prepare_existing_model_chunks_for_distributed_training", + return_value=[wrapped_encoder], + ) + mocker.patch("examples.mimo.training.runtime._freeze_modality_submodule") + mocker.patch("examples.mimo.training.runtime._module_config", return_value=mocker.MagicMock()) + mocker.patch("examples.mimo.training.runtime.print_rank_0") + + wrap_active_modules_with_ddp( + _args(encoder_ddp_overlap=True, overlap_grad_reduce=True, overlap_param_gather=True), + mimo_model, + topology, + ) + + ddp_config = prepare.call_args.kwargs["ddp_config"] + assert ddp_config.overlap_grad_reduce + assert ddp_config.overlap_param_gather + assert mimo_model.modality_submodules[ENCODER] is wrapped_encoder + + def _eight_gpu_topology(): """Encoder dp=4 at ranks 0-3; language dp=4 at ranks 4-7 (non-colocated, tiles world).""" return create_topology( diff --git a/tests/unit_tests/models/mimo/test_mimo_overlap_lifecycle.py b/tests/unit_tests/models/mimo/test_mimo_overlap_lifecycle.py new file mode 100644 index 00000000000..b06d1543380 --- /dev/null +++ b/tests/unit_tests/models/mimo/test_mimo_overlap_lifecycle.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""CPU-only tests for MIMO's nested DDP overlap lifecycle.""" + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock + +from megatron.core.models.mimo.model.base import MimoModel +from megatron.core.models.mimo.optimizer import MimoOptimizer + + +def _overlap_stub(modules): + stub = SimpleNamespace() + stub._active_ddp_modules = lambda: iter(modules) + for name in ( + "no_sync", + "enable_forward_pre_hook", + "disable_forward_pre_hook", + "start_param_sync", + "start_grad_sync", + "free_overlap_buffers", + ): + setattr(stub, name, getattr(MimoModel, name).__get__(stub)) + return stub + + +def _ddp(*, grad_overlap, param_overlap, events, name): + module = MagicMock() + module.ddp_config = SimpleNamespace( + overlap_grad_reduce=grad_overlap, overlap_param_gather=param_overlap + ) + + @contextmanager + def no_sync(): + events.append(f"{name}:enter") + try: + yield + finally: + events.append(f"{name}:exit") + + module.no_sync.side_effect = no_sync + return module + + +def test_nested_overlap_lifecycle_routes_only_to_enabled_modules(): + events = [] + language = _ddp(grad_overlap=True, param_overlap=True, events=events, name="language") + encoder = _ddp(grad_overlap=True, param_overlap=False, events=events, name="encoder") + inactive = _ddp(grad_overlap=False, param_overlap=False, events=events, name="inactive") + model = _overlap_stub([language, encoder, inactive]) + + with model.no_sync(): + events.append("body") + + assert events == ["language:enter", "encoder:enter", "body", "encoder:exit", "language:exit"] + inactive.no_sync.assert_not_called() + + model.enable_forward_pre_hook() + model.disable_forward_pre_hook(param_sync=False) + model.start_param_sync(force_sync=True, force_dispatch=True) + model.start_grad_sync() + model.free_overlap_buffers() + + language.enable_forward_pre_hook.assert_called_once_with() + language.disable_forward_pre_hook.assert_called_once_with(param_sync=False) + language.start_param_sync.assert_called_once_with(force_sync=True, force_dispatch=True) + language.start_grad_sync.assert_called_once_with() + language.free_overlap_buffers.assert_called_once_with() + + encoder.enable_forward_pre_hook.assert_not_called() + encoder.start_param_sync.assert_not_called() + encoder.start_grad_sync.assert_called_once_with() + inactive.start_grad_sync.assert_not_called() + + +def test_mimo_optimizer_stages_each_active_optimizer_before_param_sync(): + language_optimizer = MagicMock() + encoder_optimizer = MagicMock() + optimizer = SimpleNamespace(_active_optimizers=[language_optimizer, encoder_optimizer]) + + MimoOptimizer.prepare_model_params_for_param_sync(optimizer) + + language_optimizer.prepare_model_params_for_param_sync.assert_called_once_with() + encoder_optimizer.prepare_model_params_for_param_sync.assert_called_once_with() From af3d24027f2373f1372d28bf510e74d237a178b5 Mon Sep 17 00:00:00 2001 From: Laura Dang Date: Fri, 24 Jul 2026 10:42:44 -0700 Subject: [PATCH 103/290] rl: release G-submission gate slots on consumption instead of assembly (#5853) Signed-off-by: Laura Dang Co-authored-by: Claude Fable 5 --- megatron/rl/agent/api.py | 33 ++++--- megatron/rl/rollout_granularity.py | 8 -- tests/unit_tests/rl/test_grouped_rollouts.py | 96 ++++++++++++++++++++ 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/megatron/rl/agent/api.py b/megatron/rl/agent/api.py index 8b75feadba9..ce5297ceca1 100644 --- a/megatron/rl/agent/api.py +++ b/megatron/rl/agent/api.py @@ -19,12 +19,7 @@ LLMChatMessage, ReturnsRaw, ) -from ..rollout_granularity import ( - RELEASE_STATE_BY_SUBMISSION, - ConsumptionGranularity, - ReleaseState, - SubmissionGranularity, -) +from ..rollout_granularity import ConsumptionGranularity, SubmissionGranularity class AgentBaseModel(BaseModel, extra='allow'): @@ -227,7 +222,14 @@ def _validate(request: GroupedRolloutRequest) -> None: class _SubmissionGate: - """Gate capacity is measured in units of the configured submission granularity.""" + """Gate capacity is measured in units of the configured submission granularity. + + Each granularity has a single release point: R slots free when inference + completes, so the gate bounds engine concurrency in rollouts. G and B + slots free when the trainer consumes the group/batch, so the gate + enforces the --rl-generation-lag run-ahead cap in groups/batches + respectively. + """ def __init__( self, @@ -237,7 +239,6 @@ def __init__( ) -> None: self._sem = asyncio.Semaphore(capacity) self._submission = submission - self._release_on = RELEASE_STATE_BY_SUBMISSION[submission] self.capacity = capacity # Observability counters, updated only on the configured submission # granularity (the only path that touches the semaphore). `held` @@ -256,8 +257,8 @@ async def acquire_for(self, granularity: SubmissionGranularity) -> None: self.held += 1 self.acquire_calls += 1 - def release_after(self, state: ReleaseState) -> None: - if self._release_on == state: + def release_for(self, granularity: SubmissionGranularity) -> None: + if self._submission == granularity: self._sem.release() self.held -= 1 self.release_calls += 1 @@ -401,7 +402,7 @@ async def _infer_one(self, item: _InferWorkItem) -> None: self.request, item.params.inference_request ) inferred_at = time.monotonic() - self.gate.release_after("inferred") + self.gate.release_for("R") if item.infer_dequeued_at: self.engine_dwell.append(inferred_at - item.infer_dequeued_at) self.inferred_count += 1 @@ -430,13 +431,15 @@ async def stage_assemble(self) -> None: rollouts = await asyncio.gather( *[item.item.params.build_rollout(item.response) for item in completed] ) - self.gate.release_after("assembled") self.assembled_count += 1 # NOTE: this filter is currently non-functional dead code: # _GranularityConfig._validate rejects filter_groups_with_same_reward # at pipeline construction, so `keep` is always True. Kept for a # future PR that regenerates dropped groups instead of - # under-delivering to the caller. + # under-delivering to the caller. That PR must also release the + # gate slot on the drop path: G/B slots free on consumption, and + # a dropped group never reaches stage_consume, so its slot (and + # eventually its batch's) would leak permanently. keep = ( not self.request.filter_groups_with_same_reward or np.std([rollout.reward for rollout in rollouts]) > 1e-6 @@ -474,6 +477,7 @@ async def stage_consume(self) -> AsyncIterator[RolloutGroup]: return self._record_output_dwell(group) yield group + self.gate.release_for("G") next_batch_id = 0 pending = self._consume_pending @@ -493,7 +497,8 @@ async def stage_consume(self) -> AsyncIterator[RolloutGroup]: next_batch_id += 1 for group in batch: yield group - self.gate.release_after("consumed") + self.gate.release_for("G") + self.gate.release_for("B") class GroupedRolloutGenerator(Agent, ABC): diff --git a/megatron/rl/rollout_granularity.py b/megatron/rl/rollout_granularity.py index 7bc13ab5b21..b9432f0bd4d 100644 --- a/megatron/rl/rollout_granularity.py +++ b/megatron/rl/rollout_granularity.py @@ -6,14 +6,6 @@ SubmissionGranularity = Literal["R", "G", "B"] ConsumptionGranularity = Literal["G", "B"] -ReleaseState = Literal["inferred", "assembled", "consumed"] - - -RELEASE_STATE_BY_SUBMISSION: dict[SubmissionGranularity, ReleaseState] = { - "R": "inferred", - "G": "assembled", - "B": "consumed", -} def get_rl_parallel_generation_tasks(args) -> int: diff --git a/tests/unit_tests/rl/test_grouped_rollouts.py b/tests/unit_tests/rl/test_grouped_rollouts.py index ef80319ea74..52aafb270be 100644 --- a/tests/unit_tests/rl/test_grouped_rollouts.py +++ b/tests/unit_tests/rl/test_grouped_rollouts.py @@ -14,6 +14,7 @@ Rollout, RolloutGenerator, RolloutRequest, + _SubmissionGate, ) from megatron.rl.agent.reward_only_agent import RewardOnlyAgent from megatron.rl.agent.weighted_multi_task import AgentConfig, WeightedMultiTask @@ -103,6 +104,101 @@ async def get_reward(self, response, golden, finish_reason): return float(int(response.removeprefix("t")) == golden["idx"]) +async def _flush(rounds: int = 50): + """Let pipeline stage tasks settle (mock inference is zero-delay).""" + for _ in range(rounds): + await asyncio.sleep(0) + + +class TestSubmissionGate: + @pytest.mark.asyncio + @pytest.mark.parametrize("submission", ["R", "G", "B"]) + async def test_release_requires_matching_granularity(self, submission): + gate = _SubmissionGate(capacity=1, submission=submission) + await gate.acquire_for(submission) + assert gate.held == 1 + for granularity in ("R", "G", "B"): + if granularity == submission: + continue + gate.release_for(granularity) + assert gate.held == 1 + assert gate.release_calls == 0 + gate.release_for(submission) + assert gate.held == 0 + assert gate.release_calls == 1 + + +class TestConsumptionRelease: + """G-submission gate slots must recycle on trainer consumption, not assembly.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "consumption_granularity, num_groups", + [ + pytest.param("G", 1, id="group_consumption"), + pytest.param("B", 2, id="batch_consumption"), + ], + ) + async def test_group_submission_stalls_until_consumption( + self, consumption_granularity, num_groups + ): + capacity = 4 + gen = MockGenerator(parallel_generation_tasks=capacity) + request = GroupedRolloutRequest( + num_groups=num_groups, + rollouts_per_group=1, + inference_interface=MockInferenceInterface(), + streaming=True, + submission_granularity="G", + consumption_granularity=consumption_granularity, + ) + it = gen.get_grouped_rollouts(request) + try: + for pulled in range(1, capacity + 3): + # wait_for turns the deadlock failure mode (a slot never freed) + # into a test failure instead of a hang. + await asyncio.wait_for(anext(it), timeout=10) + await _flush() + # Each yield frees exactly one group slot on the consumer's next + # resume, so submission tracks consumption with a one-slot skew + # (the release for the latest pull hasn't fired yet). On + # assembly-release semantics this runs away unbounded; if no + # consume-site release existed, the loop would deadlock at + # `pulled == capacity + 1`. + assert gen.prepare_group_rollout_calls == capacity + pulled - 1 + finally: + await it.aclose() + + @pytest.mark.asyncio + async def test_batch_submission_releases_once_per_batch(self): + gen = MockGenerator(parallel_generation_tasks=1) + request = GroupedRolloutRequest( + num_groups=2, + rollouts_per_group=1, + inference_interface=MockInferenceInterface(), + streaming=True, + submission_granularity="B", + consumption_granularity="B", + ) + it = gen.get_grouped_rollouts(request) + try: + await asyncio.wait_for(anext(it), timeout=10) + await asyncio.wait_for(anext(it), timeout=10) + await _flush() + gate = gen._active_pipeline.gate + # Batch 0 fully yielded but the consumer hasn't come back yet: its + # single batch slot is still held (a per-group release here would + # show release_calls == 2 and prepared == 4). + assert gate.release_calls == 0 + assert gen.prepare_group_rollout_calls == 2 + await asyncio.wait_for(anext(it), timeout=10) + await _flush() + assert gate.release_calls == 1 + assert gen.prepare_group_rollout_calls == 4 + finally: + await it.aclose() + + class TestRewardRollouts: @pytest.mark.asyncio async def test_get_reward_rollouts_matches_per_rollout_composition(self): From 648bc011f063b9e0ce1bf7152fa23f18be029a87 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Fri, 24 Jul 2026 11:19:24 -0700 Subject: [PATCH 104/290] Inference: Optimized triton kernels to extract mamba states in prefix caching (#5866) --- .../attention_context/mamba_metadata.py | 20 +- megatron/core/ssm/mamba_mixer.py | 80 +++--- .../core/ssm/ops/intermediate_extraction.py | 227 ++++++++++++++++ megatron/core/ssm/ops/ssd_combined.py | 29 +- tests/unit_tests/ssm/ops/test_ssd_combined.py | 257 +++++++++++++----- 5 files changed, 475 insertions(+), 138 deletions(-) create mode 100644 megatron/core/ssm/ops/intermediate_extraction.py diff --git a/megatron/core/inference/contexts/attention_context/mamba_metadata.py b/megatron/core/inference/contexts/attention_context/mamba_metadata.py index 953202a3c5b..9984b2dd71a 100644 --- a/megatron/core/inference/contexts/attention_context/mamba_metadata.py +++ b/megatron/core/inference/contexts/attention_context/mamba_metadata.py @@ -111,13 +111,11 @@ def __init__( self._intermediate_abs_positions_buffer = torch.full( (self.max_intermediate_count,), d_conv, dtype=torch.int32, device=self.device ) - # Constant gather offsets for conv state extraction: [-d_conv, ..., -1] - if d_conv > 0: - self.conv_gather_offsets = torch.arange( - -d_conv, 0, dtype=torch.int32, device=self.device - ) - else: - self.conv_gather_offsets = None + # Runtime real-count tensor read by the fused gather+scatter Triton + # kernels (intermediate_extraction.py). Fixed-address, rewritten each step + # so captured CUDA graphs stay valid while the kernels skip padded slots + # (pid_slot >= real_count). + self._intermediate_real_count_buffer = torch.zeros(1, dtype=torch.int32, device=self.device) # Coalesced production path: pinned CPU views + shared GPU views bound # by DynamicInferenceContext so that the per-step Mamba metadata fields @@ -180,6 +178,7 @@ def reset_varlen_metadata(self) -> None: # Intermediate state extraction views self.intermediate_chunk_indices = None self.intermediate_abs_positions = None + self.intermediate_real_count = None self.intermediate_count = 0 self.per_request_intermediate_counts = [] @@ -494,6 +493,11 @@ def _update_intermediate_metadata( self.intermediate_chunk_indices = self._intermediate_chunk_indices_buffer[:max_count] self.intermediate_abs_positions = self._intermediate_abs_positions_buffer[:max_count] + # Publish real_count to the fixed-address GPU tensor the scatter + # kernels consult. fill_ is async (no host sync) and keeps the tensor + # at the same address captured graphs reference. + self._intermediate_real_count_buffer.fill_(self.intermediate_count) + self.intermediate_real_count = self._intermediate_real_count_buffer else: # No extraction: fill with safe defaults for CUDA graph warmup # (same rationale as padding comment above; abs_positions=d_conv may @@ -505,6 +509,8 @@ def _update_intermediate_metadata( self.per_request_intermediate_counts = [] self.intermediate_chunk_indices = self._intermediate_chunk_indices_buffer[:max_count] self.intermediate_abs_positions = self._intermediate_abs_positions_buffer[:max_count] + self._intermediate_real_count_buffer.fill_(0) + self.intermediate_real_count = self._intermediate_real_count_buffer def compute_cpu_metadata( self, diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index 9ac04a60dd5..bd96afa511e 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -26,6 +26,10 @@ from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.ssm.ops.causal_conv1d_triton import causal_conv1d_update +from megatron.core.ssm.ops.intermediate_extraction import ( + scatter_intermediate_conv, + scatter_intermediate_ssm, +) from megatron.core.ssm.ops.mamba_ssm import selective_state_update from megatron.core.ssm.utils import _split_tensor_factory from megatron.core.tensor_parallel import get_cuda_rng_tracker @@ -649,6 +653,7 @@ def _dynamic_inference_prefill( slot_allocator = context.mamba_slot_allocator intermediate_chunk_indices = metadata.intermediate_chunk_indices intermediate_abs_positions = metadata.intermediate_abs_positions + intermediate_real_count = metadata.intermediate_real_count intermediate_ssm_out = None intermediate_conv_out = None if slot_allocator is not None and mamba_layer_idx is not None: @@ -664,9 +669,9 @@ def _dynamic_inference_prefill( batch_indices=batch_indices, intermediate_chunk_indices=intermediate_chunk_indices, intermediate_abs_positions=intermediate_abs_positions, + intermediate_real_count=intermediate_real_count, intermediate_ssm_out=intermediate_ssm_out, intermediate_conv_out=intermediate_conv_out, - conv_gather_offsets=metadata.conv_gather_offsets, cu_chunk_seqlens=metadata.cu_chunk_seqlens, last_chunk_indices=metadata.last_chunk_indices, seq_idx_for_varlen=metadata.seq_idx_for_varlen, @@ -778,9 +783,9 @@ def _ssm_prefill( batch_indices: Optional[torch.Tensor] = None, intermediate_chunk_indices: Optional[torch.Tensor] = None, intermediate_abs_positions: Optional[torch.Tensor] = None, + intermediate_real_count: Optional[torch.Tensor] = None, intermediate_ssm_out: Optional[torch.Tensor] = None, intermediate_conv_out: Optional[torch.Tensor] = None, - conv_gather_offsets: Optional[torch.Tensor] = None, cu_chunk_seqlens: Optional[torch.Tensor] = None, last_chunk_indices: Optional[torch.Tensor] = None, seq_idx_for_varlen: Optional[torch.Tensor] = None, @@ -805,12 +810,13 @@ def _ssm_prefill( intermediate state extraction (fixed size, padded with 0). intermediate_abs_positions: Pre-allocated tensor of absolute token positions for conv state extraction (fixed size, padded with d_conv). + intermediate_real_count: int32[1] GPU tensor holding the number of + meaningful entries in the intermediate buffers this step. Read + inside the Triton scatter kernels so padded slots cost nothing. intermediate_ssm_out: Output buffer for extracted SSM states [max_intermediate_count, *ssm_shape]. intermediate_conv_out: Output buffer for extracted conv states [max_intermediate_count, *conv_shape]. - conv_gather_offsets: Constant tensor [-d_conv, ..., -1] for gathering - conv states. cu_chunk_seqlens: Precomputed chunk boundaries from MambaMetadata. last_chunk_indices: Precomputed last chunk index per sequence. seq_idx_for_varlen: Precomputed request ID per chunk. @@ -975,6 +981,13 @@ def _ssm_prefill( chunk_starts = cu_chunk_seqlens[:-1] seq_idx_for_varlen = seq_idx[0, chunk_starts].contiguous() + # Extraction is enabled when the slot allocator wired buffers in via + # the caller. When enabled, the chunk scan returns its raw states so + # our Triton kernels do a fused gather+conditional-scatter directly, + # skipping the dense intermediate tensor and the padded-slot writes. + extract_intermediates = ( + intermediate_chunk_indices is not None and intermediate_ssm_out is not None + ) ssm_varlen_result = mamba_chunk_scan_combined_varlen( x=x, dt=dt, @@ -994,53 +1007,44 @@ def _ssm_prefill( z=z if not self.rmsnorm else None, dt_bias=self.cp.get_dt_bias().float(), initial_states=initial_ssm_state, - return_intermediate_states=False, - intermediate_chunk_indices=intermediate_chunk_indices, + return_raw_states=extract_intermediates, dt_softplus=True, dt_limit=(0.0, float("inf")), state_dtype=ssm_state.dtype, ) - if intermediate_chunk_indices is not None: - ssm_varlen_states, intermediate_ssm_states = ssm_varlen_result + if extract_intermediates: + ssm_varlen_states, raw_ssm_states = ssm_varlen_result else: ssm_varlen_states = ssm_varlen_result - intermediate_ssm_states = None + raw_ssm_states = None y = y.unsqueeze(0) z = z.unsqueeze(0) tensor_masked_update(ssm_state, batch_indices, ssm_varlen_states) - # Write intermediate states to pre-allocated output buffers - # All tensor ops, no Python loops, fully CUDA graph compatible. - # The destination buffers are sized to the global max_intermediate_count - # but we only fill the per-graph-bucket prefix; readers consult - # per_request_intermediate_counts to know the real count. - if intermediate_chunk_indices is not None and intermediate_ssm_out is not None: - n = intermediate_ssm_states.shape[0] - intermediate_ssm_out[:n].copy_(intermediate_ssm_states) - - # Vectorized conv state extraction - # conv_gather_offsets: [d_conv] = [-d_conv, ..., -1] - gather_positions = ( - intermediate_abs_positions.unsqueeze(1).long() - + conv_gather_offsets.unsqueeze(0).long() - ) # [n, d_conv] - # Clamp into the valid token range. Padding/warmup slots use the - # safe-default abs_position == d_conv, which yields gather indices - # [0..d_conv-1]; when the prefill sequence is shorter than d_conv - # (e.g. a small CUDA-graph warmup bucket with fewer than d_conv - # tokens), those indices overrun the token axis. Clamping keeps the - # gather in bounds. Real slots are always in range, so this is a - # no-op for them, and padding-slot results are never read (callers - # consult per_request_intermediate_counts). - seq_len = xBC_pre_conv.shape[1] - gather_positions = gather_positions.clamp_(0, seq_len - 1) - intermediate_conv = xBC_pre_conv[0, gather_positions, :] - # [n, d_conv, conv_dim] - intermediate_conv_out[:n].copy_(intermediate_conv.transpose(1, 2)) - # [n, conv_dim, d_conv] + if extract_intermediates: + # Fused gather+conditional-scatter for SSM: read row + # raw_ssm_states[chunk_indices[i]] into intermediate_ssm_out[i], + # only for i < real_count. + scatter_intermediate_ssm( + raw_ssm_states, + intermediate_chunk_indices, + intermediate_real_count, + intermediate_ssm_out, + ) + # Same pattern for conv: gather a length-d_conv window ending at + # abs_positions[i] (clamped into the valid token range) from + # xBC_pre_conv and scatter (transposed) into intermediate_conv_out[i], + # only for i < real_count. + scatter_intermediate_conv( + xBC_pre_conv, + intermediate_abs_positions, + intermediate_real_count, + intermediate_conv_out, + d_conv=intermediate_conv_out.shape[-1], + ) else: # Non-dynamic-batching path (static batching) initial_ssm_state = None diff --git a/megatron/core/ssm/ops/intermediate_extraction.py b/megatron/core/ssm/ops/intermediate_extraction.py new file mode 100644 index 00000000000..ec6552d798e --- /dev/null +++ b/megatron/core/ssm/ops/intermediate_extraction.py @@ -0,0 +1,227 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Fused gather + conditional-scatter kernels for Mamba intermediate-state +extraction used by prefix caching. + +These replace the two-step ``states[indices]`` (dense gather) + ``.copy_()`` +(scratch write) pattern with a single kernel that: + +1. Reads a runtime ``real_count`` from a fixed-address GPU tensor. +2. For each slot ``i < real_count``, gathers the source row indexed by the + per-slot index/position and writes it directly into the destination scratch. +3. For each slot ``i >= real_count``, returns immediately (no work, no write). + +This is CUDA-graph safe: the launch grid is sized at capture time to the maximum +possible slot count, but per-program execution is data-conditional on the +runtime ``real_count``, so padded slots cost almost nothing. +""" + +import torch +from torch import Tensor + +try: + import triton + import triton.language as tl + + HAVE_TRITON = True +except ImportError: + from unittest.mock import MagicMock + + from megatron.core.utils import null_decorator + + triton = MagicMock() + triton.jit = null_decorator + tl = MagicMock() + HAVE_TRITON = False + + +@triton.jit +def _scatter_intermediate_ssm_kernel( + states_ptr, # [num_chunks, state_flat]: source SSM states from the chunk scan + chunk_indices_ptr, # [max_count] int64: gather index per scratch slot + real_count_ptr, # int32[1]: number of meaningful slots this step + out_ptr, # [max_count, state_flat]: scratch destination (per layer) + state_flat, # tl.int32: product of (nheads, headdim, dstate) + states_row_stride, # tl.int64: stride between chunks in states_ptr + out_row_stride, # tl.int64: stride between slots in out_ptr + BLOCK_N: tl.constexpr, # columns per program +): + """Conditional gather+scatter for SSM intermediate states. + + Grid: ``(max_count, ceil(state_flat / BLOCK_N))``. Each program owns one + (slot, column-block) pair; programs with ``pid_slot >= real_count`` exit + immediately, so padded slots produce no HBM traffic. + """ + pid_slot = tl.program_id(0) + pid_col = tl.program_id(1) + + real_count = tl.load(real_count_ptr).to(tl.int32) + if pid_slot >= real_count: + return + + chunk_idx = tl.load(chunk_indices_ptr + pid_slot).to(tl.int64) + + col_offset = pid_col * BLOCK_N + cols = col_offset + tl.arange(0, BLOCK_N) + mask = cols < state_flat + + src = states_ptr + chunk_idx * states_row_stride + cols.to(tl.int64) + dst = out_ptr + pid_slot.to(tl.int64) * out_row_stride + cols.to(tl.int64) + + val = tl.load(src, mask=mask) + tl.store(dst, val, mask=mask) + + +def scatter_intermediate_ssm( + states: Tensor, chunk_indices: Tensor, real_count_gpu: Tensor, out: Tensor +) -> None: + """Gather rows of ``states`` at ``chunk_indices`` and scatter into ``out``, + for the first ``real_count_gpu`` slots only. + + Args: + states: ``(num_chunks, *ssm_state_shape)`` chunk-scan output for one layer. + chunk_indices: ``(max_count,)`` int64 per-slot gather index. + real_count_gpu: ``int32[1]`` GPU tensor with the runtime real count. + out: ``(max_count, *ssm_state_shape)`` destination scratch slice (one layer). + """ + assert states.is_cuda and chunk_indices.is_cuda and real_count_gpu.is_cuda and out.is_cuda + assert states.dim() >= 2, f"expected states to be at least 2D, got {states.shape}" + assert ( + out.shape[1:] == states.shape[1:] + ), f"per-slot shape mismatch: out {tuple(out.shape[1:])} vs states {tuple(states.shape[1:])}" + assert chunk_indices.dtype == torch.int64, chunk_indices.dtype + assert real_count_gpu.dtype == torch.int32 and real_count_gpu.numel() == 1 + + # The grid follows the per-step view length (chunk_indices.numel()), bounded + # above by out.shape[0] (the full scratch pool); programs past real_count + # exit immediately inside the kernel. + n_slots = int(chunk_indices.numel()) + if n_slots == 0: + return + if n_slots > out.shape[0]: + raise ValueError(f"chunk_indices length {n_slots} exceeds scratch capacity {out.shape[0]}") + + state_flat = 1 + for d in states.shape[1:]: + state_flat *= int(d) + + # Contiguity is required for the row-stride math; the engine pre-allocates + # these contiguous, so assert to fail loudly in tests rather than corrupt. + assert states.is_contiguous(), "states must be contiguous" + assert out.is_contiguous(), "out must be contiguous" + + BLOCK_N = 1024 + grid = (n_slots, triton.cdiv(state_flat, BLOCK_N)) + _scatter_intermediate_ssm_kernel[grid]( + states, + chunk_indices, + real_count_gpu, + out, + state_flat=state_flat, + states_row_stride=state_flat, + out_row_stride=state_flat, + BLOCK_N=BLOCK_N, + ) + + +@triton.jit +def _scatter_intermediate_conv_kernel( + src_ptr, # xBC_pre_conv batch-0 slice: addressed via strides + abs_positions_ptr, # [max_count] int32: extraction-window end position per slot + real_count_ptr, # int32[1]: meaningful slot count this step + out_ptr, # [max_count, conv_dim, d_conv]: scratch destination + seq_len, # tl.int32: bound for clamping + conv_dim, # tl.int32: feature dim + src_stride_s, # tl.int64: stride along seq_len + src_stride_c, # tl.int64: stride along conv_dim + out_slot_stride, # tl.int64: stride between slots in out_ptr (conv_dim * D_CONV) + D_CONV: tl.constexpr, + BLOCK_C: tl.constexpr, +): + """Conditional gather of a length-``D_CONV`` conv window per slot. + + Reads window ``[abs_pos - D_CONV, abs_pos)`` from ``src_ptr`` (clamped into + ``[0, seq_len)``) and writes it transposed into ``out[slot, :, :]`` of shape + ``(conv_dim, D_CONV)``. The transpose is folded into the write pattern to + match the slot allocator's storage layout. + """ + pid_slot = tl.program_id(0) + pid_c = tl.program_id(1) + + real_count = tl.load(real_count_ptr).to(tl.int32) + if pid_slot >= real_count: + return + + abs_pos = tl.load(abs_positions_ptr + pid_slot).to(tl.int32) + + c_offset = pid_c * BLOCK_C + c_idxs = c_offset + tl.arange(0, BLOCK_C) + c_mask = c_idxs < conv_dim + + # out[slot, c, j]: c outer, j inner (contiguous), so for fixed c the D_CONV + # values are stride-1. + slot_base = pid_slot.to(tl.int64) * out_slot_stride + + for j in tl.static_range(D_CONV): + p_raw = abs_pos - D_CONV + j + # Clamp into [0, seq_len - 1]. A no-op for real slots; defensive for any + # future caller that doesn't filter padded slots via real_count. + p = tl.maximum(0, tl.minimum(p_raw, seq_len - 1)) + + src = src_ptr + p.to(tl.int64) * src_stride_s + c_idxs.to(tl.int64) * src_stride_c + dst = out_ptr + slot_base + c_idxs.to(tl.int64) * D_CONV + j + + val = tl.load(src, mask=c_mask) + tl.store(dst, val, mask=c_mask) + + +def scatter_intermediate_conv( + src: Tensor, abs_positions: Tensor, real_count_gpu: Tensor, out: Tensor, d_conv: int +) -> None: + """Gather length-``d_conv`` conv windows from ``src`` at ``abs_positions`` and + scatter (transposed) into ``out``, for the first ``real_count_gpu`` slots only. + + Args: + src: ``(batch, seq_len, conv_dim)`` pre-conv xBC tensor. Batch is assumed + to be 1 (inference); only batch index 0 is read. + abs_positions: ``(max_count,)`` int32 extraction-window end position per + slot (window is ``[pos - d_conv, pos)``). + real_count_gpu: ``int32[1]`` GPU tensor with the runtime real count. + out: ``(max_count, conv_dim, d_conv)`` destination scratch slice (one layer). + d_conv: conv window length (constexpr in the kernel). + """ + assert src.is_cuda and abs_positions.is_cuda and real_count_gpu.is_cuda and out.is_cuda + assert src.dim() == 3, f"expected src (batch, seq_len, conv_dim), got {src.shape}" + assert src.shape[0] == 1, f"batch must be 1 for inference, got {src.shape[0]}" + assert abs_positions.dtype == torch.int32, abs_positions.dtype + assert real_count_gpu.dtype == torch.int32 and real_count_gpu.numel() == 1 + assert ( + out.dim() == 3 and out.shape[2] == d_conv + ), f"out shape {tuple(out.shape)} does not match (max_count, conv_dim, {d_conv})" + + _, conv_dim, _ = out.shape + n_slots = int(abs_positions.numel()) + if n_slots == 0: + return + if n_slots > out.shape[0]: + raise ValueError(f"abs_positions length {n_slots} exceeds scratch capacity {out.shape[0]}") + + seq_len = int(src.shape[1]) + src_stride_s = int(src.stride(1)) + src_stride_c = int(src.stride(2)) + + BLOCK_C = 128 + grid = (n_slots, triton.cdiv(conv_dim, BLOCK_C)) + _scatter_intermediate_conv_kernel[grid]( + src, + abs_positions, + real_count_gpu, + out, + seq_len=seq_len, + conv_dim=conv_dim, + src_stride_s=src_stride_s, + src_stride_c=src_stride_c, + out_slot_stride=conv_dim * d_conv, + D_CONV=d_conv, + BLOCK_C=BLOCK_C, + ) diff --git a/megatron/core/ssm/ops/ssd_combined.py b/megatron/core/ssm/ops/ssd_combined.py index 4fcee98b13e..ba43dbc295a 100644 --- a/megatron/core/ssm/ops/ssd_combined.py +++ b/megatron/core/ssm/ops/ssd_combined.py @@ -32,11 +32,10 @@ def _mamba_chunk_scan_combined_fwd( z=None, dt_bias=None, initial_states=None, - return_intermediate_states=False, seq_idx=None, cu_chunk_seqlens=None, last_chunk_indices=None, - intermediate_chunk_indices=None, + return_raw_states=False, dt_softplus=False, dt_limit=(0.0, float("inf")), state_dtype=None, @@ -149,15 +148,11 @@ def _mamba_chunk_scan_combined_fwd( initial_states=initial_states, ) - if return_intermediate_states: - return states - final_states = states[last_chunk_indices] - if intermediate_chunk_indices is not None: - intermediate_states = states[intermediate_chunk_indices] - return final_states, intermediate_states - else: - return final_states + if return_raw_states: + # Caller extracts any chunk-boundary states itself from the raw states. + return final_states, states + return final_states def mamba_chunk_scan_combined_varlen( @@ -177,8 +172,7 @@ def mamba_chunk_scan_combined_varlen( initial_states=None, dt_softplus=False, dt_limit=(0.0, float("inf")), - return_intermediate_states=False, - intermediate_chunk_indices=None, + return_raw_states=False, state_dtype=None, ): """ @@ -198,13 +192,13 @@ def mamba_chunk_scan_combined_varlen( dt_bias: (nheads,) initial_states: (batch, nheads, headdim, dstate) dt_softplus: Whether to apply softplus to dt - intermediate_chunk_indices: (N,) optional int64 tensor of chunk indices at which to - extract intermediate SSM states. When provided, returns (final_states, - intermediate_states) instead of just final_states. + return_raw_states: If True, returns ``(varlen_states, raw_states)`` where + ``raw_states`` is the full ``(nchunks, nheads, headdim, dstate)`` + chunk-boundary state tensor for the caller to extract from directly. state_dtype: The data type of the ssm state Return: varlen_states: (batch, nheads, headdim, dstate), or - (varlen_states, intermediate_states) if intermediate_chunk_indices is provided + (varlen_states, raw_states) if return_raw_states is True """ assert seq_idx is not None @@ -221,11 +215,10 @@ def mamba_chunk_scan_combined_varlen( z=z, dt_bias=dt_bias, initial_states=initial_states, - return_intermediate_states=return_intermediate_states, seq_idx=seq_idx, cu_chunk_seqlens=cu_chunk_seqlens, last_chunk_indices=last_chunk_indices, - intermediate_chunk_indices=intermediate_chunk_indices, + return_raw_states=return_raw_states, dt_softplus=dt_softplus, dt_limit=dt_limit, state_dtype=state_dtype, diff --git a/tests/unit_tests/ssm/ops/test_ssd_combined.py b/tests/unit_tests/ssm/ops/test_ssd_combined.py index b5ef14f7a79..6ca8f466023 100644 --- a/tests/unit_tests/ssm/ops/test_ssd_combined.py +++ b/tests/unit_tests/ssm/ops/test_ssd_combined.py @@ -5,6 +5,10 @@ import torch try: + from megatron.core.ssm.ops.intermediate_extraction import ( + scatter_intermediate_conv, + scatter_intermediate_ssm, + ) from megatron.core.ssm.ops.ssd_combined import is_int_pow_2, mamba_chunk_scan_combined_varlen HAVE_SSD_OPS = True @@ -158,7 +162,15 @@ def test_mamba_chunk_scan_combined_varlen_single_sequence(self): @unittest.skipIf(not HAVE_SSD_OPS, "SSD ops (Triton 3+) not available") @unittest.skipIf(not torch.cuda.is_available(), "CUDA required for SSD ops") class TestIntermediateStateExtraction(unittest.TestCase): - """Tests for intermediate_chunk_indices parameter.""" + """Tests for the ``return_raw_states=True`` contract of the chunk scan. + + Intermediate extraction was refactored: the kernel no longer gathers + requested chunks internally (old ``intermediate_chunk_indices`` / + ``return_intermediate_states`` kwargs). Instead the scan returns the full + ``(nchunks, ...)`` raw state tensor via ``return_raw_states=True``, and the + caller extracts from it with the fused ``scatter_intermediate_ssm`` kernel + (covered directly in TestScatterIntermediateKernels below). + """ def setUp(self): torch.manual_seed(42) @@ -181,8 +193,8 @@ def _make_inputs(self, seqlen): ) return x, dt, A, B, C, out - def test_intermediate_states_shape_and_no_nan(self): - """1 sequence, 4 chunks. Request intermediates at chunks [0, 1, 2].""" + def test_raw_states_shape_and_no_nan(self): + """1 sequence, 4 chunks. return_raw_states yields (final, all-chunk) states.""" seqlen = 64 # 4 chunks of 16 nchunks = seqlen // self.chunk_size x, dt, A, B, C, out = self._make_inputs(seqlen) @@ -191,7 +203,6 @@ def test_intermediate_states_shape_and_no_nan(self): ) last_chunk_indices = torch.tensor([nchunks - 1], dtype=torch.int64, device=self.device) seq_idx = torch.zeros(nchunks, dtype=torch.int32, device=self.device) - intermediate_chunk_indices = torch.tensor([0, 1, 2], dtype=torch.int64, device=self.device) result = mamba_chunk_scan_combined_varlen( x=x, @@ -204,18 +215,22 @@ def test_intermediate_states_shape_and_no_nan(self): last_chunk_indices=last_chunk_indices, seq_idx=seq_idx, out=out, - intermediate_chunk_indices=intermediate_chunk_indices, + return_raw_states=True, ) self.assertIsInstance(result, tuple) - final_states, intermediate_states = result + final_states, raw_states = result self.assertEqual(final_states.shape, (1, self.nheads, self.headdim, self.dstate)) - self.assertEqual(intermediate_states.shape, (3, self.nheads, self.headdim, self.dstate)) + # raw_states is the full per-chunk boundary state tensor. + self.assertEqual(raw_states.shape, (nchunks, self.nheads, self.headdim, self.dstate)) self.assertFalse(torch.isnan(final_states).any()) - self.assertFalse(torch.isnan(intermediate_states).any()) + self.assertFalse(torch.isnan(raw_states).any()) + # The final state is exactly the last chunk's raw state. + torch.testing.assert_close(final_states[0], raw_states[nchunks - 1]) - def test_intermediate_states_match_full_states(self): - """Intermediate states should match corresponding entries from full states.""" + def test_scatter_from_scan_raw_states(self): + """End-to-end: extract requested chunks from scan raw_states via the fused + scatter kernel and compare against the reference gather.""" seqlen = 64 # 4 chunks nchunks = seqlen // self.chunk_size x, dt, A, B, C, out = self._make_inputs(seqlen) @@ -225,9 +240,7 @@ def test_intermediate_states_match_full_states(self): last_chunk_indices = torch.tensor([nchunks - 1], dtype=torch.int64, device=self.device) seq_idx = torch.zeros(nchunks, dtype=torch.int32, device=self.device) - # Run with return_intermediate_states=True to get all states - out1 = torch.empty_like(out) - all_states = mamba_chunk_scan_combined_varlen( + final_states, raw_states = mamba_chunk_scan_combined_varlen( x=x, dt=dt, A=A, @@ -237,63 +250,42 @@ def test_intermediate_states_match_full_states(self): cu_chunk_seqlens=cu_chunk_seqlens, last_chunk_indices=last_chunk_indices, seq_idx=seq_idx, - out=out1, - return_intermediate_states=True, + out=out, + return_raw_states=True, ) + raw_states = raw_states.contiguous() - # Run with intermediate_chunk_indices indices = [0, 1, 2] - intermediate_chunk_indices = torch.tensor(indices, dtype=torch.int64, device=self.device) - out2 = torch.empty_like(out) - final_states, intermediate_states = mamba_chunk_scan_combined_varlen( - x=x, - dt=dt, - A=A, - B=B, - C=C, - chunk_size=self.chunk_size, - cu_chunk_seqlens=cu_chunk_seqlens, - last_chunk_indices=last_chunk_indices, - seq_idx=seq_idx, - out=out2, - intermediate_chunk_indices=intermediate_chunk_indices, + chunk_indices = torch.tensor(indices, dtype=torch.int64, device=self.device) + real_count_gpu = torch.tensor([len(indices)], dtype=torch.int32, device=self.device) + scratch = torch.empty( + len(indices), + self.nheads, + self.headdim, + self.dstate, + device=self.device, + dtype=raw_states.dtype, ) + scatter_intermediate_ssm(raw_states, chunk_indices, real_count_gpu, scratch) - # Intermediate states should match the corresponding all_states entries - for i, chunk_idx in enumerate(indices): - torch.testing.assert_close( - intermediate_states[i], - all_states[chunk_idx], - msg=f"intermediate state at index {i} (chunk {chunk_idx}) does not match", - ) + torch.testing.assert_close(scratch, raw_states[chunk_indices]) - # Final state should match last chunk - torch.testing.assert_close(final_states[0], all_states[nchunks - 1]) - - def test_intermediate_states_multi_sequence(self): - """2 packed sequences, verify intermediate extraction across sequence boundaries.""" + def test_scatter_from_scan_raw_states_multi_sequence(self): + """2 packed sequences: extract chunks that straddle a sequence boundary.""" seq1_len = 32 # 2 chunks seq2_len = 48 # 3 chunks total_len = seq1_len + seq2_len x, dt, A, B, C, out = self._make_inputs(total_len) - # cu_chunk_seqlens: seq1 has chunks at [0, 16, 32], seq2 at [32, 48, 64, 80] boundaries = list(range(0, seq1_len + 1, self.chunk_size)) + list( range(seq1_len + self.chunk_size, total_len + 1, self.chunk_size) ) cu_chunk_seqlens = torch.tensor(boundaries, dtype=torch.int32, device=self.device) nchunks = len(boundaries) - 1 # 5 chunks total - # Last chunk for seq1 is chunk 1, for seq2 is chunk 4 last_chunk_indices = torch.tensor([1, 4], dtype=torch.int64, device=self.device) - # seq_idx: [0, 0, 1, 1, 1] seq_idx = torch.tensor([0, 0, 1, 1, 1], dtype=torch.int32, device=self.device) - # Request chunk 0 from seq1 and chunks 2, 3 from seq2 - intermediate_chunk_indices = torch.tensor([0, 2, 3], dtype=torch.int64, device=self.device) - - # Also get full states for comparison - out_full = torch.empty_like(out) - all_states = mamba_chunk_scan_combined_varlen( + final_states, raw_states = mamba_chunk_scan_combined_varlen( x=x, dt=dt, A=A, @@ -303,34 +295,31 @@ def test_intermediate_states_multi_sequence(self): cu_chunk_seqlens=cu_chunk_seqlens, last_chunk_indices=last_chunk_indices, seq_idx=seq_idx, - out=out_full, - return_intermediate_states=True, - ) - - out2 = torch.empty_like(out) - final_states, intermediate_states = mamba_chunk_scan_combined_varlen( - x=x, - dt=dt, - A=A, - B=B, - C=C, - chunk_size=self.chunk_size, - cu_chunk_seqlens=cu_chunk_seqlens, - last_chunk_indices=last_chunk_indices, - seq_idx=seq_idx, - out=out2, - intermediate_chunk_indices=intermediate_chunk_indices, + out=out, + return_raw_states=True, ) - + raw_states = raw_states.contiguous() self.assertEqual(final_states.shape, (2, self.nheads, self.headdim, self.dstate)) - self.assertEqual(intermediate_states.shape, (3, self.nheads, self.headdim, self.dstate)) + self.assertEqual(raw_states.shape, (nchunks, self.nheads, self.headdim, self.dstate)) + + # Request chunk 0 from seq1 and chunks 2, 3 from seq2. + indices = [0, 2, 3] + chunk_indices = torch.tensor(indices, dtype=torch.int64, device=self.device) + real_count_gpu = torch.tensor([len(indices)], dtype=torch.int32, device=self.device) + scratch = torch.empty( + len(indices), + self.nheads, + self.headdim, + self.dstate, + device=self.device, + dtype=raw_states.dtype, + ) + scatter_intermediate_ssm(raw_states, chunk_indices, real_count_gpu, scratch) - # Verify intermediate states match full states - for i, chunk_idx in enumerate([0, 2, 3]): - torch.testing.assert_close(intermediate_states[i], all_states[chunk_idx]) + torch.testing.assert_close(scratch, raw_states[chunk_indices]) - def test_no_intermediate_returns_tensor(self): - """Without intermediate_chunk_indices, result should be a plain tensor.""" + def test_no_raw_states_returns_tensor(self): + """Without return_raw_states, result should be a plain final-state tensor.""" seqlen = 32 nchunks = seqlen // self.chunk_size x, dt, A, B, C, out = self._make_inputs(seqlen) @@ -357,5 +346,123 @@ def test_no_intermediate_returns_tensor(self): self.assertEqual(result.shape, (1, self.nheads, self.headdim, self.dstate)) +@unittest.skipIf(not HAVE_SSD_OPS, "SSD ops (Triton 3+) not available") +@unittest.skipIf(not torch.cuda.is_available(), "CUDA required for SSD ops") +class TestScatterIntermediateKernels(unittest.TestCase): + """Direct tests for the fused gather+scatter extraction kernels. + + These are the sole correctness coverage for scatter_intermediate_ssm / + scatter_intermediate_conv: equivalence vs. a reference gather, real_count + gating (padded slots left untouched), and the sub-d_conv clamp. + """ + + def setUp(self): + torch.manual_seed(0) + self.device = torch.device("cuda") + self.nheads = 4 + self.headdim = 16 + self.dstate = 8 + + def _ssm_states(self, num_chunks): + return torch.randn(num_chunks, self.nheads, self.headdim, self.dstate, device=self.device) + + @staticmethod + def _ref_conv(src, abs_positions, real_count, d_conv): + """Reference for scatter_intermediate_conv: for each meaningful slot, gather + the window [pos - d_conv, pos) (clamped into [0, seq_len-1]) and store it + transposed as out[slot, c, j].""" + _, seq_len, conv_dim = src.shape + max_count = abs_positions.shape[0] + out = torch.zeros(max_count, conv_dim, d_conv, device=src.device, dtype=src.dtype) + for slot in range(real_count): + pos = int(abs_positions[slot].item()) + for j in range(d_conv): + p = max(0, min(pos - d_conv + j, seq_len - 1)) + out[slot, :, j] = src[0, p, :] + return out + + def test_scatter_ssm_matches_reference(self): + """Fused gather matches the dense states[chunk_indices] reference.""" + states = self._ssm_states(num_chunks=6) + indices = [4, 0, 2] + chunk_indices = torch.tensor(indices, dtype=torch.int64, device=self.device) + real_count_gpu = torch.tensor([len(indices)], dtype=torch.int32, device=self.device) + out = torch.empty(len(indices), self.nheads, self.headdim, self.dstate, device=self.device) + + scatter_intermediate_ssm(states, chunk_indices, real_count_gpu, out) + + torch.testing.assert_close(out, states[chunk_indices]) + + def test_scatter_ssm_real_count_gating(self): + """Slots >= real_count are never written (padded scratch left untouched).""" + states = self._ssm_states(num_chunks=6) + max_count, real_count = 5, 3 + # Trailing indices are valid but must NOT be gathered (gated out). + chunk_indices = torch.tensor([4, 0, 2, 1, 5], dtype=torch.int64, device=self.device) + real_count_gpu = torch.tensor([real_count], dtype=torch.int32, device=self.device) + sentinel = 12345.0 + out = torch.full( + (max_count, self.nheads, self.headdim, self.dstate), sentinel, device=self.device + ) + + scatter_intermediate_ssm(states, chunk_indices, real_count_gpu, out) + + # First real_count slots gathered... + torch.testing.assert_close(out[:real_count], states[chunk_indices[:real_count]]) + # ...trailing slots left at the sentinel (no HBM write). + self.assertTrue(torch.all(out[real_count:] == sentinel)) + + def test_scatter_conv_matches_reference(self): + """Fused conv-window gather matches the transposed reference (positions in range).""" + d_conv = 4 + seq_len, conv_dim = 32, 12 + src = torch.randn(1, seq_len, conv_dim, device=self.device) + abs_positions = torch.tensor([10, 20, 5], dtype=torch.int32, device=self.device) + real_count_gpu = torch.tensor([3], dtype=torch.int32, device=self.device) + out = torch.empty(3, conv_dim, d_conv, device=self.device) + + scatter_intermediate_conv(src, abs_positions, real_count_gpu, out, d_conv) + + ref = self._ref_conv(src, abs_positions, real_count=3, d_conv=d_conv) + torch.testing.assert_close(out, ref) + + def test_scatter_conv_sub_dconv_clamp(self): + """A window whose start falls below token 0 clamps into range (reads token 0).""" + d_conv = 4 + seq_len, conv_dim = 32, 12 + src = torch.randn(1, seq_len, conv_dim, device=self.device) + # slot 0: pos=2 < d_conv -> window [-2, -1, 0, 1] clamps to [0, 0, 0, 1]. + abs_positions = torch.tensor([2, 20], dtype=torch.int32, device=self.device) + real_count_gpu = torch.tensor([2], dtype=torch.int32, device=self.device) + out = torch.empty(2, conv_dim, d_conv, device=self.device) + + scatter_intermediate_conv(src, abs_positions, real_count_gpu, out, d_conv) + + ref = self._ref_conv(src, abs_positions, real_count=2, d_conv=d_conv) + torch.testing.assert_close(out, ref) + # Explicitly: the three out-of-range positions all clamp to token 0. + torch.testing.assert_close(out[0, :, 0], src[0, 0, :]) + torch.testing.assert_close(out[0, :, 1], src[0, 0, :]) + torch.testing.assert_close(out[0, :, 2], src[0, 0, :]) + torch.testing.assert_close(out[0, :, 3], src[0, 1, :]) + + def test_scatter_conv_real_count_gating(self): + """Slots >= real_count are never written by the conv kernel either.""" + d_conv = 4 + seq_len, conv_dim = 32, 12 + src = torch.randn(1, seq_len, conv_dim, device=self.device) + max_count, real_count = 4, 2 + abs_positions = torch.tensor([10, 20, 15, 25], dtype=torch.int32, device=self.device) + real_count_gpu = torch.tensor([real_count], dtype=torch.int32, device=self.device) + sentinel = -999.0 + out = torch.full((max_count, conv_dim, d_conv), sentinel, device=self.device) + + scatter_intermediate_conv(src, abs_positions, real_count_gpu, out, d_conv) + + ref = self._ref_conv(src, abs_positions, real_count=real_count, d_conv=d_conv) + torch.testing.assert_close(out[:real_count], ref[:real_count]) + self.assertTrue(torch.all(out[real_count:] == sentinel)) + + if __name__ == "__main__": unittest.main() From 066145edc3da2403d92661f2f5e96be13bbf7519 Mon Sep 17 00:00:00 2001 From: Cory Ye <44509866+cspades@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:51:09 -0700 Subject: [PATCH 105/290] [Inference] Set different random seeds for each DP rank for generation. (#5983) Signed-off-by: Cory Ye --- megatron/core/inference/config.py | 9 ++ .../text_generation_controller.py | 24 +++- megatron/training/arguments.py | 5 + megatron/training/config/inference_config.py | 6 + .../inference/engines/test_dynamic_engine.py | 129 +++++++++++------- .../inference/engines/test_static_engine.py | 4 + .../inference/test_inference_config.py | 29 ++++ .../test_text_generation_controller.py | 121 +++++++++++++++- 8 files changed, 268 insertions(+), 59 deletions(-) diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index a2ae3182b9a..c9bdfb50575 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -371,6 +371,15 @@ class InferenceConfig: """Which sampling kernels to use during inference. Falls back to "torch" with a warning if "flashinfer" is requested but the package is not installed.""" + offset_sampling_seed_by_dp_rank: bool = True + """ + If True, offset `inference_sampling_seed` by the data-parallel rank when seeding the + sampling RNG. This gives each DP rank a unique generation seed so that the same prompt + routed to different ranks produces different samples (important for RL training). + If False (or `ModelParallelConfig.deterministic_mode` / `--deterministic-mode` is + enabled), then all DP ranks share the same sampling / generation seed. + """ + async_sched_mode: AsyncScheduleMode = AsyncScheduleMode.LEGACY """Mode used to schedule dynamic batching inference work.""" diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 99e818c141b..6385bb2bf70 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -142,8 +142,10 @@ def __init__(self, inference_wrapped_model: AbstractModelInferenceWrapper, token pg_collection = inference_config.pg_collection if pg_collection is not None: self.pp_group = pg_collection.pp + self.dp_group = pg_collection.dp else: self.pp_group = parallel_state.get_pipeline_model_parallel_group() + self.dp_group = parallel_state.get_data_parallel_group() self.model_is_pipeline_parallel = self.model_config.pipeline_model_parallel_size > 1 @@ -155,8 +157,20 @@ def __init__(self, inference_wrapped_model: AbstractModelInferenceWrapper, token else: self.vocab_size = unwrapped_model.vocab_size + # Build and seed sampling RNG. Optionally offset by DP rank so each rank gets a + # unique generation seed (avoids identical samples when the same prompt is + # assigned to multiple DP ranks, which can corrupt RL training). Controlled by + # InferenceConfig.offset_sampling_seed_by_dp_rank, but deactivated when enabling + # --deterministic-mode (model_config.deterministic_mode). self.sampling_rng = torch.Generator(device=torch.cuda.current_device()) - self.sampling_rng.manual_seed(self.model_config.inference_sampling_seed) + seed = self.model_config.inference_sampling_seed + offset_by_dp = ( + inference_config.offset_sampling_seed_by_dp_rank + and not self.model_config.deterministic_mode + ) + if offset_by_dp: + seed += torch.distributed.get_rank(group=self.dp_group) + self.sampling_rng.manual_seed(seed) if not self.num_speculative_tokens: self.num_mtp_depths = 0 @@ -2915,11 +2929,11 @@ def generate_all_output_tokens_static_batch( request.status = Status.COMPLETED + # Detokenize up to input_prompt_length + required_sequence_length for this idx. + sequence_length = input_prompt_length + required_sequence_length text, segments = self.detokenize_generations( - batch_prompt_tokens_with_generations[ - idx, : (input_prompt_length + required_sequence_length) - ], - input_prompt_length + generated_sequence_lengths, + batch_prompt_tokens_with_generations[idx, :sequence_length], + torch.tensor([sequence_length], device=batch_prompt_tokens_with_generations.device), sampling_params.return_segments, ) request.text = text # Inference server returns prompts & generations together diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 9ecc061fd2b..c209aaa9cda 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2003,6 +2003,11 @@ def _add_inference_args(parser): help='Which sampling kernels to use during inference. ' 'Falls back to "torch" with a warning if "flashinfer" ' 'is requested but the package is not installed.') + group.add_argument('--use-same-sampling-seed-across-dp-ranks', + action='store_false', dest='offset_sampling_seed_by_dp_rank', + default=True, + help='Use the same inference sampling seed on every data-parallel rank. ' + '--deterministic-mode also uses the same seed on every DP rank.') group.add_argument('--inference-dynamic-batching-async-sched-mode', type=str, default='legacy', choices=['legacy', 'serial', 'overlap'], diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index 5c8f34e456f..59f545b8525 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -136,6 +136,11 @@ class InferenceSetupConfig: """Which sampling kernels to use during inference. Defaults to "flashinfer" and falls back to "torch" with a warning if the flashinfer package is not installed.""" + offset_sampling_seed_by_dp_rank: bool = True + """Offset the inference sampling seed by the data-parallel rank so each DP rank gets a unique + generation seed. Disable with --use-same-sampling-seed-across-dp-ranks. Also forced off when + --deterministic-mode is enabled.""" + inference_dynamic_batching_async_sched_mode: Literal["legacy", "serial", "overlap"] = "legacy" """Async scheduling mode for dynamic batching. "legacy" (default) preserves the existing resolve-before-prepare path. "serial" speculatively prepares and forwards decode-only @@ -373,6 +378,7 @@ def to_inference_config( use_synchronous_zmq_collectives=self.inference_use_synchronous_zmq_collectives, disable_ep_consensus=self.inference_disable_ep_consensus, sampling_backend=self.inference_dynamic_batching_sampling_backend, + offset_sampling_seed_by_dp_rank=self.offset_sampling_seed_by_dp_rank, async_sched_mode=AsyncScheduleMode( self.inference_dynamic_batching_async_sched_mode ), diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 2513bd78b6c..9c99c8d1019 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -681,48 +681,56 @@ def test_simple(self, model_provider, num_cuda_graphs, inference_cuda_graph_scop for layer in model.decoder.layers: assert layer.cudagraph_manager.cudagraph_runners - # Validate generated tokens. - gpt_expected_generated_tokens = [ - [69, 85, 55, 74, 56, 89, 64, 59, 55, 67, 15, 58, 6, 37, 54, 47], - [29, 54, 33, 72, 45, 76, 41, 56, 28, 25, 17, 2, 61, 6, 98, 76], - [35, 78, 54, 16, 79, 98, 22, 5, 60, 0, 1, 76, 77, 11, 25, 7], - [25, 75, 57, 85, 81, 37, 88, 17, 71, 15, 70, 64, 50, 0, 64, 45], - [32, 5, 85, 75, 30, 68, 23, 33, 20, 26, 89, 20, 49, 28, 38, 81], - [33, 69, 32, 49, 93, 24, 33, 6, 54, 89, 92, 97, 42, 80, 50, 53], - [82, 78, 78, 65, 26, 5, 69, 36, 37, 99], - [51, 70, 22, 1, 87, 42, 36, 26, 27, 56, 82, 32, 8, 80, 20, 43], - ] - - mamba_expected_generated_tokens = [ - [69, 85, 55, 74, 85, 89, 64, 59, 55, 67, 15, 58, 6, 37, 34, 47], - [29, 16, 33, 30, 45, 76, 41, 46, 82, 17, 17, 2, 61, 6, 98, 76], - [35, 78, 54, 16, 79, 98, 22, 5, 37, 30, 1, 76, 5, 11, 25, 86], - [25, 75, 57, 85, 81, 59, 88, 38, 71, 15, 70, 64, 50, 0, 64, 45], - [32, 5, 85, 75, 30, 68, 23, 33, 20, 26, 35, 20, 49, 28, 34, 81], - [87, 69, 32, 49, 93, 24, 33, 6, 54, 89, 92, 97, 42, 80, 50, 53], - [82, 78, 78, 19, 70, 5, 97, 36, 37, 99], - [51, 70, 22, 1, 87, 42, 36, 26, 27, 56, 82, 32, 8, 20, 20, 43], - ] - - if model_provider == "gpt": - expected_generated_tokens_list = gpt_expected_generated_tokens - elif model_provider == "hybrid": - expected_generated_tokens_list = mamba_expected_generated_tokens - else: - raise ValueError(f"Invalid model_provider {model_provider}") + # Because the TextGenerationController produces different outputs on different DP ranks, + # only verify the accuracy of the output on DP rank 0. + if parallel_state.get_data_parallel_rank() == 0: + + # Validate generated tokens. + gpt_expected_generated_tokens = [ + [69, 85, 55, 74, 56, 89, 64, 59, 55, 67, 15, 58, 6, 37, 54, 47], + [29, 54, 33, 72, 45, 76, 41, 56, 28, 25, 17, 2, 61, 6, 98, 76], + [35, 78, 54, 16, 79, 98, 22, 5, 60, 0, 1, 76, 77, 11, 25, 7], + [25, 75, 57, 85, 81, 37, 88, 17, 71, 15, 70, 64, 50, 0, 64, 45], + [32, 5, 85, 75, 30, 68, 23, 33, 20, 26, 89, 20, 49, 28, 38, 81], + [33, 69, 32, 49, 93, 24, 33, 6, 54, 89, 92, 97, 42, 80, 50, 53], + [82, 78, 78, 65, 26, 5, 69, 36, 37, 99], + [51, 70, 22, 1, 87, 42, 36, 26, 27, 56, 82, 32, 8, 80, 20, 43], + ] + + mamba_expected_generated_tokens = [ + [69, 85, 55, 74, 85, 89, 64, 59, 55, 67, 15, 58, 6, 37, 34, 47], + [29, 16, 33, 30, 45, 76, 41, 46, 82, 17, 17, 2, 61, 6, 98, 76], + [35, 78, 54, 16, 79, 98, 22, 5, 37, 30, 1, 76, 5, 11, 25, 86], + [25, 75, 57, 85, 81, 59, 88, 38, 71, 15, 70, 64, 50, 0, 64, 45], + [32, 5, 85, 75, 30, 68, 23, 33, 20, 26, 35, 20, 49, 28, 34, 81], + [87, 69, 32, 49, 93, 24, 33, 6, 54, 89, 92, 97, 42, 80, 50, 53], + [82, 78, 78, 19, 70, 5, 97, 36, 37, 99], + [51, 70, 22, 1, 87, 42, 36, 26, 27, 56, 82, 32, 8, 20, 20, 43], + ] + + if model_provider == "gpt": + expected_generated_tokens_list = gpt_expected_generated_tokens + elif model_provider == "hybrid": + expected_generated_tokens_list = mamba_expected_generated_tokens + else: + raise ValueError(f"Invalid model_provider {model_provider}") - print(f"Validating {len(env.requests)} requests.") - print(f"Expected generated tokens: {expected_generated_tokens_list}") - print(f"Actual generated tokens: {[request.generated_tokens for request in env.requests]}") + print(f"Validating {len(env.requests)} requests.") + print(f"Expected generated tokens: {expected_generated_tokens_list}") + print( + f"Actual generated tokens: {[request.generated_tokens for request in env.requests]}" + ) - assert len(env.requests) == len(expected_generated_tokens_list) + assert len(env.requests) == len(expected_generated_tokens_list) - for request, expected_generated_tokens in zip(env.requests, expected_generated_tokens_list): - assert request.generated_tokens == expected_generated_tokens, ( - f"request {request.request_id}, " - f"result ({request.generated_tokens}) != " - f"expected ({expected_generated_tokens})." - ) + for request, expected_generated_tokens in zip( + env.requests, expected_generated_tokens_list + ): + assert request.generated_tokens == expected_generated_tokens, ( + f"request {request.request_id}, " + f"result ({request.generated_tokens}) != " + f"expected ({expected_generated_tokens})." + ) @pytest.mark.internal @pytest.mark.skipif( @@ -2429,15 +2437,20 @@ def test_max_requests(self, max_requests: int | None): context = env.engine.context if max_requests is None: assert context.max_requests == 816 - assert step_count == 23 else: assert max_requests < len(env.requests), ( f"Test is only useful if max_requests ({max_requests}) < " f"num_requests ({len(env.requests)})." ) assert context.max_requests == 4 - assert step_count == 35 - assert context.kv_block_allocator.active_count == 655 + # Exact step counts and KV occupancy depend on sampled token sequences. + # With DP-offset sampling seeds, only DP rank 0 matches the golden seed. + if parallel_state.get_data_parallel_rank() == 0: + if max_requests is None: + assert step_count == 23 + else: + assert step_count == 35 + assert context.kv_block_allocator.active_count == 655 @pytest.mark.internal @pytest.mark.skipif( @@ -4226,8 +4239,8 @@ def test_speculative_decoding_non_greedy_with_top_n_logprobs(self): env.engine.controller.tokenizer.detokenize = lambda tokens, **kw: f"tok_{tokens[0]}" - # top_n must be >= top_k so the sampled token is guaranteed to appear - # in the top-n dict for the consistency check below. + # top_n must be >= top_k so the top_k-sampled token is guaranteed to + # have a higher probability than the least probable token in top_n. top_n = 10 num_requests = 3 prompt_lengths = [4, 6, 8] @@ -4266,20 +4279,30 @@ def test_speculative_decoding_non_greedy_with_top_n_logprobs(self): assert isinstance(top_n_dict, dict) assert 0 < len(top_n_dict) <= top_n - # Consistency: selected token's log prob should appear in top-n. + # Consistency: selected token's log prob should appear in top-n when the + # sampled token is among the strict top-n indices. With nearly-tied logits + # (random models), top-k filtering keeps all ties at the cutoff, so a sampled + # token can fall outside a separate top-n index list. In that case the + # selected logprob must still be no worse than the weakest top-n entry. if req.generated_log_probs is not None: for j, (lp, top_n_dict, token_id) in enumerate( zip(req.generated_log_probs, req.generated_top_n_logprobs, req.generated_tokens) ): token_str = env.engine.controller.tokenizer.detokenize([token_id]) - assert token_str in top_n_dict, ( - f"Request {req.request_id}, token {j}: " - f"selected token '{token_str}' not in top-n" - ) - assert abs(lp - top_n_dict[token_str]) < 0.01, ( - f"Request {req.request_id}, token {j}: " - f"log_prob {lp} vs top-n {top_n_dict[token_str]}" - ) + if token_str in top_n_dict: + # Sampled token is in Top N. + assert abs(lp - top_n_dict[token_str]) < 0.01, ( + f"Request {req.request_id}, token {j}: " + f"log_prob {lp} vs top-n {top_n_dict[token_str]}" + ) + else: + # Sampled token is not in the Top N. It must be a tie. + # Check that it is at least as probable as Top N tokens. + assert lp + 0.01 >= min(top_n_dict.values()), ( + f"Request {req.request_id}, token {j}: " + f"selected token '{token_str}' log_prob {lp} is worse than " + f"top-n minimum {min(top_n_dict.values())}" + ) @pytest.mark.internal @pytest.mark.skipif( diff --git a/tests/unit_tests/inference/engines/test_static_engine.py b/tests/unit_tests/inference/engines/test_static_engine.py index e9befc290fe..d40766b6a26 100644 --- a/tests/unit_tests/inference/engines/test_static_engine.py +++ b/tests/unit_tests/inference/engines/test_static_engine.py @@ -212,6 +212,10 @@ def test_generate_dynamic(self, batch_size: int, num_trials: int, empty_prompt: async def test_streaming(self): self.setup_engine(legacy=True) + # Possible for a rank to not generate any tokens, i.e. EOD only. + # Make that impossible when testing streaming. + self.mock_tokenizer.eod = self.vocab_size + async def collect_stream(stream_generator, num_tokens_to_generate): prev_log_probs = None prev_text = "" diff --git a/tests/unit_tests/inference/test_inference_config.py b/tests/unit_tests/inference/test_inference_config.py index e22bc2619f4..5e591c8fe81 100644 --- a/tests/unit_tests/inference/test_inference_config.py +++ b/tests/unit_tests/inference/test_inference_config.py @@ -67,3 +67,32 @@ def test_inference_setup_config_maps_async_sched_mode(self): ) assert inference_config.async_sched_mode == AsyncScheduleMode.OVERLAP + + def test_offset_sampling_seed_argparse_plumbing(self): + """Ensure the CLI can select a shared sampling seed across DP ranks.""" + parser = _add_inference_args(ArgumentParser()) + default_args = parser.parse_args([]) + assert default_args.offset_sampling_seed_by_dp_rank is True + + disabled_args = parser.parse_args(["--use-same-sampling-seed-across-dp-ranks"]) + assert disabled_args.offset_sampling_seed_by_dp_rank is False + + def test_inference_setup_config_maps_offset_sampling_seed_by_dp_rank(self): + """Ensure declarative inference config maps DP seed offset to runtime config.""" + model = SimpleNamespace( + position_embedding_type="rope", + max_sequence_length=4096, + pg_collection="pg", + decoder=SimpleNamespace(layer_type_list=None), + ) + setup_config = InferenceSetupConfig(offset_sampling_seed_by_dp_rank=False) + + inference_config = setup_config.to_inference_config( + model=model, + kv_cache_management_mode="persist", + static_kv_memory_pointers=False, + enable_cuda_graphs=False, + verbose=False, + ) + + assert inference_config.offset_sampling_seed_by_dp_rank is False diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 63912327d86..0f59b679bb7 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1216,7 +1216,7 @@ def test_generate_all_output_tokens_static_batch(self, dtype, symmetric_ar_type, assert ( len(request.segments) == len(request.prompt_log_probs) + len(request.generated_log_probs) + 1 - ), "Segments should be returned for both prompt and generated tokens" + ), f"Segments should be returned for both prompt and generated tokens: {request}" assert len(request.prompt) + len(request.generated_text) == len( request.text ), "Output text should include prompts and generations" @@ -2415,6 +2415,7 @@ def setup_model( Utils.initialize_model_parallel( tensor_model_parallel_size=tensor_model_parallel_size, pipeline_model_parallel_size=pipeline_model_parallel_size, + expert_model_parallel_size=expert_model_parallel_size, ) super().setup_model( dtype, @@ -2584,3 +2585,121 @@ def test_sampled_tokens_match_with_parallelism(self, static, tp_size, pp_size): assert ( expected == actual ), f"Rank {i} tokens differ from rank {local_rank} tokens for request {j}" + + @pytest.mark.parametrize("static", [True, False]) + @pytest.mark.parametrize("enable_prefix_caching", [True, False]) + def test_sampled_tokens_dp_mismatch(self, static, enable_prefix_caching): + """ + TextGenerationController should generate different tokens + on every DP rank given the same prompt / request. + """ + if not static and not is_fa_min_version("2.7.3"): + pytest.skip(reason="Need latest flash attn for dynamic batching") + + self.setup_model( + dtype=torch.bfloat16, + # Set all model parallelisms to 1. + # No rank should shard the model. + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=1, + # Test all batching and balancing strategies. + static=static, + enable_prefix_caching=enable_prefix_caching, + # Set a random seed for generation, so we can + # verify that different DP ranks produce disparate + # generations given the DP rank seed offset. + # Without this, generations will always be random + # and we don't be able to verify DP disparity. + use_training_random_init=True, + ) + # Ensure only data parallelism is used. This is critical since + # we expect generation parity across model parallel ranks. + dp_size = parallel_state.get_data_parallel_group().size() + assert ( + dp_size == torch.distributed.get_world_size() + ), "[test_sampled_tokens_dp_mismatch] Expected DP size to match WORLD size for this DP disparity test." + + # Prepare requests. + active_requests: Dict[str, InferenceRequest] = OrderedDict() + for i in range(self.batch_size): + # Create a batch of constant prompts to test DP disparity. + # Same inputs should produce different outputs. + prompt = "sample" * (i + 1) + prompt_tokens = [1] * (i + 1) + request_id = str(i) + inference_request = InferenceRequest( + request_id=request_id, + prompt=prompt, + sampling_params=SamplingParams( + top_k=10, num_tokens_to_generate=25, return_log_probs=True + ), + arrival_time=time.time(), + prompt_tokens=prompt_tokens, + status=Status.ACTIVE_BUT_NOT_GENERATING_TOKENS, + ) + active_requests[request_id] = inference_request + + # Generate tokens for each sample of the batch. + if static: + # Static batching requires dummy metadata and functions. + self.mock_tokenizer.vocab_size = self.vocab_size + self.mock_tokenizer.eod = self.vocab_size - 1 + self.mock_tokenizer.detokenize.side_effect = ( + lambda x, skip_special_tokens=False: ' '.join( + [ + ''.join(random.choices(string.ascii_letters, k=random.randint(4, 10))) + for _ in range(len(x)) + ] + ) + ) + self.mock_tokenizer.offsets.side_effect = lambda _, s: [ + i for i, c in enumerate(s) if c == ' ' + ] + [len(s)] + + # Generate. + requests = self.text_generation_controller.generate_all_output_tokens_static_batch( + active_requests + ) + all_generated_tokens = [req.generated_tokens.tolist() for req in requests.values()] + else: + all_generated_tokens = [[] for _ in range(len(active_requests))] + context = self.text_generation_controller.inference_wrapped_model.inference_context + for request_id, request in active_requests.items(): + context.add_request( + DynamicInferenceRequest( + request_id=int(request_id), + prompt_tokens=torch.tensor( + request.prompt_tokens, + dtype=torch.long, + device=torch.cuda.current_device(), + ), + sampling_params=SamplingParams( + top_k=10, return_log_probs=True, num_tokens_to_generate=25 + ), + ) + ) + expected_active_requests = set(int(x) for x in active_requests.keys()) + while context.has_unfinished_requests(): + result = self.text_generation_controller.generate_output_tokens_dynamic_batch() + new_tokens = result["sample"] + active_ids = result["active_request_ids"].tolist() + finished_ids = result["finished_request_ids"].tolist() + assert len(new_tokens) == len(expected_active_requests) + assert set(active_ids) == expected_active_requests + expected_active_requests -= set(finished_ids) + for i, token in enumerate(new_tokens.tolist()): + all_generated_tokens[i].append(token) + + # Wait for all requests on all host ranks to complete before proceeding. + torch.distributed.barrier() + + # All-gather the generated tokens on every DP rank. + all_dp_generated_tokens = [None] * dp_size + torch.distributed.all_gather_object(all_dp_generated_tokens, all_generated_tokens) + for i in range(self.batch_size): + # Get the i-th generation for each DP rank. + dp_batch = [tuple(batch[i]) for batch in all_dp_generated_tokens] + assert len(set(dp_batch)) == len( + dp_batch + ), "Detected duplicate generations across DP ranks." From dfe9d04edc406cadf1c7c97a95c38a9692ae914e Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Fri, 24 Jul 2026 12:51:15 -0700 Subject: [PATCH 106/290] Ensure Mamba prefix cache snapshots are recorded for multi-chunk prompts (#5952) Signed-off-by: Keshav Santhanam --- .../core/inference/engines/dynamic_engine.py | 29 +++++++ .../engines/test_hybrid_prefix_caching_e2e.py | 80 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index c0372127e98..0e9105fbd59 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -1852,6 +1852,35 @@ def schedule_chunked_prefill(self): prefill_chunk_length = prefix_skip + computed_chunk + # Mamba prefix caching: keep chunk boundaries block-aligned. + # compute_and_store_offsets() records a recurrent-state snapshot at a + # KV-block boundary only when that boundary lands on a multiple of the + # SSM chunk size measured FROM the start of the current prefill chunk + # (it filters on `offset % mamba_chunk_size == 0`, where the chunk start + # equals `finished_chunk_token_count` on continuation chunks). Block + # boundaries are multiples of `block_size_tokens` (itself a multiple of + # the SSM chunk size), so the filter only passes when + # `finished_chunk_token_count` is block-aligned. If a chunk ends at an + # arbitrary token offset, every candidate boundary in the following + # chunks becomes unrecordable and the last-block snapshot that lets a + # future request skip prefill is silently dropped. Stop a partial + # (non-final) chunk short at the nearest lower block boundary so the + # running `finished_chunk_token_count` stays block-aligned. + if ( + self.context.is_hybrid_model + and self.context.mamba_slot_allocator is not None + and prefill_chunk_length < remaining_len + ): + block_size = self.context.block_size_tokens + chunk_end = req.finished_chunk_token_count + prefill_chunk_length + aligned_end = (chunk_end // block_size) * block_size + aligned_chunk_length = aligned_end - req.finished_chunk_token_count + # Only snap down when the aligned chunk still computes at least one + # token beyond the skipped prefix (a chunk whose budget is smaller + # than a block cannot be block-aligned; leave it unchanged). + if aligned_chunk_length > prefix_skip: + prefill_chunk_length = aligned_chunk_length + # Flash-attn guard: if this chunk would leave exactly 1 token for the # final chunk, reduce by 1 (or defer if we only have 1 computed token). # See https://github.com/Dao-AILab/flash-attention/issues/1537 diff --git a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py index 149c2c7fc22..0e92304c6dc 100644 --- a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py +++ b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py @@ -192,6 +192,9 @@ def _build_engine( prefix_caching_mamba_gb=2.0, request_rounder=4, num_cuda_graphs=None, + enable_chunked_prefill=False, + max_tokens=None, + max_requests=None, ): set_rounder(request_rounder) inference_config_kwargs = dict( @@ -204,7 +207,12 @@ def _build_engine( unified_memory_level=0, num_cuda_graphs=num_cuda_graphs, sampling_backend='torch', + enable_chunked_prefill=enable_chunked_prefill, ) + if max_tokens is not None: + inference_config_kwargs['max_tokens'] = max_tokens + if max_requests is not None: + inference_config_kwargs['max_requests'] = max_requests if enable_prefix_caching: inference_config_kwargs.update( prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, @@ -624,3 +632,75 @@ def _run_one(req_id, prompt): assert req_G._mamba_num_matched_blocks == 0 assert h_E0 in ctx.mamba_slot_allocator.hash_to_block_id assert finished[0] == finished[2] + + @torch.inference_mode() + def test_mamba_chunked_prefill_unaligned_boundary_snapshot(self): + """Chunked prefill snapshots Mamba state at the last block boundary. + + ``compute_and_store_offsets`` records a Mamba state snapshot at a KV-block + boundary only when that boundary is a whole multiple of the SSM chunk size + measured from the start of the current prefill chunk. Because the chunk + start equals ``finished_chunk_token_count`` on continuation chunks, this + holds exactly when every chunk boundary is block-aligned. + + Here ``max_tokens`` (300) is intentionally not a multiple of the block size + (256), so the request spans several chunks and its last full-block boundary + (token 768) lands in a continuation chunk. The scheduler keeps each chunk + boundary block-aligned, so the final chunk begins at token 512 and the + token-768 snapshot is extracted and committed. A second request sharing the + 768-token prefix then restores that state and skips those blocks. + """ + skip_if_mamba_sequence_packing_not_available() + model = self._create_model() + mamba_config = MambaInferenceStateConfig.from_model(model) + + device = torch.cuda.current_device() + # 800-token prompt -> 3 full blocks (256/512/768) + a 32-token tail. + # The last full-block boundary (768) falls in the final continuation chunk. + prompt = torch.arange(9000, 9800, dtype=torch.int64, device=device) + assert len(prompt) == 800 + + engine = self._build_engine( + model, + mamba_config, + enable_prefix_caching=True, + enable_chunked_prefill=True, + max_tokens=300, # not a multiple of BLOCK_SIZE (256) -> forces unaligned cuts + max_requests=4, + request_rounder=4, + ) + ctx = engine.context + # Sanity: the prompt genuinely spans multiple prefill chunks. + assert ctx.max_tokens < len(prompt) + + # --- Seed request: fills the cache, no prior matches. --- + seed = self._make_request(0, prompt, enable_pc=True, num_tokens=4) + engine._add_request(seed) + while engine.has_unfinished_requests(): + engine.step_modern() + # Seed has no prior cache, so no Mamba blocks are matched during its prefill. + assert seed._mamba_num_matched_blocks == 0 + + # block index 2 == the boundary at token 768 (768 // 256 - 1). The final + # chunk begins block-aligned at token 512, so (768 - 512) % 128 == 0 and + # the state at this boundary is extracted and committed. + assert len(seed.precomputed_block_hashes) == 3 + last_block_hash = seed.precomputed_block_hashes[2] + assert ( + last_block_hash in ctx.mamba_slot_allocator.hash_to_block_id + ), "Mamba snapshot at the last block boundary (token 768) was not recorded." + + # --- Reuse request: shares the full 768-token prefix, should restore the + # cached Mamba state and skip those blocks entirely. --- + reuse_prompt = torch.cat( + [prompt[:768], torch.arange(9800, 9900, dtype=torch.int64, device=device)] + ) + reuse = self._make_request(1, reuse_prompt, enable_pc=True, num_tokens=4) + engine._add_request(reuse) + while engine.has_unfinished_requests(): + engine.step_modern() + + assert reuse._mamba_num_matched_blocks == 3, ( + "Reuse request should restore Mamba state from the token-768 snapshot " + f"(3 matched blocks), got {reuse._mamba_num_matched_blocks}." + ) From 50cc27a80bc2027455fded2f92980dc070ccfbce Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sat, 25 Jul 2026 02:01:48 +0200 Subject: [PATCH 107/290] ci(actions): AUT-977 retry transient log artifact uploads (#6027) Signed-off-by: svcnemo-autobot --- .github/actions/action.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/actions/action.yml b/.github/actions/action.yml index b4261cf1ce9..069ac4395df 100644 --- a/.github/actions/action.yml +++ b/.github/actions/action.yml @@ -306,9 +306,24 @@ runs: include-hidden-files: true - name: Upload logs + id: upload-logs uses: actions/upload-artifact@v6 if: always() + continue-on-error: true with: name: ${{ steps.check.outputs.logs_report }} path: ${{ inputs.is_unit_test == 'true' && 'assets_dir/logs' || 'assets_dir' }} include-hidden-files: true + + - name: Back off after log upload failure + if: ${{ always() && steps.upload-logs.outcome == 'failure' }} + shell: bash + run: sleep 10 + + - name: Retry log upload + uses: actions/upload-artifact@v6 + if: ${{ always() && steps.upload-logs.outcome == 'failure' }} + with: + name: ${{ steps.check.outputs.logs_report }}-retry + path: ${{ inputs.is_unit_test == 'true' && 'assets_dir/logs' || 'assets_dir' }} + include-hidden-files: true From 21fe0fe1597932421f1bb0efd93f469376a7a255 Mon Sep 17 00:00:00 2001 From: Ajay Date: Fri, 24 Jul 2026 17:24:25 -0700 Subject: [PATCH 108/290] fix: add additional error checks for flaky failures (#6029) Signed-off-by: Ajay Balasa --- tests/test_utils/python_scripts/launch_jet_workload.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_utils/python_scripts/launch_jet_workload.py b/tests/test_utils/python_scripts/launch_jet_workload.py index 543db4f904c..a6e5f330259 100644 --- a/tests/test_utils/python_scripts/launch_jet_workload.py +++ b/tests/test_utils/python_scripts/launch_jet_workload.py @@ -414,6 +414,8 @@ def is_flaky_failure(concat_allranks_logs: str) -> bool: or "free(): corrupted unsorted chunks" in concat_allranks_logs or "Segfault encountered" in concat_allranks_logs or "Fatal glibc error" in concat_allranks_logs + or "Disk quota exceeded" in concat_allranks_logs + or "basic_ios::clear: iostream error" in concat_allranks_logs ) From 10c93836c69eed271867d311adc9ec63d2bbe9c6 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sat, 25 Jul 2026 09:18:21 +0200 Subject: [PATCH 109/290] fix(inference): AUT-980 disable fp8 primary weights in graph tests (#6038) Signed-off-by: svcnemo-autobot --- .../model_config.yaml | 1 - .../model_config.yaml | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml index 21fd7749ea7..71701790bc0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml @@ -30,7 +30,6 @@ MODEL_ARGS: --bf16: true --fp8-recipe: tensorwise --fp8-format: hybrid - --fp8-param-gather: true --first-last-layers-bf16: true --log-memory-to-tensorboard: true --log-num-zeros-in-grad: true diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml index 9996433cf1f..d9298d649c6 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml @@ -30,7 +30,6 @@ MODEL_ARGS: --bf16: true --fp8-recipe: tensorwise --fp8-format: hybrid - --fp8-param-gather: true --first-last-layers-bf16: true --log-memory-to-tensorboard: true --log-num-zeros-in-grad: true From 7e6a045a2a99659ddd2919fc19013a164c508825 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sat, 25 Jul 2026 18:06:26 +0200 Subject: [PATCH 110/290] chore(deps): AUT-967 stabilize Transformer Engine 2.18 upgrade (#5997) Signed-off-by: Ajay Balasa Signed-off-by: svcnemo-autobot Co-authored-by: Ajay Balasa --- docker/Dockerfile.ci.dev | 11 +- docker/common/install_nccl.sh | 2 +- .../core/extensions/transformer_engine.py | 2 + pyproject.toml | 7 +- .../golden_values_dev_dgx_gb200.json | 163 ++--- .../golden_values_dev_dgx_gb200.json | 123 ++-- .../golden_values_dev_dgx_gb200.json | 643 ++++++++++++++++++ .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../golden_values_dev_dgx_h100.json | 16 +- .../models/test_moe_experts.py | 3 + .../test_transformer_engine_grouped_linear.py | 12 + uv.lock | 10 +- 15 files changed, 821 insertions(+), 175 deletions(-) create mode 100644 tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_gb200.json diff --git a/docker/Dockerfile.ci.dev b/docker/Dockerfile.ci.dev index bf9ddbe8ed4..e8d7efa6cc8 100644 --- a/docker/Dockerfile.ci.dev +++ b/docker/Dockerfile.ci.dev @@ -7,8 +7,8 @@ ENV PIP_CONSTRAINT="" ENV DEBIAN_FRONTEND=noninteractive ARG UV_VERSION=0.7.2 ARG YQ_VERSION=4.44.1 -# NCCL 2.30.4 supports CUDA 13.x; NVIDIA publishes its CUDA 13 package as cuda13.2. -ARG NCCL_VERSION=2.30.4-1+cuda13.2 +# TE 2.18's NCCL EP build requires NCCL 2.30.7+; the 26.06 base uses CUDA 13.3. +ARG NCCL_VERSION=2.30.7-1+cuda13.3 ENV PATH="/root/.local/bin:$PATH" ARG UV_PROJECT_ENVIRONMENT=/opt/venv ENV UV_PROJECT_ENVIRONMENT=${UV_PROJECT_ENVIRONMENT} @@ -56,7 +56,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \ fi uv venv ${UV_PROJECT_ENVIRONMENT} --system-site-packages uv sync --only-group build - uv sync --extra ${IMAGE_TYPE} --extra mlm --extra ssm --extra te ${FLASH_MLA_GROUP} --link-mode copy --locked \ + # Limit per-package compiler concurrency on ARM. + if [ "$(uname -m)" = "aarch64" ]; then + export MAX_JOBS=8 + fi + uv sync -v \ + --extra ${IMAGE_TYPE} --extra mlm --extra ssm --extra te ${FLASH_MLA_GROUP} --link-mode copy --locked \ --no-install-package torch \ --no-install-package torchvision \ --no-install-package triton \ diff --git a/docker/common/install_nccl.sh b/docker/common/install_nccl.sh index b303009b625..786e6116e49 100644 --- a/docker/common/install_nccl.sh +++ b/docker/common/install_nccl.sh @@ -16,7 +16,7 @@ set -ex -NCCL_VER="2.30.4-1+cuda13.2" +NCCL_VER="2.30.7-1+cuda13.3" for i in "$@"; do case $i in diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 3a8d7f23d09..a5df48354de 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -2284,6 +2284,8 @@ def _split_extra_state(self, state): return [state] * self.num_gemms state = self._decode_extra_state(state) + if state is None: + return [torch.empty(0, dtype=torch.uint8)] * self.num_gemms extra_states = [] extra_fp8_variables = state["extra_fp8_variables"] extra_fp8_variables["num_gemms"] = 1 diff --git a/pyproject.toml b/pyproject.toml index f35e7627ceb..653321556da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,7 +163,8 @@ build = [ "pybind11", "Cython>=3.0.0", "torch", - "nvidia-mathdx", # for TE + "nvidia-cudnn-frontend>=1.25.0", # for TE + "nvidia-mathdx", # for TE ] linting = [ "ruff~=0.9.0", @@ -203,7 +204,7 @@ requires-dist = [] [[tool.uv.dependency-metadata]] name = "transformer-engine" -version = "2.17.0+2e559f06" +version = "2.18.0+e7c550c5" requires-dist = [ # Cap below 2.14: pydantic 2.14 breaks langchain_core's module-level # RunnablePassthrough() instantiation, which is imported transitively in @@ -228,7 +229,7 @@ requires-dist = ["torch", "packaging", "ninja"] flash_mla = [ { git = "https://github.com/deepseek-ai/FlashMLA", rev = "nv_dev" }, ] -transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "2e559f062497bef768dfbe9d7e45548fadeca80a" } +transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" } nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "17ae86b64d7f75653351664f5d8c9e466faede00" } emerging_optimizers = { git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git", rev = "v0.2.0" } fast-hadamard-transform = { git = "https://github.com/Dao-AILab/fast-hadamard-transform.git", rev = "f134af63deb2df17e1171a9ec1ea4a7d8604d5ca" } diff --git a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/golden_values_dev_dgx_gb200.json index 194f987a54b..ec514555615 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/golden_values_dev_dgx_gb200.json +++ b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/golden_values_dev_dgx_gb200.json @@ -11,24 +11,24 @@ "5": 12.59271, "6": 12.5926, "7": 12.57996, - "8": 12.54298, + "8": 12.54297, "9": 12.51056, - "10": 12.49675, - "11": 12.3289, - "12": 12.29947, - "13": 12.23477, - "14": 12.23314, - "15": 11.81705, - "16": 11.80136, - "17": 11.76443, - "18": 11.73997, - "19": 11.60915, - "20": 11.5065, - "21": 11.26958, - "22": 11.37974, - "23": 11.28805, - "24": 11.16345, - "25": 10.99906 + "10": 12.49679, + "11": 12.32899, + "12": 12.29951, + "13": 12.23485, + "14": 12.23311, + "15": 11.81703, + "16": 11.80142, + "17": 11.76436, + "18": 11.74003, + "19": 11.609, + "20": 11.50653, + "21": 11.26959, + "22": 11.37981, + "23": 11.28811, + "24": 11.16346, + "25": 10.99907 } }, "num-zeros": { @@ -43,24 +43,24 @@ "5": 520995904.0, "6": 521373344.0, "7": 521419904.0, - "8": 521057824.0, - "9": 521461920.0, - "10": 521178048.0, - "11": 522280352.0, - "12": 521439424.0, - "13": 521476608.0, - "14": 522446400.0, - "15": 521592960.0, - "16": 521416256.0, - "17": 521026624.0, - "18": 521278848.0, - "19": 521153408.0, - "20": 521134528.0, - "21": 522908192.0, - "22": 521590080.0, - "23": 521352192.0, - "24": 521425184.0, - "25": 523544480.0 + "8": 521058080.0, + "9": 521461632.0, + "10": 521178880.0, + "11": 522279200.0, + "12": 521440128.0, + "13": 521476352.0, + "14": 522446304.0, + "15": 521592384.0, + "16": 521415808.0, + "17": 521026688.0, + "18": 521277952.0, + "19": 521154016.0, + "20": 521132896.0, + "21": 522907936.0, + "22": 521589216.0, + "23": 521351264.0, + "24": 521424512.0, + "25": 523543552.0 } }, "mem-allocated-bytes": { @@ -100,63 +100,50 @@ "end_step": 25, "step_interval": 1, "values": { - "1": 52730814464.0, - "2": 60519473152.0, - "3": 60519473152.0, - "4": 60519473152.0, - "5": 60519473152.0, - "6": 60519473152.0, - "7": 60519473152.0, - "8": 60519473152.0, - "9": 60519473152.0, - "10": 60519473152.0, - "11": 60519473152.0, - "12": 60519473152.0, - "13": 60519473152.0, - "14": 60519473152.0, - "15": 60519473152.0, - "16": 60519473152.0, - "17": 60519473152.0, - "18": 60519473152.0, - "19": 60519473152.0, - "20": 60519473152.0, - "21": 60519473152.0, - "22": 60519473152.0, - "23": 60519473152.0, - "24": 60519473152.0, - "25": 60519473152.0 + "1": 52730679296.0, + "2": 60518293504.0, + "3": 60518293504.0, + "4": 60518293504.0, + "5": 60518293504.0, + "6": 60519342080.0, + "7": 60519342080.0, + "8": 60519342080.0, + "9": 60519342080.0, + "10": 60519342080.0, + "11": 60519342080.0, + "12": 60519342080.0, + "13": 60519342080.0, + "14": 60519342080.0, + "15": 60519342080.0, + "16": 60519342080.0, + "17": 60519342080.0, + "18": 60519342080.0, + "19": 60519342080.0, + "20": 60519342080.0, + "21": 60519342080.0, + "22": 60519342080.0, + "23": 60519342080.0, + "24": 60519342080.0, + "25": 60519342080.0 } }, "iteration-time": { - "start_step": 1, - "end_step": 25, - "step_interval": 1, + "start_step": 2, + "end_step": 24, + "step_interval": 2, "values": { - "1": "nan", - "2": 12.34355, - "3": "nan", - "4": 0.82427, - "5": "nan", - "6": 0.7895, - "7": "nan", - "8": 0.78941, - "9": "nan", - "10": 0.78954, - "11": "nan", - "12": 0.78913, - "13": "nan", - "14": 0.78971, - "15": "nan", - "16": 0.78895, - "17": "nan", - "18": 0.78925, - "19": "nan", - "20": 0.78861, - "21": "nan", - "22": 0.80114, - "23": "nan", - "24": 0.7878, - "25": "nan" + "2": 8.5543, + "4": 0.8124, + "6": 0.7891, + "8": 0.78729, + "10": 0.78713, + "12": 0.78559, + "14": 0.78688, + "16": 0.99679, + "18": 0.78545, + "20": 1.00358, + "22": 0.78589, + "24": 0.7843 } } } \ No newline at end of file diff --git a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/golden_values_dev_dgx_gb200.json index 93c46e85f35..cdf05080630 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/golden_values_dev_dgx_gb200.json +++ b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/golden_values_dev_dgx_gb200.json @@ -21,14 +21,14 @@ "15": 11.82248, "16": 11.80414, "17": 11.76134, - "18": 11.73724, - "19": 11.6132, - "20": 11.50159, + "18": 11.73729, + "19": 11.61311, + "20": 11.50146, "21": 11.2649, - "22": 11.37652, - "23": 11.28407, - "24": 11.15662, - "25": 10.99872 + "22": 11.37648, + "23": 11.28398, + "24": 11.15661, + "25": 10.99873 } }, "num-zeros": { @@ -52,15 +52,15 @@ "14": 524478016.0, "15": 523636992.0, "16": 523464160.0, - "17": 523079488.0, - "18": 523362784.0, - "19": 523210592.0, - "20": 523228960.0, - "21": 524938144.0, - "22": 523659552.0, - "23": 523415648.0, - "24": 523485952.0, - "25": 525637760.0 + "17": 523079808.0, + "18": 523362944.0, + "19": 523208960.0, + "20": 523228832.0, + "21": 524936704.0, + "22": 523660416.0, + "23": 523415808.0, + "24": 523485984.0, + "25": 525638784.0 } }, "mem-allocated-bytes": { @@ -101,62 +101,49 @@ "step_interval": 1, "values": { "1": 51363229696.0, - "2": 58217480192.0, - "3": 58217480192.0, - "4": 58217480192.0, - "5": 58217480192.0, - "6": 58217480192.0, - "7": 58217480192.0, - "8": 58217480192.0, - "9": 58217480192.0, - "10": 58217480192.0, - "11": 58217480192.0, - "12": 58217480192.0, - "13": 58217480192.0, - "14": 58217480192.0, - "15": 58217480192.0, - "16": 58217480192.0, - "17": 58217480192.0, - "18": 58217480192.0, - "19": 58217480192.0, - "20": 58217480192.0, - "21": 58217480192.0, - "22": 58217480192.0, - "23": 58217480192.0, - "24": 58217480192.0, - "25": 58217480192.0 + "2": 58217476096.0, + "3": 58217476096.0, + "4": 58217476096.0, + "5": 58217476096.0, + "6": 58217476096.0, + "7": 58217476096.0, + "8": 58217476096.0, + "9": 58217476096.0, + "10": 58217476096.0, + "11": 58217476096.0, + "12": 58217476096.0, + "13": 58217476096.0, + "14": 58217476096.0, + "15": 58217476096.0, + "16": 58217476096.0, + "17": 58217476096.0, + "18": 58217476096.0, + "19": 58217476096.0, + "20": 58217476096.0, + "21": 58217476096.0, + "22": 58217476096.0, + "23": 58217476096.0, + "24": 58217476096.0, + "25": 58217476096.0 } }, "iteration-time": { - "start_step": 1, - "end_step": 25, - "step_interval": 1, + "start_step": 2, + "end_step": 24, + "step_interval": 2, "values": { - "1": "nan", - "2": 6.3811, - "3": "nan", - "4": 0.89501, - "5": "nan", - "6": 0.8758, - "7": "nan", - "8": 0.87508, - "9": "nan", - "10": 0.87542, - "11": "nan", - "12": 0.8777, - "13": "nan", - "14": 0.87555, - "15": "nan", - "16": 0.87675, - "17": "nan", - "18": 0.87727, - "19": "nan", - "20": 0.90219, - "21": "nan", - "22": 0.90185, - "23": "nan", - "24": 0.87477, - "25": "nan" + "2": 5.12464, + "4": 1.10296, + "6": 0.85346, + "8": 0.85394, + "10": 0.85229, + "12": 0.85257, + "14": 0.85257, + "16": 0.85029, + "18": 0.85109, + "20": 1.07621, + "22": 0.85078, + "24": 0.86196 } } } \ No newline at end of file diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_gb200.json new file mode 100644 index 00000000000..68a37fce071 --- /dev/null +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/golden_values_dev_dgx_gb200.json @@ -0,0 +1,643 @@ +{ + "lm loss": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 0.0, + "2": 0.0, + "3": 0.0, + "4": 0.0, + "5": 0.0, + "6": 0.0, + "7": 0.0, + "8": 0.0, + "9": 0.0, + "10": 0.0, + "11": 0.0, + "12": 0.0, + "13": 0.0, + "14": 0.0, + "15": 0.0, + "16": 0.0, + "17": 0.0, + "18": 0.0, + "19": 0.0, + "20": 0.0, + "21": 0.0, + "22": 0.0, + "23": 0.0, + "24": 0.0, + "25": 0.0, + "26": 0.0, + "27": 0.0, + "28": 0.0, + "29": 0.0, + "30": 0.0, + "31": 0.0, + "32": 0.0, + "33": 0.0, + "34": 0.0, + "35": 0.0, + "36": 0.0, + "37": 0.0, + "38": 0.0, + "39": 0.0, + "40": 0.0, + "41": 0.0, + "42": 0.0, + "43": 0.0, + "44": 0.0, + "45": 0.0, + "46": 0.0, + "47": 0.0, + "48": 0.0, + "49": 0.0, + "50": 0.0, + "51": 0.0, + "52": 0.0, + "53": 0.0, + "54": 0.0, + "55": 0.0, + "56": 0.0, + "57": 0.0, + "58": 0.0, + "59": 0.0, + "60": 0.0, + "61": 0.0, + "62": 0.0, + "63": 0.0, + "64": 0.0, + "65": 0.0, + "66": 0.0, + "67": 0.0, + "68": 0.0, + "69": 0.0, + "70": 0.0, + "71": 0.0, + "72": 0.0, + "73": 0.0, + "74": 0.0, + "75": 0.0, + "76": 0.0, + "77": 0.0, + "78": 0.0, + "79": 0.0, + "80": 0.0, + "81": 0.0, + "82": 0.0, + "83": 0.0, + "84": 0.0, + "85": 0.0, + "86": 0.0, + "87": 0.0, + "88": 0.0, + "89": 0.0, + "90": 0.0, + "91": 0.0, + "92": 0.0, + "93": 0.0, + "94": 0.0, + "95": 0.0, + "96": 0.0, + "97": 0.0, + "98": 0.0, + "99": 0.0, + "100": 0.0 + } + }, + "total loss": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 0.49229, + "2": 0.49235, + "3": 0.49306, + "4": 0.49235, + "5": 0.49191, + "6": 0.49253, + "7": 0.49213, + "8": 0.49258, + "9": 0.4937, + "10": 0.4928, + "11": 0.49252, + "12": 0.49348, + "13": 0.49141, + "14": 0.49211, + "15": 0.49198, + "16": 0.49259, + "17": 0.49109, + "18": 0.49167, + "19": 0.49252, + "20": 0.49093, + "21": 0.4912, + "22": 0.49168, + "23": 0.49231, + "24": 0.49276, + "25": 0.49294, + "26": 0.49277, + "27": 0.49224, + "28": 0.49234, + "29": 0.49234, + "30": 0.49298, + "31": 0.49185, + "32": 0.49196, + "33": 0.49228, + "34": 0.49226, + "35": 0.49223, + "36": 0.49134, + "37": 0.49055, + "38": 0.4907, + "39": 0.49142, + "40": 0.49151, + "41": 0.49131, + "42": 0.49104, + "43": 0.49075, + "44": 0.49093, + "45": 0.4897, + "46": 0.49004, + "47": 0.49013, + "48": 0.48992, + "49": 0.48896, + "50": 0.48992, + "51": 0.48905, + "52": 0.48885, + "53": 0.49017, + "54": 0.48791, + "55": 0.48706, + "56": 0.48701, + "57": 0.48685, + "58": 0.48532, + "59": 0.48558, + "60": 0.48393, + "61": 0.48541, + "62": 0.48361, + "63": 0.48138, + "64": 0.48017, + "65": 0.47951, + "66": 0.47917, + "67": 0.47919, + "68": 0.47884, + "69": 0.47925, + "70": 0.47516, + "71": 0.47456, + "72": 0.47368, + "73": 0.473, + "74": 0.47029, + "75": 0.47117, + "76": 0.46983, + "77": 0.46684, + "78": 0.46518, + "79": 0.46566, + "80": 0.46487, + "81": 0.46125, + "82": 0.46293, + "83": 0.45894, + "84": 0.45692, + "85": 0.45714, + "86": 0.45544, + "87": 0.45196, + "88": 0.45104, + "89": 0.45379, + "90": 0.44862, + "91": 0.4482, + "92": 0.4449, + "93": 0.44739, + "94": 0.4421, + "95": 0.44338, + "96": 0.44316, + "97": 0.43975, + "98": 0.44261, + "99": 0.43686, + "100": 0.43249 + } + }, + "num-zeros": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 24026928.0, + "2": 24143812.0, + "3": 24187244.0, + "4": 23877068.0, + "5": 23864828.0, + "6": 24045444.0, + "7": 23949008.0, + "8": 24026020.0, + "9": 23886348.0, + "10": 24120268.0, + "11": 24066420.0, + "12": 23899156.0, + "13": 23975336.0, + "14": 24178010.0, + "15": 23831062.0, + "16": 23945590.0, + "17": 23816820.0, + "18": 24135244.0, + "19": 23732252.0, + "20": 23890840.0, + "21": 23832704.0, + "22": 24119708.0, + "23": 23826508.0, + "24": 23976604.0, + "25": 24147570.0, + "26": 24493544.0, + "27": 24200086.0, + "28": 24034610.0, + "29": 23844336.0, + "30": 24090330.0, + "31": 23953480.0, + "32": 24038172.0, + "33": 24007426.0, + "34": 24113576.0, + "35": 24080250.0, + "36": 24163534.0, + "37": 24106944.0, + "38": 24232868.0, + "39": 23929552.0, + "40": 24008410.0, + "41": 23998468.0, + "42": 24167966.0, + "43": 24092170.0, + "44": 24091696.0, + "45": 24073596.0, + "46": 23743600.0, + "47": 24161662.0, + "48": 23921924.0, + "49": 24004830.0, + "50": 23789460.0, + "51": 24147816.0, + "52": 24110948.0, + "53": 23841400.0, + "54": 24148992.0, + "55": 24145062.0, + "56": 24073800.0, + "57": 24208330.0, + "58": 24028408.0, + "59": 24173804.0, + "60": 23985040.0, + "61": 24130440.0, + "62": 24025044.0, + "63": 24060980.0, + "64": 23777584.0, + "65": 23728788.0, + "66": 24203238.0, + "67": 24283560.0, + "68": 24057064.0, + "69": 24046326.0, + "70": 23915612.0, + "71": 23818212.0, + "72": 24018984.0, + "73": 24159400.0, + "74": 24251924.0, + "75": 24123716.0, + "76": 24075268.0, + "77": 24195152.0, + "78": 23979564.0, + "79": 23984260.0, + "80": 23956760.0, + "81": 24113864.0, + "82": 24171568.0, + "83": 24025912.0, + "84": 23832644.0, + "85": 24063532.0, + "86": 24206276.0, + "87": 23923704.0, + "88": 23940504.0, + "89": 24254836.0, + "90": 23856988.0, + "91": 23983564.0, + "92": 23931634.0, + "93": 24104520.0, + "94": 23955112.0, + "95": 24049972.0, + "96": 24021030.0, + "97": 23738292.0, + "98": 24436300.0, + "99": 23954008.0, + "100": 23844172.0 + } + }, + "mem-allocated-bytes": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 733251072.0, + "2": 733251072.0, + "3": 733251072.0, + "4": 733251072.0, + "5": 733251072.0, + "6": 733251072.0, + "7": 733251072.0, + "8": 733251072.0, + "9": 733251072.0, + "10": 733251072.0, + "11": 733251072.0, + "12": 733251072.0, + "13": 733251072.0, + "14": 733251072.0, + "15": 733251072.0, + "16": 733251072.0, + "17": 733251072.0, + "18": 733251072.0, + "19": 733251072.0, + "20": 733251072.0, + "21": 733251072.0, + "22": 733251072.0, + "23": 733251072.0, + "24": 733251072.0, + "25": 733251072.0, + "26": 733251072.0, + "27": 733251072.0, + "28": 733251072.0, + "29": 733251072.0, + "30": 733251072.0, + "31": 733251072.0, + "32": 733251072.0, + "33": 733251072.0, + "34": 733251072.0, + "35": 733251072.0, + "36": 733251072.0, + "37": 733251072.0, + "38": 733251072.0, + "39": 733251072.0, + "40": 733251072.0, + "41": 733251072.0, + "42": 733251072.0, + "43": 733251072.0, + "44": 733251072.0, + "45": 733251072.0, + "46": 733251072.0, + "47": 733251072.0, + "48": 733251072.0, + "49": 733251072.0, + "50": 733251072.0, + "51": 733251072.0, + "52": 733251072.0, + "53": 733251072.0, + "54": 733251072.0, + "55": 733251072.0, + "56": 733251072.0, + "57": 733251072.0, + "58": 733251072.0, + "59": 733251072.0, + "60": 733251072.0, + "61": 733251072.0, + "62": 733251072.0, + "63": 733251072.0, + "64": 733251072.0, + "65": 733251072.0, + "66": 733251072.0, + "67": 733251072.0, + "68": 733251072.0, + "69": 733251072.0, + "70": 733251072.0, + "71": 733251072.0, + "72": 733251072.0, + "73": 733251072.0, + "74": 733251072.0, + "75": 733251072.0, + "76": 733251072.0, + "77": 733251072.0, + "78": 733251072.0, + "79": 733251072.0, + "80": 733251072.0, + "81": 733251072.0, + "82": 733251072.0, + "83": 733251072.0, + "84": 733251072.0, + "85": 733251072.0, + "86": 733251072.0, + "87": 733251072.0, + "88": 733251072.0, + "89": 733251072.0, + "90": 733251072.0, + "91": 733251072.0, + "92": 733251072.0, + "93": 733251072.0, + "94": 733251072.0, + "95": 733251072.0, + "96": 733251072.0, + "97": 733251072.0, + "98": 733251072.0, + "99": 733251072.0, + "100": 733251072.0 + } + }, + "mem-max-allocated-bytes": { + "start_step": 1, + "end_step": 100, + "step_interval": 1, + "values": { + "1": 3237344256.0, + "2": 3326439424.0, + "3": 3326439424.0, + "4": 3326439424.0, + "5": 3326439424.0, + "6": 3326439424.0, + "7": 3326439424.0, + "8": 3326439424.0, + "9": 3326439424.0, + "10": 3326439424.0, + "11": 3326439424.0, + "12": 3326439424.0, + "13": 3326439424.0, + "14": 3326439424.0, + "15": 3326504960.0, + "16": 3326504960.0, + "17": 3326504960.0, + "18": 3326504960.0, + "19": 3326504960.0, + "20": 3326504960.0, + "21": 3326504960.0, + "22": 3326504960.0, + "23": 3326504960.0, + "24": 3326504960.0, + "25": 3326504960.0, + "26": 3326504960.0, + "27": 3326504960.0, + "28": 3326504960.0, + "29": 3326504960.0, + "30": 3326504960.0, + "31": 3326504960.0, + "32": 3326504960.0, + "33": 3326504960.0, + "34": 3326504960.0, + "35": 3326504960.0, + "36": 3326963712.0, + "37": 3326963712.0, + "38": 3326963712.0, + "39": 3326963712.0, + "40": 3326963712.0, + "41": 3326963712.0, + "42": 3326963712.0, + "43": 3326963712.0, + "44": 3326963712.0, + "45": 3326963712.0, + "46": 3326963712.0, + "47": 3326963712.0, + "48": 3326963712.0, + "49": 3326963712.0, + "50": 3326963712.0, + "51": 3328271872.0, + "52": 3328271872.0, + "53": 3328271872.0, + "54": 3328271872.0, + "55": 3328271872.0, + "56": 3328271872.0, + "57": 3328271872.0, + "58": 3328271872.0, + "59": 3328271872.0, + "60": 3328271872.0, + "61": 3328271872.0, + "62": 3328271872.0, + "63": 3328271872.0, + "64": 3328271872.0, + "65": 3328271872.0, + "66": 3328271872.0, + "67": 3328271872.0, + "68": 3328271872.0, + "69": 3328271872.0, + "70": 3328271872.0, + "71": 3328271872.0, + "72": 3328271872.0, + "73": 3328271872.0, + "74": 3328271872.0, + "75": 3328271872.0, + "76": 3328271872.0, + "77": 3328271872.0, + "78": 3328271872.0, + "79": 3328271872.0, + "80": 3328271872.0, + "81": 3328271872.0, + "82": 3328271872.0, + "83": 3328271872.0, + "84": 3328271872.0, + "85": 3328271872.0, + "86": 3328271872.0, + "87": 3328271872.0, + "88": 3328271872.0, + "89": 3328271872.0, + "90": 3328271872.0, + "91": 3328271872.0, + "92": 3328271872.0, + "93": 3328271872.0, + "94": 3328271872.0, + "95": 3328271872.0, + "96": 3328271872.0, + "97": 3328271872.0, + "98": 3328271872.0, + "99": 3328271872.0, + "100": 3328271872.0 + } + }, + "iteration-time": { + "start_step": 2, + "end_step": 100, + "step_interval": 1, + "values": { + "2": 5.4207, + "3": 0.32071, + "4": 0.32419, + "5": 0.27463, + "6": 0.27557, + "7": 0.27601, + "8": 0.27318, + "9": 0.27411, + "10": 0.27381, + "11": 0.27545, + "12": 0.30916, + "13": 0.31371, + "14": 0.3026, + "15": 0.29432, + "16": 0.31261, + "17": 0.31431, + "18": 0.30656, + "19": 0.3174, + "20": 0.32061, + "21": 0.31778, + "22": 0.35357, + "23": 0.3637, + "24": 0.40747, + "25": 0.37189, + "26": 0.40857, + "27": 0.30615, + "28": 0.31235, + "29": 0.31541, + "30": 0.4272, + "31": 0.40504, + "32": 0.51821, + "33": 0.46673, + "34": 0.42968, + "35": 0.414, + "36": 0.39324, + "37": 0.31311, + "38": 0.30297, + "39": 0.3022, + "40": 0.30204, + "41": 0.30404, + "42": 0.3041, + "43": 0.31703, + "44": 0.30981, + "45": 0.30867, + "46": 0.30996, + "47": 0.31229, + "48": 0.31038, + "49": 0.30472, + "50": 0.32046, + "51": 0.57524, + "52": 0.36905, + "53": 0.3012, + "54": 0.31617, + "55": 0.31476, + "56": 0.32079, + "57": 0.31553, + "58": 0.30275, + "59": 0.33016, + "60": 0.32202, + "61": 0.31778, + "62": 0.32418, + "63": 0.31518, + "64": 0.3137, + "65": 0.31372, + "66": 0.31738, + "67": 0.31707, + "68": 0.31289, + "69": 0.31334, + "70": 0.311, + "71": 0.30529, + "72": 0.31341, + "73": 0.30623, + "74": 0.31657, + "75": 0.31992, + "76": 0.31396, + "77": 0.31781, + "78": 0.32836, + "79": 0.32318, + "80": 0.32087, + "81": 0.31407, + "82": 0.30679, + "83": 0.3123, + "84": 0.31429, + "85": 0.31233, + "86": 0.30659, + "87": 0.31428, + "88": 0.32007, + "89": 0.34521, + "90": 0.32648, + "91": 0.31607, + "92": 0.3249, + "93": 0.32018, + "94": 0.32003, + "95": 0.31498, + "96": 0.30649, + "97": 0.30738, + "98": 0.30612, + "99": 0.31249, + "100": 0.30688 + } + } +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml index 40b45024cb1..e4cd2240764 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml index 9a47281703a..b79909b1d74 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml index 99bcc433ad1..381e694cbe6 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml index 1c78b466b1e..0d6834ee2d6 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json index 2b2029d40f9..2922120bca8 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/golden_values_dev_dgx_h100.json @@ -6,14 +6,14 @@ "values": { "1": 1.58285, "2": 1.6902, - "3": 0.1002, - "4": 0.06419, - "5": 0.06295, - "6": 0.06725, - "7": 0.16463, - "8": 0.06043, - "9": 0.06715, - "10": 0.05355 + "3": 0.08676, + "4": 0.064, + "5": 0.06231, + "6": 0.06777, + "7": 0.16431, + "8": 0.06122, + "9": 0.06703, + "10": 0.05211 } }, "lm loss": { diff --git a/tests/unit_tests/dist_checkpointing/models/test_moe_experts.py b/tests/unit_tests/dist_checkpointing/models/test_moe_experts.py index 57de698ddff..126968a3af9 100644 --- a/tests/unit_tests/dist_checkpointing/models/test_moe_experts.py +++ b/tests/unit_tests/dist_checkpointing/models/test_moe_experts.py @@ -346,6 +346,7 @@ def test_sequential_grouped_mlp_interchangeable( def test_sequential_grouped_mlp_extra_state( self, tmp_path_dist_ckpt, + monkeypatch, src_tp_pp_exp, dest_tp_pp_exp, src_module, @@ -393,6 +394,8 @@ def test_sequential_grouped_mlp_extra_state( ckpt_dir_A, load_strategy, ) + # This checkpoint was created by the test and is therefore trusted. + monkeypatch.setenv("NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE", "1") model_A.load_state_dict( {k.removeprefix(layer_prefix): v for k, v in state_dict.items()} ) diff --git a/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py b/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py index 467a6c31322..d0a411091f7 100644 --- a/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py +++ b/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py @@ -36,6 +36,18 @@ def _empty_load_args(): return {}, True, [], [], [] +def test_split_empty_extra_state_for_stateless_recipe(): + module = _grouped_linear_stub(num_gemms=2) + module.fp8_meta = {"fp8_checkpoint": True} + module.fp8 = False + module.fp8_calibration = False + + states = module._split_extra_state(torch.empty(0, dtype=torch.uint8)) + + assert len(states) == 2 + assert all(state.dtype == torch.uint8 and state.numel() == 0 for state in states) + + def test_split_grouped_checkpoint_tensor_uses_quantized_members(): module = _grouped_linear_stub(num_gemms=2) members = [torch.tensor([1, 2]), torch.tensor([3, 4])] diff --git a/uv.lock b/uv.lock index dbbbab03056..e66b7c9f996 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ version = "1.0.0+9edee0c" [[manifest.dependency-metadata]] name = "transformer-engine" -version = "2.17.0+2e559f06" +version = "2.18.0+e7c550c5" requires-dist = ["pydantic<2.14", "importlib-metadata>=1.0", "packaging", "torch>=2.1", "einops", "onnxscript", "onnx", "nvdlfw-inspect"] [[package]] @@ -2223,6 +2223,7 @@ training = [ build = [ { name = "cython" }, { name = "hatchling" }, + { name = "nvidia-cudnn-frontend" }, { name = "nvidia-mathdx" }, { name = "packaging" }, { name = "pybind11" }, @@ -2308,7 +2309,7 @@ requires-dist = [ { name = "tiktoken", marker = "extra == 'training'" }, { name = "torch", specifier = ">=2.6.0" }, { name = "tqdm", marker = "extra == 'dev'" }, - { name = "transformer-engine", extras = ["core-cu13", "pytorch"], marker = "extra == 'te'", git = "https://github.com/NVIDIA/TransformerEngine.git?rev=2e559f062497bef768dfbe9d7e45548fadeca80a" }, + { name = "transformer-engine", extras = ["core-cu13", "pytorch"], marker = "extra == 'te'", git = "https://github.com/NVIDIA/TransformerEngine.git?rev=e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" }, { name = "transformers", marker = "extra == 'mlm'" }, { name = "transformers", marker = "extra == 'training'" }, { name = "wandb", marker = "extra == 'mlm'" }, @@ -2322,6 +2323,7 @@ provides-extras = ["training", "mlm", "dev", "lts", "te", "ssm"] build = [ { name = "cython", specifier = ">=3.0.0" }, { name = "hatchling" }, + { name = "nvidia-cudnn-frontend", specifier = ">=1.25.0" }, { name = "nvidia-mathdx" }, { name = "packaging", specifier = ">=24.2" }, { name = "pybind11" }, @@ -5166,8 +5168,8 @@ wheels = [ [[package]] name = "transformer-engine" -version = "2.17.0+2e559f06" -source = { git = "https://github.com/NVIDIA/TransformerEngine.git?rev=2e559f062497bef768dfbe9d7e45548fadeca80a#2e559f062497bef768dfbe9d7e45548fadeca80a" } +version = "2.18.0+e7c550c5" +source = { git = "https://github.com/NVIDIA/TransformerEngine.git?rev=e7c550c5f80636cf841a8204b1d6f85a5f3f28b7#e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" } dependencies = [ { name = "einops" }, { name = "importlib-metadata" }, From c5ff22b7f11822e0a36526b0434708d99c8206b0 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Sun, 26 Jul 2026 03:17:31 +0800 Subject: [PATCH 111/290] [feat] Generalized Tensor Parallelism (GTP) (#4967) Signed-off-by: Shiqing Fan Signed-off-by: Deepak Narayanan Signed-off-by: Jiangfei Duan Co-authored-by: Jieming Zhang Co-authored-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 Co-authored-by: Jiangfei Duan --- .../core/generalized_tensor_parallel.md | 495 ++++ docs/api-guide/core/index.md | 1 + .../0611_ddp_egtp_orthogonal_bucketing.png | Bin 0 -> 187192 bytes .../0612_gtp_dcp_tp2gtp2_save_load.png | Bin 0 -> 188105 bytes .../0613_gtp_dcp_save_call_workflow.png | Bin 0 -> 147645 bytes .../0617_gtp64_weak_scaling_efficiency.png | Bin 0 -> 133612 bytes .../0628_gtp_remat_class_hierarchy.png | Bin 0 -> 122127 bytes .../0712_gtp_te_protocol_redesign.png | Bin 0 -> 326222 bytes .../distributed/distributed_data_parallel.py | 26 +- .../core/distributed/finalize_model_grads.py | 84 +- .../core/distributed/param_and_grad_buffer.py | 30 +- .../core/extensions/transformer_engine.py | 175 +- megatron/core/model_parallel_config.py | 94 + megatron/core/optimizer/__init__.py | 9 +- megatron/core/optimizer/clip_grads.py | 12 +- megatron/core/optimizer/distrib_optimizer.py | 3 + .../core/optimizer/emerging_optimizers.py | 42 +- .../core/optimizer/layer_wise_optimizer.py | 115 +- megatron/core/optimizer/optimizer.py | 131 +- megatron/core/optimizer/param_layout.py | 6 +- megatron/core/parallel_state.py | 595 ++++- megatron/core/process_groups_config.py | 200 +- megatron/core/ssm/mamba_mixer.py | 75 +- megatron/core/tensor_parallel/__init__.py | 4 + .../generalized_tensor_parallelism.py | 2109 +++++++++++++++++ megatron/core/tensor_parallel/gtp_api.py | 59 + megatron/core/tensor_parallel/layers.py | 117 +- megatron/core/tensor_parallel/random.py | 26 + megatron/core/transformer/cuda_graphs.py | 277 ++- megatron/core/transformer/mlp.py | 8 + megatron/core/transformer/moe/moe_logging.py | 11 +- megatron/core/transformer/moe/moe_utils.py | 5 +- .../core/transformer/transformer_config.py | 25 +- megatron/core/transformer/utils.py | 22 + megatron/core/utils.py | 61 +- megatron/training/arguments.py | 128 +- megatron/training/checkpointing.py | 24 +- megatron/training/global_vars.py | 3 +- megatron/training/initialize.py | 16 + megatron/training/models/dist_utils.py | 7 +- megatron/training/training.py | 82 +- megatron/training/utils/__init__.py | 1 + megatron/training/utils/common_utils.py | 224 +- .../distributed/test_param_and_grad_buffer.py | 49 + .../generalized_tensor_parallel/__init__.py | 1 + .../gtp_test_utils.py | 158 ++ .../test_attention_gtp.py | 232 ++ .../test_gtp_basics.py | 1105 +++++++++ .../test_gtp_cudagraph_grad.py | 95 + .../test_gtp_dcp.py | 1089 +++++++++ .../test_gtp_fp8_param_gather.py | 201 ++ .../test_gtp_grad_correctness.py | 559 +++++ .../test_gtp_loss_correctness.py | 183 ++ .../test_gtp_muon_dcp.py | 327 +++ .../test_mamba_gtp.py | 343 +++ .../test_moe_egtp.py | 342 +++ .../test_tp_gtp.py | 397 ++++ .../models/test_hybrid_moe_model.py | 4 + tests/unit_tests/test_fp8_param.py | 24 +- .../unit_tests/test_process_groups_config.py | 6 +- 60 files changed, 10108 insertions(+), 309 deletions(-) create mode 100644 docs/api-guide/core/generalized_tensor_parallel.md create mode 100644 docs/images/generalized_tensor_parallel/0611_ddp_egtp_orthogonal_bucketing.png create mode 100644 docs/images/generalized_tensor_parallel/0612_gtp_dcp_tp2gtp2_save_load.png create mode 100644 docs/images/generalized_tensor_parallel/0613_gtp_dcp_save_call_workflow.png create mode 100644 docs/images/generalized_tensor_parallel/0617_gtp64_weak_scaling_efficiency.png create mode 100644 docs/images/generalized_tensor_parallel/0628_gtp_remat_class_hierarchy.png create mode 100644 docs/images/generalized_tensor_parallel/0712_gtp_te_protocol_redesign.png create mode 100644 megatron/core/tensor_parallel/generalized_tensor_parallelism.py create mode 100644 megatron/core/tensor_parallel/gtp_api.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/__init__.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_attention_gtp.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_cudagraph_grad.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_fp8_param_gather.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_loss_correctness.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_mamba_gtp.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_moe_egtp.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_tp_gtp.py diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md new file mode 100644 index 00000000000..2b886d1417b --- /dev/null +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -0,0 +1,495 @@ +# Generalized Tensor Parallelism (GTP) + +> ⚠️ **Experimental.** GTP is an experimental feature and its API, configuration, and behavior may change in future versions without notice. + +> 📦 **Requires TransformerEngine >= 2.19** (GTP support is merged into TE main). On an older TE, GTP is disabled at import (`HAVE_GTP = False`) and enabling it raises an `ImportError` — please install TransformerEngine >= 2.19. + +**At a glance.** GTP factors the weight-parallel domain into two orthogonal sub-axes — `GTP = TP × GTP_remat`. Each linear weight is sharded `1/(TP × GTP_remat)` along `out_features`: + +- **`TP`** slice — kept sharded through the GEMM. Ordinary tensor parallelism; the output is TP-sharded. +- **`GTP_remat`** slice — *rematerialized* just before the GEMM. Only the `GTP_remat` group all-gathers its part, so each rank's GEMM sees the full TP slice. The wgrad is reduce-scattered the same way on the way back. Both collectives overlap the previous layer's compute (forward and backward). + +This is **ZeRO-3-on-the-weight, on top of TP**. Per-GPU weight (and optimizer/grad) memory shrinks to `1/(TP × GTP_remat)`. It composes orthogonally with TP / SP / EP / DDP / CUDA Graphs. The `GTP_remat` degree is `gtp_weight_remat_size`, derived from `--tensor-parallel-num-weight-shards` (= `tensor_model_parallel_size × gtp_weight_remat_size`); when it is 1, GTP is inactive — byte-identical to plain TP+DP. + +**Scope**: a high-level summary of GTP_remat — design intent, public CLI surface, and Megatron-LM ↔ TransformerEngine integration touchpoints. + +Core implementation: `megatron/core/tensor_parallel/generalized_tensor_parallelism.py`. The public surface is re-exported from `megatron/core/tensor_parallel/gtp.py`. Low-precision tensor primitives (FP8 / MXFP8 / NVFP4) remain in TransformerEngine and are imported by `generalized_tensor_parallelism.py`. + +**Outline:** + +1. [Features](#1-features) + - 1.1 [Fine-grained, per-weight materialization & gradient reduction](#11-fine-grained-per-weight-materialization--gradient-reduction) + - 1.2 [CUDA graph compatibility](#12-cuda-graph-compatibility) + - 1.3 [Low-precision gather (native FP8 / NVFP4 param)](#13-low-precision-gather-native-fp8--nvfp4-param) + - 1.4 [Composability with TP / SP / EP / DDP](#14-composability-with-tp--sp--ep--ddp) + - 1.5 [Opt-in, minimally invasive integration](#15-opt-in-minimally-invasive-integration) + - 1.6 [Optimizer-agnostic (Adam + Muon)](#16-optimizer-agnostic-adam--muon) + - 1.7 [Scaling](#17-scaling) + - 1.8 [Native distributed checkpointing (DCP)](#18-native-distributed-checkpointing-dcp) +2. [Usage](#2-usage) + - 2.1 [Required flags](#21-required-flags) + - 2.2 [High-priority streams (Blackwell and later)](#22-high-priority-streams-blackwell-and-later) + - 2.3 [Minimal end-to-end example](#23-minimal-end-to-end-example) + - 2.4 [Tuning knobs](#24-tuning-knobs) +3. [Implementation details](#3-implementation-details) + - 3.1 [GTP_remat architecture (Mcore ↔ TE integration)](#31-gtp_remat-architecture-mcore--te-integration) + - [Class hierarchy: which linears shard](#class-hierarchy-which-linears-shard) + - 3.2 [DDP buckets with (E)GTP_remat](#32-ddp-buckets-with-egtp_remat) + - 3.3 [Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) + - 3.4 [Prefetch-chain construction and its design assumptions](#34-prefetch-chain-construction-and-its-design-assumptions) +4. [Testing](#4-testing) + +--- + +## 1. Features + +### 1.1 Fine-grained, per-weight materialization & gradient reduction + +Each weight is sharded 1/N across a GTP_remat group along `out_features`, stored as a `GTPShardedParam` subclass of `nn.Parameter`. Materialization and gradient reduction are both **per-weight, per-call** — not per-model or per-module: + +- **Independent state per param**: each has its own AG state (`state`) and RS state (`rs_state`) machines, both cycling `NONE → ASYNC_WAIT → DATA_READY → NONE` and tracked separately so fwd and bwd async ops don't interfere. +- **Prefetch chain for AG** (doubly-linked `prev_w` / `next_w`): during fwd, each weight's `all_gather_and_prefetch` issues async AG for `next_w`; during bwd, `all_gather_and_prefetch_bwd` issues async AG for `prev_w`. Layer *i*'s AG overlaps with layer *i−1*'s GEMM. For an L-layer model, L−1 all-gathers are fully hidden behind compute. When activation recompute is enabled, a **third** chain prefetches the recompute-forward gathers during backward — see §3.1 *Recompute-forward prefetch chain*. +- **Deferred RS finalize for wgrad**: `wgrad_reduce_scatter` on param *i* launches an **async** reduce-scatter (handle stashed in `_wgrad_rs_handle`) and returns `None` to autograd — the wgrad is NOT finalized into `main_grad` yet. Finalization is **deferred one step**: the next bwd step (param *i−1*'s `wgrad_reduce_scatter`) calls `self.next_w._wait_reduce_scatter()` + `_finalize_wgrad()`, which waits on the stashed handle, accumulates the reduced wgrad into `main_grad`, and fires the DDP `register_grad_ready` hook. The chain's head (first-in-fwd, last-in-bwd) uses a synchronous RS since nothing follows it. This one-step deferral is what lets layer *i*'s RS overlap with layer *i−1*'s bwd GEMMs. +- **Cold start only**: every weight's very first AG is synchronous (`DATA_READY_SYNC`, no prefetch has run yet); the async prefetch chain kicks in from the second forward onward. + +Contrast with FSDP: FSDP gathers at module-group granularity in full precision with PyTorch-managed lifecycle. GTP_remat works at individual-weight granularity, in quantized form, with its own explicit ticket-based buffer pool and a one-step-deferred RS finalizer. + +> **FSDP can't shrink into GTP_remat because FSDP's overlap is bucket-grained by design** — bucket granularity exists *to avoid* paying NCCL launch latency on tiny params (LayerNorm γ/β, biases, Mamba `dt_bias`/`D`/`A_log`) and *to avoid* the per-weight scheduling state that GTP_remat relies on (per-param prefetch chain, ticket-based buffer cache, stream choreography). Removing buckets doesn't make FSDP faster; it makes FSDP into GTP_remat, with all the engineering that entails — selective wrapping (only large GEMM weights), per-weight prefetch chain, per-param buffer ticket, and explicit AG/RS stream choreography on a side stream so external drains have something meaningful to wait on. + +### 1.2 CUDA graph compatibility + +CG compatibility is designed-in from day one, not retrofitted. The entire sync / buffer / chain architecture is shaped around making **captured fwd/bwd replays produce identical bit-for-bit behavior** — without the usual capture-vs-eager pitfalls that force other weight-sharding schemes to either disable CG or require special handling. + +- **Two chains, never cross-linked** (`GTPChain.GRAPHED` / `GTPChain.UNGRAPHED`). `prev_w` / `next_w` only connect same-chain params, so a captured traversal never reaches into eager Python and vice-versa. +- **`torch.cuda.Event(external=True)`** for `ag_event` / `rs_event` — the events survive CG capture boundaries and can be waited on from replay-time streams. +- **Idempotent ticket cache**: `GTPWeightCache.get(ticket)` keeps `slot.buf` set even after `release()`, so replays read the same buffer address as capture. `clear()` drops buffers while keeping tickets valid → supports CG re-capture with lazy re-allocation. +- **Allocate-in-pool at creation** (`set_cuda_graph_mempool` + `_graphed_alloc`): GRAPHED-chain AG/RS buffers and quantized weight storage are allocated **directly into the CG memory pool** at first creation (during warmup, before capture), so no CUDA allocations happen inside the captured graph — and no post-hoc reallocation/clone is needed. UNGRAPHED buffers stay in regular allocator memory. +- **Lazy, one-shot chain linking**: `prefetch_initialized` is flipped during the first fwd (warmup), so the chain-construction Python side-effects never execute inside a captured graph. The link table is buffered and flushed atomically at the second forward. +- **DDP hook manual triggering**: `register_grad_accum_hook` stores the DDP hook on the param; `_CudagraphReplayNode.backward` calls it manually after replay (since `AccumulateGrad` hooks are silenced by replay). This is also how the `assert self.grad_reduce_handle is not None` failure from partial-CG + overlap-grad-reduce is resolved. +- **Warmup is side-effect-free on `main_grad`**: GTP_remat accumulates wgrad into `main_grad` *inside* the backward (the fusion path returns wgrads as graph outputs instead). Graph capture only *records* ops; it never runs them. But `create_fwd_graph` runs an **eager** warmup fwd+bwd before capturing. That warmup backward executes GTP_remat's `main_grad.add_`. Its deferred cascade adds into a cross-graph `next_w` (another module) from a **stale RS ticket** — the prior backward's wgrad. And `create_cudagraphs()` runs *after* `finalize_model_grads`. So this overwrites the finalized (reduced + per-token-scaled) grads and spikes the step's grad norm. **Fix**: `create_fwd_graph` snapshots the grads its warmup touches — own params + cross-graph `next_w` — via `_backup_grads_before_capture`, then restores them after capture. The bwd graph has no warmup, so it needs none. Bounded to one module's grads. +- **Drains at CG / eager boundary**: `_drain_gtp_side_streams()` before eager MoE expert compute. Inside bwd capture, two-phase drain: Phase 1 joins the within-graph cascade and records `bwd_completion_event` (next runner unblocks); Phase 2 calls `wait_async_comms(GRAPHED)` to drain the chain-tail handle and re-joins side streams (queued after the event so it doesn't delay the next runner). +- **Side-stream registration**: the `(GRAPHED, gtp_remat_group)` ag/rs streams are materialized at runner init (`_register_gtp_side_streams`) so they are captured before the first forward. + +### 1.3 Low-precision gather (native FP8 / NVFP4 param) + +Wire bandwidth scales with the **quantized** size, not BF16 size — GTP_remat composes with low-precision training rather than fighting it. The shard is stored as a native **MXFP8**, native **NVFP4**, or **BF16** weight, gathered with the following mechanics: + +- **Native MXFP8 param — `mxfp8` + `--fp8-param-gather` (always paired, see §2.1).** The shard **is** a native `MXFP8Tensor` (§3.1); the optimizer writes FP32 master → FP8 once per step (off the forward critical path), and the forward **all-gathers the FP8 shard directly** — no per-microbatch quantize, no cast. The rowwise (fwd) / columnwise (bwd) view comes from a *separate* gather-quantizer copy (`_gtp_gather_quantizer`), leaving the param's own quantizer for the optimizer's write path. +- **Native NVFP4 param — `--fp4-param-gather` (required).** Same shape as MXFP8: the shard **is** a native `NVFP4Tensor`, all-gathered as packed 4-bit (`kFloat4E2M1`) and optimizer-maintained, no per-microbatch quantize. See the *GTP + NVFP4* subsection below. +- **BF16 (no FP8/NVFP4 params).** The BF16 shard is all-gathered as-is. +- **Coalesced NCCL**: `grouped_gather_along_first_dim` uses `torch.distributed._coalescing_manager` to batch E experts' AGs into a single NCCL op. +- **Padding**: shards are allocated **already padded** so each rank's dim0 stays `pad_for_alignment`-divisible (MXFP8: 32). Column-parallel pads the per-TP slice (`out_features / tp_size`) to a multiple of `pad_for_alignment × gtp_remat_size` so it survives TE's TP split aligned; row-parallel / Megatron-local pad the TP-local tensor directly (§3.1). Padding lands contiguous at the tail, so stripping is one trailing slice (`tensor[:-pad_length]`). + +#### Per-microbatch schedule + +``` +Steady-state fwd (MXFP8 native FP8 param / BF16): + default: ──GEMM(W_0)───────────────────GEMM(W_1)───────────────────GEMM(W_2)──... + ag_str: [AG_issue W_1] [AG_issue W_2] + (no per-microbatch quantize: the FP8 shard is + maintained by the optimizer; BF16 gathers as-is) + +Steady-state bwd (MXFP8 / BF16): + default: ──bwd GEMMs(W_i)──... + ag_str: [AG_issue W_{i-1}] + (columnwise view of the same FP8 shard; no quant) +``` + +For the native-FP8 (MXFP8), native-NVFP4, and BF16 paths the forward all-gather is a **single** NCCL op per weight on the GTP_remat ncclStream, with no per-microbatch quantize or GTP_remat-group amax on the critical path (the standard DP-group FP8 amax allreduce in `reduce_and_update_fp8_tensors` is unchanged by GTP_remat). Only the `dist.all_gather` issue is wrapped in `with torch.cuda.stream(ag_stream)`; the NCCL kernel runs on c10d's private ncclStream and overlaps with the next GEMM until it reaches its wait. + +#### Communication volume breakdown + +Per-microbatch per-weight comm budget (assuming bf16 wgrad reduce-scatter): + +| Format | Block | Data B/elem | Scale_inv B/elem | Per-elem | Fwd AR(amax) | Fwd AG | Bwd AG | Wgrad RS (bf16) | Total B/elem | vs BF16 | +|--------|-------|-------------|------------------|----------|--------------------------------|--------|--------|-----------------|--------------|----------------| +| BF16 | n/a | 2.0000 | — | 2.0000 | — | 2.0000 | 2.0000 | 2.0000 | 6.0000 | 1.00× (baseline) | +| MXFP8 | 32 | 1.0000 | 1/32 = 0.0313 | 1.0313 | — (microscale, no global amax) | 1.0313 | 1.0313 | 2.0000 | 4.0626 | 0.68× (–32%) | +| NVFP4 | 16 | 0.5000 | 1/16 = 0.0625 | 0.5625 | — (scale set at opt-step quantize) | 0.5625 | 0.5625 | 2.0000 | 3.1250 | 0.52× (–48%) | + +How to read the columns: +- `Per-elem` = `Data B/elem + Scale_inv B/elem` — wire cost of one quantized weight buffer (data + scale_inv together). +- `Fwd AG` and `Bwd AG` each carry the quantized buffer once, so they equal `Per-elem`. Bwd all-gathers the same FP8 shard (columnwise view) — no re-quantize, no AR(amax). +- `Wgrad RS (bf16)` = 2.0 B/elem — gradient is reduce-scattered in bf16 regardless of weight precision. +- `Fwd AR(amax)` — none per microbatch for either native format: MXFP8 is microscale-only, and native NVFP4 carries its block scales in the gathered buffer with the per-tensor scale set at the optimizer-step quantize (not per forward). +- `Total B/elem` = `Fwd AG + Bwd AG + Wgrad RS` — there is no per-microbatch amax AR to add. + +Gathering the pre-quantized weight attacks AG only: the AG portion shrinks ~72% from BF16 → NVFP4, but RS is untouched, so the wgrad RS becomes the dominant comm path in NVFP4 (~64% of the budget at bf16 RS, ~78% at fp32 RS). + +#### GTP + NVFP4 (native NVFP4 param) + +NVFP4 GTP_remat keeps each shard as a native `NVFP4Tensor` and all-gathers it as packed 4-bit (`kFloat4E2M1`) — the native-param path, mirroring native MXFP8: the distributed optimizer writes the NVFP4 shard directly once per step and the forward all-gathers it with no per-microbatch quantize. + +- **`--fp4-param-gather` is mandatory.** Without it NVFP4 GTP falls back to a BF16 all-gather that trips TE's scaling-mode assert (`DELAYED` vs `NVFP4`); `validate_args` enforces it and raises early. +- **Mixed-precision models (per-layer quant config).** A model may assign recipes per layer — e.g. NVFP4 default, MXFP8 for `mixer.out_proj`, BF16 for attention (`linear_qkv`/`linear_proj`) and latent MLPs. NVFP4 params gather natively as above. **MXFP8 params cannot be native-param-gathered** — the DDP param buffer has no MXFP8 storage remap (`replace_raw_data` is unimplemented for `MXFP8Tensor`, unlike NVFP4's packed-rowwise remap), so they are all-gathered in **BF16** and re-quantized with the layer's **own MXFP8 quantizer** inside the TE backward dgrad path (not the global delayed recipe). BF16-recipe layers gather BF16 unchanged. + +### 1.4 Composability with TP / SP / EP / DDP + +- **TP** (intra-layer): orthogonal axis — GTP_remat shards `out_features` regardless of TP's parallel mode (column or row). 2D grid naturally formed via `tp_group × gtp_remat_group`. +- **SP** (sequence-parallel): transparent — GTP_remat operates at weight dim, SP at sequence dim. +- **EP** (MoE): `GroupedLinear` with GTP_remat → each routed expert sharded across `EXPERT_GTP_WEIGHT_REMAT_GROUP`, independent of EP. MoE AllToAll (HybridEP/NVLink) runs independently of GTP_remat AG/RS (NCCL/IB). +- **DDP**: GTP_remat bypasses autograd's grad accumulator (async RS returns `None`; `_finalize_wgrad` accumulates directly into `main_grad`). DDP registers its grad-ready hook on GTP_remat params via `register_grad_accum_hook` (not autograd's `AccumulateGrad`); GTP_remat invokes it from `_finalize_wgrad` (eager path) and `_CudagraphReplayNode.backward` (captured path) **after** the wgrad lands in `main_grad`, so a bucket's DDP reduce-scatter runs strictly after every GTP_remat param's `{RS → main_grad add}` — never over a stale `main_grad` — and DDP↔GTP_remat NIC deadlock at IB scale is avoided. See §3.2. + +### 1.5 Opt-in, minimally invasive integration + +- **TE is GTP-agnostic.** Mcore builds the plain TE linear with an already-sharded `out_features` and attaches a `GTPShardedParam` *after* construction; TE dispatches through its generic **`DistributedWeight` protocol** (gates on `is_distributed_weight`) and takes no GTP argument, so there is no framework-level refactor and callers never thread a group (§3.1). +- **Opt-in by linear *class*; sharding stays per-*weight*.** *Which* linears opt in is class-based — GTP_remat wraps the TE classes that resolve a shard group internally (`TEColumnParallelLinear` / `TERowParallelLinear` / `TELayerNormColumnParallelLinear` for dense, `TEGroupedLinear` for routed experts), so upper-level modules thread no `gtp_remat_group`. But materialization and gradient reduction stay at **individual-weight** granularity — each wrapped weight is its own `GTPShardedParam`, gathered/reduce-scattered per-weight, per-call (§1.1). Base `TELinear` (e.g. MoE latent-proj MLPs) and small replicated tensors (LayerNorm γ/β, biases, Mamba `dt_bias`/`A_log`/`D`/`conv1d`, MoE router) **stay full** — the all-gather wouldn't amortize (§3.2 *dense non-GTP_remat* vs *dense GTP_remat*). +- **Off is a byte-for-byte no-op.** When the resolved group is `None`/size-1, `_gtp_pre_init` leaves `out_features` unsharded and `_gtp_attach_post_init` short-circuits (as does `wrap_module_params_gtp` for Megatron-local linears); when `gtp_weight_remat_size == 1` the `layers.py` GTP_remat path is skipped entirely. +- **Chain setup is one pass.** `classify_gtp_chains(model)` walks `named_parameters()` once at init and sets `chain_id` on every `GTPShardedParam` from the current `cuda_graph_modules` (§3.4). +- **Knobs.** `GTPRematConfig.{pad_for_alignment, weight_prefetch, check_param_states}`, plus the debug-name tagger `tag_gtp_params_with_names` for readable link-table output. + +### 1.6 Optimizer-agnostic (Adam + Muon) + +GTP_remat runs under both the standard **Adam** `DistributedOptimizer` and **Muon** (the `LayerWiseDistributedOptimizer`), DCP save/load included: + +- **Adam** shards optimizer state over the gtp_remat/egtp_remat-excluded replicate group, like any GTP_remat run (§3.2). +- **Muon** keeps matrix params *whole* (Newton–Schulz needs the full 2D weight). A GTP_remat-replicated whole param (e.g. MoE router, latent-proj MLPs) then lands on one checkpoint key shared by all GTP_remat peers, so the LayerWise optimizer folds `gtp_rank` into its `replica_id` — exactly one peer writes (the optimizer-state analog of the model-side fold in §3.3). +- **Native-FP8 optimizer-state matching (Muon path).** The save-side dequantize (§3.3) hands DCP a *fresh* BF16 tensor, which breaks the id-based optimizer-param → model-`ShardedTensor` match for every native-FP8 GTP_remat weight. The dequantized copy carries a `_gtp_dequant_src` backlink to the live FP8 param, and `_backfill_gtp_sharded_param_map` reuses the model's **own** entry (backlink first, tagged-name second) — preserving its full offsets (expert axes included) and `replica_id`. Only truly-unmatched params (Mamba `in_proj`, a gathered+split factory) take the per-shard rebuild, which refuses expert-parallel params rather than emit EP-colliding shards. + +Neither path adds a GTP_remat-specific checkpoint format or call site. + +### 1.7 Scaling + +Effective per-GPU weight size = `W / (TP × GTP_remat)`. Example: TP=4 + GTP_remat=8 with NVFP4 → 32× weight-memory reduction and 128× wire-bandwidth reduction vs full BF16 replication, before data parallelism. + +**Weak scaling.** GTP_remat fixes the shard width and grows the job by adding data-parallel replicas (DP = #GPUs / GTP_remat), so per-GPU compute stays constant while only the DP gradient reduction widens with scale. + +The best GTP_remat size is model- and cluster-dependent — driven by weight sizes, per-GPU memory headroom, and which collectives can be kept on fast links — so there is no single recommended value. The example below runs on **GB200 NVL72** (a 72-GPU NVLink domain) and uses **GTP64**, which places communication as: + +- **NVLink-local:** the *dense-layer* (Mamba / attention / shared-expert) GTP_remat weight all-gather + wgrad reduce-scatter, **and** the `EP64` all-to-all dispatch/combine — all kept inside one ≤72-GPU NVLink domain (EP64 ≤ NVL72). +- **Inter-node (IB / CX7):** the DP gradient reduction **plus** the `EGTP2` expert-weight all-gather / wgrad reduce-scatter, whose 2 shards land on different NVLink domains and so cross nodes. + +On an Ultra-proxy hybrid Mamba-MoE model (**~280B parameters**; `GTP64 · EP64 · EGTP2`, mb1, MXFP8, BF16 reduce-scatter, no CUDA graph), scaling efficiency holds **≥93 % of the single-domain (128-GPU / DP2) baseline out to 3072 GPUs (DP48)**, while max reserved memory *decreases* with scale (137 → 104 GB) as the distributed optimizer shards optimizer/grad state across more DP replicas. + +> **Takeaway:** near-flat weak scaling — **≥93 % efficiency from 128 → 3072 GPUs**, with per-GPU memory shrinking as DP grows. + +![GTP64 weak-scaling efficiency](../../images/generalized_tensor_parallel/0617_gtp64_weak_scaling_efficiency.png) + +### 1.8 Native distributed checkpointing (DCP) + +**GTP_remat + DCP is straightforward:** +- Reuses the existing checkpoint stack rather than adding a parallel one. GTP_remat-sharded weights *and* distributed-optimizer state save/load through the standard PyTorch / Mcore `torch_dist` sharded checkpoint, with **no GTP_remat-specific format or call path** and a tiny code footprint (one new helper + one helper made GTP_remat-aware). +- Checkpoints **reshard freely** across different `(TP, GTP_remat, EGTP_remat, DP, PP)` topologies — including a different GTP_remat/EGTP_remat size — with no offline conversion. + +See [§3.3 Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) for details. + +--- + +## 2. Usage + +GTP_remat is enabled through two CLI flags on Megatron's training launcher; everything else (process-group construction, parameter slicing, prefetch chain wiring, optimizer routing) is automatic once the flags are set. + +### 2.1 Required flags + +```bash +# Total number of shards each dense weight (attention, mamba, MLP linears) is split into along +# out_features, across the tensor-parallel + GTP_remat axes. Must be >= --tensor-model-parallel-size and +# divisible by it. The GTP_remat degree is derived as num_weight_shards / tensor_model_parallel_size +# (e.g. TP=1 + num_weight_shards=2 -> GTP_remat=2; TP=2 + num_weight_shards=8 -> GTP_remat=4). +--tensor-parallel-num-weight-shards + +# Total number of shards each MoE routed-expert weight is split into along out_features, across the +# expert-tensor-parallel + expert-GTP_remat axes. Must be >= --expert-tensor-parallel-size and divisible +# by it. The expert-GTP_remat degree is derived as num_weight_shards / expert_tensor_parallel_size. +# Independent from --tensor-parallel-num-weight-shards; can be left unset for non-MoE models. +--expert-tensor-parallel-num-weight-shards +``` + +> The (dense / expert) GTP_remat degree is exposed **only** through +> `--tensor-parallel-num-weight-shards` / `--expert-tensor-parallel-num-weight-shards`. The internal +> `gtp_weight_remat_size` / `expert_gtp_weight_remat_size` config fields are derived from them and +> have no CLI flag. + +**Low precision (MXFP8).** GTP_remat + `--fp8-recipe mxfp8` **requires** both `--fp8-param-gather` +and `--reuse-grad-buf-for-mxfp8-param-ag` (`arguments.py` asserts this) — the weight is a native FP8 +param, and since MXFP8 cannot map into the contiguous param buffer (`replace_raw_data` unsupported) +the all-gather reuses the grad buffer. Mechanism: §1.3, §3.1. + +**Low precision (NVFP4).** GTP_remat + `--fp4-format` **requires** `--fp4-param-gather` +(`arguments.py` asserts this) — without it NVFP4 weights fall back to a BF16 gather that fails the +backward GEMM. Mechanism and mixed-recipe (MXFP8-override) handling: §1.3 → *GTP + NVFP4*. + +### 2.2 High-priority streams (Blackwell and later) + +Required on GB200 / GB300 so the GTP_remat comm streams get the SM priority needed for AG/RS overlap with compute: + +```bash +--high-priority-stream-groups ep gtp_remat expt_gtp_remat tp +``` + +The launcher also exports `CUDA_GRAPHS_USE_NODE_PRIORITY=1` so captured CUDA graphs respect the inherited stream priority. + +### 2.3 Minimal end-to-end example + +```bash +# 4 ranks, TP=2 + GTP_remat=2 across out_features, BF16 weights. +# TP=2 + num-weight-shards=4 -> GTP_remat = 4 / 2 = 2. +torchrun --nproc-per-node 4 pretrain_gpt.py \ + --tensor-model-parallel-size 2 \ + --pipeline-model-parallel-size 1 \ + --tensor-parallel-num-weight-shards 4 \ + --expert-tensor-parallel-num-weight-shards 1 \ + --high-priority-stream-groups ep gtp_remat expt_gtp_remat \ + --bf16 \ + --num-layers 12 --hidden-size 1024 --num-attention-heads 16 \ + --seq-length 1024 --max-position-embeddings 1024 \ + --micro-batch-size 1 --global-batch-size 4 \ + --train-iters 10 \ + --use-mcore-models \ + --transformer-impl transformer_engine \ + --tokenizer-type NullTokenizer --vocab-size 32000 \ + --data-path --split 99,1,0 +``` + +At iter-0 you'll see one rank-0 log line confirming the active config: + +``` +GTP_remat enabled. GTPRematConfig(pad_for_alignment=16, check_param_states=False, + weight_prefetch=True, async_reduction=True, calculate_per_token_loss=False) +``` + +### 2.4 Tuning knobs + +Set via `from megatron.core.tensor_parallel.gtp import GTP_CONFIG, update_gtp_config`: + +```python +update_gtp_config( + pad_for_alignment=16, # NVFP4: 16, MXFP8: 32, BF16: any; auto-set in training.py + weight_prefetch=True, # Disable to debug the cold-start path + async_reduction=True, # Whether to perform GTP_remat gradient reduction asynchronously + calculate_per_token_loss=False, # Mirror config.calculate_per_token_loss (SUM vs MEAN RS) +) +``` + +`training.py` auto-tunes `pad_for_alignment` based on the quantization recipe (`--fp4`, `--fp8-recipe=mxfp8`, etc.) before model construction. The other knobs are usually left at defaults. + +> **CUDA-graph warmup under GTP_remat.** When CUDA graphs are enabled, GTP_remat forces a minimum of **2** per-graph warmup steps regardless of `--cuda-graph-warmup-steps` (e.g. a user-set `0` is bumped to `2`): the first warmup builds the weight-prefetch chain and the second exercises the prefetch path before capture. + +--- + +## 3. Implementation details + +### 3.1 GTP_remat architecture (Mcore ↔ TE integration) + +![GTP_remat / Mcore-TE integration architecture](../../images/generalized_tensor_parallel/0712_gtp_te_protocol_redesign.png) + +**Ownership.** TE owns the linear primitives (`Linear` / `LayerNormLinear` / `LayerNormMLP` / `GroupedLinear`), the low-precision tensor types (FP8 / MXFP8 / NVFP4), and a generic **`DistributedWeight` protocol** (`transformer_engine/pytorch/distributed_weight.py`). Megatron owns **all** GTP_remat logic — sharding, the prefetch chain, the buffer cache, the AG/RS state machines, and DDP integration. **TE never names GTP.** + +**The bridge** — three touch points, nothing more: + +- **Construction.** Mcore pre-shards `out_features` (`_gtp_pre_init`) so plain TE builds *this rank's shard* directly; GTP is attached *after* build (`_gtp_attach_post_init`). TE takes no GTP argument. +- **Runtime.** TE's fwd/bwd gate on `is_distributed_weight(weight)` and call the generic list-shaped dispatchers (`materialize_weight_for_forward` / `materialize_weight_for_backward`, `finalize_weight_grads`). `GTPShardedParam` implements the protocol (`materialize_group_for_forward`/`_backward`, `finalize_group_grads`, `grad_buffer`); the concrete collectives (`all_gather_and_prefetch`, `wgrad_reduce_scatter`) live only in Megatron. A plain tensor is a no-op. +- **Streams.** `_register_gtp_side_streams` / drain calls synchronize TE's GEMMs with the side stream that owns the AG/RS NCCL ops. + +**One init path, all precisions.** Since `out_features` is pre-sharded, TE builds the shard directly — native `MXFP8Tensor` (`--fp8-param-gather`), native `NVFP4Tensor` (`--fp4-param-gather`), or BF16 — **with no full weight ever materialized**. `attach_gtp_to_presharded_module` then turns it into a `GTPShardedParam`: a native quantized shard is reclassed in place to `GTP_` (stays buffer-resident on the quantized dist-opt path); a BF16 shard is re-registered (no slice — already shard-sized). The optimizer maintains the shard end-to-end, gathered each forward with **no per-microbatch re-quantize** (§1.3). + +> **Per-GTP-rank init.** Each rank draws its *own* shard, so GTP weights need *distinct* random values per GTP_remat peer (else the gather would be `gtp_remat_size` identical blocks). `model_parallel_cuda_manual_seed` adds `gtp-remat-rng` / `egtp-remat-rng` trackers (offset per peer) that `_gtp_pre_init` routes init through; replicated params keep the shared trackers. Added only when the axis is active, so non-GTP runs keep a byte-identical tracker set. + +> **Megatron-local linears** (`ColumnParallelLinear` etc. in `tensor_parallel/layers.py`) still build the full weight and slice post-init via `wrap_module_params_gtp` — unchanged. + +#### What the flags do under the hood + +The `--*-num-weight-shards` flags flow through five stages, from process groups to the prefetch chain: + +1. **Process groups.** `initialize_model_parallel(...)` treats GTP_remat/EGTP_remat as **first-class orthogonal axes** (`world = TP·GTP_remat·CP·DP`; experts `= ETP·EP·EGTP_remat·PP·expert_dp`), building `_GTP_WEIGHT_REMAT_GROUP` and `_EXPERT_GTP_WEIGHT_REMAT_GROUP` (sizes = `num-weight-shards / TP` and `/ ETP`). **DP and gtp_remat stay orthogonal:** `get_data_parallel_group()` is the replicate axis (DDP + optimizer shard over it); `with_gtp_remat=True` gives the combined DP × gtp_remat axis for data distribution. + + > **Batch-size arithmetic.** `args.data_parallel_size` is the **replicate degree only** — gtp_remat is *divided out* of it (folded into `total_model_size` at `arguments.py:446`). But data is distributed over the **full DP × gtp_remat axis**, so each gtp_remat peer consumes a *distinct* microbatch and the global sample count is `micro_batch_size × data_parallel_size × gtp_weight_remat_size × num_microbatches`. The training loop therefore **re-applies `gtp_weight_remat_size`** to close the gap: *multiplied back in* for the LR-scheduler `increment` and the logged `batch_size`, *divided back out* to recover `eval_num_microbatches`. Without this it would read as a double-count — it is not. + +2. **Per-class sharding.** `extensions/transformer_engine.py` decides *per linear class* whether to shard, so **no `gtp_remat_group` is threaded through the module APIs** (attention, Mamba, MLP, embedding, MTP). Dense wrappers resolve the group via `utils.get_gtp_weight_remat_group(...)`; `TEGroupedLinear` uses `pg_collection.expt_gtp_remat`. Group `None`/size-1 → left full; otherwise `_gtp_pre_init` pre-shards `out_features` and `_gtp_attach_post_init` makes the shard a **`GTPShardedParam`** (the `DistributedWeight` implementer; native FP8/NVFP4 by reclass, BF16 by re-register). Base `te.Linear` (MoE latent projections) gets no group and stays full → see [Class hierarchy](#class-hierarchy-which-linears-shard). + +3. **Gradients (DDP).** GTP_remat shards are ordinary DDP params in the usual dense/expert buffers, reduced over the **replicate** group. The gtp_remat axis is completed separately: **GTP shards by their reduce-scatter, replicated params by an all-reduce** in `finalize_model_grads` (mean-vs-sum per `calculate_per_token_loss`) → see §3.2. + +4. **Optimizer.** State is sharded over the same replicate group; **global-norm clipping** reduces over the dist-opt grad-stats group spanning the full world (incl. gtp_remat/egtp_remat), counting replicated params **once per axis** to avoid over-counting. + +5. **Prefetch chains.** `classify_gtp_chains(model)` runs once after build (`get_model`) and wires each `GTPShardedParam` into a **`GRAPHED`/`UNGRAPHED`** chain from `cuda_graph_modules` → see [§3.4 Prefetch-chain construction](#34-prefetch-chain-construction-and-its-design-assumptions). + +#### Class hierarchy: which linears shard + +The figure visualizes the per-class split from the list above: green = resolves a GTP_remat group and shards, red = base `TELinear` (MoE latent projections) that stays full. Dashed arrows are *builds* (module → leaf); solid arrows are *inherits* (leaf → TE primitive). + +![GTP_remat class hierarchy — which TE linear classes shard](../../images/generalized_tensor_parallel/0628_gtp_remat_class_hierarchy.png) + +#### Buffer / memory management + +Two distinct pools with explicit lifecycle rules: + +- **`GTPWeightCache`** (AG/RS output buffers) — ticket-based, keyed on `(shape, dtype, fwd, expert_idx, reduce_scatter)`. Same-shape buffers across layers are shared. Tickets persistent; buffer allocated lazily on first `get()`; addresses stable across iterations for CG replay. +- **`_wgrad_buf_pool`** (wgrad-GEMM output recycling) — holds the **full, unsharded** wgrad-GEMM output buffer (shape `_unsharded_shape`, dtype `main_grad.dtype` — fp32 when `grad_reduce_in_fp32`, else bf16). The TE backward writes the wgrad into it via `main_grad_func = weight.grad_buffer` (a `DistributedWeight` protocol method backed by `get_wgrad_tensor`; it is a *scratch*, distinct from the sharded `param.main_grad`); the protocol's `finalize_group_grads` (backed by `wgrad_reduce_scatter`) then reduce-scatters it down to the shard and the buffer is returned here. This is a full-weight-shaped fp32/bf16 transient — one of the larger per-weight buffers — and is **precision-independent** (wgrad is always computed in high precision), so it is identical in BF16 vs MXFP8 runs. Buffers are tagged `_from_gtp_wgrad_pool=True` at `_wgrad_pool_get`; `_wgrad_pool_put` no-ops on foreign buffers (fresh allocs from Megatron `layers.py` or aten F.embedding bwd) → caching allocator handles those, so the pool never accumulates untagged buffers. + +#### Overlap design summary + +``` +fwd: AG(W_{i+1}) ∥ GEMM(W_i) ∥ CG replay of captured layers +bwd: AG(W_{i-1}) ∥ dgrad(W_i) → wgrad(W_i) ∥ RS(wgrad_i) ∥ [finalize wgrad_{i+1} + DDP hook] +``` + +GTP_remat runs up to **three** independent prefetch chains, all following one rule — *prefetch the weight the next consume will need*: + +| # | when | consume | prefetch (overlap) | AG direction | slot | +|---|------|---------|--------------------|--------------|------| +| 1 | fwd | weight `i` | `next_w` = i+1 ‖ `GEMM_i` | rowwise (`fwd=True`) | `_prefetch_handle` | +| 2 | bwd dgrad | weight `i` | `prev_w` = i−1 ‖ `Dgrad_i` | columnwise (`fwd=False`) | `_prefetch_handle` | +| 3 | bwd recompute | weight `i` | `_recompute_next` = i+1 ‖ `recompute_GEMM_i` | rowwise (`fwd=True`) | `_recompute_prefetch_handle` (separate) | + +Chain 3 exists only when activation recompute is on. It mirrors chain 1 (rowwise, prefetch `next`) but runs *during* backward, so it overlaps chain 2 in time on the same weight — hence its **own** slot. fwd (1) and bwd-dgrad (2) never overlap in time, so they safely share `_prefetch_handle`. See *Recompute-forward prefetch chain* below. + +At bwd step *i* the step is launching *RS of wgrad_i* while finalizing the *previous* iter's wgrad (`wgrad_{i+1}` in bwd order = the next-one-over in fwd order). That one-step deferral is what makes the RS run concurrent with the next layer's dgrad/wgrad GEMMs instead of blocking after every layer. + +Communication never blocks compute except at the very first layer of each direction (cold start) and at enforced serialization points (CG/eager drains, finalize-grads barrier). + +##### wgrad-before-dgrad schedule *(deferred to a follow-up MR)* + +Current behavior: backward always runs dgrad GEMM, then wgrad GEMM, then issues the GTP_remat wgrad RS — the RS overlaps with the *next* layer's bwd GEMMs (the one-step deferral above). + +A future MR will add an opt-in wgrad-before-dgrad schedule on `_Linear` / `_LayerNormLinear` so the GTP_remat wgrad RS NCCL overlaps with the dgrad GEMM of the **same** layer (best for the GTP_remat + no-TP case). + +##### Recompute-forward prefetch chain *(GTP_remat + activation recompute)* + +When a GTP_remat-sharded module is in `--recompute-modules` (e.g. `shared_experts`), its forward is **re-run during backward** to regenerate activations. That recompute-forward must all-gather each weight **rowwise** again — a *third* gather lifecycle, concurrent with the in-flight **columnwise** dgrad gather of the *same* weight. Since both share one `GTPShardedParam`, the recompute path gets its **own** prefetch slot (`_recompute_prefetch_handle` / `_recompute_ag_event`, reusing the `_ag_ticket_fwd` rowwise buffer) so it never clobbers the dgrad lifecycle's `state` / `_prefetch_handle` / `ag_event`. + +The recompute weights form a **separate** linked list (`_recompute_next`), **self-populated** on the first backward from the weights actually re-gathered while `in_fp8_activation_recompute_phase()` is true — membership is *observed*, not configured (no tagging, so it tracks exactly what each checkpointed module re-gathers). Each recompute-forward consume prefetches the next recompute weight, so every gather **except the global-first** overlaps preceding recompute / dgrad / wgrad compute: + +``` +recompute-fwd of shared_experts (per layer: GEMM fc1 → SReLU → GEMM fc2, then dgrad+wgrad) + + Before (on-demand): + default: AG(fc1)─GEMM fc1─SReLU─AG(fc2)─GEMM fc2─dgrad─wgrad─... every AG exposed + After (recompute chain): + default: GEMM fc1─SReLU─GEMM fc2─dgrad─wgrad─GEMM fc1'─... back-to-back + ag_str: AG(fc1) [AG fc2] [AG fc1' (next layer)] only AG(fc1) exposed +``` + +`AG(fc2)` is issued at `fc1`'s consume (overlaps GEMM fc1 + SReLU); `AG(fc1')` for the next layer is issued at `fc2`'s consume, so it overlaps the **whole** layer's `dgrad + wgrad` window. The cross-layer link is what hides every region head except the very first. + +Under **full-iteration CUDA graphs** the recompute-forward is captured; `wait_async_comms(GRAPHED)` drains the recompute handle too (sets `_recompute_already_drained`) so the captured consumer skips its cross-graph wait — the same producer-drain pattern as the fwd/bwd chains. + +> **When *not* to recompute a GTP_remat weight.** Recompute on a GTP_remat-sharded weight adds this extra rowwise gather. For MLP-like blocks at short context (`SeqLen ≤ 2 × HiddenSize`), GTP_remat-sharding the weight saves *more* memory than recomputing its activations, so the better trade is to keep such modules GTP_remat-sharded and **out** of `--recompute-modules` (offload their activations if needed) — avoiding the third gather entirely. Build the recompute chain only for modules that genuinely need both. + +### 3.2 DDP buckets with (E)GTP_remat + +![DDP + (E)GTP_remat interaction with the distributed optimizer](../../images/generalized_tensor_parallel/0611_ddp_egtp_orthogonal_bucketing.png) + +**(E)GTP_remat is *super loosely coupled* to DDP and the distributed optimizer — they stay completely GTP_remat-agnostic.** GTP_remat is just another sub-axis of the rank grid (`world = TP×GTP_remat×CP×DP`); a GTP_remat-sharded weight rides the *exact same* code path as an ordinary param. There are **no** GTP_remat/EGTP_remat-specific buffers, optimizers, gradient-scaling factors, or bucket groups. The entire DDP/DistOpt stack touches GTP_remat in only **three** narrow places: + +1. **finalize all-reduce** (`_allreduce_replicated_grads_over_gtp_remat_group`) — completes the gtp_remat axis for *replicated* (non-GTP_remat) params (SUM under `calculate_per_token_loss`, AVG otherwise; see §3.2 table); a no-op when GTP_remat is inactive. +2. **`is_gtp_weight_remat` / `allreduce` tags** propagated onto the optimizer's master shards — consumed only by the grad-norm dedup filter. +3. **grad-ready hook routing** (`DistributedDataParallel.__init__`) — for a GTP_remat param, DDP registers its backward post-hook via GTP_remat's `register_grad_accum_hook` instead of autograd's `AccumulateGrad`. GTP_remat fires it from `_handle_megatron_grad_accum` **after** the per-param `{wgrad RS → main_grad add}`. This enforces the invariant below; a no-op (plain autograd path) when GTP_remat is inactive. + +> **Ordering invariant.** A bucket's DDP gradient reduction (the reduce-scatter / all-to-all + local fp32 accumulation) runs **strictly after every GTP_remat param in that bucket has finished `{GTP_remat wgrad RS → main_grad add}`**. `register_grad_ready` only fires the bucket collective once *all* its params are ready, and for GTP_remat params "ready" is signalled by GTP_remat after the add — never by autograd's `AccumulateGrad`, which (because the wgrad RS is async and its `main_grad` accumulation is deferred to a later backward node) can fire **before** the add and would make the bucket reduce read a stale/empty `main_grad` (notably under `reduce_scatter_with_fp32_accumulation`). + +Everything else — bucketing, the reduce-scatter/all-reduce schedule and its overlap, master-state sharding, grad clipping, the checkpoint format — is unchanged and unaware of GTP_remat. + +**Why this matters:** + +- **Free reuse of a mature stack.** GTP_remat inherits DDP's bucketing + comm/compute overlap, the distributed optimizer's fp32-master + Adam-moment sharding, grad-norm/clip, and the existing checkpoint format — no parallel re-implementation to write or maintain (contrast FSDP, which replaces all of these). +- **Orthogonal composability.** Because GTP_remat is a rank-grid sub-axis cut like TP (along `out_features`), it composes with TP/EP/CP/PP and the DistOpt the same way TP does — no special nesting logic. +- **Zero-cost when off.** With GTP_remat disabled the gtp_remat axis is size-1 and the hooks become no-ops, so non-GTP_remat runs hit byte-identical behavior — GTP_remat can be toggled without forking the DDP/optimizer code paths. +- **Small, auditable surface.** These three hooks are the whole integration contract, which is what makes the correctness argument below tractable. + +DDP groups parameters into **two buffers** by `is_expert_parallel` (MoE tag) — a dense buffer and an expert buffer. GTP_remat/EGTP_remat shards are **merged into** these buffers like ordinary params (no separate GTP_remat/EGTP_remat buckets): they reduce over the replicate group (the default `intra_dp_cp_group` / `intra_expt_dp_group`). + +The DP collective only covers the replicate axis; the gtp_remat axis is completed separately, and **how both axes are scaled depends on the loss normalization** (`config.calculate_per_token_loss`). In all cases each gtp_remat contribution is summed exactly once: + +| | `calculate_per_token_loss=False` (default) | `calculate_per_token_loss=True` | +|---|---|---| +| DDP pre-scale (`gradient_scaling_factor`) | `1/replicate` (= `1/dp_cp_group.size()`) | `1.0` (no pre-scale) | +| gtp_remat reduce-scatter (sharded weights) | **MEAN** (pre-scale wgrad by `1/gtp_remat`) | **SUM** (plain reduce-scatter) | +| finalize over gtp_remat (replicated params) | **AVG** all-reduce | **SUM** all-reduce | +| final normalization | net grad = full `(replicate × gtp_remat)` **mean** | grads summed over all axes, then `÷ total_global_tokens` in `finalize_model_grads` | + +- **Default (mean) path** decouples gradient scaling from the gtp_remat degree: the DP `1/replicate` mean × the reduce-scatter `1/gtp_remat` mean (sharded weights) — or × the finalize AVG (replicated params) — equals the exact full mean, independent of the gtp_remat axis size. +- **Per-token-loss path** must SUM over gtp_remat (like the DP axis): `total_global_tokens` already counts the gtp_remat peers' distinct tokens, so the single `÷ total_global_tokens` does all normalization. A `1/gtp_remat` mean here would shrink every gtp_remat gradient by `1/gtp_remat` (grad-norm mismatch + divergence), so the reduce-scatter mean and finalize AVG are both gated on `not calculate_per_token_loss`. + +> **`average_in_collective` must be off (the default).** The default-path scaling is a *pre-scale* applied before a SUM collective. `average_in_collective=True` instead uses NCCL AVG over the collective's own (replicate) group, which interacts incorrectly with the gtp_remat completion. Asserted via `ProcessGroupCollection.is_gtp_remat_active` in both `arguments.py` (training) and `DistributedDataParallel.__init__` (direct megatron-core users). (Independently, `calculate_per_token_loss` already forbids `average_in_collective`.) + +**Buffer caching.** The per-buffer lists are concatenated once at init into a single flat view for fast iteration in the grad-reduction hot path. + +> **Single distopt instance with GTP_remat.** GTP_remat currently requires `num_distributed_optimizer_instances == 1` (asserted in `parallel_state.py`): partial-distopt sharding of the data domain would need gtp_remat-aware sizing. The dist-opt grad-stats group is therefore the full world. + +### 3.3 Distributed checkpointing (DCP) + +![GTP_remat + DCP save/load reshard for a TP2×GTP2 weight](../../images/generalized_tensor_parallel/0612_gtp_dcp_tp2gtp2_save_load.png) + +GTP_remat supports **PyTorch / Mcore sharded distributed checkpointing** (`--ckpt-format torch_dist`, the `megatron.core.dist_checkpointing` `ShardedTensor` / `ShardedObject` format) for **both model weights and distributed-optimizer state**. Checkpoints are **fully resharding-capable**: a checkpoint saved at one `(TP, GTP_remat, EGTP_remat, DP, PP)` topology can be loaded at a *different* one — including a different GTP_remat/EGTP_remat size — without an offline conversion step. + +Consistent with §3.2, GTP_remat stays *loosely coupled* to the checkpoint stack: there is **no GTP_remat-specific checkpoint format or call path**. The shared `make_sharded_tensors_for_checkpoint` helper became GTP_remat-aware and **delegates internally** to a GTP_remat variant only when the `state_dict` actually contains a `GTPShardedParam` (a no-op otherwise), so call sites are unchanged and non-GTP_remat runs are byte-identical. + +**Save-side call workflow.** The diagram below traces the save path — from `model.sharded_state_dict()` through the `make_*` helpers down to the terminal `ShardedTensor` / `ShardedObject` sinks. The GTP_remat footprint is deliberately tiny: exactly **one new function** (`make_sharded_tensors_for_checkpoint_with_gtp_remat`, in `gtp.py`, which sets `replica_id` for the GTP_remat-*duplicated* entries) plus **one modified function** (the per-tensor `make_tp_sharded_tensor_for_checkpoint` in `core/utils.py`, made GTP_remat-aware in place to emit the GTP_remat-*sharded* offsets). Every other helper is untouched. + +![GTP_remat + DCP checkpoint-save call workflow](../../images/generalized_tensor_parallel/0613_gtp_dcp_save_call_workflow.png) + +**How a GTP_remat weight is described to DCP.** GTP_remat always shards `out_features` (axis 0). The helper layers that GTP_remat split onto the existing TP offsets in the `ShardedTensor`, so the global tensor DCP sees is the *full, unsharded* weight: + +| Weight kind | TP axis | Emitted axis-0 offset | Other axis | +|-------------|---------|------------------------|------------| +| Column-parallel (qkv, fc1) | 0 (same as GTP_remat) | composite `(tp_rank·gtp_remat + gtp_rank, tp·gtp_remat)` | — | +| Row-parallel (proj, fc2) | 1 | GTP_remat-only `(gtp_rank, gtp_remat)` | TP offset on axis 1 | +| No TP (GTP_remat-only) | – | `(gtp_rank, gtp_remat)` | — | + +Because the offsets reconstruct the global shape, the checkpoint is independent of the save-time grid. On load, DCP reads each rank's `[offset : offset+local]` slice from that global and re-tiles it onto the new grid — e.g. `TP1×GTP2`, `TP2×GTP4`, or a DP change. + +**replica_id.** GTP_remat peers hold *distinct* shards (not replicas), so they're disambiguated by their offsets; `replica_id`'s DP coordinate is the GTP_remat-*excluded* replicate rank (one elected writer per shard, per replicate group). **Replicated** tensors that live alongside GTP_remat weights (LayerNorm γ/β, biases, `_extra_state` objects) would otherwise collide across GTP_remat peers, so the helper folds `gtp_rank` into their `replica_id` — exactly one peer is then elected DCP writer per key. + +**`_extra_state`.** This is TransformerEngine's per-module **FP8 calibration state** — for delayed-scaling recipes it holds the `recipe`, the forward/backward `scale` tensors and `amax_history` buffers, plus picklable `extra_fp8_variables`; for BF16 (non-FP8) runs it is an empty tensor. Because it is a pickled byte blob rather than a tensor with a meaningful shape, it is emitted as a `ShardedObject` (via `make_sharded_object_for_checkpoint`), not a `ShardedTensor`. Its amax/scale statistics are *per-tensor globals* for the **full** weight (amax is reduced across the FP8 group), so every GTP_remat peer carries an identical copy — which is exactly why it takes the replicated path above, with `gtp_rank` folded into its `replica_id`. + +**Alignment padding & cross-topology reshard.** When `_gtp_slice_one_param` pads `out_features` to a multiple of `gtp_remat_size · pad_for_alignment`, the saved global describes the *padded* shape, so the helper sets `allow_shape_mismatch=True`. DCP then tolerates a load-side topology whose alignment yields a different padded size — the unpadded data overlaps and the tail pad rows are zeros GTP_remat recomputes. + +>> Note: Mamba's `in_proj` is a special case: it **all-gathers its GTP_remat shards** back to the logical TP-local size and strips the pad *before* saving, so its global is topology-independent and needs no `allow_shape_mismatch`. + +**Optimizer state.** The distributed optimizer's master/moment `ShardedObject`s are keyed by `dp_group_idx`. Under GTP_remat/EGTP_remat each peer owns a *different* master shard (the optimizer shards over the gtp_remat/egtp_remat-**excluded** replicate group), so the index is taken from the gtp_remat/egtp_remat-**merged** model-parallel group (`mp_group` for dense, `expt_tp_pp_with_egtp_remat_group` for expert) — giving every peer a distinct key while replicate-group ranks remain true replicas under that key. + +**Pre-save forced param-sync.** Before a save (and around any `disable_forward_pre_hook(param_sync=True)`, e.g. pre-eval), the training loop force-syncs DDP params. `force_param_sync` / `disable_forward_pre_hook` first call `optimizer.prepare_model_params_for_param_sync()`, which copies the FP32 masters into the DDP param buffer, so the sync's `_post_param_sync` copy-back re-quantizes each native-FP8 weight — GTP_remat shards included — from up-to-date masters instead of stale grad scratch under `--reuse-grad-buf-for-mxfp8-param-ag`. The copy-back therefore writes the correct MXFP8 shard, so the forced sync leaves GTP_remat's self-gathered weight intact and does not perturb the next iteration's loss — no GTP-specific preservation is needed. + +### 3.4 Prefetch-chain construction and its design assumptions + +The prefetch chains (§3.1) are **not configured — they are observed at runtime and stored in process-global state**, which imposes assumptions on the weights that every feature combined with GTP_remat must be checked against. + +**Construction (two steps).** + +1. **Classification (once, at build).** `classify_gtp_chains(model)` runs in `training.py`'s `get_model` after the model is built. It walks `named_parameters()` and, for each `GTPShardedParam`, sets `chain_id` to `GRAPHED` or `UNGRAPHED` (via `_classify_param_chain`, from the active `cuda_graph_modules`) and to the dense vs. expert chain. Membership is fixed from here on; re-classifying an already-linked param into a different chain is rejected. +2. **Linking (lazily, on the first forward).** The doubly-linked list (`prev_w` / `next_w`) is built the **first time each weight is materialized** inside `all_gather_and_prefetch`: a class-level per-chain cursor (`GTPShardedParam._chain_state[chain_id]["last_weight"]`) records the previously-seen weight, and the current weight links itself after it. The chain therefore **encodes the forward execution order of the first step** and replays it every step after to predict the next weight to prefetch. The recompute chain (`_recompute_next`) self-populates the same way, from the weights re-gathered while `in_fp8_activation_recompute_phase()` is true. + +Weights that must **not** join a chain (embedding, output_layer — they all-gather synchronously and run outside the CUDA-graph boundary) are excluded by setting `weight.prefetch_initialized = True` (and `_need_weight_prefetch = False`) at construction, which skips registration entirely. + +**Why this needs careful consideration.** Because `_chain_state` is a *class attribute* and `prev_w`/`next_w` are strong references between `GTPShardedParam` instances, the chain **holds the weights alive for the life of the process** and **assumes the first step's behavior is representative of every step**. Neither is free: + +| Assumption | What breaks it | Symptom | +|---|---|---| +| **Stable object identity** — `prev_w`/`next_w` point at fixed Python objects | Replacing a weight object at runtime (re-wrapping, checkpoint load that rebinds `.data`, optimizer param swap, resharding) | Chain gathers/prefetches the stale object → wrong weight in the GEMM | +| **Deterministic, fixed forward order** — the observed order is replayed every step | Data-dependent control flow: conditional layers, early exit, MoE routing that skips experts, reordered visitation | Predicted `next_w` is wrong → stale-buffer read or missed prefetch | +| **Single, non-reentrant pass** — one global `last_weight` cursor + per-weight in-flight handles | Two models in one process, an extra autograd graph, unexpected microbatch interleaving | Corrupted cursor / async handles | +| **Fixed, single membership** — `chain_id` and graphed-vs-eager decided once | A weight whose CG scope or dense/expert context changes between steps | Unrepresentable in one linear slot | +| **No parameter sharing/tying** — a linear list gives each weight one slot | A tied/shared param used in two positions (e.g. tied I/O embeddings) | One identity cannot occupy two chain positions; must be excluded | +| **Build-once, run-forever lifetime** — strong refs never released | Building/tearing down GTP models in-process (successive UTs, model re-init, multi-model drivers) | Leaks all GTP params/buffers; a new model's chain can cross-link onto a previous model's stale params | + +**Mitigations.** + +- `reset_gtp_state()` clears the class-level cursors before an in-process rebuild (call it once before `classify_gtp_chains`) — but it does *not* drop `prev_w`/`next_w` links already held by live weights. +- `prefetch_initialized = True` keeps a weight out of the chain — but it is opt-*out* by convention; a new weight that forgets it silently joins. + +**Rule of thumb:** any change that creates/replaces params at runtime, makes forward order data-dependent, runs GTP_remat concurrently, or builds multiple GTP models per process must be checked against the table above. When in doubt, exclude the affected weights so they fall back to synchronous, chain-free all-gather. + +## 4. Testing + +**Whenever you add or change a GTP_remat/EGTP_remat feature, run the GTP_remat unit-test suite below as a sanity check before opening a PR.** These tests exercise the full TE↔Mcore path (weight gather/RS, DDP, distributed optimizer, finalize, grad-norm) and catch silent-correctness regressions that don't surface as crashes. + +```bash +# 4 GPUs. GTP_remat requires TransformerEngine >= 2.19. +torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parallel/ -v +``` + +| Test file | What it guards | +|-----------|----------------| +| `test_gtp_basics.py` | Core GTP_remat shard/gather + DDP bucket alignment. | +| `test_attention_gtp.py` | GTP_remat on attention linears, loss parity vs no-GTP_remat. | +| `test_mamba_gtp.py` | GTP_remat on Mamba projection weights. | +| `test_tp_gtp.py` | GTP_remat composed with tensor parallelism (`tp_group × gtp_remat_group`). | +| `test_moe_egtp.py` | EGTP_remat on MoE routed-expert weights. | +| `test_gtp_loss_correctness.py` | End-to-end: GTP_remat per-step loss trajectory matches a no-GTP_remat baseline. | +| `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. | +| `test_gtp_cudagraph_grad.py` | Capture-step grad-norm guard (§1.2): `_backup_grads_before_capture`/`_restore_grads_after_capture` keep a graph capture from clobbering finalized `main_grad` (own params + cross-graph `next_w`, incl. routed-expert `weight_list`). | +| `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | +| `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | +| `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. | + +All tests require ≥ 4 GPUs and TransformerEngine >= 2.19; they self-skip when those are unavailable. A green run (skips for unmet hardware/config are acceptable) is the minimum bar for any GTP_remat change. diff --git a/docs/api-guide/core/index.md b/docs/api-guide/core/index.md index 0d39e46e744..af22af6c6e0 100644 --- a/docs/api-guide/core/index.md +++ b/docs/api-guide/core/index.md @@ -16,6 +16,7 @@ Low-level API reference for core Megatron components. transformer tensor_parallel +generalized_tensor_parallel pipeline_parallel fusions distributed diff --git a/docs/images/generalized_tensor_parallel/0611_ddp_egtp_orthogonal_bucketing.png b/docs/images/generalized_tensor_parallel/0611_ddp_egtp_orthogonal_bucketing.png new file mode 100644 index 0000000000000000000000000000000000000000..2d311138e8d9dad68b2b05ab0213c86a715f9e46 GIT binary patch literal 187192 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
rahWqW!BK=xjy}8$@jAH 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

E8?*C3Ex;wAsqXUv)LGkIb8 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�Lk+7J{zY?YOpnUIwfxa+U1}#0 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 `#X0l*PgE{B-^gDB$Yhf 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%0Ayd;=N5 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

Knbs_*uO}uA@LN(FA(daj-3y@T!O)UyI4W`0D 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 z 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 H;n{xy5iF=i literal 0 HcmV?d00001 diff --git a/docs/images/generalized_tensor_parallel/0613_gtp_dcp_save_call_workflow.png b/docs/images/generalized_tensor_parallel/0613_gtp_dcp_save_call_workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..b69bd835769d3e34e6a8d693167e8a3160ba8b17 GIT binary patch literal 147645 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

}QBY6jH3;6>=geHU=eSNGJ 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

FC;x3^}jj&0FIG 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 literal 0 HcmV?d00001 diff --git a/docs/images/generalized_tensor_parallel/0628_gtp_remat_class_hierarchy.png b/docs/images/generalized_tensor_parallel/0628_gtp_remat_class_hierarchy.png new file mode 100644 index 0000000000000000000000000000000000000000..e98dca8d4809263871174e5466d824a2ffe46f71 GIT binary patch literal 122127 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 literal 0 HcmV?d00001 diff --git a/docs/images/generalized_tensor_parallel/0712_gtp_te_protocol_redesign.png b/docs/images/generalized_tensor_parallel/0712_gtp_te_protocol_redesign.png new file mode 100644 index 0000000000000000000000000000000000000000..479e39b3f57e8249e553a427128689e5e4cc59fe GIT binary patch literal 326222 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

Y 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�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

cgGp3D3uk!Q?%P9XKkn?uU;!ixT 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Þyc(-5|4SF%aaE9e=Yxu>xmi*i@<5>(Olf57I)E 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 literal 0 HcmV?d00001 diff --git a/megatron/core/distributed/distributed_data_parallel.py b/megatron/core/distributed/distributed_data_parallel.py index 8da6b1d74dc..68f020fb638 100644 --- a/megatron/core/distributed/distributed_data_parallel.py +++ b/megatron/core/distributed/distributed_data_parallel.py @@ -166,6 +166,15 @@ def __init__( self.full_param_layout = full_param_layout + # GTP_remat needs average_in_collective=False: the per-bucket collective runs over the + # replicate group, so NCCL AVG would miss the 1/gtp_remat factor. arguments.py + # guards the training path; this assert covers direct megatron-core users. + gtp_active = ProcessGroupCollection.is_gtp_remat_active(process_group_dict) + assert not (gtp_active and self.ddp_config.average_in_collective), ( + "GTP requires average_in_collective=False (the default); averaged collectives reduce " + "over the GTP-excluded group and would miss the 1/gtp_remat gradient scaling factor." + ) + # Compute gradient scaling factors. if config.calculate_per_token_loss: assert ( @@ -364,6 +373,13 @@ def unmap_weight_tensor(m): self._make_backward_post_hook(param) ) break + elif getattr(param, 'is_gtp_weight_remat', False) and hasattr( + param, 'register_grad_accum_hook' + ): + # GTP_remat defers the main_grad add to a later backward node, so drive the + # post-hook from its manual call (_handle_megatron_grad_accum) rather than + # autograd's AccumulateGrad, which would fire grad-ready on stale main_grad. + param.register_grad_accum_hook(None, self._make_backward_post_hook(param)) else: # Expand so we get access to grad_fn. param_tmp = param.expand_as(param) @@ -464,9 +480,13 @@ def hook(*unused): assert param.requires_grad cudagraph_wgrad_ready_event = getattr(param, '_cudagraph_wgrad_ready_event', None) if self.ddp_config.overlap_grad_reduce and cudagraph_wgrad_ready_event is None: - assert ( - param.grad is not None - ), 'param.grad being None is not safe when overlap_grad_reduce is True' + # GTP_remat keeps its real wgrad in main_grad (via finalize); param.grad here is + # throwaway (None or a dummy), so skip this assert and rely on + # grad_added_to_main_grad below. + if not getattr(param, 'is_gtp_weight_remat', False): + assert ( + param.grad is not None + ), 'param.grad being None is not safe when overlap_grad_reduce is True' if param.grad is not None and ( not param.grad_added_to_main_grad or getattr(param, 'zero_out_wgrad', False) ): diff --git a/megatron/core/distributed/finalize_model_grads.py b/megatron/core/distributed/finalize_model_grads.py index 12253e6c4b6..af81145b6d8 100644 --- a/megatron/core/distributed/finalize_model_grads.py +++ b/megatron/core/distributed/finalize_model_grads.py @@ -491,6 +491,75 @@ def _allreduce_non_tensor_model_parallel_grads( _allreduce_layernorm_grads = _allreduce_non_tensor_model_parallel_grads +def _allreduce_replicated_grads_over_gtp_remat_group( + model: List[torch.nn.Module], calculate_per_token_loss: bool = False +): + """Complete the gtp_remat / egtp_remat axis reduction for replicated parameters. + + Replicated (non-gtp-sharded) params have a grad per gtp_remat peer (each from distinct data); + the data-parallel collective only reduced the replicate axis, so the + gtp_remat axis is still missing. How to complete it depends on the loss normalization: + + - ``calculate_per_token_loss=False`` (default): the DP collective produced the 1/replicate mean, + so a MEAN (AVG) over the gtp_remat axis yields the exact full (replicate x gtp) mean, keeping + gradient scaling decoupled from the DP degree. (gtp_remat-sharded params self-average via + their reduce-scatter mean and are skipped here.) + - ``calculate_per_token_loss=True``: DDP applies NO 1/dp scaling; finalize divides every grad by + 1/total_global_tokens (which counts the gtp_remat peers' distinct tokens). The gtp_remat axis + must therefore be SUM-reduced (like the DP axis) — an AVG would shrink each grad by 1/gtp. + + No-op when GTP_remat is inactive (group size <= 1). + """ + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = pg_collection.gtp_remat + egtp_remat_group = pg_collection.expt_gtp_remat + + dense_active = gtp_remat_group is not None and gtp_remat_group.size() > 1 + expert_active = egtp_remat_group is not None and egtp_remat_group.size() > 1 + if not dense_active and not expert_active: + return + + dense_params, dense_grads = [], [] + expert_params, expert_grads = [], [] + for model_chunk in model: + for name, param in get_attr_wrapped_model(model_chunk, 'named_parameters')(): + if not param.requires_grad or getattr(param, 'is_gtp_weight_remat', False): + continue # GTP-sharded params: their gtp_remat axis is handled by the RS-mean. + grad_attr = _get_main_grad_attr(param) + grad = getattr(param, grad_attr, None) + if grad is None: + continue + grad = _unshard_if_dtensor(grad) + if getattr(param, 'allreduce', True): + dense_params.append(param) + dense_grads.append(grad.data) + else: + expert_params.append(param) + expert_grads.append(grad.data) + + for params, grads, group in ( + (dense_params, dense_grads, gtp_remat_group), + (expert_params, expert_grads, egtp_remat_group), + ): + if not grads or group is None or group.size() <= 1: + continue + coalesced = _flatten_dense_tensors(grads) + # SUM vs AVG per the loss-normalization regime documented above. + op = ( + torch.distributed.ReduceOp.SUM + if calculate_per_token_loss + else torch.distributed.ReduceOp.AVG + ) + torch.distributed.all_reduce(coalesced, op=op, group=group) + for param, buf, synced in zip(params, grads, _unflatten_dense_tensors(coalesced, grads)): + buf.copy_(synced) + grad_attr = _get_main_grad_attr(param) + orig_grad = getattr(param, grad_attr) + setattr(param, grad_attr, _reshard_if_dtensor(buf, orig_grad)) + + def finalize_model_grads( model: List[torch.nn.Module], num_tokens: Optional[torch.Tensor] = None, @@ -532,7 +601,9 @@ def finalize_model_grads( pp_group = pg_collection.pp embd_group = pg_collection.embd pos_emb_group = pg_collection.pos_embd - dp_cp_group = pg_collection.dp_cp + # Full DP x CP x gtp_remat group: num_tokens (the per-token-loss divisor below) counts the + # gtp_remat peers' distinct tokens. Falls back to replicate dp_cp when gtp is inactive. + dp_cp_group = getattr(pg_collection, 'dp_cp_gtp_remat', None) or pg_collection.dp_cp else: tp_group = parallel_state.get_tensor_model_parallel_group() pp_group = parallel_state.get_pipeline_model_parallel_group() @@ -540,6 +611,14 @@ def finalize_model_grads( pos_emb_group = parallel_state.get_position_embedding_group(check_initialized=False) dp_cp_group = parallel_state.get_data_parallel_group(with_context_parallel=True) + # Fence the current stream against all GTP backward grad work before the DP gradient sync. + if config.gtp_weight_remat_size > 1 or config.expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.gtp_api import ( + wait_for_gtp_grad_reduction_on_current_stream, + ) + + wait_for_gtp_grad_reduction_on_current_stream() + # All-reduce / reduce-scatter across DP replicas. if config.timers is not None: config.timers('all-grads-sync', log_level=1).start(barrier=config.barrier_with_L1_time) @@ -566,6 +645,9 @@ def finalize_model_grads( barrier=config.barrier_with_L1_time ) _allreduce_non_tensor_model_parallel_grads(model, config, tp_group) + _allreduce_replicated_grads_over_gtp_remat_group( + model, calculate_per_token_loss=config.calculate_per_token_loss + ) if config.timers is not None: config.timers('non-tensor-parallel-grads-all-reduce').stop() diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index a82c4fb8a77..e2e60adfadf 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -302,6 +302,11 @@ def _post_param_sync(self): """Run post-processing after param all-gather completes.""" if self.ddp_config.reuse_grad_buf_for_mxfp8_param_ag: for bucket in self.buckets: + if bucket.param_data is None: + # LayerWise variable-size gather path: params are already updated via + # unflatten + copy_ in finish_param_sync, and there is no param_data + # buffer to copy back from. + continue has_non_quantized_weight = False for param in bucket.params: # Non-quantized weights are already mapped to param.data. Skip @@ -446,9 +451,21 @@ def start_param_sync(self, force_sync: bool = False): # Detach from autograd since start_param_sync may be called # during the forward pass where autograd is active. if local_size > 0: - flat_local_params = _flatten_dense_tensors( - bucket.layerwise_params_list[local_rank] - ).detach() + # MXFP8 params can't be flattened (view(-1) unsupported); gather the + # fp32 master (param.main_param -> bf16), which the receive-side copy_ + # re-quantizes. Non-mxfp8 params flatten as-is. + src_params = [] + for p in bucket.layerwise_params_list[local_rank]: + if is_mxfp8tensor(p): + main_param = getattr(p, "main_param", None) + assert main_param is not None, ( + "LayerWise mxfp8 param sync needs param.main_param (fp32 " + "master) to stage the all-gather source; got None." + ) + src_params.append(main_param.to(param_dtype)) + else: + src_params.append(p) + flat_local_params = _flatten_dense_tensors(src_params).detach() local_slot_view.copy_(flat_local_params) bucket.layerwise_gather_list = gather_list @@ -738,7 +755,12 @@ def start_grad_sync(self, force_all_reduce: Optional[bool] = False): ) if async_op: - if self.ddp_config.reduce_scatter_with_fp32_accumulation and not force_all_reduce: + # fp32-accum RS needs the distributed optimizer; else fall through (all-reduce -> cm). + if ( + self.ddp_config.reduce_scatter_with_fp32_accumulation + and self.ddp_config.use_distributed_optimizer + and not force_all_reduce + ): assert ( len(self.buckets) == 1 ), "Only 1 bucket supported with reduce_scatter_with_fp32_accumulation=True" diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index a5df48354de..51a9457883a 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -9,7 +9,7 @@ import os import pickle import warnings -from contextlib import nullcontext +from contextlib import contextmanager, nullcontext from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, cast import torch @@ -19,7 +19,7 @@ from torch.nn.parameter import Parameter from typing_extensions import override -from megatron.core.dist_checkpointing.mapping import ShardedStateDict +from megatron.core.dist_checkpointing.mapping import ShardedObject, ShardedStateDict from megatron.core.dist_checkpointing.utils import replace_prefix_for_sharding from megatron.core.enums import Fp4Recipe, Fp8Recipe from megatron.core.model_parallel_config import ModelParallelConfig @@ -381,6 +381,90 @@ def condition_init_method(config, init_method): return init_method if config.perform_initialization else (lambda w: None) +def _gtp_pre_init( + module, + output_size, + gtp_remat_group, + extra_kwargs, + *, + is_expert=False, + rng_via_kwarg=True, + out_split_size=1, +): + """Pre-shard ``out_features`` so plain TE builds this rank's shard; route init to a per-rank + RNG region (``rng_via_kwarg=False`` for LayerNormLinear). Returns ``(out_features, gtp_ctx)``. + + ``out_split_size`` is the factor TE further splits ``out_features`` by AFTER GTP (=tp_size for + column-parallel, else 1). GTP pads the per-TP slice (``output_size // out_split_size``) so each + rank's final shard stays alignment-divisible. Padding the full ``out_features`` would leave the + post-TP-split shard mis-aligned (MXFP8 needs dims divisible by 32). + """ + from megatron.core.tensor_parallel.gtp_api import gtp_remat_shard_dim0 + from megatron.core.tensor_parallel.random import get_gtp_remat_rng_tracker_name + + assert ( + output_size % out_split_size == 0 + ), f"_gtp_pre_init: output_size={output_size} not divisible by out_split_size={out_split_size}" + per_rank, pad_length = gtp_remat_shard_dim0(output_size // out_split_size, gtp_remat_group) + shard_out = per_rank * out_split_size + gtp_ctx = (gtp_remat_group, pad_length, output_size) + + tracker_name = get_gtp_remat_rng_tracker_name(is_expert=is_expert) + if rng_via_kwarg: + extra_kwargs["rng_tracker_name"] = tracker_name + else: + module.rng_tracker_name = tracker_name + return shard_out, gtp_ctx + + +def _gtp_attach_post_init(module, gtp_ctx, is_grouped=False): + """Attach the GTP surface to a pre-sharded TE module's weights and restore logical out_features. + + ``is_grouped=True`` for GroupedLinear (per-expert weight0..N, coalesced AG via weight_list). + """ + from megatron.core.tensor_parallel.gtp_api import attach_gtp_to_presharded_module + + gtp_remat_group, pad_length, logical_out_features = gtp_ctx + # Restore the LOGICAL out_features (the sharded value was only needed to size the weight in + # super().__init__): downstream code reads it, e.g. the grouped-MLP fusion gate checks + # fc1.out_features == 2 * fc2.in_features (a shard-sized fc1 would silently disable fusion). + module.out_features = logical_out_features + attach_gtp_to_presharded_module(module, gtp_remat_group, pad_length, is_grouped=is_grouped) + + +@contextmanager +def _init_gtp_remat_context( + module, + output_size, + gtp_remat_group, + extra_kwargs, + *, + is_expert=False, + is_grouped=False, + rng_via_kwarg=True, + out_split_size=1, +): + """Wrap a plain TE constructor: yield out_features for ``super().__init__`` (pre-sharded under + GTP), then attach GTP wiring on exit (skipped if construction raises, so it can't half-init). + + ``out_split_size`` = tp_size TE splits ``out_features`` by after GTP (column-parallel), else 1. + """ + if gtp_remat_group is None or gtp_remat_group.size() <= 1: + yield output_size + return + out_features, gtp_ctx = _gtp_pre_init( + module, + output_size, + gtp_remat_group, + extra_kwargs, + is_expert=is_expert, + rng_via_kwarg=rng_via_kwarg, + out_split_size=out_split_size, + ) + yield out_features + _gtp_attach_post_init(module, gtp_ctx, is_grouped=is_grouped) + + def split_te_layernorm_column_parallel_linear( fused_layer, config, @@ -762,6 +846,7 @@ def __init__( symmetric_ar_type: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + gtp_remat_group: Optional[torch.distributed.ProcessGroup] = None, ): """ Args: @@ -894,8 +979,16 @@ def __init__( init_quant_context = _get_fp8_model_init_for_quant_params( self.te_quant_params, torch.is_grad_enabled() ) + init_gtp_remat_context = _init_gtp_remat_context( + self, + output_size, + gtp_remat_group, + extra_kwargs, + is_expert=is_expert, + out_split_size=tp_size if te_parallel_mode == "column" else 1, + ) - with init_quant_context: + with init_quant_context, init_gtp_remat_context as output_size: super().__init__( in_features=input_size, out_features=output_size, @@ -1101,6 +1194,10 @@ def __init__( ), "Must have at least TE version 2.3 or higher to use symmetric memory all reduce" extra_kwargs["symmetric_ar_type"] = self.config.symmetric_ar_type + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat self.stride = stride self.te_quant_params: Optional[TEQuantizationParams] = None @@ -1109,11 +1206,22 @@ def __init__( init_quant_context = _get_fp8_model_init_for_quant_params( self.te_quant_params, torch.is_grad_enabled() ) + # Yield a separate gtp_output_size: the logical output_size is reused below for cpu-init + # (divide(output_size, tp_size)), so it must stay unsharded. + # rng_via_kwarg=False: TE's LayerNormLinear constructor has no rng_tracker_name kwarg. + init_gtp_remat_context = _init_gtp_remat_context( + self, + output_size, + gtp_remat_group, + extra_kwargs, + rng_via_kwarg=False, + out_split_size=self.tp_size, + ) - with init_quant_context: + with init_quant_context, init_gtp_remat_context as gtp_output_size: super().__init__( in_features=input_size, - out_features=output_size, + out_features=gtp_output_size, eps=self.config.layernorm_epsilon, sequence_parallel=self.config.sequence_parallel, fuse_wgrad_accumulation=self.config.gradient_accumulation_fusion, @@ -1217,6 +1325,11 @@ def extra_repr(self) -> str: f"out_features={self.out_features}, " f"bias={self.use_bias}, " f"TP={self.tp_size}" + + ( + f", GTP_remat={self.weight.gtp_remat_size}" + if getattr(self.weight, "gtp_remat_size", None) is not None + else "" + ) ) def backward_dw(self): @@ -1263,6 +1376,10 @@ def __init__( world_size = get_pg_size(tp_group) rank = get_pg_rank(tp_group) self.stride = stride + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat super().__init__( input_size=input_size, @@ -1282,6 +1399,7 @@ def __init__( symmetric_ar_type=config.symmetric_ar_type, tp_group=tp_group, name=name, + gtp_remat_group=gtp_remat_group, ) # Set proper partition_stride @@ -1333,6 +1451,11 @@ def extra_repr(self) -> str: f"out_features={self.out_features}, " f"bias={self.use_bias}, " f"TP={self.tp_size}" + + ( + f", GTP_remat={self.weight.gtp_remat_size}" + if getattr(self.weight, "gtp_remat_size", None) is not None + else "" + ) ) def backward_dw(self): @@ -1505,6 +1628,10 @@ def __init__( ) tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self._tp_group = tp_group + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat super().__init__( input_size=input_size, @@ -1525,6 +1652,7 @@ def __init__( symmetric_ar_type=config.symmetric_ar_type, tp_group=tp_group, name=name, + gtp_remat_group=gtp_remat_group, ) if config.use_cpu_initialization: world_size = get_pg_size(tp_group) @@ -1572,6 +1700,11 @@ def extra_repr(self) -> str: f"out_features={self.out_features}, " f"bias={self.use_bias}, " f"TP={self.tp_size}" + + ( + f", GTP_remat={self.weight.gtp_remat_size}" + if getattr(self.weight, "gtp_remat_size", None) is not None + else "" + ) ) def backward_dw(self): @@ -1986,6 +2119,7 @@ def __init__( self._tp_group = tp_group tp_size = get_pg_size(tp_group) tp_group_for_te = tp_group + gtp_remat_group = pg_collection.expt_gtp_remat self.explicit_expert_comm = is_expert and (tp_size > 1 or self.expert_parallel) @@ -2019,8 +2153,17 @@ def __init__( init_quant_context = _get_fp8_model_init_for_quant_params( self.te_quant_params, torch.is_grad_enabled() ) + init_gtp_remat_context = _init_gtp_remat_context( + self, + output_size, + gtp_remat_group, + extra_kwargs, + is_expert=True, + is_grouped=True, + out_split_size=tp_size if parallel_mode == "column" else 1, + ) - with init_quant_context: + with init_quant_context, init_gtp_remat_context as output_size: super().__init__( num_gemms=num_gemms, in_features=input_size, @@ -2385,7 +2528,12 @@ def get_gemm_tensor(param_name: str, gemm_idx: int) -> torch.Tensor: ) if self.use_bias: sharded_state_dict[f"{prefix}bias{gemm_idx}"] = sub_sd[f"{gemm_idx}.bias"] - # Adjust replica ids - replication along DP modulo EP + # Set the expert-DP replica_id, picking the group by what EGTP_remat does to each entry: + # - _extra_state ShardedObject: REPLICATED across EGTP_remat → need distinct ids + # to avoid duplicate-writer collisions → use the full ``expt_dp_gtp_remat``. + # - weight ShardedTensor: SHARDED across EGTP_remat (distinct) → not replicas → + # elect the writer over the replicate group ``expt_dp``. + # EGTP_remat=1: the two groups coincide, so this is a no-op. for k, sh_ten in sharded_state_dict.items(): replica_id = sh_ten.replica_id assert ( @@ -2393,6 +2541,8 @@ def get_gemm_tensor(param_name: str, gemm_idx: int) -> torch.Tensor: ), f"Expected replica_id for {k} to be in (PP, TP, DP) format, got: {replica_id}" if getattr(sh_ten, "is_data_parallel_fully_shard", False): edp_replica_id = 0 + elif isinstance(sh_ten, ShardedObject): + edp_replica_id = get_pg_rank(self._pg_collection.expt_dp_gtp_remat) else: edp_replica_id = get_pg_rank(self._pg_collection.expt_dp) sh_ten.replica_id = (*replica_id[:2], edp_replica_id) @@ -2406,6 +2556,17 @@ def backward_dw(self): if self.delay_wgrad_compute: super().backward_dw() + def __repr__(self): + gtp_remat = getattr(getattr(self, "weight0", None), "gtp_remat_size", None) + gtp_str = f", GTP_remat={gtp_remat}" if gtp_remat is not None else "" + return ( + f"{type(self).__name__}(per expert([" + f"in={self.in_features}, out={self.out_features}]) " + f"X num_gemms={self.num_gemms}, " + f"bias={self.use_bias}, TP={self.tp_size}" + f"{gtp_str})" + ) + class TEColumnParallelGroupedLinear(TEGroupedLinear): """ Wrapper for the Transformer-Engine's `GroupedLinear` layer but specialized diff --git a/megatron/core/model_parallel_config.py b/megatron/core/model_parallel_config.py index 88bb070e105..157ae1437f5 100644 --- a/megatron/core/model_parallel_config.py +++ b/megatron/core/model_parallel_config.py @@ -7,6 +7,41 @@ import torch +def resolve_tensor_parallel_weight_shards( + tensor_model_parallel_size: int, + tensor_parallel_num_weight_shards: Optional[int], + gtp_weight_remat_size: int, + shards_field: str = "tensor_parallel_num_weight_shards", + tp_field: str = "tensor_model_parallel_size", +) -> tuple: + """Reconcile ``tensor_parallel_num_weight_shards`` and ``gtp_weight_remat_size``. + + ``tensor_parallel_num_weight_shards`` is the user-facing total number of shards each weight is + split into across the tensor-parallel + GTP axes. It is the source of truth and implies + ``gtp_weight_remat_size = tensor_parallel_num_weight_shards // tensor_model_parallel_size``. + When None it defaults to ``tensor_model_parallel_size * gtp_weight_remat_size`` (so the pair + stays consistent, and equals ``tensor_model_parallel_size`` in the no-GTP default). Idempotent. + + Returns the reconciled ``(tensor_parallel_num_weight_shards, gtp_weight_remat_size)``. + """ + tp = tensor_model_parallel_size + if tensor_parallel_num_weight_shards is None: + tensor_parallel_num_weight_shards = tp * gtp_weight_remat_size + else: + if tensor_parallel_num_weight_shards < tp: + raise ValueError( + f"{shards_field} ({tensor_parallel_num_weight_shards}) must be " + f">= {tp_field} ({tp})." + ) + if tensor_parallel_num_weight_shards % tp != 0: + raise ValueError( + f"{shards_field} ({tensor_parallel_num_weight_shards}) must be " + f"divisible by {tp_field} ({tp})." + ) + gtp_weight_remat_size = tensor_parallel_num_weight_shards // tp + return tensor_parallel_num_weight_shards, gtp_weight_remat_size + + @dataclass class ModelParallelConfig: """Base configuration for Megatron Core @@ -20,6 +55,26 @@ class ModelParallelConfig: tensor_model_parallel_size: int = 1 """Intra-layer model parallelism. Splits tensors across GPU ranks.""" + tensor_parallel_num_weight_shards: Optional[int] = None + """Total number of shards each weight is split into across the tensor-parallel + GTP axes + (i.e. ``tensor_model_parallel_size * gtp_weight_remat_size``). This is the user-facing knob: + it must be ``>= tensor_model_parallel_size`` and divisible by it. When None it defaults to + ``tensor_model_parallel_size`` (no GTP sharding). It is the source of truth and implies + ``gtp_weight_remat_size = tensor_parallel_num_weight_shards // tensor_model_parallel_size`` + (resolved in ``__post_init__``). + """ + + gtp_weight_remat_size: int = 1 + """Generalized tensor parallelism with weight rematerialization. Shards model weights + across GPU ranks along ``out_features``; each weight is rematerialized independently + (per-weight, not per-layer) via async all-gather on every forward AND backward pass. + Placed right after tensor parallelism in the parallelism ordering. + + INTERNAL / DERIVED — there is no CLI flag for it; do not set directly. It is computed in + ``__post_init__`` from ``tensor_parallel_num_weight_shards`` (= that value divided by + ``tensor_model_parallel_size``). Use ``tensor_parallel_num_weight_shards`` to control GTP. + """ + pipeline_model_parallel_comm_backend: Optional[Literal["nccl", "ucc"]] = None """Configuring backend option of pipeline parallel communication (e.g., nccl, ucc) If None, the default backend will be used. @@ -77,6 +132,27 @@ class ModelParallelConfig: Default is None, which will be set to the value of tensor_model_parallel_size. """ + expert_tensor_parallel_num_weight_shards: Optional[int] = None + """Total number of shards each expert weight is split into across the expert-tensor-parallel + + expert-GTP axes (i.e. ``expert_tensor_parallel_size * expert_gtp_weight_remat_size``). This + is the user-facing knob for expert layers: it must be ``>= expert_tensor_parallel_size`` and + divisible by it. When None it defaults to ``expert_tensor_parallel_size`` (no expert GTP + sharding). It is the source of truth and implies + ``expert_gtp_weight_remat_size = expert_tensor_parallel_num_weight_shards // + expert_tensor_parallel_size`` (resolved in ``__post_init__``). + """ + + expert_gtp_weight_remat_size: int = 1 + """Generalized tensor parallelism with weight rematerialization, for expert layers. Independent + from the decoder's ``gtp_weight_remat_size``. + Placed right after expert parallelism in the parallelism ordering. + + INTERNAL / DERIVED — there is no CLI flag for it; do not set directly. It is computed in + ``__post_init__`` from ``expert_tensor_parallel_num_weight_shards`` (= that value divided by + ``expert_tensor_parallel_size``). Use ``expert_tensor_parallel_num_weight_shards`` to control + expert GTP. + """ + ################### # Initialization ################### @@ -430,6 +506,24 @@ def __post_init__(self): if self.expert_tensor_parallel_size is None: self.expert_tensor_parallel_size = self.tensor_model_parallel_size + # Derive the internal gtp_weight_remat_size from the user-facing + # tensor_parallel_num_weight_shards: + # num_weight_shards = tensor_model_parallel_size * gtp_weight_remat + _, self.gtp_weight_remat_size = resolve_tensor_parallel_weight_shards( + self.tensor_model_parallel_size, + self.tensor_parallel_num_weight_shards, + self.gtp_weight_remat_size, + ) + + # Same reconciliation for expert layers (expert_tensor_parallel_size finalized above). + _, self.expert_gtp_weight_remat_size = resolve_tensor_parallel_weight_shards( + self.expert_tensor_parallel_size, + self.expert_tensor_parallel_num_weight_shards, + self.expert_gtp_weight_remat_size, + shards_field="expert_tensor_parallel_num_weight_shards", + tp_field="expert_tensor_parallel_size", + ) + if self.pipeline_model_parallel_size > 1: if self.pipeline_dtype is None: raise ValueError( diff --git a/megatron/core/optimizer/__init__.py b/megatron/core/optimizer/__init__.py index bfe3d9e3c85..b7ea0bbe069 100644 --- a/megatron/core/optimizer/__init__.py +++ b/megatron/core/optimizer/__init__.py @@ -1064,10 +1064,12 @@ def get_megatron_optimizer( intra_expt_dp_group = process_groups_dict['intra_expt_dp_group'] mp_group = process_groups_dict['mp_group'] expt_tp_pp_group = process_groups_dict['expt_tp_pp_group'] + expt_tp_pp_with_egtp_remat_group = process_groups_dict['expt_tp_pp_with_egtp_remat_group'] intra_dp_cp_group_gloo = process_groups_dict['intra_dp_cp_group_gloo'] intra_expt_dp_group_gloo = process_groups_dict['intra_expt_dp_group_gloo'] intra_dist_opt_group = process_groups_dict['intra_dist_opt_group'] + # ``mp_group`` spans TP×GTP_remat×PP (GTP_remat-merged). model_parallel_rank = get_pg_rank(mp_group) if get_pg_size(dp_cp_group) > get_pg_size(intra_dp_cp_group): @@ -1192,8 +1194,9 @@ def get_megatron_optimizer( param_to_param_group[param_name] = param_group_id param_group_id += 1 if len(moe_param_groups) > 0: - expt_model_parallel_rank = get_pg_rank(expt_tp_pp_group) - # Pass Gloo process groups into optimizer only if needed. + # Expert analog of dense ``model_parallel_rank``; use the EGTP_remat-merged group so each + # EGTP_remat peer gets a distinct distopt ShardedObject key (else DCP "duplicate" error). + expt_model_parallel_rank = get_pg_rank(expt_tp_pp_with_egtp_remat_group) if use_gloo_process_groups: expt_data_parallel_group_gloo = intra_expt_dp_group_gloo else: @@ -1204,7 +1207,7 @@ def get_megatron_optimizer( model_chunks=model_chunks, param_groups=moe_param_groups, per_model_buffers=moe_buffers, - model_parallel_group=expt_tp_pp_group, + model_parallel_group=expt_tp_pp_with_egtp_remat_group, data_parallel_group=intra_expt_dp_group, data_parallel_group_gloo=expt_data_parallel_group_gloo, data_parallel_group_idx=expt_model_parallel_rank, diff --git a/megatron/core/optimizer/clip_grads.py b/megatron/core/optimizer/clip_grads.py index 762d4aa8dcd..55848e104ae 100644 --- a/megatron/core/optimizer/clip_grads.py +++ b/megatron/core/optimizer/clip_grads.py @@ -47,7 +47,7 @@ multi_tensor_scale_tensor_impl = None -from ..tensor_parallel import param_is_not_tensor_parallel_duplicate +from ..tensor_parallel import param_is_not_gtp_duplicate, param_is_not_tensor_parallel_duplicate from ..transformer.module import param_is_not_shared from ..utils import get_data_parallel_group_if_dtensor, to_local_if_dtensor @@ -202,9 +202,9 @@ def count_zeros_fp32( The count is performed in FP32. This method filters parameters to ensure gradients are not double-counted by checking if the gradient is not None, - the parameter is not shared, and the parameter is not a replica due - to tensor model parallelism. It also handles parameters managed by - Megatron FSDP specifically. + the parameter is not shared, and the parameter is not a replica due to + tensor model parallelism or (expert) generalized tensor parallelism. It also + handles parameters managed by Megatron FSDP specifically. Args: parameters (Union[List[torch.Tensor], torch.Tensor]): An iterable of @@ -226,6 +226,7 @@ def count_zeros_fp32( # - grad should not be none # - parameter should not be shared # - should not be a replica due to tensor model parallelism + # - should not be a replica due to (expert) generalized tensor parallelism total_num_zeros = torch.zeros(1, dtype=torch.int64, device='cuda') data_parallel_group = None use_megatron_fsdp = False @@ -242,7 +243,8 @@ def count_zeros_fp32( continue is_not_shared = param_is_not_shared(param) is_not_tp_duplicate = param_is_not_tensor_parallel_duplicate(param, tp_group=tp_group) - if grad_not_none and is_not_shared and is_not_tp_duplicate: + is_not_gtp_duplicate = param_is_not_gtp_duplicate(param) + if grad_not_none and is_not_shared and is_not_tp_duplicate and is_not_gtp_duplicate: grad_obj = getattr(param, grad_attr) data_parallel_group = get_data_parallel_group_if_dtensor(grad_obj, data_parallel_group) grad = to_local_if_dtensor(grad_obj).detach() diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py index e8d3995f087..0e0520e6157 100644 --- a/megatron/core/optimizer/distrib_optimizer.py +++ b/megatron/core/optimizer/distrib_optimizer.py @@ -403,6 +403,7 @@ def _build_model_and_main_param_groups( tensor_parallel.copy_tensor_model_parallel_attributes( shard_model_param, model_param ) + tensor_parallel.copy_gtp_attributes(shard_model_param, model_param) copy_optimizer_param_metadata(shard_model_param, model_param) # Generate main param. @@ -434,6 +435,7 @@ def _build_model_and_main_param_groups( tensor_parallel.copy_tensor_model_parallel_attributes( shard_main_param, model_param ) + tensor_parallel.copy_gtp_attributes(shard_main_param, model_param) copy_optimizer_param_metadata(shard_main_param, model_param) else: # When using precision-aware optimizer, main params are held by FusedAdam. @@ -456,6 +458,7 @@ def _build_model_and_main_param_groups( tensor_parallel.copy_tensor_model_parallel_attributes( shard_model_param, model_param ) + tensor_parallel.copy_gtp_attributes(shard_model_param, model_param) copy_optimizer_param_metadata(shard_model_param, model_param) else: diff --git a/megatron/core/optimizer/emerging_optimizers.py b/megatron/core/optimizer/emerging_optimizers.py index 4dfed6199b3..53ac956b35c 100644 --- a/megatron/core/optimizer/emerging_optimizers.py +++ b/megatron/core/optimizer/emerging_optimizers.py @@ -17,7 +17,7 @@ from torch.optim.optimizer import ParamsT from megatron.core.process_groups_config import ProcessGroupCollection -from megatron.core.utils import get_pg_size, log_single_rank +from megatron.core.utils import get_pg_rank, get_pg_size, log_single_rank from .optimizer_config import ParamKey, ParamPredicate @@ -230,6 +230,42 @@ def scaled_orthogonalize_fn( scaled_orthogonalize_fn=scaled_orthogonalize_fn, ) + def scaled_orthogonalize_fn_with_gtp_remat(self, p, grad, tp_group, partition_dim): + """All-gather grad along GTP_remat/EGTP_remat dim 0, orthogonalize, then slice back. + + GTP_remat shards weights along dim 0 independently of TP's partition_dim. Newton-Schulz + needs the full weight matrix, so we reconstruct the GTP_remat dimension before running + the TP-aware orthogonalization, then extract the local GTP_remat shard from the result. + When GTP_remat is inactive this is a plain passthrough to scaled_orthogonalize_fn. + """ + # TODO: Clean up code that determines if parameter is a MoE layer and which TP group to use + is_expert = getattr(p, 'expert_tp', False) + gtp_remat_group = ( + (self.pg_collection.expt_gtp_remat if is_expert else self.pg_collection.gtp_remat) + if self.pg_collection + else None + ) + + if gtp_remat_group is None or get_pg_size(gtp_remat_group) <= 1: + return self.scaled_orthogonalize_fn(grad, tp_group, partition_dim) + + # Parameters with is_gtp_weight_remat=False are not sharded along the + # GTP process group, and do not require all-gathering prior to + # orthogonalization. + if not getattr(p, 'is_gtp_weight_remat', False): + return self.scaled_orthogonalize_fn(grad, tp_group, partition_dim) + + gtp_remat_size = get_pg_size(gtp_remat_group) + gtp_rank = get_pg_rank(gtp_remat_group) + shards = [torch.empty_like(grad) for _ in range(gtp_remat_size)] + torch.distributed.all_gather(shards, grad, gtp_remat_group) + gathered_grad = torch.cat(shards, dim=0) + + gathered_grad = self.scaled_orthogonalize_fn(gathered_grad, tp_group, partition_dim) + + shard_size = gathered_grad.shape[0] // gtp_remat_size + return gathered_grad[gtp_rank * shard_size : (gtp_rank + 1) * shard_size].contiguous() + def orthogonalize(self, p: torch.Tensor, grad: torch.Tensor, **kwargs: Any) -> torch.Tensor: """Orthogonalize the momentum. @@ -280,14 +316,14 @@ def orthogonalize(self, p: torch.Tensor, grad: torch.Tensor, **kwargs: Any) -> t qkv_grads = [g.reshape(-1, grad_shape[-1]) for g in qkv_grads] qkv_grads = [ - self.scaled_orthogonalize_fn(g, tp_group, partition_dim).view( + self.scaled_orthogonalize_fn_with_gtp_remat(p, g, tp_group, partition_dim).view( num_query_groups, -1, grad_shape[-1] ) for g in qkv_grads ] grad = torch.cat(qkv_grads, dim=1).view(grad_shape) else: - grad = self.scaled_orthogonalize_fn(grad, tp_group, partition_dim) + grad = self.scaled_orthogonalize_fn_with_gtp_remat(p, grad, tp_group, partition_dim) return grad diff --git a/megatron/core/optimizer/layer_wise_optimizer.py b/megatron/core/optimizer/layer_wise_optimizer.py index 606525f8097..f39e4d44169 100644 --- a/megatron/core/optimizer/layer_wise_optimizer.py +++ b/megatron/core/optimizer/layer_wise_optimizer.py @@ -2,6 +2,7 @@ import logging import math +import re from typing import Callable, Dict, List, Optional, Tuple import torch @@ -86,6 +87,83 @@ def tag_params_for_buffer_routing(model_chunks) -> None: param.is_managed_by_layer_wise_optimizer = is_managed_by_layer_wise_optimizer(param) +def _build_gtp_replica_fold(pg_collection, model_chunks) -> Dict[str, Tuple[int, int]]: + """Map each (E)GTP_remat-REPLICATED param to ``(gtp_rank, gtp_remat_size)`` for folding. + + PROBLEM: LayerWise keeps (E)GTP_remat-replicated params (identical per gtp_remat peer) WHOLE, so + their optimizer-state ShardedTensors share one key+offset across those peers. The DP-coord reset + in ``sharded_state_dict`` would then mark all peers the all-zero "main replica" -> DCP sees N + writers for one shard and rejects the save. + + FIX: fold the (e)gtp_remat rank into ``replica_id[1]`` so one peer writes. (E)GTP_remat-SHARDED + params (``is_gtp_param``) are offset-sharded and excluded -- each shard already has a + distinct offset, hence a unique writer. + + Returns: ``{param_name: (gtp_rank, gtp_remat_size)}``, empty when GTP_remat is unavailable or + no group spans >1 rank. Names are bare (all ``module.`` wrappers stripped, layer index + collapsed) to match the optimizer-state checkpoint key suffix. + """ + gtp_fold: Dict[str, Tuple[int, int]] = {} + try: + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP, is_gtp_param + except ImportError: + return gtp_fold + if not HAVE_GTP: + return gtp_fold + + assert pg_collection is not None, ( + "_build_gtp_replica_fold requires a pg_collection carrying gtp_remat/expt_gtp_remat; " + "the optimizer factory must materialize it before constructing the optimizer." + ) + gtp_remat_group = getattr(pg_collection, 'gtp_remat', None) + egtp_remat_group = getattr(pg_collection, 'expt_gtp_remat', None) + + for model_chunk in model_chunks: + for name, p in model_chunk.named_parameters(): + if is_gtp_param(p): + continue + grp = egtp_remat_group if getattr(p, 'is_expert_parallel', False) else gtp_remat_group + if grp is None or grp.size() <= 1: + continue + # Normalize the param name so it matches the optimizer-state checkpoint key suffix, + # which is wrapper-free and layer-collapsed. Three transforms, in order: + # 1. drop every leading 'module.' (DDP + Float16Module can double-wrap the model), + # 2. collapse the layer index (the checkpoint key drops it -- a sharded axis), and + # 3. collapse SequentialMLP 'local_experts.' to the grouped key 'experts' (the + # checkpoint groups them, matching TEGroupedMLP), else expert replicas collide. + # e.g. 'module.module.decoder.layers.3.mlp.router.weight' + # -> 'decoder.layers.mlp.router.weight' + nm = name + while nm.startswith('module.'): + nm = nm[len('module.') :] + nm = re.sub(r'\.layers\.\d+\.', '.layers.', nm) + nm = re.sub(r'\.local_experts\.\d+\.', '.experts.', nm) + gtp_fold[nm] = (grp.rank(), grp.size()) + return gtp_fold + + +def _fold_replica_id(replica_id, key, gtp_fold: Dict[str, Tuple[int, int]]): + """Compute a ShardedTensor's writer-disambiguating replica_id for fixed-DP checkpointing. + + Base reset: keep (PP, TP), zero DP -- every DP rank holds the same shard, so one writer + remains. Correct for normal params. + + For an (e)gtp-replicated param (in ``gtp_fold``), reset leaves ``gtp_remat_size`` writers, so + fold the peer gtp_remat rank into TP slot to re-spread: ``new_tp = old_tp * gtp_remat_size + + gtp_rank`` (rank 0 stays the writer, the others move off the all-zero main replica) -> one + writer per shard. Suffix-match (bare fold name vs fully-qualified key) and collapse the key's + layer index too, so it matches per-layer and already-collapsed keys. + """ + rid = (*replica_id[:2], 0) + if not gtp_fold: + return rid + key = re.sub(r'\.layers\.\d+\.', '.layers.', key or '') + for nm, (gtp_rank, gtp_remat_size) in gtp_fold.items(): + if key.endswith(nm): + return (rid[0], rid[1] * gtp_remat_size + gtp_rank, rid[2]) + return rid + + class LayerWiseDistributedOptimizer(ChainedOptimizer): """Layer-wise distributed optimizer for Megatron-core models. @@ -288,6 +366,7 @@ def _emit_bucket( bucket_indices=bucket_indices, per_bucket_numel_unpadded=per_bucket_numel_unpadded, param_indices=param_indices if param_indices is not None else [], + num_optimizer_shards=dp_size, ) @staticmethod @@ -478,14 +557,15 @@ def _shard_params_from_layout(self, optimizers, full_param_layouts, dp_cp_size, # separate DistributedOptimizer; LayerWise does not own them. if not buffer_key.is_managed_by_layer_wise_optimizer: continue - dp_size = expt_dp_size if buffer_key.is_expert_parallel else dp_cp_size for param, ( param_start_index, param_end_index, bucket_id, ) in layout.param_index_map.items(): bucket_start_index, bucket_end_index = layout.bucket_indices[bucket_id] - shard_size = (bucket_end_index - bucket_start_index) // dp_size + shard_size = ( + bucket_end_index - bucket_start_index + ) // layout.num_optimizer_shards shard_id = (param_start_index - bucket_start_index) // shard_size shard_end_index = bucket_start_index + (shard_id + 1) * shard_size assert param_end_index <= shard_end_index, ( @@ -599,13 +679,20 @@ def set_bucket_layerwise_params_list(self, model_chunks): for bucket in group.buckets: if not _bucket_is_managed_by_layer_wise_optimizer(bucket): continue - bucket_params_list = [[] for _ in range(get_pg_size(self.pg_collection.dp_cp))] - for bucket_list, full_params_list in zip( - bucket_params_list, self.dp_cp_params_list - ): - for param in full_params_list: - if param in bucket.params: - bucket_list.append(param) + if self.dp_cp_params_list is not None: + bucket_params_list = [ + [] for _ in range(get_pg_size(self.pg_collection.dp_cp)) + ] + for bucket_list, full_params_list in zip( + bucket_params_list, self.dp_cp_params_list + ): + for param in full_params_list: + if param in bucket.params: + bucket_list.append(param) + else: + # dp_cp_size == 1: single rank owns all params, no + # all-gather needed but data structures must be initialized. + bucket_params_list = [list(bucket.params_list)] bucket.set_layerwise_params_list(bucket_params_list) # Do the same for expert parallel bucket groups. for group in model_chunk.expert_parallel_bucket_groups: @@ -830,14 +917,20 @@ def sharded_state_dict( model_sharded_state_dict, is_loading, **kwargs ) + # (E)GTP_remat-replicated -> (gtp_rank, gtp_remat_size), consumed by _fold_replica_id. + gtp_fold = _build_gtp_replica_fold(self.pg_collection, self.model_chunks) + # for fixed DP usage only for sh_base in nested_values(sharded_state_dict): if hasattr(sh_base, 'replica_id'): assert ( isinstance(sh_base.replica_id, int) or len(sh_base.replica_id) == 3 ), f'Expected replica_id as int or (PP, TP, DP), got: {sh_base}' - sh_base.replica_id = ( - 0 if isinstance(sh_base.replica_id, int) else (*sh_base.replica_id[:2], 0) + if isinstance(sh_base.replica_id, int): + sh_base.replica_id = 0 + continue + sh_base.replica_id = _fold_replica_id( + sh_base.replica_id, getattr(sh_base, 'key', ''), gtp_fold ) # later code assume list but chained optimizer fallback to non-list if there's only one diff --git a/megatron/core/optimizer/optimizer.py b/megatron/core/optimizer/optimizer.py index e503a16dde3..77cd20de10e 100644 --- a/megatron/core/optimizer/optimizer.py +++ b/megatron/core/optimizer/optimizer.py @@ -239,6 +239,7 @@ def _filter_grads_for_norm( - parameter should not be shared (i.e., grads shouldn't be double counted while computing norms). - should not be a replica due to tensor model parallelism. + - should not be a replica due to (expert) generalized tensor parallelism. """ grads_for_norm = [] for param in params: @@ -267,7 +268,8 @@ def _filter_grads_for_norm( is_not_tp_duplicate = tensor_parallel.param_is_not_tensor_parallel_duplicate( param, getattr(self, 'tp_group', None) ) - if grad_not_none and is_not_shared and is_not_tp_duplicate: + is_not_gtp_duplicate = tensor_parallel.param_is_not_gtp_duplicate(param) + if grad_not_none and is_not_shared and is_not_tp_duplicate and is_not_gtp_duplicate: grads_for_norm.append(grad) return grads_for_norm @@ -786,7 +788,13 @@ def step_with_ready_grads(self) -> bool: barrier=self.config.barrier_with_L1_time ) if not self.is_stub_optimizer: - if self.config.reuse_grad_buf_for_mxfp8_param_ag: + # The reuse_grad_buf (fp8-param-gather) path stages master params into the DDP + # param buffer, which only DistributedOptimizer owns. Optimizers without it + # (e.g. LayerWiseDistributedOptimizer's Float16 base opts) must instead copy + # master -> model params so the forward sees the update. + if self.config.reuse_grad_buf_for_mxfp8_param_ag and hasattr( + self, "_copy_main_params_to_param_buffer" + ): # In the case of overlap_param_gather, # copy is manually called in the training loop if not self.config.overlap_param_gather: @@ -834,6 +842,120 @@ def step(self): return success, grad_norm, num_zeros_in_grad +def _strip_module_prefix(name: str) -> str: + """Strip wrapper ``module.`` prefixes (DDP/Float16Module) off a dotted param name.""" + while name.startswith('module.'): + name = name[len('module.') :] + return name + + +def _backfill_gtp_sharded_param_map( + id_to_sharded_param_map: dict, float16_groups, model_sharded_state_dict=None +) -> None: + """Backfill the optimizer id->ShardedTensor map with GTP_remat shards it is missing (in place). + + WHAT: ``get_param_id_to_sharded_param_map`` matches an optimizer param to its model + ShardedTensor by object identity (``id(model_entry.data) == id(optim_param)``). Two GTP_remat + cases break that match: + 1. Native-FP8 GTP weights: the model entry's data is a *dequantized BF16 copy* of the param + (make_tp_sharded_tensor_for_checkpoint). The copy carries a ``_gtp_dequant_src`` backlink + to the live FP8 param, so the model's OWN entry is reused here (identity first, tagged + ``_debug_name`` second) -- preserving its full offsets (expert axes included) and + replica_id. + 2. Gathered+split factory params (Mamba ``in_proj``): the model entry exposes the *gathered* + tensor, so nothing matches the per-shard GTP param. Rebuild the same per-shard + ShardedTensor every other GTP_remat weight gets. The rebuild is NOT expert-parallel + aware (no expert offsets/replica), so expert params must resolve via case 1; refuse + loudly instead of writing colliding shards across EP groups. + + WHEN: only the distributed-Muon path reaches here. ``LayerWiseDistributedOptimizer`` keeps such + matrix params whole and routes them through this ``Float16OptimizerWithFloat16Params``. + Distributed Adam uses its own ``DistributedOptimizer.sharded_state_dict`` (flat-buffer path) + and is unaffected. + + No-op when GTP is unavailable or when every param already matched. + """ + try: + from megatron.core.tensor_parallel.gtp_api import ( + is_gtp_param, + make_sharded_tensors_for_checkpoint_with_gtp_remat, + ) + except ImportError: + return # GTP not built in -- nothing to backfill. + + # is_gtp_param matches both the legacy BF16 slice params and native-FP8 GTP params. + unmatched = [ + (param_id, p) + for param_id, p in enumerate(chain.from_iterable(float16_groups)) + if param_id not in id_to_sharded_param_map and is_gtp_param(p) + ] + if not unmatched: + return + + from ..dist_checkpointing.dict_utils import nested_values + from ..dist_checkpointing.mapping import ShardedTensor + + # Index the model's own entries by (a) the dequantized-copy backlink and (b) checkpoint key. + src_id_to_entry = {} + key_to_entry = {} + if model_sharded_state_dict is not None: + for entry in nested_values(model_sharded_state_dict): + src = getattr(getattr(entry, 'data', None), '_gtp_dequant_src', None) + if src is not None: + src_id_to_entry[id(src)] = entry + key = getattr(entry, 'key', None) + if key is not None: + # Grouped-expert entries share one key (offsets differ) -> ambiguous, drop. + key_to_entry[key] = None if key in key_to_entry else entry + + # Groups sourced lazily (below) only when a rebuild is needed, so GTP models on + # explicit grids (e.g. MiMo) don't require the global MPU groups unless they hit it. + tp_group = None + dp_cp_gtp_remat_group = None + for param_id, p in unmatched: + # Case 1: reuse the model's own entry (native-FP8 dequantized copy broke the id match). + entry = src_id_to_entry.get(id(p)) + if entry is None: + name = _strip_module_prefix(getattr(p, '_debug_name', '') or '') + candidate = key_to_entry.get(name) + # Reuse only a plain ShardedTensor with this shard's local shape; a factory + # (gathered data, e.g. Mamba in_proj) must take the per-shard rebuild below. + if ( + candidate is not None + and isinstance(candidate, ShardedTensor) + and tuple(candidate.data.shape) == tuple(p.shape) + ): + entry = candidate + if entry is not None: + id_to_sharded_param_map[param_id] = entry + continue + # Case 2: rebuild. Not EP-aware -- an expert param rebuilt here would collide across + # expert-parallel groups (duplicate writers), so it must have matched above. + if not getattr(p, 'allreduce', True): + raise ValueError( + f"GTP expert-parallel param '{getattr(p, '_debug_name', '')}' (id {param_id}) " + "has no matching model ShardedTensor; refusing the EP-unaware rebuild (it would " + "write duplicate shards across expert-parallel groups)." + ) + if tp_group is None: + tp_group = parallel_state.get_tensor_model_parallel_group() + # Required kwarg, unused for GTP-sharded params (offset/replica from the gtp axis). + dp_cp_gtp_remat_group = parallel_state.get_data_parallel_group( + with_context_parallel=True + ) + # Key by the param's dotted name (set in prod by tag_gtp_params_with_names); the fallback + # keeps the function usable in tests where the name was not tagged. + key = p._debug_name or f'_gtp_optim_param_{param_id}' + rebuilt = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {key: p}, + prefix='', + tensor_parallel_layers_axis_map={key: 0}, + tp_group=tp_group, + dp_cp_group=dp_cp_gtp_remat_group, + ) + id_to_sharded_param_map[param_id] = rebuilt[key] + + class Float16OptimizerWithFloat16Params(MixedPrecisionOptimizer): """Float16 optimizer for fp16 and bf16 data types. @@ -885,6 +1007,7 @@ def __init__( main_param = param.detach().clone().float() # Copy tensor model parallel attributes. tensor_parallel.copy_tensor_model_parallel_attributes(main_param, param) + tensor_parallel.copy_gtp_attributes(main_param, param) copy_optimizer_param_metadata(main_param, param) # Replace the optimizer params with the new fp32 copy. param_group['params'][i] = main_param @@ -1023,6 +1146,10 @@ def sharded_state_dict( model_sharded_state_dict, chain.from_iterable(g for g in self.float16_groups) ) + _backfill_gtp_sharded_param_map( + id_to_sharded_param_map, self.float16_groups, model_sharded_state_dict + ) + # Convert fp32_from_fp16_params assert len(state_dict['fp32_from_fp16_params']) == len( state_dict['optimizer']['param_groups'] diff --git a/megatron/core/optimizer/param_layout.py b/megatron/core/optimizer/param_layout.py index 2ee511c6126..808bda1941b 100644 --- a/megatron/core/optimizer/param_layout.py +++ b/megatron/core/optimizer/param_layout.py @@ -11,7 +11,7 @@ import math from dataclasses import dataclass, field -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple import torch @@ -79,12 +79,16 @@ class PerBufferParamLayout: param_indices: The index of each param among same-dtype params (using the "fake" high-precision dtype for FP8/NVFP4 params). Needed for loading non-native-fp8 checkpoints in native-fp8 mode. Order matches param_index_map iteration order. + num_optimizer_shards: Number of shards the bucket boundaries were aligned to. Set only + by ``LayerWiseDistributedOptimizer``, to recover a param's shard index; ``None`` for + ``DistributedOptimizer``, which takes the count from the buffer's data_parallel_group. """ param_index_map: Dict[torch.nn.Parameter, Tuple[int, int, int]] = field(default_factory=dict) bucket_indices: List[Tuple[int, int]] = field(default_factory=list) per_bucket_numel_unpadded: List[int] = field(default_factory=list) param_indices: List[int] = field(default_factory=list) + num_optimizer_shards: Optional[int] = None @dataclass diff --git a/megatron/core/parallel_state.py b/megatron/core/parallel_state.py index 1859d207bce..2a3c7581122 100644 --- a/megatron/core/parallel_state.py +++ b/megatron/core/parallel_state.py @@ -27,6 +27,9 @@ # Intra-layer model parallel group that the current rank belongs to. _TENSOR_MODEL_PARALLEL_GROUP = None +# Generalized tensor parallelism group that the current rank belongs to. +_GTP_WEIGHT_REMAT_GROUP = None +_GTP_WEIGHT_REMAT_GLOBAL_RANKS = None # Inter-layer model parallel group that the current rank belongs to. _PIPELINE_MODEL_PARALLEL_GROUP = None # Model parallel group (both intra- and pipeline) that the current rank belongs to. @@ -50,6 +53,9 @@ # _EXPERT_TENSOR denotes tensor parallelism of expert which splits tensor across the group. # _EXPERT_DATA denotes data parallelism of expert which replicates weight across the group. +# Expert generalized tensor parallelism group that current rank belongs to. +_EXPERT_GTP_WEIGHT_REMAT_GROUP = None +_EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS = None # Expert model parallel group that current rank belongs to. _EXPERT_MODEL_PARALLEL_GROUP = None # Expert tensor parallel group that current rank belongs to. @@ -58,12 +64,18 @@ _EXPERT_TENSOR_AND_MODEL_PARALLEL_GROUP = None # Expert tensor, model, pipeline combined parallel group _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP = None +# Same as above, but additionally merged across EGTP peers (analog of dense _MODEL_PARALLEL_GROUP +# under GTP_remat). Identical to the above when EGTP_remat_size=1. +_EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP = None # Expert data parallel group _EXPERT_DATA_PARALLEL_GROUP = None _EXPERT_DATA_PARALLEL_GROUP_GLOO = None _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP = None _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_GLOO = None _INTER_PARTIAL_EXPERT_DATA_PARALLEL_GROUP = None +# Full expert data-parallel groups: span the egtp_remat axis, for data distribution. +_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = None +_INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = None # Parallel state values changed on the fly _MPU_EXPERT_MODEL_PARALLEL_WORLD_SIZE = None _MPU_EXPERT_MODEL_PARALLEL_RANK = None @@ -118,6 +130,13 @@ # Hybrid context parallel groups _HYBRID_DP_CP_GROUPS = {} +# Full data-parallel groups: span every distinct-data rank +# (size = replicate_DP x gtp_remat). Used for data distribution (batch split, num-microbatches, +# gradient scaling) and reductions covering all distinct-data ranks. +_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = None +_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = None +_INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = None + # Data parallel group information with context parallel combined. _DATA_PARALLEL_GROUP_WITH_CP = None _DATA_PARALLEL_GROUP_WITH_CP_GLOO = None @@ -130,7 +149,7 @@ # combined parallel group of TP and CP _TENSOR_AND_CONTEXT_PARALLEL_GROUP = None -# combined parallel group of TP, DP, and CP used for fp8 +# combined parallel group of TP, DP, and CP used for fp8 (spans gtp_remat, like dp) _TENSOR_AND_DATA_PARALLEL_GROUP_WITH_CP = None # Paralel group of all GPUs in a distributed optimizer instance @@ -447,7 +466,15 @@ class RankGenerator(object): """A class for generating rank groups for different modes of parallelism.""" def __init__( - self, tp: int, ep: int, dp: int, pp: int, cp: int, order: str, rank_offset: int = 0 + self, + tp: int, + ep: int, + dp: int, + pp: int, + cp: int, + order: str, + rank_offset: int = 0, + gtp_remat: int = 1, ) -> None: assert ( ep == 1 or cp == 1 @@ -459,8 +486,9 @@ def __init__( self.dp = dp self.pp = pp self.cp = cp + self.gtp_remat = gtp_remat self.rank_offset = rank_offset - self.world_size = tp * dp * pp * cp * ep + self.world_size = tp * dp * pp * cp * ep * gtp_remat self.name_to_size = { "tp": self.tp, @@ -468,6 +496,7 @@ def __init__( "dp": self.dp, "ep": self.ep, "cp": self.cp, + "gtp_remat": self.gtp_remat, } self.order = order order = order.lower() @@ -520,6 +549,13 @@ def get_ranks(self, token): rank_group[i] += self.rank_offset return ranks + def get_gtp_ranks(self, gtp_remat_size: int): + """Get the GTP weight-sharding groups (singletons when ``gtp_remat_size == 1``).""" + assert ( + self.gtp_remat == gtp_remat_size + ), f"gtp_remat axis size ({self.gtp_remat}) != requested gtp_remat_size ({gtp_remat_size})" + return self.get_ranks('gtp_remat') + def default_embedding_ranks(pp_ranks): """Return the default ranks that constitute the stages on which the word embeddings live. @@ -543,6 +579,24 @@ def overwrite_nccl_comm_cfgs(nccl_comm_cfgs, pg_name, key_value_pair): nccl_comm_cfgs[pg_name][key_value_pair[0]] = key_value_pair[1] +def _inject_gtp_remat_axis(order_str: str, after: str = "tp") -> str: + """Inject the 'gtp_remat' axis into a RankGenerator order string for NCCL locality. + + Position controls locality (leftmost token = smallest stride = most adjacent ranks): + - dense/decoder: inject after 'tp' -> 'tp-gtp_remat-cp-ep-dp-pp' (GTP_remat local). + - expert: inject after 'ep' -> 'tp-cp-ep-gtp_remat-dp-pp' so EP keeps more-local placement + than EGTP (the MoE EP all-to-all is the heavier expert-side collective). + When gtp_remat/egtp_remat size is 1 the injected axis is a no-op (singleton groups). + """ + toks = order_str.split("-") + if "gtp_remat" in toks: + return order_str + anchor = after if after in toks else "tp" + pos = (toks.index(anchor) + 1) if anchor in toks else 0 + toks.insert(pos, "gtp_remat") + return "-".join(toks) + + # pylint: disable=C0301 def initialize_model_parallel( tensor_model_parallel_size: int = 1, @@ -554,6 +608,8 @@ def initialize_model_parallel( hierarchical_context_parallel_sizes: Optional[List[int]] = None, hybrid_context_parallel: bool = False, expert_model_parallel_size: int = 1, + gtp_remat_size: int = 1, + expert_gtp_remat_size: int = 1, num_distributed_optimizer_instances: int = 1, expert_tensor_parallel_size: Optional[int] = None, nccl_communicator_config_path: Optional[str] = None, @@ -633,6 +689,22 @@ def initialize_model_parallel( The number of Mixture of Experts parallel GPUs in each expert parallel group. + gtp_remat_size (int, default = 1): + Generalized tensor parallelism with weight rematerialization (GTP). + Shards model weights along ``out_features`` across this many ranks; + each weight is rematerialized independently (per-weight, not per- + layer) via async all-gather on every forward AND backward pass. A + first-class orthogonal axis (world_size = TP*GTP*CP*DP). Maps to the + dataclass field ``ModelParallelConfig.gtp_weight_remat_size``. + NOTE: "remat" here is NOT activation recomputation/checkpointing. + + expert_gtp_remat_size (int, default = 1): + Expert-side counterpart of ``gtp_remat_size`` — shards routed-expert + weights along ``out_features`` and rematerializes per-weight on + every forward AND backward pass. A first-class orthogonal axis on the + expert grid. Independent from ``gtp_remat_size``. Maps to + ``ModelParallelConfig.expert_gtp_weight_remat_size``. + num_distributed_optimizer_instances (int, default = 1): The number of distributed optimizer replicas across the data- parallel domain. @@ -730,7 +802,22 @@ def initialize_model_parallel( local_world_size if local_world_size is not None else torch.distributed.get_world_size() ) - model_size = tensor_model_parallel_size * pipeline_model_parallel_size * context_parallel_size + # GTP_remat requires a single distributed-optimizer instance: partial-distopt sharding of the + # data domain would need gtp_remat-aware sizing. Assert early so all group builds below can + # assume one instance when GTP_remat/EGTP is active. + assert not ( + (gtp_remat_size > 1 or expert_gtp_remat_size > 1) + and num_distributed_optimizer_instances > 1 + ), "GTP_remat with num_distributed_optimizer_instances > 1 is not yet supported." + + # gtp_remat counts toward model_size (it consumes its own ranks and carries distinct data), + # so data_parallel_size becomes the replicate degree. + model_size = ( + tensor_model_parallel_size + * pipeline_model_parallel_size + * context_parallel_size + * gtp_remat_size + ) if world_size % model_size != 0: raise RuntimeError(f"world_size ({world_size}) is not divisible by {model_size}") @@ -767,21 +854,28 @@ def initialize_model_parallel( for pg_name in high_priority_stream_groups: overwrite_nccl_comm_cfgs(nccl_comm_cfgs, pg_name, ("is_high_priority_stream", True)) + decoder_order = _inject_gtp_remat_axis(order, after="tp") + decoder_rank_generator = RankGenerator( tp=tensor_model_parallel_size, ep=1, dp=data_parallel_size, pp=pipeline_model_parallel_size, cp=context_parallel_size, - order=order, + order=decoder_order, rank_offset=rank_offset, + gtp_remat=gtp_remat_size, ) # Build expert rank generator if expert_tensor_parallel_size is None: expert_tensor_parallel_size = tensor_model_parallel_size + # EGTP is a world-size factor for the expert grid too (mirrors gtp_remat on the dense grid). expert_tensor_model_pipeline_parallel_size = ( - expert_tensor_parallel_size * expert_model_parallel_size * pipeline_model_parallel_size + expert_tensor_parallel_size + * expert_model_parallel_size + * pipeline_model_parallel_size + * expert_gtp_remat_size ) expert_data_parallel_size = world_size // expert_tensor_model_pipeline_parallel_size if world_size % expert_tensor_model_pipeline_parallel_size != 0: @@ -789,15 +883,16 @@ def initialize_model_parallel( f"world_size ({world_size}) is not divisible by expert_tensor_model_pipeline_parallel size ({expert_tensor_model_pipeline_parallel_size})" ) - # TODO: support expert specific ordering + expert_order = _inject_gtp_remat_axis(order, after="ep") expert_decoder_rank_generator = RankGenerator( tp=expert_tensor_parallel_size, ep=expert_model_parallel_size, dp=expert_data_parallel_size, pp=pipeline_model_parallel_size, cp=1, - order=order, + order=expert_order, rank_offset=rank_offset, + gtp_remat=expert_gtp_remat_size, ) assert ( @@ -833,6 +928,29 @@ def initialize_model_parallel( data_parallel_size * context_parallel_size ) // num_distributed_optimizer_instances + # Build the generalized tensor parallel groups. + # GTP_remat overlaps with the CP-DP domain because GTP_remat only shards weights + # while CP only shards activations — they are independent and can share ranks. + global _GTP_WEIGHT_REMAT_GROUP + global _GTP_WEIGHT_REMAT_GLOBAL_RANKS + assert ( + _GTP_WEIGHT_REMAT_GROUP is None + ), "generalized tensor parallel group is already initialized" + for gtp_ranks in decoder_rank_generator.get_gtp_ranks(gtp_remat_size): + group = create_group( + gtp_ranks, + timeout=timeout, + pg_options=get_nccl_options("gtp_remat", nccl_comm_cfgs), + group_desc="GTP_WEIGHT_REMAT_GROUP", + ) + if rank in gtp_ranks: + _GTP_WEIGHT_REMAT_GROUP = group + _GTP_WEIGHT_REMAT_GLOBAL_RANKS = gtp_ranks + + # Disable Gloo under GTP_remat (out of scope; the GTP_remat optimizer uses DCP). + if gtp_remat_size > 1: + create_gloo_process_groups = False + # Set NCCL_COLLNET_ENABLE to 1 to enable SHARP for the dp group. if sharp_enabled_group == "dp": os.environ["NCCL_COLLNET_ENABLE"] = "1" @@ -842,7 +960,7 @@ def initialize_model_parallel( # is eligible for using the NCCL COLLNET feature. # Therefore, dp-cp group, which potentially requires SHARP-enablement, # need to be created before all the other groups - for ranks_with_cp in decoder_rank_generator.get_ranks('dp-cp'): + for ranks_with_cp in decoder_rank_generator.get_ranks("dp-cp"): group_with_cp = create_group( ranks_with_cp, timeout=timeout, @@ -932,7 +1050,7 @@ def initialize_model_parallel( ) # TODO: Are gloo groups needed for hybrid cp? - for ranks in decoder_rank_generator.get_ranks('dp'): + for ranks in decoder_rank_generator.get_ranks("dp"): group = create_group( ranks, timeout=timeout, @@ -950,6 +1068,48 @@ def initialize_model_parallel( _DATA_PARALLEL_GROUP_GLOO = group_gloo _DATA_PARALLEL_GLOBAL_RANKS = ranks + # Full data-distribution groups: span gtp_remat explicitly + # ('gtp_remat-dp' / 'gtp_remat-dp-cp'). Used only for batch split, num-microbatches, gradient + # scaling, and reductions covering every distinct-data rank. No Gloo (data distribution uses + # ranks/sizes only). When GTP_remat is inactive they alias the default groups built above. + global _DATA_PARALLEL_GROUP_WITH_GTP_REMAT + global _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT + global _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT + if gtp_remat_size > 1: + # Every rank iterates all groups so each create_group collective is entered by all ranks. + for dp_ranks in decoder_rank_generator.get_ranks("gtp_remat-dp"): + group = create_group( + dp_ranks, + timeout=timeout, + pg_options=get_nccl_options("gtp_remat_dp", nccl_comm_cfgs), + group_desc="DATA_PARALLEL_GROUP_WITH_GTP_REMAT", + ) + if rank in dp_ranks: + _DATA_PARALLEL_GROUP_WITH_GTP_REMAT = group + + for dp_cp_ranks in decoder_rank_generator.get_ranks("gtp_remat-dp-cp"): + group = create_group( + dp_cp_ranks, + timeout=timeout, + pg_options=get_nccl_options("gtp_remat_dp_cp", nccl_comm_cfgs), + group_desc="DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT", + ) + if rank in dp_cp_ranks: + _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = group + + # GTP_remat requires a single distributed-optimizer instance (asserted above), so the + # per-instance partial full group is just the full group. + _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = ( + _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT + ) + else: + # GTP_remat inactive: the full data-distribution groups coincide with the defaults. + _DATA_PARALLEL_GROUP_WITH_GTP_REMAT = _DATA_PARALLEL_GROUP + _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = _DATA_PARALLEL_GROUP_WITH_CP + _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = ( + _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP + ) + # Build the context-parallel groups. global _CONTEXT_PARALLEL_GROUP global _CONTEXT_PARALLEL_GLOBAL_RANKS @@ -979,11 +1139,12 @@ def initialize_model_parallel( if rank in ranks: _HIERARCHICAL_CONTEXT_PARALLEL_GROUPS = hierarchical_groups - # Build the model-parallel groups. + # Model-parallel groups (TP × GTP_remat × PP). gtp_remat is a RankGenerator axis, so the + # 'tp-gtp_remat-pp' token spans it directly; with gtp_remat=1 it reduces to plain tp-pp groups. global _MODEL_PARALLEL_GROUP global _MODEL_PARALLEL_GLOBAL_RANKS assert _MODEL_PARALLEL_GROUP is None, 'model parallel group is already initialized' - for ranks in decoder_rank_generator.get_ranks('tp-pp'): + for ranks in decoder_rank_generator.get_ranks('tp-gtp_remat-pp'): group = create_group( ranks, timeout=timeout, @@ -1133,7 +1294,10 @@ def initialize_model_parallel( assert ( _TENSOR_AND_DATA_PARALLEL_GROUP is None ), 'Tensor + data parallel group is already initialized' - for ranks in decoder_rank_generator.get_ranks('tp-dp-cp'): + # Spans gtp_remat (like dp): gtp_remat peers are distinct-data ranks, so this group serves both + # FP8 amax reduction and the MoE router's expert-bias / load-balancing token reduction. The + # gtp_remat axis is a no-op when its size is 1. + for ranks in decoder_rank_generator.get_ranks('tp-gtp_remat-dp-cp'): group = create_group( ranks, timeout=timeout, @@ -1142,7 +1306,7 @@ def initialize_model_parallel( ) if rank in ranks: _TENSOR_AND_DATA_PARALLEL_GROUP_WITH_CP = group - for ranks in decoder_rank_generator.get_ranks('tp-dp'): + for ranks in decoder_rank_generator.get_ranks('tp-gtp_remat-dp'): group = create_group( ranks, timeout=timeout, @@ -1167,6 +1331,26 @@ def initialize_model_parallel( _TENSOR_AND_CONTEXT_PARALLEL_GROUP = group ### Expert-related parallel groups initialization + # Build the expert generalized tensor parallel group + # Expert GTP_remat overlaps with the expert DP domain (experts don't use CP). + global _EXPERT_GTP_WEIGHT_REMAT_GROUP + global _EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS + assert ( + _EXPERT_GTP_WEIGHT_REMAT_GROUP is None + ), 'Expert generalized tensor parallel group is already initialized' + # EGTP shard groups are get_ranks('gtp_remat') on the expert generator (singletons when + # expert_gtp_remat_size == 1). See RankGenerator.get_gtp_ranks. + for egtp_ranks in expert_decoder_rank_generator.get_gtp_ranks(expert_gtp_remat_size): + group = create_group( + egtp_ranks, + timeout=timeout, + pg_options=get_nccl_options("expt_gtp_remat", nccl_comm_cfgs), + group_desc="EXPERT_GTP_WEIGHT_REMAT_GROUP", + ) + if rank in egtp_ranks: + _EXPERT_GTP_WEIGHT_REMAT_GROUP = group + _EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS = egtp_ranks + # Build the expert model parallel group global _EXPERT_MODEL_PARALLEL_GROUP, _EXPERT_MODEL_PARALLEL_RANKS assert _EXPERT_MODEL_PARALLEL_GROUP is None, 'Expert parallel group is already initialized' @@ -1226,6 +1410,22 @@ def initialize_model_parallel( if rank in ranks: _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP = group + # Expert+tensor+pipeline group merged across EGTP peers — expert analog of the dense + # _MODEL_PARALLEL_GROUP merge (above). The 'tp-ep-gtp_remat-pp' token spans the egtp axis; with + # expert_gtp_remat_size=1 it reduces to the plain tp-ep-pp groups. Merging gives EGTP peers + # distinct ranks; see docs/api-guide/core/generalized_tensor_parallel.md §3.3 + # (Optimizer state) for the DCP-collision rationale. + global _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP + for ranks in expert_decoder_rank_generator.get_ranks('tp-ep-gtp_remat-pp'): + group = create_group( + ranks, + timeout=timeout, + pg_options=get_nccl_options("tp_ep_gtp_remat_pp", nccl_comm_cfgs), + group_desc="EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP", + ) + if rank in ranks: + _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP = group + # Build the expert data parallel group global _EXPERT_DATA_PARALLEL_GROUP assert _EXPERT_DATA_PARALLEL_GROUP is None, "Expert data group is already initialized" @@ -1251,7 +1451,10 @@ def initialize_model_parallel( expert_data_parallel_size // num_distributed_optimizer_instances ) - for ranks in expert_decoder_rank_generator.get_ranks('dp'): + # Gloo only on the non-EGTP path (EGTP + Gloo out of scope; the EGTP optimizer uses DCP). + if expert_gtp_remat_size > 1: + create_gloo_process_groups = False + for ranks in expert_decoder_rank_generator.get_ranks("dp"): group = create_group( ranks, timeout=timeout, @@ -1307,6 +1510,29 @@ def initialize_model_parallel( else: _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP = _EXPERT_DATA_PARALLEL_GROUP _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_GLOO = _EXPERT_DATA_PARALLEL_GROUP_GLOO + # Full expert data-distribution group: spans gtp_remat explicitly. Used only + # where distinct-data distribution matters; no Gloo. Aliases the default when EGTP is inactive. + global _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT + global _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT + if expert_gtp_remat_size > 1: + for dp_ranks in expert_decoder_rank_generator.get_ranks("gtp_remat-dp"): + group = create_group( + dp_ranks, + timeout=timeout, + pg_options=get_nccl_options("ep_gtp_remat_dp", nccl_comm_cfgs), + group_desc="EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT", + ) + if rank in dp_ranks: + _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = group + _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = ( + _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT + ) + else: + _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = _EXPERT_DATA_PARALLEL_GROUP + _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = ( + _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP + ) + ### End of expert related parallel groups initialization # build the intra distributed optimizer instance group @@ -1315,21 +1541,40 @@ def initialize_model_parallel( _INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP is None ), "Intra distributed optimizer instance group is already initialized" - model_parallel_group_id = 0 - intra_dist_opt_ranks = [] - for ranks in expert_decoder_rank_generator.get_ranks('tp-ep-pp'): - model_parallel_group_id += 1 - intra_dist_opt_ranks.extend(ranks) - if model_parallel_group_id % intra_partial_expert_data_parallel_size == 0: - intra_dist_opt_instance_group = create_group( - intra_dist_opt_ranks, - timeout=timeout, - pg_options=get_nccl_options("intra_dist_opt_instance", nccl_comm_cfgs), - group_desc="INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP", - ) - if rank in intra_dist_opt_ranks: - _INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP = intra_dist_opt_instance_group - intra_dist_opt_ranks = [] + if gtp_remat_size > 1 or expert_gtp_remat_size > 1: + # GTP_remat requires num_distributed_optimizer_instances == 1 (asserted above); dist-opt + # grad-stats group (used only for grad-norm + num_zeros reductions) must span the ENTIRE + # world. The per-instance accumulation below would NOT: gtp/egtp are factored out of + # expert_data_parallel_size (via expert_gtp_remat_size), so expert-generator groups omit + # gtp/egtp axes — under-counting the grad-norm for gtp/egtp-sharded params. Build one + # full-world group from all tp-ep-pp groups instead (get_ranks already applies rank_offset). + all_ranks = sorted( + r for ranks in expert_decoder_rank_generator.get_ranks('tp-ep-pp') for r in ranks + ) + intra_dist_opt_instance_group = create_group( + all_ranks, + timeout=timeout, + pg_options=get_nccl_options("intra_dist_opt_instance", nccl_comm_cfgs), + group_desc="INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP", + ) + if rank in all_ranks: + _INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP = intra_dist_opt_instance_group + else: + model_parallel_group_id = 0 + intra_dist_opt_ranks = [] + for ranks in expert_decoder_rank_generator.get_ranks('tp-ep-pp'): + model_parallel_group_id += 1 + intra_dist_opt_ranks.extend(ranks) + if model_parallel_group_id % intra_partial_expert_data_parallel_size == 0: + intra_dist_opt_instance_group = create_group( + intra_dist_opt_ranks, + timeout=timeout, + pg_options=get_nccl_options("intra_dist_opt_instance", nccl_comm_cfgs), + group_desc="INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP", + ) + if rank in intra_dist_opt_ranks: + _INTRA_DISTRIBUTED_OPTIMIZER_INSTANCE_GROUP = intra_dist_opt_instance_group + intra_dist_opt_ranks = [] # Initialize global memory buffer # This isn't really "parallel state" but there isn't another good place to @@ -1377,11 +1622,19 @@ def create_all_gather_groups(for_expert_parallelism=False, timeout=None, nccl_co tp_size = get_tensor_model_parallel_world_size() ep_size = get_expert_model_parallel_world_size() dp_size = get_data_parallel_world_size() + gtp_remat_size = get_gtp_weight_remat_world_size() or 1 # Create regular DP all-gather group dp_cp_ag_group = None decoder_rank_gen = RankGenerator( - tp=tp_size, ep=1, dp=dp_size, pp=pp_size, cp=cp_size, order='tp-cp-ep-dp-pp', rank_offset=0 + tp=tp_size, + ep=1, + dp=dp_size, + pp=pp_size, + cp=cp_size, + gtp_remat=gtp_remat_size, + order=_inject_gtp_remat_axis('tp-cp-ep-dp-pp', after='tp'), + rank_offset=0, ) for ranks_with_cp in decoder_rank_gen.get_ranks('dp-cp'): @@ -1399,6 +1652,7 @@ def create_all_gather_groups(for_expert_parallelism=False, timeout=None, nccl_co if for_expert_parallelism and ep_size > 1: expert_tp_size = get_expert_tensor_parallel_world_size() expert_dp_size = get_expert_data_parallel_world_size() + egtp_remat_size = get_expert_gtp_weight_remat_world_size() or 1 expert_rank_gen = RankGenerator( tp=expert_tp_size, @@ -1406,7 +1660,8 @@ def create_all_gather_groups(for_expert_parallelism=False, timeout=None, nccl_co dp=expert_dp_size, pp=pp_size, cp=1, - order='tp-cp-ep-dp-pp', + gtp_remat=egtp_remat_size, + order=_inject_gtp_remat_axis('tp-cp-ep-dp-pp', after='ep'), rank_offset=0, ) @@ -1455,6 +1710,42 @@ def get_tensor_model_parallel_group(check_initialized=True): return _TENSOR_MODEL_PARALLEL_GROUP +def get_gtp_weight_remat_group(check_initialized=True): + """Get the parameter-sharding group the caller rank belongs to.""" + if check_initialized: + assert ( + _GTP_WEIGHT_REMAT_GROUP is not None + ), "generalized tensor parallel group is not initialized" + return _GTP_WEIGHT_REMAT_GROUP + + +def get_gtp_weight_remat_world_size(): + """Return world size for the parameter-sharding group.""" + if torch.distributed.is_available() and torch.distributed.is_initialized(): + group = get_gtp_weight_remat_group(check_initialized=False) + return group.size() if group is not None else 0 + else: + return 0 + + +def get_gtp_weight_remat_rank(): + """Return caller's rank in the parameter-sharding group.""" + if torch.distributed.is_available() and torch.distributed.is_initialized(): + group = get_gtp_weight_remat_group(check_initialized=False) + return group.rank() if group is not None else 0 + else: + return 0 + + +def get_gtp_weight_remat_global_ranks(check_initialized=True): + """Get all global ranks of the parameter-sharding group that the caller rank belongs to.""" + if check_initialized: + assert ( + _GTP_WEIGHT_REMAT_GLOBAL_RANKS is not None + ), "generalized tensor parallel group is not initialized" + return _GTP_WEIGHT_REMAT_GLOBAL_RANKS + + def get_pipeline_model_parallel_group(check_initialized=True): """Get the pipeline-model-parallel group the caller rank belongs to.""" if check_initialized: @@ -1464,22 +1755,56 @@ def get_pipeline_model_parallel_group(check_initialized=True): return _PIPELINE_MODEL_PARALLEL_GROUP -def get_data_parallel_group(with_context_parallel=False, partial_data_parallel=False): - """Get the data-parallel group the caller rank belongs to.""" - if with_context_parallel: - if partial_data_parallel: - assert ( - _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP is not None - ), "Intra partial data parallel group is not initialized" - return _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP - assert ( - _DATA_PARALLEL_GROUP_WITH_CP is not None - ), "data parallel group with context parallel combined is not initialized" - return _DATA_PARALLEL_GROUP_WITH_CP +def get_data_parallel_group( + with_context_parallel=False, with_gtp_remat=True, partial_data_parallel=False +): + """Get the data-parallel group the caller rank belongs to. + + GTP_remat is an independent axis layered on DP. + DEFAULT (``with_gtp_remat=True``): full data-distribution group (replicate_DP x + gtp_remat) — gtp_remat peers hold distinct micro-batches, so use it for batch split, + num-microbatches, grad scaling, and reductions over all distinct-data ranks. + ``with_gtp_remat=False``: replicate group — grad all-reduce, optimizer-state + sharding, checkpoint replicas. + + Args: + with_context_parallel: If True, include context-parallel ranks. + with_gtp_remat: True (default) = full data-distribution group; False = replicate. + partial_data_parallel: If True, return partial DP group (requires with_context_parallel). + """ + assert ( + with_context_parallel or not partial_data_parallel + ), "Partial DP for Optimizer needs to include CP" + # (with_cp, partial_data_parallel) -> (group, description). Globals are read at call time + # (assigned during initialize_model_parallel). partial requires CP, so the (False, True) row + # is unreachable and omitted. + if with_gtp_remat: + group_table = { + (False, False): ( + _DATA_PARALLEL_GROUP_WITH_GTP_REMAT, + "data parallel group (with GTP_remat)", + ), + (True, False): ( + _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT, + "data parallel group with CP (with GTP_remat)", + ), + (True, True): ( + _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT, + "intra partial data parallel group with CP (with GTP_remat)", + ), + } else: - assert _DATA_PARALLEL_GROUP is not None, "data parallel group is not initialized" - assert partial_data_parallel == False, "Partial DP for Optimizer needs to include CP" - return _DATA_PARALLEL_GROUP + group_table = { + (False, False): (_DATA_PARALLEL_GROUP, "data parallel group"), + (True, False): (_DATA_PARALLEL_GROUP_WITH_CP, "data parallel group with CP"), + (True, True): ( + _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP, + "intra partial data parallel group with CP", + ), + } + group, description = group_table[(with_context_parallel, partial_data_parallel)] + assert group is not None, f"{description} is not initialized" + return group def get_data_parallel_group_gloo(with_context_parallel=False, partial_data_parallel=False): @@ -1576,7 +1901,11 @@ def get_amax_reduction_group(with_context_parallel=False, tp_only_amax_red=False def get_tensor_and_data_parallel_group(check_initialized=True, with_context_parallel=False): - """Get the tensor- and data-parallel group the caller rank belongs to.""" + """Get the tensor- and data-parallel group the caller rank belongs to. + + The group spans gtp_remat (like dp), so it serves both FP8 amax reduction and the MoE router's + expert-bias / load-balancing token reduction across every distinct-data rank. + """ if with_context_parallel: if check_initialized: assert ( @@ -1788,14 +2117,22 @@ def get_pipeline_model_parallel_prev_rank(): return _PIPELINE_GLOBAL_RANKS[(rank_in_pipeline - 1) % world_size] -def get_data_parallel_world_size(with_context_parallel=False, partial_data_parallel=False): - """Return world size for the data parallel group.""" +def get_data_parallel_world_size( + with_context_parallel=False, with_gtp_remat=True, partial_data_parallel=False +): + """Return the data-parallel world size. + + DEFAULT (with_gtp_remat=True): full degree (replicate_DP x gtp_remat). + with_gtp_remat=False: replicate degree. + """ global _MPU_DATA_PARALLEL_WORLD_SIZE if _MPU_DATA_PARALLEL_WORLD_SIZE is not None: return _MPU_DATA_PARALLEL_WORLD_SIZE if torch.distributed.is_available() and torch.distributed.is_initialized(): return get_data_parallel_group( - with_context_parallel=with_context_parallel, partial_data_parallel=partial_data_parallel + with_context_parallel=with_context_parallel, + with_gtp_remat=with_gtp_remat, + partial_data_parallel=partial_data_parallel, ).size() else: return 0 @@ -1807,14 +2144,22 @@ def set_data_parallel_rank(rank): _MPU_DATA_PARALLEL_RANK = rank -def get_data_parallel_rank(with_context_parallel=False, partial_data_parallel=False): - """Return caller's rank in the data-parallel group.""" +def get_data_parallel_rank( + with_context_parallel=False, with_gtp_remat=True, partial_data_parallel=False +): + """Return the caller's data-parallel rank. + + DEFAULT (with_gtp_remat=True): rank in the full group (replicate_DP x gtp_remat). + with_gtp_remat=False: rank in the replicate group. + """ global _MPU_DATA_PARALLEL_RANK if _MPU_DATA_PARALLEL_RANK is not None: return _MPU_DATA_PARALLEL_RANK if torch.distributed.is_available() and torch.distributed.is_initialized(): return get_data_parallel_group( - with_context_parallel=with_context_parallel, partial_data_parallel=partial_data_parallel + with_context_parallel=with_context_parallel, + with_gtp_remat=with_gtp_remat, + partial_data_parallel=partial_data_parallel, ).rank() else: return 0 @@ -1853,6 +2198,42 @@ def get_tensor_and_context_parallel_rank(): ### Expert-related parallel states functions +def get_expert_gtp_weight_remat_group(check_initialized=True): + """Get the expert-parameter-sharding group the caller rank belongs to.""" + if check_initialized: + assert ( + _EXPERT_GTP_WEIGHT_REMAT_GROUP is not None + ), "expert generalized tensor parallel group is not initialized" + return _EXPERT_GTP_WEIGHT_REMAT_GROUP + + +def get_expert_gtp_weight_remat_world_size(): + """Return world size for the expert-parameter-sharding group.""" + if torch.distributed.is_available() and torch.distributed.is_initialized(): + group = get_expert_gtp_weight_remat_group(check_initialized=False) + return group.size() if group is not None else 0 + else: + return 0 + + +def get_expert_gtp_weight_remat_rank(): + """Return caller's rank in the expert-parameter-sharding group.""" + if torch.distributed.is_available() and torch.distributed.is_initialized(): + group = get_expert_gtp_weight_remat_group(check_initialized=False) + return group.rank() if group is not None else 0 + else: + return 0 + + +def get_expert_gtp_weight_remat_global_ranks(check_initialized=True): + """Get all global ranks of the expert-parameter-sharding group that the caller rank belongs to.""" + if check_initialized: + assert ( + _EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS is not None + ), "expert generalized tensor parallel group is not initialized" + return _EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS + + def get_expert_model_parallel_group(check_initialized=True): """Get the expert-model-parallel group the caller rank belongs to.""" if check_initialized: @@ -1974,8 +2355,23 @@ def get_expert_tensor_and_model_parallel_rank(): return 0 -def get_expert_tensor_model_pipeline_parallel_group(check_initialized=True): - """Get expert tensor-model-pipeline parallel group.""" +def get_expert_tensor_model_pipeline_parallel_group(check_initialized=True, with_egtp_remat=False): + """Get expert tensor-model-pipeline parallel group. + + Args: + check_initialized: If True (default), asserts the group has been created. + with_egtp_remat: If True, return the EGTP-merged variant — the analog of dense + ``get_model_parallel_group()`` (which merges across GTP peers). Use this when you + need a group whose rank uniquely identifies each (ETP, EP, PP, EGTP) position; + e.g. for the MoE distributed optimizer's ``data_parallel_group_idx``. Identical + to the vanilla group when EGTP_remat_size=1. + """ + if with_egtp_remat: + if check_initialized: + assert ( + _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP is not None + ), "Expert tensor-model-pipeline parallel group with EGTP is not initialized" + return _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP if check_initialized: assert ( _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP is not None @@ -1983,20 +2379,36 @@ def get_expert_tensor_model_pipeline_parallel_group(check_initialized=True): return _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP -def get_expert_data_parallel_group(check_initialized=True, partial_expert_data_parallel=False): - """Get expert data parallel group.""" - if partial_expert_data_parallel: - if check_initialized: - assert ( - _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP is not None - ), "Intra partial expert data parallel group is not initialized" - return _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP - else: - if check_initialized: - assert ( - _EXPERT_DATA_PARALLEL_GROUP is not None - ), "Expert data parallel group is not initialized" - return _EXPERT_DATA_PARALLEL_GROUP +def get_expert_data_parallel_group( + check_initialized=True, with_gtp_remat=True, partial_expert_data_parallel=False +): + """Get the expert data parallel group. + + DEFAULT (with_gtp_remat=True): full group for data distribution (EGTP_remat peers + hold distinct micro-batches). + with_gtp_remat=False: replicate group — expert grad all-reduce, optimizer state, + checkpoint replicas. + """ + # (with_gtp_remat, partial_expert_data_parallel) -> (group, description). Read at call time. + group_table = { + (False, False): (_EXPERT_DATA_PARALLEL_GROUP, "Expert data parallel group"), + (False, True): ( + _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP, + "Intra partial expert data parallel group", + ), + (True, False): ( + _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT, + "Expert data parallel group (with GTP_remat)", + ), + (True, True): ( + _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT, + "Intra partial expert data parallel group (with GTP_remat)", + ), + } + group, description = group_table[(with_gtp_remat, partial_expert_data_parallel)] + if check_initialized: + assert group is not None, f"{description} is not initialized" + return group def get_expert_data_parallel_group_gloo(partial_expert_data_parallel=False): @@ -2013,21 +2425,21 @@ def get_expert_data_parallel_group_gloo(partial_expert_data_parallel=False): return _EXPERT_DATA_PARALLEL_GROUP_GLOO -def get_expert_data_parallel_rank(partial_expert_data_parallel=False): - """Return caller's rank in the expert data parallel group.""" +def get_expert_data_parallel_rank(with_gtp_remat=True, partial_expert_data_parallel=False): + """Return the caller's expert-data-parallel rank (default: EGTP_remat-inclusive).""" if torch.distributed.is_available() and torch.distributed.is_initialized(): return get_expert_data_parallel_group( - partial_expert_data_parallel=partial_expert_data_parallel + with_gtp_remat=with_gtp_remat, partial_expert_data_parallel=partial_expert_data_parallel ).rank() else: return 0 -def get_expert_data_parallel_world_size(partial_expert_data_parallel=False): - """Return world size for the expert data parallel group.""" +def get_expert_data_parallel_world_size(with_gtp_remat=True, partial_expert_data_parallel=False): + """Return the expert-data-parallel world size (default: EGTP_remat-inclusive).""" if torch.distributed.is_available() and torch.distributed.is_initialized(): return get_expert_data_parallel_group( - partial_expert_data_parallel=partial_expert_data_parallel + with_gtp_remat=with_gtp_remat, partial_expert_data_parallel=partial_expert_data_parallel ).size() else: return 0 @@ -2082,6 +2494,7 @@ def get_all_ranks(): pipeline-model-parallel and expert-model-parallel groups.""" ranks = [ get_tensor_model_parallel_rank(), + get_gtp_weight_remat_rank(), get_data_parallel_rank(), get_context_parallel_rank(), get_pipeline_model_parallel_rank(), @@ -2109,15 +2522,30 @@ def destroy_model_parallel(): global _TENSOR_MODEL_PARALLEL_GROUP _TENSOR_MODEL_PARALLEL_GROUP = None + global _GTP_WEIGHT_REMAT_GROUP + _GTP_WEIGHT_REMAT_GROUP = None + + global _GTP_WEIGHT_REMAT_GLOBAL_RANKS + _GTP_WEIGHT_REMAT_GLOBAL_RANKS = None + global _PIPELINE_MODEL_PARALLEL_GROUP _PIPELINE_MODEL_PARALLEL_GROUP = None global _DATA_PARALLEL_GROUP _DATA_PARALLEL_GROUP = None + global _DATA_PARALLEL_GROUP_WITH_GTP_REMAT + _DATA_PARALLEL_GROUP_WITH_GTP_REMAT = None + global _DATA_PARALLEL_GROUP_WITH_CP _DATA_PARALLEL_GROUP_WITH_CP = None + global _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT + _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = None + + global _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT + _INTRA_PARTIAL_DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT = None + global _CONTEXT_PARALLEL_GROUP _CONTEXT_PARALLEL_GROUP = None @@ -2184,6 +2612,12 @@ def destroy_model_parallel(): _DATA_PARALLEL_GROUP_WITH_CP_GLOO = None # Destroy parallel state related to expert parallelism. + global _EXPERT_GTP_WEIGHT_REMAT_GROUP + _EXPERT_GTP_WEIGHT_REMAT_GROUP = None + + global _EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS + _EXPERT_GTP_WEIGHT_REMAT_GLOBAL_RANKS = None + global _EXPERT_MODEL_PARALLEL_GROUP _EXPERT_MODEL_PARALLEL_GROUP = None @@ -2208,9 +2642,18 @@ def destroy_model_parallel(): global _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP = None + global _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP + _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP = None + global _EXPERT_DATA_PARALLEL_GROUP _EXPERT_DATA_PARALLEL_GROUP = None + global _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT + _EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = None + + global _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT + _INTRA_PARTIAL_EXPERT_DATA_PARALLEL_GROUP_WITH_GTP_REMAT = None + global _EXPERT_DATA_PARALLEL_GROUP_GLOO if ( _EXPERT_DATA_PARALLEL_GROUP_GLOO is not None diff --git a/megatron/core/process_groups_config.py b/megatron/core/process_groups_config.py index 6c1e3651387..ccb6dce0eb8 100644 --- a/megatron/core/process_groups_config.py +++ b/megatron/core/process_groups_config.py @@ -44,11 +44,17 @@ class ProcessGroupCollection: expt_tp: Expert tensor parallel group tp_ep: Tensor and expert parallel group tp_ep_pp: Tensor, expert, and pipeline parallel group + tp_ep_pp_with_egtp_remat: tp_ep_pp merged across EGTP peers (dense ``mp`` analog); + identical to ``tp_ep_pp`` when EGTP_remat_size=1 # Data Parallelism Groups dp: Data parallel process group dp_cp: Data and context parallel group + dp_cp_gtp_remat: Full data-distribution group, dp_cp x gtp_remat; + identical to dp_cp when GTP_remat_size=1 expt_dp: Expert data parallel group + expt_dp_gtp_remat: Full expert data-distribution group, expt_dp x egtp_remat; + identical to expt_dp when EGTP_remat_size=1 intra_dp_cp: Intra partial data parallel group intra_expt_dp: Intra partial expert data parallel group inter_dist_opt: Inter distributed optimizer instance group @@ -104,7 +110,12 @@ class ProcessGroupCollection: # _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP tp_ep_pp: torch.distributed.ProcessGroup = field(init=False) - # _TENSOR_AND_DATA_PARALLEL_GROUP_WITH_CP + # _EXPERT_TENSOR_MODEL_PIPELINE_PARALLEL_GROUP_WITH_EGTP — expert "model parallel" group + # merged across EGTP peers (analog of dense ``mp`` under GTP). Identical to ``tp_ep_pp`` + # when EGTP_remat_size=1. + tp_ep_pp_with_egtp_remat: torch.distributed.ProcessGroup = field(init=False) + + # _TENSOR_AND_DATA_PARALLEL_GROUP_WITH_CP (spans gtp_remat, like dp) tp_dp_cp: torch.distributed.ProcessGroup = field(init=False) # Data Parallelism Process Groups @@ -114,9 +125,21 @@ class ProcessGroupCollection: # _DATA_PARALLEL_GROUP_WITH_CP dp_cp: torch.distributed.ProcessGroup = field(init=False) + # _DATA_PARALLEL_GROUP_WITH_CP_WITH_GTP_REMAT — the full data-distribution group, DP x CP x + # gtp_remat. Used where data is split/aggregated across every rank that holds a distinct + # micro-batch (batch split, num_microbatches, loss/metric reductions, non-gtp param broadcast). + # Identical to ``dp_cp`` when gtp_remat_size=1. + dp_cp_gtp_remat: torch.distributed.ProcessGroup = field(init=False) + # Separate dp_cp communicator for param all-gather (AG/RS overlap) dp_cp_ag: torch.distributed.ProcessGroup = field(init=False) + # _GTP_WEIGHT_REMAT_GROUP + gtp_remat: torch.distributed.ProcessGroup = field(init=False) + + # _EXPERT_GTP_WEIGHT_REMAT_GROUP + expt_gtp_remat: torch.distributed.ProcessGroup = field(init=False) + # MoE layers need expt_dp group for sharded state dict # we need this workaround until distributed checkpoint is refactored # to have sharded_state_dict can take the PG and pass it down @@ -124,6 +147,11 @@ class ProcessGroupCollection: # _EXPERT_DATA_PARALLEL_GROUP expt_dp: torch.distributed.ProcessGroup = field(init=False) + # _EXPERT_DATA_PARALLEL_GROUP_WITH_EGTP — full expert data-distribution group, expt_dp x + # egtp_remat. Used for non-egtp expert param broadcast / sharded state dict over the full + # axis. Identical to ``expt_dp`` when egtp_remat_size=1. + expt_dp_gtp_remat: torch.distributed.ProcessGroup = field(init=False) + # _EXPERT_DATA_PARALLEL_GROUP_AG expt_dp_ag: torch.distributed.ProcessGroup = field(init=False) @@ -146,19 +174,27 @@ def __init__(self, **kwargs): else: raise ValueError(f"Unknown attribute: {key}") + def __getattr__(self, name: str): + # Return None for any declared field that was not set during partial construction + # (e.g. when use_mpu_process_groups is called with a subset of required_pgs). + if name in {f.name for f in fields(self.__class__)}: + return None + raise AttributeError(f"'ProcessGroupCollection' object has no attribute '{name}'") + def __repr__(self): """Return a concise representation showing which process groups exist and their sizes.""" active_pgs = [] for field_info in fields(self): - if hasattr(self, field_info.name): - pg = getattr(self, field_info.name) - if pg is None: - active_pgs.append(f"{field_info.name}(None)") - elif isinstance(pg, list): - sizes = [g.size() for g in pg] - active_pgs.append(f"{field_info.name}({sizes})") - else: - active_pgs.append(f"{field_info.name}({pg.size()})") + if field_info.name not in vars(self): + continue + pg = getattr(self, field_info.name) + if pg is None: + continue + elif isinstance(pg, list): + sizes = [g.size() for g in pg] + active_pgs.append(f"{field_info.name}({sizes})") + else: + active_pgs.append(f"{field_info.name}({pg.size()})") return ( f"ProcessGroupCollection({', '.join(active_pgs)})" if active_pgs @@ -212,21 +248,35 @@ def use_mpu_process_groups(cls, required_pgs: Optional[List[str]] = None): parallel_state.get_expert_tensor_model_pipeline_parallel_group, check_initialized=False, ), + 'tp_ep_pp_with_egtp_remat': partial( + parallel_state.get_expert_tensor_model_pipeline_parallel_group, + check_initialized=False, + with_egtp_remat=True, + ), 'embd': partial(parallel_state.get_embedding_group, check_initialized=False), 'pos_embd': partial( parallel_state.get_position_embedding_group, check_initialized=False ), - 'dp': parallel_state.get_data_parallel_group, - 'dp_cp': partial(parallel_state.get_data_parallel_group, with_context_parallel=True), + 'dp': partial(parallel_state.get_data_parallel_group, with_gtp_remat=False), + 'dp_cp': partial( + parallel_state.get_data_parallel_group, + with_context_parallel=True, + with_gtp_remat=False, + ), + 'dp_cp_gtp_remat': partial( + parallel_state.get_data_parallel_group, with_context_parallel=True + ), 'dp_cp_ag': lambda: None, 'intra_dp_cp': partial( parallel_state.get_data_parallel_group, with_context_parallel=True, + with_gtp_remat=False, partial_data_parallel=True, ), 'intra_expt_dp': partial( parallel_state.get_expert_data_parallel_group, check_initialized=False, + with_gtp_remat=False, partial_expert_data_parallel=True, ), 'inter_dist_opt': partial( @@ -239,6 +289,11 @@ def use_mpu_process_groups(cls, required_pgs: Optional[List[str]] = None): ), # TODO (Hepteract): remove this once distributed checkpoint is refactored 'expt_dp': partial( + parallel_state.get_expert_data_parallel_group, + check_initialized=False, + with_gtp_remat=False, + ), + 'expt_dp_gtp_remat': partial( parallel_state.get_expert_data_parallel_group, check_initialized=False ), 'expt_dp_ag': lambda: None, @@ -247,6 +302,12 @@ def use_mpu_process_groups(cls, required_pgs: Optional[List[str]] = None): check_initialized=False, with_context_parallel=True, ), + 'gtp_remat': partial( + parallel_state.get_gtp_weight_remat_group, check_initialized=False + ), + 'expt_gtp_remat': partial( + parallel_state.get_expert_gtp_weight_remat_group, check_initialized=False + ), } assert all( @@ -259,6 +320,19 @@ def use_mpu_process_groups(cls, required_pgs: Optional[List[str]] = None): return cls(**init_dict) + @staticmethod + def is_gtp_remat_active(process_group_dict: Dict) -> bool: + """True iff GTP_remat or EGTP_remat is active (a weight-shard group spans >1 rank). + + Reads 'gtp_remat_group'/'expt_gtp_remat_group' from setup_process_groups_for_* + builders; a None group means that axis is unused. + """ + gtp_remat = process_group_dict.get('gtp_remat_group') + expt_gtp_remat = process_group_dict.get('expt_gtp_remat_group') + return (gtp_remat is not None and gtp_remat.size() > 1) or ( + expt_gtp_remat is not None and expt_gtp_remat.size() > 1 + ) + @staticmethod def setup_process_groups_for_optimizer( pg_collection: Optional['ProcessGroupCollection'], @@ -294,22 +368,30 @@ def setup_process_groups_for_optimizer( if pg_collection is None: # Use parallel_state groups dp_group = parallel_state.get_data_parallel_group( - with_context_parallel=False, partial_data_parallel=False + with_context_parallel=False, with_gtp_remat=False, partial_data_parallel=False ) dp_cp_group = parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=False + with_context_parallel=True, with_gtp_remat=False, partial_data_parallel=False ) intra_dp_cp_group = parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=True + with_context_parallel=True, with_gtp_remat=False, partial_data_parallel=True ) - expt_dp_group = parallel_state.get_expert_data_parallel_group() + expt_dp_group = parallel_state.get_expert_data_parallel_group(with_gtp_remat=False) intra_expt_dp_group = parallel_state.get_expert_data_parallel_group( - partial_expert_data_parallel=True + with_gtp_remat=False, partial_expert_data_parallel=True + ) + gtp_remat_group = parallel_state.get_gtp_weight_remat_group(check_initialized=False) + expt_gtp_remat_group = parallel_state.get_expert_gtp_weight_remat_group( + check_initialized=False ) intra_dist_opt_group = parallel_state.get_intra_distributed_optimizer_instance_group() - # Gloo groups - if use_gloo_process_groups: + # Gloo is not built under GTP_remat (the GTP_remat optimizer uses DCP); fetching the + # absent group would assert, so gate on gtp_active and leave the Gloo groups None. + gtp_active = (gtp_remat_group is not None and gtp_remat_group.size() > 1) or ( + expt_gtp_remat_group is not None and expt_gtp_remat_group.size() > 1 + ) + if use_gloo_process_groups and not gtp_active: intra_dp_cp_group_gloo = parallel_state.get_data_parallel_group_gloo( with_context_parallel=True, partial_data_parallel=True ) @@ -323,6 +405,9 @@ def setup_process_groups_for_optimizer( # Model communication groups mp_group = parallel_state.get_model_parallel_group() expt_tp_pp_group = parallel_state.get_expert_tensor_model_pipeline_parallel_group() + expt_tp_pp_with_egtp_remat_group = ( + parallel_state.get_expert_tensor_model_pipeline_parallel_group(with_egtp_remat=True) + ) # Inter distributed optimizer group if hasattr(model_chunks[0], 'ddp_config'): @@ -338,14 +423,15 @@ def setup_process_groups_for_optimizer( else: # Use provided process group collection with validation and fallbacks + pg_set = vars(pg_collection) # 1. dp group - this is always required - if not hasattr(pg_collection, 'dp'): + if 'dp' not in pg_set: raise ValueError("dp process group is required but not provided in pg_collection") dp_group = pg_collection.dp # 2. dp_cp group: fallback logic based on context_parallel_size - if hasattr(pg_collection, 'dp_cp'): + if 'dp_cp' in pg_set: dp_cp_group = pg_collection.dp_cp else: model_config = get_model_config(model_chunks[0]) @@ -360,7 +446,7 @@ def setup_process_groups_for_optimizer( ) # 3. Handle expert data parallel group - if not hasattr(pg_collection, 'expt_dp'): + if 'expt_dp' not in pg_set: raise ValueError( "expt_dp process group is required but not provided in pg_collection. " "Please explicitly set it to None if you don't need it." @@ -381,10 +467,10 @@ def setup_process_groups_for_optimizer( else: # With multiple optimizer instances, both groups must be provided if not ( - hasattr(pg_collection, 'intra_dp_cp') - and hasattr(pg_collection, 'intra_expt_dp') - and hasattr(pg_collection, 'inter_dist_opt') - and hasattr(pg_collection, 'intra_dist_opt') + 'intra_dp_cp' in pg_set + and 'intra_expt_dp' in pg_set + and 'inter_dist_opt' in pg_set + and 'intra_dist_opt' in pg_set ): raise ValueError( "intra_dp_cp, intra_expt_dp, inter_dist_opt, and intra_dist_opt " @@ -396,7 +482,7 @@ def setup_process_groups_for_optimizer( inter_dist_opt_group = pg_collection.inter_dist_opt if ddp_config.use_distributed_optimizer: - if not hasattr(pg_collection, 'intra_dist_opt'): + if 'intra_dist_opt' not in pg_set: raise ValueError( "intra_dist_opt process group is required but not provided in " "pg_collection. Please explicitly set it to None if you don't need it." @@ -412,7 +498,7 @@ def setup_process_groups_for_optimizer( intra_dist_opt_group = None # 5. Model communication groups - if not hasattr(pg_collection, 'mp'): + if 'mp' not in pg_set: raise ValueError( "mp process group is required but not provided in pg_collection. " "Please explicitly set it to None if you don't need it." @@ -420,13 +506,25 @@ def setup_process_groups_for_optimizer( mp_group = pg_collection.mp # Expert tensor-model-pipeline group for MoE - if not hasattr(pg_collection, 'tp_ep_pp'): + if 'tp_ep_pp' not in pg_set: raise ValueError( "tp_ep_pp process group is required but not provided in pg_collection. " "Please explicitly set it to None if you don't need it." ) expt_tp_pp_group = pg_collection.tp_ep_pp + # EGTP-MERGED variant of tp_ep_pp: includes the egtp axis, so each EGTP peer gets a + # distinct rank — used for the distopt ShardedObject keys. Falls back to tp_ep_pp + # when not provided. + if 'tp_ep_pp_with_egtp_remat' in pg_set: + expt_tp_pp_with_egtp_remat_group = pg_collection.tp_ep_pp_with_egtp_remat + else: + expt_tp_pp_with_egtp_remat_group = expt_tp_pp_group + + # GTP weight-shard groups (None when inactive); used to detect whether GTP is on. + gtp_remat_group = getattr(pg_collection, 'gtp_remat', None) + expt_gtp_remat_group = getattr(pg_collection, 'expt_gtp_remat', None) + # Gloo groups - not supported when pg_collection is provided if use_gloo_process_groups: raise ValueError( @@ -442,8 +540,11 @@ def setup_process_groups_for_optimizer( 'intra_dp_cp_group': intra_dp_cp_group, 'expt_dp_group': expt_dp_group, 'intra_expt_dp_group': intra_expt_dp_group, + 'gtp_remat_group': gtp_remat_group, + 'expt_gtp_remat_group': expt_gtp_remat_group, 'mp_group': mp_group, 'expt_tp_pp_group': expt_tp_pp_group, + 'expt_tp_pp_with_egtp_remat_group': expt_tp_pp_with_egtp_remat_group, 'inter_dist_opt_group': inter_dist_opt_group, 'intra_dist_opt_group': intra_dist_opt_group, 'intra_dp_cp_group_gloo': intra_dp_cp_group_gloo, @@ -478,21 +579,29 @@ def setup_process_groups_for_ddp( # Use parallel_state groups return { 'dp_group': parallel_state.get_data_parallel_group( - with_context_parallel=False, partial_data_parallel=False + with_context_parallel=False, with_gtp_remat=False, partial_data_parallel=False ), 'dp_cp_group': parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=False + with_context_parallel=True, with_gtp_remat=False, partial_data_parallel=False ), 'intra_dp_cp_group': parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=True + with_context_parallel=True, with_gtp_remat=False, partial_data_parallel=True + ), + 'expt_dp_group': parallel_state.get_expert_data_parallel_group( + with_gtp_remat=False ), - 'expt_dp_group': parallel_state.get_expert_data_parallel_group(), 'intra_expt_dp_group': parallel_state.get_expert_data_parallel_group( - partial_expert_data_parallel=True + with_gtp_remat=False, partial_expert_data_parallel=True ), 'tp_group': parallel_state.get_tensor_model_parallel_group(), + 'gtp_remat_group': parallel_state.get_gtp_weight_remat_group( + check_initialized=False + ), 'pp_group': parallel_state.get_pipeline_model_parallel_group(), 'ep_group': parallel_state.get_expert_model_parallel_group(), + 'expt_gtp_remat_group': parallel_state.get_expert_gtp_weight_remat_group( + check_initialized=False + ), 'inter_dist_opt_group': ( parallel_state.get_inter_distributed_optimizer_instance_group() if ddp_config.num_distributed_optimizer_instances > 1 @@ -507,14 +616,15 @@ def setup_process_groups_for_ddp( else: # Use provided process group collection with validation and fallbacks result = {} + pg_set = vars(pg_collection) # 1. dp group - this is always required - if not hasattr(pg_collection, 'dp'): + if 'dp' not in pg_set: raise ValueError("dp process group is required but not provided in pg_collection") result['dp_group'] = pg_collection.dp # 2. dp_cp group: fallback logic based on context_parallel_size - if hasattr(pg_collection, 'dp_cp'): + if 'dp_cp' in pg_set: result['dp_cp_group'] = pg_collection.dp_cp else: cp_size = getattr(config, 'context_parallel_size', 1) @@ -550,9 +660,9 @@ def setup_process_groups_for_ddp( else: # With multiple optimizer instances, groups must be provided if not ( - hasattr(pg_collection, 'intra_dp_cp') - and hasattr(pg_collection, 'intra_expt_dp') - and hasattr(pg_collection, 'inter_dist_opt') + 'intra_dp_cp' in pg_set + and 'intra_expt_dp' in pg_set + and 'inter_dist_opt' in pg_set ): raise ValueError( "intra_dp_cp, intra_expt_dp, and inter_dist_opt " @@ -564,13 +674,7 @@ def setup_process_groups_for_ddp( result['inter_dist_opt_group'] = pg_collection.inter_dist_opt # 5. Model parallel groups (DDP-specific: tp, pp, ep instead of mp, expt_tp_pp) - if not all( - [ - hasattr(pg_collection, 'tp'), - hasattr(pg_collection, 'pp'), - hasattr(pg_collection, 'ep'), - ] - ): + if not all(['tp' in pg_set, 'pp' in pg_set, 'ep' in pg_set]): raise ValueError( "tp, pp and ep process groups are required but not provided in pg_collection" ) @@ -578,6 +682,10 @@ def setup_process_groups_for_ddp( result['pp_group'] = pg_collection.pp result['ep_group'] = pg_collection.ep + # GTP weight-shard groups (None when inactive); used to detect whether GTP is on. + result['gtp_remat_group'] = getattr(pg_collection, 'gtp_remat', None) + result['expt_gtp_remat_group'] = getattr(pg_collection, 'expt_gtp_remat', None) + return result diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index bd96afa511e..7a44c8a493a 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -8,7 +8,7 @@ import inspect import logging import math -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import List, Optional, Tuple, Union import torch @@ -33,6 +33,7 @@ from megatron.core.ssm.ops.mamba_ssm import selective_state_update from megatron.core.ssm.utils import _split_tensor_factory from megatron.core.tensor_parallel import get_cuda_rng_tracker +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP from megatron.core.transformer import TransformerConfig from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.spec_utils import ModuleSpec, build_module @@ -47,8 +48,14 @@ is_mamba_min_version, is_using_quantization_scales, log_single_rank, + make_tp_sharded_tensor_for_checkpoint, ) +if HAVE_GTP: + from megatron.core.tensor_parallel.gtp_api import is_gtp_param +else: + is_gtp_param = None + from .mamba_context_parallel import MambaContextParallel try: @@ -1371,6 +1378,38 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): + 2 * self.ngroups_local_tp * self.d_state + self.nheads_local_tp ) + # Under GTP, in_proj.weight is GTP-sliced along axis 0. The [z|x|B|C|dt] split boundaries + # don't line up with GTP slice boundaries, so gather the shards back to TP-local size + # (strip the trailing pad rows from the gathered tail) and fall through to the same + # split path the non-GTP run uses — saved ckpt format matches a non-GTP run. + in_proj_gtp_remat_size = getattr(self.in_proj.weight, "gtp_remat_size", 1) + if in_proj_gtp_remat_size > 1 and HAVE_GTP and is_gtp_param(self.in_proj.weight): + gtp_remat_group = self.in_proj.weight.group + # in_proj.weight was already built at the sharded size by the submodule + # sharded_state_dict above — and, for native-FP8 GTP, dequantized to BF16 there + # (make_tp_sharded_tensor_for_checkpoint). Gather those (BF16) shards back to the + # full TP-local size so the [z|x|B|C|dt] split below matches a non-GTP run. + local = sharded_state_dict[f"{prefix}in_proj.weight"].data.contiguous() + gathered = torch.empty( + (local.shape[0] * in_proj_gtp_remat_size,) + local.shape[1:], + dtype=local.dtype, + device=local.device, + ) + torch.distributed.all_gather_into_tensor(gathered, local, group=gtp_remat_group) + if gathered.shape[0] != in_proj_dim: + gathered = gathered[:in_proj_dim].contiguous() + # Gathered weight is replicated across full dp_cp; replica_id needs only the DP slot. + dp_cp_rank = torch.distributed.get_rank(metadata['dp_cp_group']) + sharded_state_dict[f"{prefix}in_proj.weight"] = make_tp_sharded_tensor_for_checkpoint( + gathered, + f"{prefix}in_proj.weight", + tp_axis=0, + replica_id=(0, 0, dp_cp_rank), + prepend_offsets=sharded_offsets, + tp_group=self.tp_group, + dp_cp_group=metadata['dp_cp_group'], + ) + assert sharded_state_dict[f"{prefix}in_proj.weight"].data.size(0) == in_proj_dim, ( in_proj_dim, sharded_state_dict[f"{prefix}in_proj.weight"], @@ -1389,6 +1428,40 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): 0, ) + # GTP load-side inverse of the save-time all-gather (see + # docs/api-guide/core/generalized_tensor_parallel.md §3.3, in_proj + # note): the checkpoint stores the FULL TP-local in_proj.weight (pad stripped) under the + # 5 split keys [z|x|B|C|dt], so the default merge_fn cats them back to ``in_proj_dim`` + # rows with no padding. To reload into the live GTP param we must mirror init + # (``_gtp_slice_one_param``): F.pad the merged tensor with zeros up to + # ``gtp_local_size * gtp_remat_size``, then slice by ``gtp_rank``. GTP_remat_size=1 has no + # pad/slice. + if in_proj_gtp_remat_size > 1 and HAVE_GTP and is_gtp_param(self.in_proj.weight): + factory = sharded_state_dict[f"{prefix}in_proj.weight"] + gtp_local_rank = torch.distributed.get_rank(self.in_proj.weight.group) + gtp_local_size = self.in_proj.weight.data.size(0) + original_merge_fn = factory.merge_fn + + @torch.no_grad() + def _gtp_slice_after_cat( + sub_state_dict, + _orig=original_merge_fn, + _rank=gtp_local_rank, + _size=gtp_local_size, + _gtp_remat_size=in_proj_gtp_remat_size, + ): + full = _orig(sub_state_dict) + aligned_total = _size * _gtp_remat_size + pad_rows = aligned_total - full.shape[0] + if pad_rows > 0: + full = torch.nn.functional.pad(full, (0, 0, 0, pad_rows)) + start = _rank * _size + return full[start : start + _size].contiguous() + + sharded_state_dict[f"{prefix}in_proj.weight"] = replace( + factory, merge_fn=_gtp_slice_after_cat + ) + conv_dim = self.d_inner_local_tp + 2 * self.ngroups_local_tp * self.d_state assert sharded_state_dict[f"{prefix}conv1d_weight"].data.size(0) == conv_dim, ( conv_dim, diff --git a/megatron/core/tensor_parallel/__init__.py b/megatron/core/tensor_parallel/__init__.py index 0852014a859..6147ae7b65d 100644 --- a/megatron/core/tensor_parallel/__init__.py +++ b/megatron/core/tensor_parallel/__init__.py @@ -10,8 +10,10 @@ ColumnParallelLinear, RowParallelLinear, VocabParallelEmbedding, + copy_gtp_attributes, copy_tensor_model_parallel_attributes, linear_with_grad_accumulation_and_async_allreduce, + param_is_not_gtp_duplicate, param_is_not_tensor_parallel_duplicate, set_defaults_if_not_set_tensor_model_parallel_attributes, set_tensor_model_parallel_attributes, @@ -58,7 +60,9 @@ "set_tensor_model_parallel_attributes", "set_defaults_if_not_set_tensor_model_parallel_attributes", "copy_tensor_model_parallel_attributes", + "copy_gtp_attributes", "param_is_not_tensor_parallel_duplicate", + "param_is_not_gtp_duplicate", "linear_with_grad_accumulation_and_async_allreduce", # mappings.py "copy_to_tensor_model_parallel_region", diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py new file mode 100644 index 00000000000..754633240e5 --- /dev/null +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -0,0 +1,2109 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Generalized Tensor Parallelism (GTP). + +GTP factors the weight-parallel domain into ``TP x GTP_remat`` (two orthogonal +sub-axes). A weight is sharded ``1/(TP * GTP_remat)`` along its partition dim: + +* The ``TP`` slice stays sharded through the GEMM — ordinary tensor parallelism; + the output is TP-sharded and reduced/gathered as usual. +* The ``GTP_remat`` slice is *rematerialized* just before the GEMM: only the + ``gtp_remat`` sub-group async all-gathers its part, so each rank's GEMM sees + the full TP slice. This trades extra all-gather traffic for ``1/GTP_remat`` + lower weight (and optimizer/grad) memory — ZeRO-3-on-the-weight on top of TP. + +``GTP_remat`` (the rematerialization sub-axis) has degree ``gtp_weight_remat_size``, +derived from ``--tensor-parallel-num-weight-shards``. + +Materialization uses a per-weight prefetch chain + ticket-based buffer cache +co-designed for CUDA graph capture/replay. Quantized AG (FP8 / MXFP8 / NVFP4) +composes with the sharding for compounding bandwidth reduction. + +See ``docs/api-guide/core/generalized_tensor_parallel.md`` for design and usage. +""" + +from __future__ import annotations + +import logging +import math +import re +from collections import defaultdict +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional + +import torch +from packaging.version import Version + +from megatron.core.utils import log_single_rank + +logger = logging.getLogger(__name__) + +_GTP_TE_MIN_VERSION = Version("2.19.0.dev0") + +try: + import transformer_engine as te # noqa: F401 + + _te_version = Version(te.__version__) + if _te_version < _GTP_TE_MIN_VERSION: + raise ImportError( + f"megatron.core.tensor_parallel.gtp_api requires TransformerEngine " + f">= {_GTP_TE_MIN_VERSION} (found {_te_version})." + ) + + import transformer_engine_torch as tex + from transformer_engine.pytorch.constants import ( + MXFP8_BLOCK_SCALING_SIZE, + NVFP4_BLOCK_SCALING_SIZE, + ) + from transformer_engine.pytorch.distributed import ( + _NVFP4AllGatherAsyncHandle, + gather_along_first_dim, + in_fp8_activation_recompute_phase, + reduce_scatter_along_first_dim, + ) + from transformer_engine.pytorch.module.base import get_dummy_wgrad + from transformer_engine.pytorch.quantized_tensor import QuantizedTensor + from transformer_engine.pytorch.tensor import MXFP8TensorStorage, NVFP4TensorStorage + from transformer_engine.pytorch.tensor.mxfp8_tensor import MXFP8Quantizer + from transformer_engine.pytorch.utils import ( + nvtx_range_pop, + nvtx_range_push, + round_up_to_nearest_multiple, + ) + + HAVE_TE = True +except (ImportError, ModuleNotFoundError): + # TE unavailable/too old -> stub the TE-backed names so this module still imports, + # and flag GTP unusable via HAVE_TE (gtp_api.py surfaces this as HAVE_GTP=False). No + # GTP path runs without TE. The `annotations` future-import keeps the lone + # module-level TE reference (a dataclass field annotation) from being evaluated. + from unittest.mock import MagicMock + + te = tex = MagicMock() + MXFP8_BLOCK_SCALING_SIZE = NVFP4_BLOCK_SCALING_SIZE = None + _NVFP4AllGatherAsyncHandle = MagicMock() + gather_along_first_dim = reduce_scatter_along_first_dim = MagicMock() + in_fp8_activation_recompute_phase = MagicMock() + get_dummy_wgrad = MagicMock() + QuantizedTensor = MagicMock() + MXFP8TensorStorage = NVFP4TensorStorage = MagicMock() + MXFP8Quantizer = MagicMock() + nvtx_range_pop = nvtx_range_push = round_up_to_nearest_multiple = MagicMock() + HAVE_TE = False + + +class GTPChain(str, Enum): + """Prefetch chain identifier for n GTPShardedParam. + + GRAPHED — fwd/bwd captured by a CUDA graph (MLM _CudaGraphRunner). + UNGRAPHED — fwd/bwd runs eagerly. + + Chains never cross-link (prev_w/next_w stay within one chain). See + _classify_param_chain for the GRAPHED/UNGRAPHED rule. + """ + + GRAPHED = "GTP_graphed" + UNGRAPHED = "GTP_ungraphed" + + +# Active cuda_graph config, set by the integrator via set_cuda_graph_modules() before +# classify_gtp_chains(); consumed by _classify_param_chain. +_CUDA_GRAPH_MODULES: Optional[set] = None # scope tags, e.g. {"mamba","attn","moe_router"} +_MOE_SHARED_EXPERT_OVERLAP: bool = False # overlapped shared_experts can't be captured -> UNGRAPHED +_FULL_ITERATION: bool = False # whole step in one graph -> every param GRAPHED +# Empty cuda_graph_modules under per-layer CG = "graph every layer" == all tags present. +_ALL_LAYER_SCOPE_TAGS = frozenset({"mamba", "attn", "moe", "moe_router"}) + + +def set_cuda_graph_modules( + scope, moe_shared_expert_overlap: bool = False, cuda_graph_impl: str = "none" +): + """Record the active cuda_graph config for GTP chain classification. + + Called by MLM at init, before classify_gtp_chains(). ``cuda_graph_impl`` + disambiguates the empty-``scope`` cases: + - "none" -> CG disabled; all params UNGRAPHED. + - "full_iteration" -> whole step in one graph; all params GRAPHED. + - "local"/"transformer_engine" + empty scope -> graph every layer. + """ + global _CUDA_GRAPH_MODULES, _MOE_SHARED_EXPERT_OVERLAP, _FULL_ITERATION + _MOE_SHARED_EXPERT_OVERLAP = bool(moe_shared_expert_overlap) + _FULL_ITERATION = cuda_graph_impl == "full_iteration" + if _FULL_ITERATION: + _CUDA_GRAPH_MODULES = None # scope unused + elif cuda_graph_impl != "none" and not scope: + _CUDA_GRAPH_MODULES = set(_ALL_LAYER_SCOPE_TAGS) # graph every layer + else: + _CUDA_GRAPH_MODULES = set(scope) if scope else None + + +def _classify_param_chain(param_name: str) -> "GTPChain": + """Map a GTPShardedParam name + active cuda_graph config to its chain. + + Full-iteration -> GRAPHED. Otherwise embedding/output_layer are UNGRAPHED, and + each layer kind (mixer, attention, shared/routed experts) is GRAPHED iff its + scope tag is in cuda_graph_modules. + """ + n = param_name + + if _FULL_ITERATION: + return GTPChain.GRAPHED + + # embedding/output_layer live outside any per-layer CG runner. + if "embedding" in n or "output_layer" in n: + return GTPChain.UNGRAPHED + + scope = _CUDA_GRAPH_MODULES + if not scope: # CG disabled + return GTPChain.UNGRAPHED + + if ".mlp.shared_experts." in n: + if _MOE_SHARED_EXPERT_OVERLAP: + return GTPChain.UNGRAPHED + return GTPChain.GRAPHED if ("moe" in scope or "moe_router" in scope) else GTPChain.UNGRAPHED + + if ".mlp.experts." in n: + return GTPChain.GRAPHED if "moe" in scope else GTPChain.UNGRAPHED + + if ".self_attention." in n or ".cross_attention." in n: + return GTPChain.GRAPHED if "attn" in scope else GTPChain.UNGRAPHED + + if ".mixer." in n: + return GTPChain.GRAPHED if "mamba" in scope else GTPChain.UNGRAPHED + + return GTPChain.UNGRAPHED + + +def classify_gtp_chains(model) -> None: + """Walk model.named_parameters() and set chain_id on every GTPShardedParam. + + Call once at init, AFTER set_cuda_graph_modules() and BEFORE the first fwd of any + graphed param. Raises if an already-initialized param would be reclassified into a + different chain (its prev/next links are already wired into the wrong list). + """ + conflicts = [] + for name, param in model.named_parameters(): + if not is_gtp_param(param): + continue + target = _classify_param_chain(name).value + if param.prefetch_initialized and param.chain_id != target: + conflicts.append((name, param.chain_id, target)) + continue + param.chain_id = target + + # Bwd-prefetch opt-out: embedding weight needs no bwd AG (wgrad is a + # scatter-add on sharded rows, input has no dgrad) — saves one collective. + if "embedding" in name: + param._need_weight_prefetch_bwd = False + if conflicts: + raise RuntimeError( + "classify_gtp_chains: the following params were already chain-initialized " + "with a different chain_id than the classifier would assign — this means " + "their chain links are already wired into the wrong list. Move classification " + "earlier in init. Conflicts: " + + ", ".join(f"{n}: {old!r}->{new!r}" for n, old, new in conflicts[:3]) + + ("..." if len(conflicts) > 3 else "") + ) + + +class GTPWeightState(Enum): + """State of a GTPShardedParam's AG / RS lifecycle (debug / stale-read guard).""" + + NONE = "NONE" # Sharded, no pending operation + ASYNC_WAIT = "ASYNC_WAIT" # Async all-gather in progress + DATA_READY = "DATA_READY" # Async all-gather complete, result in cache + DATA_READY_SYNC = "DATA_READY_SYNC" # Sync all-gather complete, result in cache + + +# Global GTP buffer cache (persists across clear(); never set to None after creation). +_GTP_CACHE = None +_GTP_PARAMS = [] + +# Global set of GTPShardedParam with in-flight async comms (AG or RS). +_inflight_comm_params: set = set() +_AG_STREAMS: Dict[str, torch.cuda.Stream] = {} +_RS_STREAMS: Dict[str, torch.cuda.Stream] = {} + +# Wgrad input buffer pool, keyed by (shape, dtype). UNGRAPHED-only: GRAPHED +# wgrad bufs need address stability for CG replay and are not pool-recycled. +_wgrad_buf_pool: Dict[tuple, list] = {} + + +def _wgrad_pool_get(shape: tuple, dtype: torch.dtype, device) -> torch.Tensor: + """Get a pool buffer or allocate fresh, tagged so _wgrad_pool_put accepts only + pool-owned buffers (other callers fall through to the caching allocator on release).""" + key = (shape, dtype) + pool = _wgrad_buf_pool.get(key) + if pool: + buf = pool.pop() + else: + buf = torch.empty(shape, dtype=dtype, device=device, requires_grad=False) + buf._from_gtp_wgrad_pool = True + return buf + + +def _wgrad_pool_put(buf: torch.Tensor): + """Return a pool-owned buffer for reuse (no-op for untagged buffers; see + _wgrad_pool_get).""" + if not getattr(buf, "_from_gtp_wgrad_pool", False): + return + key = (tuple(buf.shape), buf.dtype) + if key not in _wgrad_buf_pool: + _wgrad_buf_pool[key] = [] + _wgrad_buf_pool[key].append(buf) + + +def _stream_key(chain_id: str, group) -> tuple: + """Key for the per-(chain, group) AG/RS stream dicts. + + Partitioned on two axes: chain_id (captured GRAPHED vs eager UNGRAPHED ops must not + share a stream) and group (independent NCCL, e.g. GTP_remat vs EGTP_remat, no serialization). + """ + return (chain_id, id(group) if group is not None else 0) + + +def get_ag_stream(chain_id: str = GTPChain.GRAPHED.value, group=None) -> torch.cuda.Stream: + """Return the GTP all-gather stream for (chain_id, group). See _stream_key.""" + key = _stream_key(chain_id, group) + if key not in _AG_STREAMS: + _AG_STREAMS[key] = torch.cuda.Stream() + return _AG_STREAMS[key] + + +def get_rs_stream(chain_id: str = GTPChain.GRAPHED.value, group=None) -> torch.cuda.Stream: + """Return the GTP reduce-scatter stream for (chain_id, group). See _stream_key.""" + key = _stream_key(chain_id, group) + if key not in _RS_STREAMS: + _RS_STREAMS[key] = torch.cuda.Stream() + return _RS_STREAMS[key] + + +def wait_for_gtp_grad_reduction_on_current_stream() -> None: + """Fence the current stream against all GTP backward grad work before the DP gradient sync. + + Drains the eager AG/RS side streams, then waits on each CG runner's replay stream + (its tail = captured Phase 2 main_grad.add_). No-op when GTP is inactive. + """ + wait_async_comms() + cur = torch.cuda.current_stream() + for s in _AG_STREAMS.values(): + cur.wait_stream(s) + for s in _RS_STREAMS.values(): + cur.wait_stream(s) + # Local import: cuda_graphs imports this module, so a module-level import would be circular. + from megatron.core.transformer.cuda_graphs import get_gtp_runner_streams + + for s in get_gtp_runner_streams(): + cur.wait_stream(s) + + +@dataclass +class GTPRematConfig: + """Global configuration for Generalized Tensor Parallelism (weight remat).""" + + pad_for_alignment: int = 16 + check_param_states: bool = False + weight_prefetch: bool = True + # True (default): non-chain-head wgrad RS is async_op=True and finalizes + # (handle.wait + main_grad.add_) in a later bwd's cascade walk, overlapping RS with + # compute. False: every wgrad RS is synchronous + inline (no overlap). + async_reduction: bool = True + # Mirrors config.calculate_per_token_loss. When True, DDP applies NO 1/dp pre-scaling + # (gradient_scaling_factor=1.0) and finalize_model_grads normalizes every gradient by + # 1/total_global_tokens instead. In that mode the gtp_remat axis must be SUM-reduced (plain + # reduce-scatter, like DP), NOT mean-reduced — a 1/gtp mean would double-count the + # normalization. When False, the gtp_remat reduce-scatter takes the MEAN so it composes with + # DDP's 1/replicate scaling to yield the full (replicate x gtp) mean. + calculate_per_token_loss: bool = False + + +GTP_CONFIG = GTPRematConfig() + + +def update_gtp_config(**kwargs): + """Update the global GTP configuration.""" + for key, value in kwargs.items(): + if not hasattr(GTP_CONFIG, key): + raise ValueError(f"Unknown GTP config option: {key}") + setattr(GTP_CONFIG, key, value) + + +def tag_gtp_params_with_names(model): + """Populate _debug_name on every GTPShardedParam with its full dotted parameter name. + + Call once after model construction so the linking log prints human-readable names + instead of raw tensor ids. + """ + for name, param in model.named_parameters(): + if is_gtp_param(param): + param._debug_name = name + + +def configure_gtp_remat_from_recipe( + *, fp4=False, fp8_recipe=None, fp8=False, calculate_per_token_loss=False +): + """ + Configure GTP weight-remat (padding + loss reduction) from the quantization recipe. + Must be called once BEFORE model construction. + """ + # gtp_remat grad reduction SUMs (not means) the gtp_remat axis under per-token-loss. + # check_param_states=False: GTP buffer reuse (notably under CUDA-graph capture) trips the + # param-state debug asserts, so keep them off for GTP runs. + update_gtp_config(calculate_per_token_loss=calculate_per_token_loss, check_param_states=False) + if fp4: + update_gtp_config(pad_for_alignment=16) + elif fp8_recipe == "mxfp8": + update_gtp_config(pad_for_alignment=32) + elif fp8: + update_gtp_config(pad_for_alignment=16) + + if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: + logger.info("> GTP_remat enabled. %s", GTP_CONFIG) + + +def classify_gtp_remat_chains( + model, *, cuda_graph_modules=None, moe_shared_expert_overlap=False, cuda_graph_impl="none" +): + """ + Tag and classify every GTP param's prefetch chain (GRAPHED vs UNGRAPHED). + Must be called once AFTER model build + DDP wrap and before the first forward (which + lazily builds chain links). + """ + cg_modules = ( + {getattr(s, "name", str(s)) for s in cuda_graph_modules} if cuda_graph_modules else None + ) + set_cuda_graph_modules( + cg_modules, + moe_shared_expert_overlap=moe_shared_expert_overlap, + cuda_graph_impl=cuda_graph_impl, + ) + # Clear stale process-global chain state so a rebuilt model starts fresh. + reset_gtp_state() + for model_module in model if isinstance(model, list) else [model]: + tag_gtp_params_with_names(model_module) + classify_gtp_chains(model_module) + + +def gtp_remat_shard_dim0(dim0, gtp_remat_group): + """Return ``(shard_dim0, pad_length)`` for allocating a dim-0 GTP weight-remat shard.""" + gtp_remat_size = gtp_remat_group.size() + if GTP_CONFIG.pad_for_alignment > 0: + alignment = GTP_CONFIG.pad_for_alignment * gtp_remat_size + pad_length = (alignment - dim0 % alignment) % alignment + else: + assert dim0 % gtp_remat_size == 0, ( + f"gtp_remat_shard_dim0: dim0={dim0} not divisible by gtp_remat_size={gtp_remat_size}. " + "Enable padding (GTP_CONFIG.pad_for_alignment > 0) or make dim-0 a multiple of the " + "GTP group size." + ) + pad_length = 0 + padded = dim0 + pad_length + return padded // gtp_remat_size, pad_length + + +def _gtp_slice_one_param(param, gtp_remat_group, *, name=""): + """Pad + slice a full-size BF16 weight to this rank's GTP shard. + + Caller attaches GTP attrs (see _gtp_attach_attrs). On the legacy post-init path under + fp8_model_init, tensor may be a QuantizedTensor — F.pad dequantizes it before slicing. + """ + gtp_remat_size = gtp_remat_group.size() + gtp_rank = gtp_remat_group.rank() + tensor = param.data + + if GTP_CONFIG.pad_for_alignment > 0: + # Pad before slicing so shards stay alignment-divisible and padding + # ends up contiguous at the tail of the gathered result. + alignment = GTP_CONFIG.pad_for_alignment * gtp_remat_size + dim0 = tensor.shape[0] + pad_length = (alignment - dim0 % alignment) % alignment + if pad_length > 0: + tensor = torch.nn.functional.pad(tensor, (0, 0, 0, pad_length)) + else: + # No-pad mode: dim-0 must divide gtp_remat_size or AG output loses tail rows. + assert tensor.shape[0] % gtp_remat_size == 0, ( + f"_gtp_slice_one_param: {name}.shape[0]={tensor.shape[0]} is not " + f"divisible by gtp_remat_size={gtp_remat_size}. Either enable padding by " + "setting GTP_CONFIG.pad_for_alignment > 0, or ensure the weight's " + "dim-0 is a multiple of the GTP group size." + ) + pad_length = 0 + + shard_size = tensor.shape[0] // gtp_remat_size + shard = tensor[gtp_rank * shard_size : (gtp_rank + 1) * shard_size] + gtp_shard = GTPShardedParam(shard.clone()) + gtp_shard.pad_length = pad_length + # Preserve the source weight's TP attributes (dropped when wrapping into GTPShardedParam), + # so param_is_not_tensor_parallel_duplicate still classifies it without GTP-specific code. + from megatron.core.tensor_parallel import copy_tensor_model_parallel_attributes + + copy_tensor_model_parallel_attributes(gtp_shard, param) + return gtp_shard + + +def _gtp_attach_attrs(gtp_shard, gtp_remat_group, *, is_grouped=False, expert_idx=0): + """Attach group / gtp_remat_size / routed-expert tags and register in _GTP_PARAMS. + + Separate from _gtp_slice_one_param so attrs land on the post-quantize param (when + quantize fires between slice and attach). + """ + # DistributedWeight requires implementers stay torch.Tensor subclasses; enforce at construction. + assert isinstance(gtp_shard, torch.Tensor), ( + "GTP param must remain a torch.Tensor subclass (DistributedWeight requirement); got " + f"{type(gtp_shard).__name__}." + ) + if is_grouped: + gtp_shard.expert_idx = expert_idx + gtp_shard.is_routed_expert = True + # Default to UNGRAPHED; classify_gtp_chains() reclassifies based on the + # cuda_graph_modules at init time. + gtp_shard.chain_id = GTPChain.UNGRAPHED.value + gtp_shard.group = gtp_remat_group + gtp_shard.gtp_remat_size = gtp_remat_group.size() + global _GTP_PARAMS + _GTP_PARAMS.append(gtp_shard) + + +def _gtp_wrap_bf16_shard(module, name, param): + """Re-register a BF16 pre-sharded weight as a :class:`GTPShardedParam`. + + The weight already IS this rank's shard (built pre-sharded), so — unlike the post-init path + :func:`_gtp_slice_one_param`, which slices a full weight — this only wraps it, no slicing. + Returns the new param (also swapped into the module). + """ + from megatron.core.tensor_parallel import copy_tensor_model_parallel_attributes + + gtp_shard = GTPShardedParam(param.data) + copy_tensor_model_parallel_attributes(gtp_shard, param) + delattr(module, name) + module._parameters[name] = gtp_shard + return gtp_shard + + +def _gtp_reclass_native_fp8_shard(param): + """Reclass a native-FP8 pre-sharded weight into a GTP subclass in place (buffer-resident). + + The dynamic ``GTP_`` subclass carries GTPShardedParam's gather/RS methods while + keeping ``is_float8tensor`` True (so DDP/distopt keep it buffer-resident) and TE's forward can + call ``weight.all_gather_and_prefetch()``. The param IS this rank's FP8 shard (``quantized`` is + itself; never re-quantized). Returns the same (mutated) param. + """ + # Preserve the param's own _quantizer (used by TE ops + the optimizer copy_->quantize_ update); + # _init_gtp_runtime_attrs clears it, so stash/restore. + native_quantizer = getattr(param, "_quantizer", None) + param.__class__ = _gtp_native_fp8_subclass(type(param)) + _init_gtp_runtime_attrs(param) + param._gtp_native_fp8 = True + param._quantizer = native_quantizer + # Gather uses a SEPARATE quantizer copy for its per-direction set_usage; reusing the param's own + # would leave rowwise=False after a bwd gather, freezing the rowwise data the forward reads. The + # copy also keeps MXFP8 scales compact for byte-concat scale all-gather. + gather_q = None + if native_quantizer is not None: + gather_q = native_quantizer.copy() + gather_q.internal = False + gather_q.optimize_for_gemm = not isinstance(gather_q, MXFP8Quantizer) + param._gtp_gather_quantizer = gather_q + param.quantized = param + return param + + +def attach_gtp_to_presharded_module(module, gtp_remat_group, pad_length, is_grouped=False): + """Turn each pre-sharded weight into a GTP param (FP8/BF16) and attach GTP wiring.""" + # GTP shards per-expert weight0..weight{num_gemms-1}; a coalesced single weight has no sibling + # shards to attach, so reject it here (once, at setup) instead of silently attaching nothing. + if is_grouped: + assert not getattr( + module, "single_grouped_weight", False + ), f"GTP grouped module {type(module).__name__} requires single_grouped_weight=False." + # Use the module's weight_names if it declares them; otherwise create them (grouped modules + # expose per-expert weight0..weight{num_gemms-1}, non-grouped a single "weight"). + weight_names = getattr(module, "weight_names", None) + if not weight_names: + weight_names = ( + [f"weight{idx}" for idx in range(module.num_gemms)] if is_grouped else ["weight"] + ) + new_weights = [] + for idx, name in enumerate(weight_names): + param = getattr(module, name, None) + if param is None or is_gtp_param(param): + continue + if isinstance(param, QuantizedTensor): + gtp_param = _gtp_reclass_native_fp8_shard(param) + else: + gtp_param = _gtp_wrap_bf16_shard(module, name, param) + gtp_param.pad_length = pad_length + _gtp_attach_attrs(gtp_param, gtp_remat_group, is_grouped=is_grouped, expert_idx=idx) + new_weights.append(gtp_param) + if is_grouped and new_weights: + new_weights[0].weight_list = new_weights + + +# Cache of dynamic ``GTP_`` subclasses, keyed by the FP8 base class. +_GTP_NATIVE_FP8_SUBCLASSES: Dict[type, type] = {} + +# GTPShardedParam members NOT copied into the dynamic ``GTP_`` subclass (and why): +# - __new__ / __init__: construction hooks — we only *reclass* an existing FP8 instance, never +# construct one; GTP attrs are set afterwards by _init_gtp_runtime_attrs. +# - __torch_function__: keep the FP8 tensor's OWN tensor dispatch, not GTPShardedParam's. +# - __dict__ / __weakref__ / __module__ / __doc__ / __qualname__ / __slots__: per-class machinery +# for GTPShardedParam itself; copying it would corrupt the new subclass's identity/layout. +_GTP_SUBCLASS_SKIP = frozenset( + { + "__new__", + "__init__", + "__torch_function__", + "__dict__", + "__weakref__", + "__module__", + "__doc__", + "__qualname__", + "__slots__", + } +) + + +def _gtp_native_fp8_subclass(base_cls: type) -> type: + """Cached ``base_cls`` subclass + GTPShardedParam's methods (isinstance(base_cls) kept True). + + CRITICAL: skip names already in the FP8 MRO — a GTPShardedParam method shadowing one TE needs + (e.g. get_data_tensors) silently freezes optimizer updates. + """ + sub = _GTP_NATIVE_FP8_SUBCLASSES.get(base_cls) + if sub is None: + base_mro_names = set() + for klass in base_cls.__mro__: + base_mro_names.update(vars(klass).keys()) + ns = { + k: v + for k, v in vars(GTPShardedParam).items() + if k not in _GTP_SUBCLASS_SKIP and k not in base_mro_names + } + # Share the (class-level) prefetch chain-state dicts with GTPShardedParam. + ns["_chain_state"] = GTPShardedParam._chain_state + ns["_recompute_chain_state"] = GTPShardedParam._recompute_chain_state + sub = type(f"GTP_{base_cls.__name__}", (base_cls,), ns) + _GTP_NATIVE_FP8_SUBCLASSES[base_cls] = sub + return sub + + +def is_gtp_param(param) -> bool: + """True if ``param`` is a GTP weight-remat shard (BF16 or native-FP8).""" + return getattr(param, "is_gtp_weight_remat", False) + + +def dequantize_gtp_native_fp8(param): + """Dequantize a native-FP8 GTP param to a plain BF16 tensor (used at the checkpoint boundary). + + TE's ``tex.dequantize`` dispatches on the *exact* FP8 class and rejects our dynamic + ``GTP_`` subclass, so restore the base FP8 class for the call and reclass after. + """ + from megatron.core.fp8_utils import dequantize_fp8_tensor + + sub_cls = type(param) + base_cls = sub_cls.__mro__[1] # _gtp_native_fp8_subclass builds type("GTP_X", (base_cls,), ...) + param.__class__ = base_cls + try: + return dequantize_fp8_tensor(param) + finally: + param.__class__ = sub_cls + + +@contextmanager +def gtp_native_fp8_load_context(module): + """Restore the base FP8 class on native-FP8 GTP params under ``module`` for a load copy. + + ``load_state_dict`` does ``param.copy_(bf16)`` -> TE ``convert_and_update_tensor``, whose + ``IsMXFP8Tensor`` C++ check rejects our dynamic subclass (the load-side twin of + :func:`dequantize_gtp_native_fp8`). Presenting the base class lets TE re-quantize into the FP8 + storage; instance attrs live in ``__dict__`` and survive the swap, so the GTP surface persists. + """ + from megatron.core.fp8_utils import is_float8tensor + + swapped = [] + for param in module.parameters(recurse=True): + if is_gtp_param(param) and is_float8tensor(param): + sub_cls = type(param) + base_cls = sub_cls.__mro__[1] + if base_cls is not sub_cls: + param.__class__ = base_cls + swapped.append((param, sub_cls)) + try: + yield + finally: + for param, sub_cls in swapped: + param.__class__ = sub_cls + + +def wrap_module_params_gtp(module, weight_names, gtp_remat_group, is_grouped=None): + """Shard and re-register module params as GTPShardedParam (post-init slice). + + Called post-init for Megatron-style local modules (ColumnParallelLinear, etc.), which build + the full weight and slice it here. TE modules do NOT use this path — they are constructed + already-shard-sized (GTP-agnostic init) and wired via :func:`attach_gtp_to_presharded_module`. + Params that are already GTP are skipped. + """ + if gtp_remat_group.size() == 1: + return + + for idx, name in enumerate(weight_names): + param = getattr(module, name, None) + if param is None: + continue + + # Already a GTP param (TE-side slice, or native-FP8 attach) — skip. + if is_gtp_param(param): + continue + + # delete the original parameter, which will be replaced by an GTP sharded one + delattr(module, name) + gtp_shard = _gtp_slice_one_param(param, gtp_remat_group, name=name) + del param + _gtp_attach_attrs(gtp_shard, gtp_remat_group, is_grouped=bool(is_grouped), expert_idx=idx) + # register the newly sharded param back to the module + module._parameters[name] = gtp_shard + + if is_grouped: + allweights = [getattr(module, name) for name in weight_names] + allweights[0].weight_list = allweights + + +class GTPShardHandle: + """Wrapper around a ``dist`` async-work handle for a GTP AG / RS. + + Tracks the participating shards so the wait-site can transition their GTPWeightState + and prune the param from _inflight_comm_params when the collective completes. + """ + + def __init__(self, handle, gtp_shards, reduce_scatter=False): + self.handle = handle + self.gtp_shards = gtp_shards + self.reduce_scatter = reduce_scatter + _inflight_comm_params.add(gtp_shards[0]) + + def wait(self): + """Wait on the underlying NCCL work and update the shards' state.""" + if self.handle is not None: + self.handle.wait() + self.handle = None # Release NCCL Work and its C++ tensor references promptly + if GTP_CONFIG.check_param_states: + for w in self.gtp_shards: + if self.reduce_scatter: + w._set_rs_state(GTPWeightState.DATA_READY) + else: + w._set_state(GTPWeightState.DATA_READY) + + _inflight_comm_params.discard(self.gtp_shards[0]) + + +def _init_gtp_runtime_attrs(obj): + """Initialize the full GTP runtime-state attribute surface on ``obj``. + + Shared by :meth:`GTPShardedParam.__init__` (legacy BF16 slice path) and + :func:`attach_gtp_to_presharded_module` (native-FP8 reclass path), so both param-class + representations carry identical state. chain_id/group are set by the caller afterward. + """ + # Canonical flag — also set on distopt's main_param copy so both kinds + # of param can be classified via a single attribute check. + obj.is_gtp_weight_remat = True + # all gather + obj.state = GTPWeightState.NONE + obj._ag_ticket_fwd = None + obj._ag_ticket_bwd = None + obj._prefetch_handle = None + obj._need_weight_prefetch = True + # Per-direction prefetch opt-outs (default True). The embedding weight needs no bwd AG + # (wgrad is a token-indexed scatter-add, input non-differentiable). classify_gtp_chains() + # sets this False for embedding.word_embeddings.weight. + obj._need_weight_prefetch_bwd = True + obj.ag_event = torch.cuda.Event(external=True) + # DDP backward hook (set by register_grad_accum_hook); invoked after + # the wgrad RS accumulation completes (Graphed.backward / chain cascade). + obj._grad_accum_hook = None + # Quantization. For native-FP8 GTP the reclass path overwrites _quantizer with the tensor's + # own MXFP8 quantizer and points quantized at self; BF16 GTP leaves both unset. + obj._quantizer = None + obj.quantized = None + # Prefetching linked list + obj.prefetch_initialized = False + obj.next_w = None + obj.prev_w = None + # Recompute-forward prefetch chain: a SEPARATE chain (own slot) for weights re-gathered + # rowwise during an activation-recompute forward in backward. Distinct from the + # state/_prefetch_handle/ag_event above so it never clobbers the concurrent columnwise + # dgrad lifecycle. Self-populates from the first backward's recompute gathers. + obj._recompute_initialized = False + obj._recompute_next = None + obj._recompute_prev = None + obj._recompute_prefetch_handle = None + obj._recompute_ag_event = torch.cuda.Event(external=True) + obj._recompute_already_drained = False + # Chain identity (GRAPHED/UNGRAPHED). Defaults to UNGRAPHED; classify_gtp_chains(model) + # walks the model at init (after set_cuda_graph_modules) and reclassifies on param name + + # active cuda_graph_modules. + obj.chain_id = GTPChain.UNGRAPHED.value + # Grouped gemm + obj.is_routed_expert = False + obj.expert_idx = None + obj.group = None + obj.weight_list = None + # Reduce-scatter state (set during wgrad_reduce_scatter) + obj.rs_state = GTPWeightState.NONE + obj._wgrad_rs_handle = None + obj.rs_event = torch.cuda.Event(external=True) + obj._rs_ticket = None + # Padding + obj.pad_length = 0 + # Debug + obj._debug_name = "" + # Hot-path caches (populated lazily on first use). chain_id/group are + # set after init, so we can't resolve streams eagerly here. + obj._cached_ag_stream = None + obj._cached_rs_stream = None + obj._cached_dtypes = None + obj._cached_gtp_remat_group = None + + +class GTPShardedParam(torch.nn.Parameter): + """A weight parameter sharded 1/N across a GTP process group. + + Materialized on-demand via async all-gather and gradient-reduced via reduce-scatter. + Carries its own prefetch-chain wiring (prev_w/next_w), per-chain state, AG/RS cache + tickets, and the metadata the integrator needs to overlap with captured compute. + """ + + # TransformerEngine DistributedWeight protocol (see te.pytorch.distributed_weight). + # `is_distributed_weight` is the capability marker TE dispatches on; no other + # GTP-specific state leaks to TE. + is_distributed_weight: bool = True + + # Per-chain linked-list state, keyed by chain_id; chains never cross-link (prev_w/next_w join + # only same-chain params). Call reset_gtp_state() before rebuilding a GTP model in-process. + _chain_state: Dict[str, dict] = {} + + # Recompute-forward prefetch cursor, keyed by chain_id; also cleared by reset_gtp_state(). + _recompute_chain_state: Dict[str, dict] = {} + + @classmethod + def _get_chain_state(cls, chain_id: str) -> dict: + if chain_id not in cls._chain_state: + cls._chain_state[chain_id] = { + "last_weight": None, + "link_node_count": 0, + "link_table_buffer": [], + "link_table_flushed": False, + } + return cls._chain_state[chain_id] + + @classmethod + def _get_recompute_chain_state(cls, chain_id: str) -> dict: + if chain_id not in cls._recompute_chain_state: + cls._recompute_chain_state[chain_id] = {"last_weight": None} + return cls._recompute_chain_state[chain_id] + + @classmethod + def _buffer_link_table_row( + cls, prev: "GTPShardedParam", curr: "GTPShardedParam", chain: dict + ) -> None: + """Buffer one prefetch-link row (flushed atomically on the second forward pass).""" + _W = 70 + _D = 20 + _S = 20 + + def _layer_id(name: str) -> str: + m = re.search(r"\d+", name) + return m.group() if m else "-" + + def _shape(param: "GTPShardedParam") -> str: + # Full (unsharded) weight shape that will be all-gathered across the gtp_remat + # group — i.e. the size actually prefetched into the chain, not the local shard. + try: + return str(tuple(param._unsharded_shape)) + except Exception: + return str(tuple(param.shape)) + + def _dtype(param: "GTPShardedParam") -> str: + # Report the dtype of the tensor that is ACTUALLY all-gathered, not the + # GTPShardedParam wrapper (whose logical dtype is the high-precision model-weight + # shard, i.e. params_dtype — bf16 in mixed precision). When the param has an FP8 + # representation (``param.quantized`` populated — by --fp8-param-gather's optimizer + # FP32->FP8 write, or by the per-forward cast otherwise), that quantized tensor is + # what gets gathered, yet a TE QuantizedTensor still reports a "fake" params_dtype + # ``.dtype``. So surface its raw storage dtype (e.g. uint8) tagged with the quantized + # class to make the FP8 all-gather unambiguous. + q = getattr(param, "quantized", None) + if getattr(param, "_gtp_native_fp8", False) and q is not None: + raw = getattr(q, "_rowwise_data", None) + if raw is None: + raw = getattr(q, "_data", None) + raw_dt = str(raw.dtype).replace("torch.", "") if raw is not None else "?" + return f"{type(q).__name__}/{raw_dt}" + return str(getattr(param, "dtype", "-")) + + chain["link_node_count"] += 1 + if chain["link_node_count"] == 1: + chain_id = getattr(curr, "chain_id", GTPChain.UNGRAPHED.value) + chain["link_table_buffer"].append( + f"\n[{chain_id} chain]\n{'node_id':>7} | {'layer_id':>8} |" + f" {'dtype':<{_D}} | {'shape':<{_S}} | {'curr_weight_name':<{_W}} |" + f" prev_weight_name\n{'-'*7}-+-{'-'*8}-+-{'-'*_D}-+-{'-'*_S}-+-{'-'*_W}-+-{'-'*_W}" + ) + # Seed weight (first GTP param) as row 0 + chain["link_table_buffer"].append( + f"{'0':>7} | {_layer_id(prev._debug_name):>8} | " + f"{_dtype(prev):<{_D}} | {_shape(prev):<{_S}} | {prev._debug_name:<{_W}} | -" + ) + chain["link_table_buffer"].append( + f"{chain['link_node_count']:>7} | {_layer_id(curr._debug_name):>8} | " + f"{_dtype(curr):<{_D}} | {_shape(curr):<{_S}} | " + f"{curr._debug_name:<{_W}} | {prev._debug_name}" + ) + + @staticmethod + def __new__(cls, tensor, *args, **kwargs): # pylint: disable=unused-argument + requires_grad = kwargs.get("requires_grad", True) + # pylint: disable-next=unexpected-keyword-arg + return super(GTPShardedParam, cls).__new__(cls, tensor, requires_grad=requires_grad) + + def __init__(self, tensor, *args, **kwargs): + del tensor, args, kwargs + super().__init__() + _init_gtp_runtime_attrs(self) + + @property + def _weights(self): + """Individual weight shards (self for non-routed, weight_list for routed).""" + weights = self.weight_list if self.is_routed_expert else [self] + # Only meaningful when _set_state is actively tracking transitions. + if GTP_CONFIG.check_param_states: + assert all(w.state == weights[0].state for w in weights) + return list(weights) + + @property + def _unsharded_shape_padded(self): + """Full unsharded shape *including* the pad rows on the last rank.""" + out_shape = list(self.size()) + out_shape[0] = out_shape[0] * self.group.size() + return tuple(out_shape) + + @property + def _unsharded_shape(self): + """Full unsharded shape with the pad rows stripped (logical shape).""" + out_shape = list(self._unsharded_shape_padded) + out_shape[0] -= self.pad_length + return tuple(out_shape) + + @property + def _sharded_padded_shape(self): + """This rank's local shard shape, padding included.""" + return tuple(self.size()) + + def get_padded_shard(self): + """Return the local shard already containing its share of padding (identity).""" + return self + + def _set_state(self, new_state: GTPWeightState): + """Advance the AG state (only inspected when ``check_param_states`` is on).""" + # Only inspected when check_param_states is on; skip writes otherwise. + if not GTP_CONFIG.check_param_states: + return + self.state = new_state + + def _set_rs_state(self, new_state: GTPWeightState): + """Advance the RS state (only inspected when ``check_param_states`` is on).""" + if not GTP_CONFIG.check_param_states: + return + self.rs_state = new_state + + def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: + """Build cache key from output shape + dtype. + + Weights with matching gathered shape and dtype share a buffer. For experts gathered + in parallel, self.expert_idx keeps each distinct; same-indexed experts across layers share. + """ + + if not isinstance(dtype, torch.dtype): + return ( + self._unsharded_shape_padded, + dtype, + fwd, + not fwd, + self.expert_idx, + reduce_scatter, + ) + return (self._unsharded_shape_padded, dtype, self.expert_idx, reduce_scatter) + + def _strip_padding(self, tensor): + if self.pad_length == 0: + return tensor + + if isinstance(tensor, QuantizedTensor): + assert isinstance( + tensor, (NVFP4TensorStorage, MXFP8TensorStorage) + ), f"Unsupported quantized tensor type for GTP padding: {type(tensor)}" + + metadata = tensor.get_metadata() + if metadata.get("rowwise_data") is not None: + metadata["rowwise_data"] = metadata["rowwise_data"][: -self.pad_length] + if metadata.get("columnwise_data") is not None: + if isinstance(tensor, NVFP4TensorStorage): + # NVFP4 transposes columnwise and packs 2 values per byte + metadata["columnwise_data"] = metadata["columnwise_data"][ + ..., : -self.pad_length // 2 + ].contiguous() + else: + # MXFP8 columnwise is not transposed, strip first dim + metadata["columnwise_data"] = metadata["columnwise_data"][: -self.pad_length] + M = self._unsharded_shape[0] + if isinstance(tensor, NVFP4TensorStorage): + # NVFP4 scale_inv shapes (see NVFP4Quantizer.get_scale_shape): + # rowwise_scale_inv: [round_up(M, 128), round_up(ceil(K/16), 4)] + # columnwise_scale_inv: [round_up(K, 128), round_up(ceil(M/16), 4)] + # GTP shards M (dim 0 of the weight), so strip to the unpadded sizes. + if metadata.get("rowwise_scale_inv") is not None: + m_rows = round_up_to_nearest_multiple(M, 128) + metadata["rowwise_scale_inv"] = metadata["rowwise_scale_inv"][:m_rows] + if metadata.get("columnwise_scale_inv") is not None: + m_tiles = round_up_to_nearest_multiple( + math.ceil(M / NVFP4_BLOCK_SCALING_SIZE), 4 + ) + metadata["columnwise_scale_inv"] = metadata["columnwise_scale_inv"][ + :, :m_tiles + ].contiguous() + else: + # MXFP8 scale_inv shapes (see MXFP8Quantizer.get_scale_shape): + # rowwise_scale_inv: [round_up(M, 128), round_up(K//32, 4)] + # columnwise_scale_inv: [round_up(M//32, 4), round_up(K, 128)] + # GTP shards M (dim 0 of the weight), so strip to the unpadded sizes. + if metadata.get("rowwise_scale_inv") is not None: + m_rows = round_up_to_nearest_multiple(M, 128) + metadata["rowwise_scale_inv"] = metadata["rowwise_scale_inv"][:m_rows] + if metadata.get("columnwise_scale_inv") is not None: + m_tiles = round_up_to_nearest_multiple(M // MXFP8_BLOCK_SCALING_SIZE, 4) + metadata["columnwise_scale_inv"] = metadata["columnwise_scale_inv"][:m_tiles] + + return type(tensor)(**metadata, shape=self._unsharded_shape, dtype=torch.bfloat16) + + return tensor[: -self.pad_length] + + def _all_gather_weight(self, async_op, fwd, nvtx_label=None): + """Quantize (if needed) and all-gather weight. Returns (weight_total, handle).""" + if nvtx_label is None: + nvtx_label = ( + self._debug_name + (".fwd" if fwd else ".bwd") + (".async" if async_op else ".sync") + ) + nvtx_range_push(f"{nvtx_label}.all_gather_weight") + + weights = self._weights + + # 1. Transition state for async gathers. Skip during recompute-forward: it gathers + # rowwise (_ag_ticket_fwd) while a bwd-chain prefetch may hold an in-flight columnwise + # AG state (_ag_ticket_bwd) on the same weight — clobbering breaks the dgrad consume. + if GTP_CONFIG.check_param_states and not in_fp8_activation_recompute_phase(): + new_state = GTPWeightState.ASYNC_WAIT if async_op else GTPWeightState.DATA_READY_SYNC + for w in weights: + w._set_state(new_state) + + # 2. Set FP8 usage direction (rowwise for fwd, columnwise for bwd) on the GATHER + # quantizer copy — NEVER on the param's own quantizer: the optimizer's + # copy_ -> quantize_ update writes whichever usages the param quantizer has enabled, + # so leaving it rowwise=False after a bwd gather would freeze the rowwise (fwd) data. + # No re-quantize here: with mxfp8 + --fp8-param-gather the shard already IS a native + # FP8 tensor. BF16 GTP carries no quantizer and gathers the BF16 shard as-is. + native_fp8 = getattr(self, "_gtp_native_fp8", False) + quantizers = [getattr(w, "_gtp_gather_quantizer", None) for w in weights] + if native_fp8: + for q in quantizers: + q.set_usage(rowwise=fwd, columnwise=not fwd) + + # 3. Build gather inputs. The gather collective takes the per-weight quantizers so it can + # reconstruct the gathered FP8 tensor's scale/metadata (None for BF16 GTP). + if native_fp8: + gather_weights = [w.quantized for w in weights] + else: + gather_weights = list(w.get_padded_shard() for w in weights) + + # 4. Cache checkout — use pooled buffers for both async and sync gathers + # to avoid allocating fresh memory each iteration. gather-buffer dtypes are stable + # post-construction (FP8 quantizer dtype for native-FP8 shards, else the BF16 dtype), + # so cache them on the anchor (self == weights[0]) instead of rebuilding each call. + dtypes = self._cached_dtypes + if dtypes is None: + dtypes = [q.dtype if q is not None else w.dtype for q, w in zip(quantizers, weights)] + self._cached_dtypes = dtypes + out_buffers = [] + cache = get_global_GTP_cache() + for p, dt in zip(weights, dtypes): + if fwd: + if p._ag_ticket_fwd is None: + p._ag_ticket_fwd = cache.reserve(p, dt, fwd=True) + cache.get(p._ag_ticket_fwd) + cache.release(p._ag_ticket_fwd) + out_buffers.append(cache.get(p._ag_ticket_fwd)) + else: + if p._ag_ticket_bwd is None: + p._ag_ticket_bwd = cache.reserve(p, dt, fwd=False) + out_buffers.append(cache.get(p._ag_ticket_bwd)) + + # 5. Communicate. + gtp_remat_group = self._cached_gtp_remat_group + if gtp_remat_group is None: + gtp_remat_group = weights[0].group + self._cached_gtp_remat_group = gtp_remat_group + if GTP_CONFIG.check_param_states and len(gather_weights) > 1: + # Debug invariant: batched AG needs distinct output buffers per expert. + assert len(set(id(b) for b in out_buffers)) == len( + out_buffers + ), "Duplicate output buffers in batched all-gather — experts need distinct cache keys" + + # ASYNC AG: issue on ag_stream so its tail reflects the collective's full lifecycle + # (what external wait_stream(ag_stream) drains depend on). The explicit outer→ag_stream + # sync event preserves the upstream quantize-writer edge the bare stream context drops; + # held on self so the event pool can't recycle it between capture and replay. + # SYNC AG: stay on caller — output ready on return. + if async_op: + outer_stream = torch.cuda.current_stream() + ag_stream = get_ag_stream(self.chain_id, gtp_remat_group) + if getattr(self, "_ag_outer_sync_event", None) is None: + self._ag_outer_sync_event = torch.cuda.Event() + outer_sync_event = self._ag_outer_sync_event + outer_sync_event.record(outer_stream) + ag_stream.wait_event(outer_sync_event) + ag_ctx = torch.cuda.stream(ag_stream) + else: + ag_ctx = nullcontext() + + with ag_ctx: + if len(gather_weights) > 1: + nvtx_range_push(f"{nvtx_label}.batched_gtp_ag") + results, handle = grouped_gather_along_first_dim( + gather_weights, + gtp_remat_group, + async_op=async_op, + quantizers=quantizers, + output_tensors=out_buffers, + ) + nvtx_range_pop(f"{nvtx_label}.batched_gtp_ag") + else: + nvtx_range_push(f"{nvtx_label}.gtp_ag") + weight_total, handle = gather_along_first_dim( + gather_weights[0], + gtp_remat_group, + quantizer=quantizers[0], + async_op=async_op, + output_tensor=out_buffers[0] if out_buffers is not None else None, + ) + nvtx_range_pop(f"{nvtx_label}.gtp_ag") + results = [weight_total] + + result = results if self.is_routed_expert else results[0] + + # 6. Wrap handle. + if async_op: + handle = GTPShardHandle(handle, weights) + else: + handle = None + + nvtx_range_pop(f"{nvtx_label}.all_gather_weight") + return result, handle + + def _wait_param_gather(self): + # Enter ag_stream context so handle.wait() + ag_event.record() both + # land on ag_stream. That makes ag_event mark ag_stream's tail, which + # is what external drains via wait_stream(ag_stream) actually block on. + ag_stream = self._cached_ag_stream + if ag_stream is None: + ag_stream = get_ag_stream(self.chain_id, self.group) + self._cached_ag_stream = ag_stream + with torch.cuda.stream(ag_stream): + if self._prefetch_handle is not None: + self._prefetch_handle.wait() + self._prefetch_handle = None + self.ag_event.record() + + def _all_gather_weight_on_demand(self, fwd): + result, _ = self._all_gather_weight(async_op=False, fwd=fwd) + result = result if self.is_routed_expert else [result] + result = [self._strip_padding(r) for r in result] + result = [r.detach().requires_grad_(w.requires_grad) for r, w in zip(result, self._weights)] + return result if self.is_routed_expert else result[0] + + def _get_prefetched_weight(self, fwd): + # Stale-read guard: state must reflect an AG issued for this cycle; + # otherwise cache.get() would return the prior iter's AG buffer. + if GTP_CONFIG.check_param_states: + for w in self._weights: + assert w.state in ( + GTPWeightState.ASYNC_WAIT, + GTPWeightState.DATA_READY, + GTPWeightState.DATA_READY_SYNC, + ), ( + f"[GTP] _get_prefetched_weight({'fwd' if fwd else 'bwd'}) on " + f"{self._debug_name} with state={w.state!r} — no AG issued; " + "cache.get() would return stale data. Check the chain's " + "_need_weight_prefetch flag and issuer's prefetch logic." + ) + _was_drained = getattr(self, "_already_ag_drained", False) + if _was_drained: + # Producer already drained via wait_async_comms; skip the captured cross-graph + # wait (a CUDA no-op anyway). Correctness comes from the eager main_stream sync. + self._already_ag_drained = False + else: + # Intra-graph or eager consume: drain inline. + self._wait_param_gather() + self.ag_event.wait() + + # Retrieve prefetched results from cache + result = [] + cache = get_global_GTP_cache() + for w in self._weights: + ticket = w._ag_ticket_fwd if fwd else w._ag_ticket_bwd + result.append(cache.get(ticket)) + + result = [self._strip_padding(r) for r in result] + + result = [r.detach().requires_grad_(w.requires_grad) for r, w in zip(result, self._weights)] + return result if self.is_routed_expert else result[0] + + def _wait_recompute_param_gather(self): + # Recompute-chain analogue of _wait_param_gather, on the _recompute_* slot. + ag_stream = self._cached_ag_stream + if ag_stream is None: + ag_stream = get_ag_stream(self.chain_id, self.group) + self._cached_ag_stream = ag_stream + with torch.cuda.stream(ag_stream): + if self._recompute_prefetch_handle is not None: + self._recompute_prefetch_handle.wait() + self._recompute_prefetch_handle = None + self._recompute_ag_event.record() + + def _recompute_prefetch_next(self, target, nvtx_label=None): + # Issue target's rowwise (fwd) AG into its recompute slot. _all_gather_weight skips the + # AG-state transition under recompute, so target's dgrad state is untouched; result lands + # in target._ag_ticket_fwd. + _, handle = target._all_gather_weight(async_op=True, fwd=True, nvtx_label=nvtx_label) + target._recompute_prefetch_handle = handle + + def _get_recompute_prefetched_weight(self): + # Recompute-chain analogue of _get_prefetched_weight (state-neutral; reads the + # rowwise _ag_ticket_fwd via the _recompute_* slot). + if self._recompute_already_drained: + # Producer already drained via wait_async_comms (CG capture); skip the + # captured cross-graph wait (CUDA no-op anyway). + self._recompute_already_drained = False + else: + self._wait_recompute_param_gather() + self._recompute_ag_event.wait() + + result = [] + cache = get_global_GTP_cache() + for w in self._weights: + result.append(cache.get(w._ag_ticket_fwd)) + result = [self._strip_padding(r) for r in result] + result = [r.detach().requires_grad_(w.requires_grad) for r, w in zip(result, self._weights)] + return result if self.is_routed_expert else result[0] + + def all_gather_and_prefetch_bwd(self, nvtx_label=None): + """Backward variant: get the current weight (cached if prefetched, else sync gather) + and async-prefetch prev_w. + + Safe via the coat-check cache: get() returns the current buffer to the pool, and the + prefetch's checkout allocates a separate buffer if the pool is empty (current buffer + still live via the caller's reference). + + Returns: + weight_total + """ + + if GTP_CONFIG.weight_prefetch and self.next_w is not None: + result = self._get_prefetched_weight(False) + else: + result = self._all_gather_weight_on_demand(False) + + if ( + GTP_CONFIG.weight_prefetch + and self.prev_w is not None + and self.prev_w._need_weight_prefetch + and self.prev_w._need_weight_prefetch_bwd + ): + # Pre-AG work (quantize, ticket lookup) runs on caller's stream; the NCCL collective + # is wrapped on ag_stream inside _all_gather_weight (see its async/sync gate). + _, handle = self.prev_w._all_gather_weight( + async_op=True, fwd=False, nvtx_label=nvtx_label + ) + self.prev_w._prefetch_handle = handle + + # The unsharded tensor has been returned, no pending work so reset state to NONE + if GTP_CONFIG.check_param_states: + for w in self._weights: + w._set_state(GTPWeightState.NONE) + + if GTP_CONFIG.weight_prefetch and self.next_w is not None: + cache = get_global_GTP_cache() + for w in self._weights: + cache.release(w._ag_ticket_bwd) + + return result + + def batched_all_gather_and_prefetch_bwd(self, nvtx_label=None): + """Batched backward all-gather + prefetch. Wrapper around all_gather_and_prefetch_bwd.""" + assert self.is_routed_expert and self.weight_list is not None + return self.all_gather_and_prefetch_bwd(nvtx_label=nvtx_label) + + def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): + """All-gather the current weight and async-prefetch the next. + + Returns: + weight_total + """ + # During an activation-recompute forward (runs in backward), route consume + + # prefetch through the recompute-forward chain on its own _recompute_* slot + # (see __init__) instead of the fwd/bwd chains; lazy-built below. + in_recompute = in_fp8_activation_recompute_phase() + use_recompute_chain = in_recompute and GTP_CONFIG.weight_prefetch + + # Consume current weight. + if use_recompute_chain and self._recompute_prev is not None: + result = self._get_recompute_prefetched_weight() + elif not in_recompute and GTP_CONFIG.weight_prefetch and self.prev_w is not None: + result = self._get_prefetched_weight(True) + else: + # On-demand: chain head (fwd or recompute global-first) or first-iter build. + result = self._all_gather_weight_on_demand(True) + + # Prefetch next weight on the matching chain. + if ( + use_recompute_chain + and self._recompute_next is not None + and self._recompute_next._need_weight_prefetch + ): + self._recompute_prefetch_next(self._recompute_next, nvtx_label=nvtx_label) + elif ( + not in_recompute + and GTP_CONFIG.weight_prefetch + and self.next_w is not None + and self.next_w._need_weight_prefetch + ): + # Pre-AG work on caller; NCCL wrap lives at the collective site + # inside _all_gather_weight. See all_gather_and_prefetch_bwd. + _, handle = self.next_w._all_gather_weight( + async_op=True, fwd=fwd, nvtx_label=nvtx_label + ) + self.next_w._prefetch_handle = handle + + # Unsharded tensor returned, no pending work → reset state to NONE. Skip during recompute: + # a bwd-chain prefetch may hold an in-flight AG state this weight's later dgrad needs. + if GTP_CONFIG.check_param_states and not in_recompute: + for w in self._weights: + w._set_state(GTPWeightState.NONE) + + cls = type(self) + + # Lazy-build the recompute-forward prefetch chain (first backward, in recompute order). + # Consume/prefetch above used the prior iter's links, so the first backward runs on-demand + # while these are established. + if in_recompute and not self._recompute_initialized: + rchain = cls._get_recompute_chain_state(self.chain_id) + last_r = rchain["last_weight"] + if last_r is not None and last_r._recompute_next is None: + last_r._recompute_next = self + self._recompute_prev = last_r + self._recompute_initialized = True + rchain["last_weight"] = self + + # Lazy population of the fwd/bwd linked list: link previous weight to current. + # Uses per-chain state so dense and expert chains never cross-link. + chain = cls._get_chain_state(self.chain_id) + if not self.prefetch_initialized: + last_w = chain["last_weight"] + if last_w is not None and last_w.next_w is None: + cls._buffer_link_table_row(last_w, self, chain) + last_w.next_w = self + self.prev_w = last_w + + cache = get_global_GTP_cache() + + # Set the fwd ag buffer (gather quantizer copy — the param's own quantizer is + # reserved for the optimizer's update path; see attach_gtp_to_presharded_module). + quantizers = [getattr(w, "_gtp_gather_quantizer", None) for w in self._weights] + dtypes = [ + q.dtype if q is not None else w.dtype for q, w in zip(quantizers, self._weights) + ] + for w, dt in zip(self._weights, dtypes): + w._ag_ticket_fwd = cache.reserve(w, dt, fwd=True) + cache.get(w._ag_ticket_fwd) + cache.release(w._ag_ticket_fwd) + + self.prefetch_initialized = True + chain["last_weight"] = self + elif not chain["link_table_flushed"] and chain["link_table_buffer"]: + # Second forward pass: flush the complete table atomically to avoid interleaving + chain["link_table_flushed"] = True + log_single_rank(logger, logging.INFO, "\n".join(chain["link_table_buffer"]) + "\n") + + return result + + def batched_all_gather_and_prefetch(self, **kwargs): + """Batched all-gather + prefetch for expert weights (wraps all_gather_and_prefetch).""" + assert self.is_routed_expert and self.weight_list is not None + return self.all_gather_and_prefetch(**kwargs) + + def get_wgrad_tensor(self): + """Pool-allocate a wgrad scratch tensor of unsharded shape for the bwd GEMM.""" + return _wgrad_pool_get(self._unsharded_shape, self.main_grad.dtype, self.device) + + def register_grad_accum_hook(self, grad_accum_node, hook): + """Register a DDP backward hook to call after the wgrad RS finalize. + + For GTP params autograd may receive None (async RS), so the normal grad-accumulator + hook never fires; the integrator (Graphed.backward for captured chains, or the eager + chain-tail cascade) calls this hook explicitly after RS wait + accumulation, so DDP's + register_grad_ready fires at the right time. grad_accum_node is accepted for API + compatibility but not retained — only the hook callable. + """ + del grad_accum_node + self._grad_accum_hook = hook + + @staticmethod + def _handle_megatron_grad_accum(param): + """Handle megatron DDP and gradient-accumulation fusion. + + Do NOT set param.grad before calling the hook — the hook checks param.grad and would + accumulate it into main_grad if zero_out_wgrad is True, corrupting it with a dummy. + + Returns a cached dummy wgrad; sync callers use it as the graph-safe grad, async drains + discard it. + """ + if hasattr(param, "grad_added_to_main_grad"): + param.grad_added_to_main_grad = True + dummy_grad = get_dummy_wgrad(list(param.main_grad.shape), param.dtype) + if getattr(param, "_grad_accum_hook", None) is not None: + param._grad_accum_hook() + + param._set_rs_state(GTPWeightState.NONE) + return dummy_grad + + def _wait_reduce_scatter(self, finalize_grad=False): + # Enter rs_stream context so handle.wait() + rs_event.record() land on rs_stream + # (mirrors _wait_param_gather). With finalize_grad=True, main_grad.add_ also runs on + # rs_stream right after the NCCL RS — starts during AG drain, not after, avoiding + # SM-saturation that blocks cross-graph overlap. + rs_stream = self._cached_rs_stream + if rs_stream is None: + rs_stream = get_rs_stream(self.chain_id, self.group) + self._cached_rs_stream = rs_stream + with torch.cuda.stream(rs_stream): + if self._wgrad_rs_handle is not None: + self._wgrad_rs_handle.wait() + self._wgrad_rs_handle = None + self.rs_event.record() + if finalize_grad: + cache = get_global_GTP_cache() + for w in self._weights: + wgrad_rs = cache.get(w._rs_ticket) + w.main_grad.add_(wgrad_rs) + cache.release(w._rs_ticket) + # Fire grad-ready AFTER all adds (separate loop so a bucket-completing + # grad-ready can't dispatch the RS before a sibling's add). With autograd + # grad-ready suppressed for GTP params (DDP register_grad_accum_hook), this + # is the only grad-ready for a weight finalized here; else the bucket orphans. + for w in self._weights: + self._handle_megatron_grad_accum(w) + self._already_finalized = True + # Release stashed wgrad inputs: UNGRAPHED buffers go back to the pool; + # GRAPHED just drops Python refs (addresses must stay stable for CG). + if getattr(self, "_wgrad_input_bufs", None) is not None: + if self.chain_id == GTPChain.UNGRAPHED.value: + for buf in self._wgrad_input_bufs: + _wgrad_pool_put(buf) + self._wgrad_input_bufs = None + + def _prescale_wgrads_for_mean_rs(self, wgrads): + """Pre-scale wgrad by 1/gtp_remat so the SUM reduce-scatter yields the gtp_remat mean. + + Single choke point for every RS path. Composes with DDP's 1/replicate prescale and + finalize's AVG to give the full (replicate x gtp_remat) mean. Skipped under + calculate_per_token_loss, where DDP does no 1/dp scaling and total_global_tokens (which + counts gtp_remat peers' tokens) normalizes instead — there the gtp_remat axis must SUM + like the DP axis (a 1/gtp_remat mean would shrink every gtp_remat grad). + """ + gtp_remat_size = self.group.size() + if gtp_remat_size > 1 and not GTP_CONFIG.calculate_per_token_loss: + torch._foreach_mul_(list(wgrads), 1.0 / gtp_remat_size) + + def _reduce_scatter(self, wgrads, async_op, nvtx_label=None): + """Reduce-scatter one or more wgrads → (outputs, handle). Single tensor: plain RS; + multiple: coalesced RS.""" + if nvtx_label is None: + nvtx_label = self._debug_name + ".bwd" + (".async" if async_op else ".sync") + + # MEAN reduce-scatter: pre-scale wgrad so the SUM collective yields the gtp_remat mean. + self._prescale_wgrads_for_mean_rs(wgrads) + + if GTP_CONFIG.check_param_states: + new_rs_state = GTPWeightState.ASYNC_WAIT if async_op else GTPWeightState.DATA_READY_SYNC + for w in self._weights: + w._set_rs_state(new_rs_state) + + if self.pad_length > 0: + wgrads = [torch.nn.functional.pad(w, (0, 0, 0, self.pad_length)) for w in wgrads] + + if async_op: + dtypes = [w.dtype for w in wgrads] + out_buffers = [] + cache = get_global_GTP_cache() + for p, dt in zip(self._weights, dtypes): + if p._rs_ticket is None: + p._rs_ticket = cache.reserve(p, dt, fwd=False, reduce_scatter=True) + out_buffers.append(cache.get(p._rs_ticket)) + else: + out_buffers = [None] * len(wgrads) + + # ASYNC RS: issue on rs_stream so its tail reflects the collective's full lifecycle + # (what external wait_stream(rs_stream) drains depend on). The explicit outer→rs_stream + # sync event preserves the wgrad-GEMM writer edge the bare stream context drops; held on + # self so the event pool can't recycle it between capture and replay. Mirrors the AG path. + # SYNC RS: stay on caller — output ready on return. + if async_op: + outer_stream = torch.cuda.current_stream() + rs_stream = get_rs_stream(self.chain_id, self.group) + if getattr(self, "_rs_outer_sync_event", None) is None: + self._rs_outer_sync_event = torch.cuda.Event() + outer_sync_event = self._rs_outer_sync_event + outer_sync_event.record(outer_stream) + rs_stream.wait_event(outer_sync_event) + rs_ctx = torch.cuda.stream(rs_stream) + else: + rs_ctx = nullcontext() + + with rs_ctx: + if len(wgrads) == 1: + nvtx_range_push(f"{nvtx_label}.gtp_rs") + out, handle = reduce_scatter_along_first_dim( + wgrads[0], self.group, async_op=async_op, output=out_buffers[0] + ) + nvtx_range_pop(f"{nvtx_label}.gtp_rs") + return [out], handle + + outputs = [] + nvtx_range_push(f"{nvtx_label}.batched_gtp_rs") + with torch.distributed._coalescing_manager( + group=self.group, device=wgrads[0].device, async_ops=async_op + ) as cm: + for out_buffer, tensor in zip(out_buffers, wgrads): + out, _ = reduce_scatter_along_first_dim(tensor, self.group, output=out_buffer) + outputs.append(out) + nvtx_range_pop(f"{nvtx_label}.batched_gtp_rs") + + return outputs, cm if async_op else None + + def wgrad_reduce_scatter(self, wgrad, nvtx_label=None): + """Reduce-scatter wgrad(s): sync for the last weight, async+deferred for others. + Accepts a single tensor (non-routed) or a list (routed experts). + + Returns: + Single tensor or list for sync (last weight) — backward returns this. + None or tuple of Nones for async — backward returns this. + """ + batched = isinstance(wgrad, (list, tuple)) + wgrads = list(wgrad) if batched else [wgrad] + weights = self._weights + + # UNGRAPHED wgrads recycle via the standalone pool (_wgrad_pool_put); GRAPHED wgrads + # cannot, since CUDA graphs require stable buffer addresses across replay. + poolable = self.chain_id == GTPChain.UNGRAPHED.value + + if GTP_CONFIG.async_reduction and self.prev_w is not None: + # Async RS (not last weight — deferred finish). Pre-RS work on caller; NCCL wrap + # lives at the collective site inside _reduce_scatter (mirrors the AG prefetch sites). + _, rs_handle = self._reduce_scatter(wgrads, async_op=True, nvtx_label=nvtx_label) + self._wgrad_rs_handle = GTPShardHandle(rs_handle, weights, reduce_scatter=True) + # Stash wgrad input buffers — cannot recycle yet because the async RS + # kernel is still reading them on rs_stream. + self._wgrad_input_bufs = wgrads + ret = tuple([None] * len(wgrads)) if batched else None + else: + # Sync reduce-scatter — reached as the natural chain-head case, recycle immediately + wgrads, _ = self._reduce_scatter(wgrads, async_op=False, nvtx_label=nvtx_label) + nvtx_range_push(f"{nvtx_label}.gtp_wgrad_accum") + if len(weights) == 1: + weights[0].main_grad.add_(wgrads[0]) + else: + torch._foreach_add_([p.main_grad for p in weights], wgrads) + nvtx_range_pop(f"{nvtx_label}.gtp_wgrad_accum") + result = [self._handle_megatron_grad_accum(p) for p in weights] + + if poolable: + for buf in wgrads: + _wgrad_pool_put(buf) + ret = result if batched else result[0] + + # Wait for last reduce scatter if it was async + # Currently only support reduce scattering in reverse order + if GTP_CONFIG.async_reduction and self.next_w is not None: + self.next_w._wait_reduce_scatter() + + if getattr(self.next_w, "_already_finalized", False): + self.next_w._already_finalized = False + else: + self.next_w.rs_event.wait() + cache = get_global_GTP_cache() + next_weights = self.next_w._weights + wgrads = [cache.get(w._rs_ticket) for w in next_weights] + nvtx_range_push(f"{self.next_w._debug_name}.gtp_wgrad_accum_deferred") + # Only batch with _foreach_add_ when finalizing multiple (routed) weights. + if len(next_weights) == 1: + next_weights[0].main_grad.add_(wgrads[0]) + else: + torch._foreach_add_([w.main_grad for w in next_weights], wgrads) + nvtx_range_pop(f"{self.next_w._debug_name}.gtp_wgrad_accum_deferred") + for w in next_weights: + self._handle_megatron_grad_accum(w) + cache.release(w._rs_ticket) + + return ret + + def batched_wgrad_reduce_scatter(self, wgrad_list, nvtx_label=None): + """Batched version of wgrad_reduce_scatter.""" + assert self.is_routed_expert and self.weight_list is not None + return self.wgrad_reduce_scatter(wgrad_list, nvtx_label=nvtx_label) + + # ------------------------------------------------------------------ + # TransformerEngine DistributedWeight protocol. TE's fwd/bwd dispatch through these generic + # names (see te.pytorch.distributed_weight.materialize_weights_for_forward et al.). The leader + # param encapsulates the whole group via self._weights, so a single call covers both the Linear + # (one weight) and GroupedLinear (routed-expert list) cases; the underlying methods already + # return a single tensor or a list accordingly. These are thin adapters over the GTP methods. + # ------------------------------------------------------------------ + def materialize_group_for_forward(self): + """Protocol: all-gather the group's shard(s) for the forward GEMM.""" + return self.all_gather_and_prefetch(fwd=True) + + def materialize_group_for_backward(self, nvtx_label=None): + """Protocol: re-materialize the group's weight(s) for the backward GEMMs.""" + return self.all_gather_and_prefetch_bwd(nvtx_label=nvtx_label) + + def finalize_group_grads(self, wgrads, nvtx_label=None): + """Protocol: reduce-scatter the group's freshly computed weight grad(s).""" + return self.wgrad_reduce_scatter(wgrads, nvtx_label=nvtx_label) + + def grad_buffer(self): + """Protocol: the wgrad accumulation scratch buffer for this weight.""" + return self.get_wgrad_tensor() + + def get_data_tensors(self): + """Expose self as the lone data tensor for TE's offload-marking interface. + + TE's mark_activation_offload treats any non-plain tensor as a storage wrapper and calls + get_data_tensors() on it; a sharded param has no inner buffers, so it is its own. + """ + return (self,) + + def __torch_function__(self, func, types, args=(), kwargs=None): + """Subclass-preserving dispatch for ``detach`` (other ops fall through).""" + del types # required by protocol, unused here + if kwargs is None: + kwargs = {} + + if func is torch.Tensor.detach: + with torch._C.DisableTorchFunctionSubclass(): + # Perform the raw detach + result = func(*args, **kwargs) + # Re-wrap it in your subclass so PyTorch is happy + return result.as_subclass(type(self)) + + # 2. For everything else (add, mul, etc.), be transparent/decay. + with torch._C.DisableTorchFunctionSubclass(): + return func(*args, **kwargs) + + +@dataclass +class _TicketSlot: + """Internal slot backing a persistent ticket in the GTP buffer cache.""" + + key: tuple # cache key (shape, dtype, ...) + param: "GTPShardedParam" # for lazy allocation metadata + dtype: object # torch.dtype or tex.DType + reduce_scatter: bool + fwd: bool + chain_id: str = GTPChain.GRAPHED.value # chain this slot belongs to + buf: Optional[torch.Tensor] = field(default=None) # None when released or after clear() + + +# CUDA-graph memory pool: routes GRAPHED-chain allocations (AG/RS buffers, quantized weight +# storage) into the capture pool at creation time, avoiding post-hoc reallocation. Registered +# via set_cuda_graph_mempool before the first graphed forward; stays None when CG is off, where +# _graphed_alloc is a no-op (regular allocator). +_CG_MEMPOOL_DEVICE = None +_CG_MEMPOOL = None + + +def set_cuda_graph_mempool(device, mempool): + """Register the CUDA-graph memory pool for GRAPHED-chain GTP allocations.""" + global _CG_MEMPOOL_DEVICE, _CG_MEMPOOL + _CG_MEMPOOL_DEVICE = device + _CG_MEMPOOL = mempool + + +@contextmanager +def _graphed_alloc(chain_id): + """Route allocations in this block into the registered CG mempool when ``chain_id`` + is GRAPHED and a pool is registered; otherwise a no-op (regular allocator).""" + if _CG_MEMPOOL is not None and chain_id == GTPChain.GRAPHED.value: + torch._C._cuda_beginAllocateCurrentThreadToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) + try: + yield + finally: + torch._C._cuda_endAllocateToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) + else: + yield + + +class GTPWeightCache: + """Ticket-based buffer pool for GTP all-gather / reduce-scatter buffers. + + - reserve(param, dtype, fwd) → ticket: assign a persistent ticket (no buffer yet). + - get(ticket) → buffer: return the buffer, lazily (re)allocating from pool or fresh. + - release(ticket): return the buffer to the pool; ticket stays valid. + - clear(): drop all buffers/pools; tickets stay valid, next get() allocates fresh. + """ + + # Bytes per element for known dtypes (for logging). Add entries when GTP caches buffers of + # new quantized dtypes — only DType values the TE pybind bindings expose (verify via + # hasattr(tex.DType, ...) before adding speculative entries). + _BYTES_PER_ELEMENT = { + torch.bfloat16: 2, + torch.float16: 2, + torch.float32: 4, + tex.DType.kFloat4E2M1: 0.5, + tex.DType.kFloat8E4M3: 1, + tex.DType.kFloat8E5M2: 1, + } + + def __init__(self): + self._pool: Dict[tuple, List[torch.Tensor]] = defaultdict(list) + self._slots: Dict[int, _TicketSlot] = {} + self._next_ticket: int = 0 + self._total_bytes: int = 0 # running total of allocated bytes + self.key_to_allocate_func = {} + + @staticmethod + def _buf_bytes(shape, dtype) -> int: + """Estimate buffer size in bytes.""" + numel = 1 + for d in shape: + numel *= d + if dtype not in GTPWeightCache._BYTES_PER_ELEMENT: + raise KeyError( + f"GTPWeightCache._buf_bytes: unknown dtype {dtype!r}. " + "Add it to GTPWeightCache._BYTES_PER_ELEMENT with its bytes-per-element." + ) + return int(numel * GTPWeightCache._BYTES_PER_ELEMENT[dtype]) + + def _allocate_buffer( + self, param: "GTPShardedParam", dtype, reduce_scatter, fwd + ) -> torch.Tensor: + if reduce_scatter: + out_shape = param._sharded_padded_shape + else: + out_shape = param._unsharded_shape_padded + + # Route GRAPHED-chain buffers into the CG mempool at creation (see _graphed_alloc). + with _graphed_alloc(getattr(param, "chain_id", GTPChain.UNGRAPHED.value)): + if not isinstance(dtype, torch.dtype): + # Use the gather quantizer copy: mutating the param's own quantizer usage + # would corrupt the optimizer's quantize_ update direction (frozen weights). + quantizer = getattr(param, "_gtp_gather_quantizer", None) or param._quantizer + assert quantizer is not None + quantizer.set_usage(rowwise=fwd, columnwise=not fwd) + + buf = quantizer.make_empty( + out_shape, dtype=torch.bfloat16, device=torch.cuda.current_device() + ) + else: + buf = torch.empty( + out_shape, + dtype=dtype, + device=param.device, + memory_format=torch.contiguous_format, + ) + + buf_bytes = self._buf_bytes(out_shape, dtype) + self._total_bytes += buf_bytes + dtype_str = ( + str(dtype) if isinstance(dtype, torch.dtype) else getattr(dtype, "name", str(dtype)) + ) + op_str = "RS(grad)" if reduce_scatter else ("AG(fwd)" if fwd else "AG(bwd)") + log_single_rank( + logger, + logging.INFO, + f"[GTP Cache] +{buf_bytes / 1024**2:.1f} MB (shape={out_shape}, dtype={dtype_str}) " + f"total={self._total_bytes / 1024**2:.1f} MB param: {param._debug_name} " + f"op: {op_str}", + ) + return buf + + def reserve(self, param: "GTPShardedParam", dtype, fwd: bool, reduce_scatter=False) -> int: + """Assign a persistent ticket. No buffer is allocated until ``get()``.""" + key = param._get_cache_key(dtype, fwd, reduce_scatter) + ticket = self._next_ticket + self._next_ticket += 1 + + self._slots[ticket] = _TicketSlot( + key=key, + param=param, + dtype=dtype, + reduce_scatter=reduce_scatter, + fwd=fwd, + chain_id=getattr(param, "chain_id", GTPChain.UNGRAPHED.value), + ) + return ticket + + def get(self, ticket: int) -> torch.Tensor: + """Return the buffer for *ticket*, lazily allocating if needed.""" + slot = self._slots[ticket] + if slot.buf is None: + pool = self._pool[slot.key] + slot.buf = ( + pool.pop() + if pool + else self._allocate_buffer( + slot.param, slot.dtype, slot.reduce_scatter, fwd=slot.fwd + ) + ) + self.key_to_allocate_func[slot.key] = ( + slot.param, + slot.dtype, + slot.reduce_scatter, + slot.fwd, + ) + + return slot.buf + + def release(self, ticket: int): + """Return the buffer to the pool (ticket stays valid). + + slot.buf is intentionally NOT cleared: get() must stay idempotent so CUDA-graph-captured + buffers keep their fixed address across replays. + """ + slot = self._slots[ticket] + if slot.buf is None: + return + # Use identity check — tensor == tensor returns a multi-element bool tensor + # which crashes in a boolean context ("Boolean value of Tensor is ambiguous"). + if not any(b is slot.buf for b in self._pool.get(slot.key, [])): + self._pool[slot.key].append(slot.buf) + + def clear(self): + """Drop all buffers; tickets remain valid and lazily re-allocate on next get().""" + for slot in self._slots.values(): + slot.buf = None + self._pool.clear() + self._total_bytes = 0 + + +def get_global_GTP_cache() -> GTPWeightCache: + """Get or lazily create the global cache instance.""" + global _GTP_CACHE + if _GTP_CACHE is None: + _GTP_CACHE = GTPWeightCache() + return _GTP_CACHE + + +def wait_async_comms( + chain_id: str = None, skip_rs: bool = False, finalize_after_drain: bool = False +): + """Drain in-flight GTP async AG / RS handles. + + Inside CUDA graph capture the drains are captured into the graph — the producer-side hook + for cross-graph overlap. A captured cudaStreamWaitEvent on another capture session's event is + a CUDA no-op, so consumers can't wait cross-graph; instead the producer drains here and flags + the param, and the consumer skips its captured wait. + + Args: + chain_id: If specified, only drain params on this chain. + skip_rs: Drain AG only; leave RS in flight. + finalize_after_drain: After RS drain, also accumulate wgrad into + main_grad. Runs main_grad.add_ on rs_stream (right after + NCCL RS) so it starts during AG drain rather than after, + avoiding SM-saturation that blocks cross-graph overlap. + Falls back to caller-stream accumulation if no RS handle. + + Per-param side effects: + * _already_ag_drained = True (if an AG handle was drained) + * _already_finalized = True (if finalize_after_drain=True) + """ + for param in list(_inflight_comm_params): + if ( + chain_id is not None + and getattr(param, "chain_id", GTPChain.UNGRAPHED.value) != chain_id + ): + continue + had_ag = param._prefetch_handle is not None + param._wait_param_gather() + if had_ag: + param._already_ag_drained = True + # Recompute-forward chain: drain its separate in-flight rowwise AG so the + # captured recompute consumer skips its cross-graph wait (full-iteration CG). + if param._recompute_prefetch_handle is not None: + param._wait_recompute_param_gather() + param._recompute_already_drained = True + if not skip_rs: + param._wait_reduce_scatter(finalize_grad=finalize_after_drain) + # Fallback inline-accumulation: only when finalize is requested, _wait_reduce_scatter + # didn't already finalize, and an RS actually ran (rs_ticket set). Skips pure-AG + # prefetches in _inflight_comm_params (no wgrad). + need_fallback_accumulation = ( + finalize_after_drain + and not getattr(param, "_already_finalized", False) + and any(w._rs_ticket is not None for w in param._weights) + ) + if need_fallback_accumulation: + cache = get_global_GTP_cache() + param.rs_event.wait() + for w in param._weights: + w._set_rs_state(GTPWeightState.NONE) + wgrad_rs = cache.get(w._rs_ticket) + w.main_grad.add_(wgrad_rs) + cache.release(w._rs_ticket) + if hasattr(w, "grad_added_to_main_grad"): + w.grad_added_to_main_grad = True + param._already_finalized = True + + +@dataclass +class BatchedNVFP4AllGatherAsyncHandle: + """Handle for batched asynchronous NVFP4 all-gathers.""" + + output_handles: List[_NVFP4AllGatherAsyncHandle] + outer_async_handle: torch.distributed.Work + _synchronized: bool = False + + def wait(self) -> None: + """Wait for the async operation to complete and post-process the tensor.""" + if self._synchronized: + return + self.outer_async_handle.wait() + # Fixes interleaved data for transposed tensor/scale inv and pads scale inv if needed. + for output_handle in self.output_handles: + if output_handle is not None: + assert output_handle.async_handle is None + output_handle.wait() + # release any tensor references just in case + output_handle.output = None + output_handle.columnwise_data_interleaved = None + output_handle.columnwise_scale_inv_interleaved = None + + self._synchronized = True + + +def grouped_gather_along_first_dim( + weights: list, + process_group, + async_op: bool = False, + quantizers: list = None, + output_tensors: list = None, +): + """All-gather multiple weights in one coalesced op; handles NVFP4 post-processing for both + sync and async paths.""" + # Determine device from first weight. + inp = weights[0] + if isinstance(inp, NVFP4TensorStorage): + device = ( + inp._rowwise_data.device + if inp._rowwise_data is not None + else inp._columnwise_data.device + ) + else: + device = inp.device + + weights_all = [] + weight_handles = [] + with torch.distributed._coalescing_manager( + group=process_group, device=device, async_ops=async_op + ) as gather_coalescing_manager: + for i, weight in enumerate(weights): + weight_all, weight_handle = gather_along_first_dim( + weight, + process_group, + quantizer=quantizers[i], + output_tensor=output_tensors[i] if output_tensors is not None else None, + external_coalescing=True, + ) + weights_all.append(weight_all) + weight_handles.append(weight_handle) + + if async_op: + handle = gather_coalescing_manager + has_nvfp4_handles = any(isinstance(wh, _NVFP4AllGatherAsyncHandle) for wh in weight_handles) + if has_nvfp4_handles: + handle = BatchedNVFP4AllGatherAsyncHandle(weight_handles, handle) + else: + for wh in weight_handles: + if isinstance(wh, _NVFP4AllGatherAsyncHandle): + wh.wait() + handle = None + + return weights_all, handle + + +class GTPEmbeddingWeight(torch.autograd.Function): + """All-gather the embedding weight across the GTP group in forward, reduce-scatter its + gradient in backward. + + The weight is stored sharded along the vocab dimension; this materializes the full weight + for the lookup and distributes the gradient back to the shard. + """ + + @staticmethod + def forward(ctx, weight): + """All-gather the full embedding weight across the GTP group for the lookup.""" + ctx.save_for_backward(weight) + return weight.all_gather_and_prefetch(fwd=True) + + @staticmethod + def backward(ctx, grad_output): + """Reduce-scatter the gradient back to this rank's vocab-dim shard.""" + (weight,) = ctx.saved_tensors + return weight.wgrad_reduce_scatter(grad_output) + + +def reset_gtp_state(): + """Clear the process-global GTP prefetch-chain state (GTPShardedParam._chain_state / + ._recompute_chain_state). + + These class-level dicts survive model teardown, so a GTP model rebuilt in-process would + inherit stale last_weight pointers / flushed link tables. Call once before the per-chunk + classify_gtp_chains loop (never inside it — chains span chunks). No-op on a fresh process. + """ + GTPShardedParam._chain_state.clear() + GTPShardedParam._recompute_chain_state.clear() + + +# ------------------------------------------------------------------------ +# Distributed-checkpointing helpers +# ------------------------------------------------------------------------ +# GTP shards axis 0 on top of TP, but the vanilla utils helpers only know TP, so their offsets +# miss the GTP slice. The helper below detects GTPShardedParam per-tensor and composes TP × GTP +# into one axis-0 offset (or two offsets), with replica_id = the DP-with-GTP-with-CP rank. + + +def make_sharded_tensors_for_checkpoint_with_gtp_remat( + state_dict, + prefix, + tensor_parallel_layers_axis_map=None, + sharded_offsets=(), + extra_state_suffix="_extra_state", + *, + tp_group, + dp_cp_group, + intra_dp_cp_group=None, +): + """GTP-aware analogue of make_sharded_tensors_for_checkpoint. + + Per-tensor (is_gtp_param): GTP tensors layer the axis-0 GTP split on the vanilla offsets (FP8 + shards dequantized to BF16 for save); non-GTP tensors delegate to the vanilla helper unchanged, + so this is zero-cost when GTP is inactive. + """ + from megatron.core.transformer.utils import ( # noqa: E402 + make_sharded_object_for_checkpoint, + make_sharded_tensors_for_checkpoint, + ) + from megatron.core.utils import ( # noqa: E402 + get_pg_rank, + get_pg_size, + make_sharded_tensor_for_checkpoint, + make_tp_sharded_tensor_for_checkpoint, + ) + + # Fast path: no GTP-sharded params → defer to vanilla helper, same output. + if not any(is_gtp_param(t) for t in state_dict.values()): + return make_sharded_tensors_for_checkpoint( + state_dict, + prefix, + tensor_parallel_layers_axis_map, + sharded_offsets, + extra_state_suffix=extra_state_suffix, + tp_group=tp_group, + dp_cp_group=dp_cp_group, + ) + + if tensor_parallel_layers_axis_map is None: + tensor_parallel_layers_axis_map = {} + + tp_rank = get_pg_rank(tp_group) + tp_size = get_pg_size(tp_group) + # All GTP params in this state_dict share the same gtp_remat_group (set by the + # wrap hook at module init), so pick it off the first GTP shard. + gtp_remat_group = next(t.group for t in state_dict.values() if is_gtp_param(t)) + gtp_rank = get_pg_rank(gtp_remat_group) + gtp_remat_size = get_pg_size(gtp_remat_group) + + # Replicate-group rank — the true replicas of a given GTP chunk live here. + if intra_dp_cp_group is not None: + dp_replica_rank = get_pg_rank(intra_dp_cp_group) + else: + from megatron.core import parallel_state # noqa: E402 + + dp_replica_rank = parallel_state.get_data_parallel_rank( + with_context_parallel=True, with_gtp_remat=False + ) + + sharded_state_dict = {} + for layer_name, tensor in state_dict.items(): + layer_key = f"{prefix}{layer_name}" + is_gtp_weight_remat = is_gtp_param(tensor) + + if layer_name.endswith(extra_state_suffix): + # ShardedObject (extra_state metadata): GTP-REPLICATED across the GTP group. Fold + # gtp_rank into position 1 of the replica_id (PP, TP-replica-coord, DP) tuple so + # GTP-peer ranks within the same TP slice get unique replica_ids. + replica_id = (0, tp_rank * gtp_remat_size + gtp_rank, dp_replica_rank) + sharded_state_dict[layer_key] = make_sharded_object_for_checkpoint( + tensor, layer_key, sharded_offsets, replica_id=replica_id + ) + continue + + if not is_gtp_weight_remat: + # Non-GTPShardedParam under a GTP-active module (e.g. bias): GTP-replicated, so GTP + # ranks would collide on the same replica_id. Inject gtp_rank into replica_id + # position 1 (same as the GTP-sharded branch below). + if layer_name in tensor_parallel_layers_axis_map: + replica_id = (0, gtp_rank, dp_replica_rank) + sharded_state_dict[layer_key] = make_tp_sharded_tensor_for_checkpoint( + tensor, + layer_key, + tp_axis=tensor_parallel_layers_axis_map[layer_name], + replica_id=replica_id, + prepend_offsets=sharded_offsets, + tp_group=tp_group, + dp_cp_group=dp_cp_group, + ) + else: + replica_id = (0, tp_rank * gtp_remat_size + gtp_rank, dp_replica_rank) + sharded_state_dict[layer_key] = make_sharded_tensor_for_checkpoint( + tensor, + layer_key, + replica_id=replica_id, + prepend_offsets=sharded_offsets, + tp_group=tp_group, + dp_cp_group=dp_cp_group, + ) + continue + + # GTP-sharded tensor: delegate to the GTP-aware single-tensor helper — it layers the + # axis-0 GTP split onto TP, elects the writer over the gtp_remat-excluded DP group, and sets + # allow_shape_mismatch for alignment padding. (tp_axis None → 0; tp_size 1 when no TP.) + tp_axis = tensor_parallel_layers_axis_map.get(layer_name, None) + sharded_state_dict[layer_key] = make_tp_sharded_tensor_for_checkpoint( + tensor, + layer_key, + tp_axis=tp_axis if tp_axis is not None else 0, + prepend_offsets=sharded_offsets, + tp_group=tp_group, + dp_cp_group=dp_cp_group, + ) + + return sharded_state_dict diff --git a/megatron/core/tensor_parallel/gtp_api.py b/megatron/core/tensor_parallel/gtp_api.py new file mode 100644 index 00000000000..b49a5c02ded --- /dev/null +++ b/megatron/core/tensor_parallel/gtp_api.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Generalized Tensor Parallelism (GTP) public API. + +Thin re-export of the implementation in +``megatron.core.tensor_parallel.generalized_tensor_parallelism`` (see that module +for the design). GTP depends on TransformerEngine: if TE is missing or too old the +inner module imports cleanly but reports ``HAVE_TE = False``, mirrored here as +``HAVE_GTP = False``. Consumers gate every GTP code path behind ``if HAVE_GTP:``, +so no core module uses GTP symbols without TE. +""" + +try: + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + HAVE_TE, + GTPChain, + GTPEmbeddingWeight, + attach_gtp_to_presharded_module, + classify_gtp_remat_chains, + configure_gtp_remat_from_recipe, + dequantize_gtp_native_fp8, + get_ag_stream, + get_rs_stream, + gtp_native_fp8_load_context, + gtp_remat_shard_dim0, + is_gtp_param, + make_sharded_tensors_for_checkpoint_with_gtp_remat, + set_cuda_graph_mempool, + wait_async_comms, + wait_for_gtp_grad_reduction_on_current_stream, + wrap_module_params_gtp, + ) + + HAVE_GTP = HAVE_TE +except ImportError: + # Defensive fallback for any unexpected inner-import failure; consumers import + # the other symbols lazily under an ``if HAVE_GTP:`` guard, so no stubs needed. + HAVE_GTP = False + + +__all__ = [ + "HAVE_GTP", + "GTPChain", + "GTPEmbeddingWeight", + "attach_gtp_to_presharded_module", + "classify_gtp_remat_chains", + "configure_gtp_remat_from_recipe", + "dequantize_gtp_native_fp8", + "get_ag_stream", + "get_rs_stream", + "gtp_native_fp8_load_context", + "gtp_remat_shard_dim0", + "is_gtp_param", + "make_sharded_tensors_for_checkpoint_with_gtp_remat", + "set_cuda_graph_mempool", + "wait_async_comms", + "wait_for_gtp_grad_reduction_on_current_stream", + "wrap_module_params_gtp", +] diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index c072c52bd05..e6e55a96a75 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -16,10 +16,13 @@ from megatron.core.model_parallel_config import ModelParallelConfig from megatron.core.parallel_state import ( + get_expert_gtp_weight_remat_rank, get_global_memory_buffer, + get_gtp_weight_remat_rank, get_tensor_model_parallel_rank, get_tensor_model_parallel_world_size, ) +from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.utils import ( divide, get_pg_rank, @@ -101,6 +104,29 @@ def param_is_not_tensor_parallel_duplicate(param, tp_group=None): return get_tensor_model_parallel_rank() == 0 +def copy_gtp_attributes(destination, source): + """Copy the GTP dedup tags (is_gtp_weight_remat, allreduce) onto a param view/copy, so the + optimizer's master shards stay classifiable by param_is_not_gtp_duplicate.""" + for attr in ("is_gtp_weight_remat", "allreduce"): + if hasattr(source, attr): + setattr(destination, attr, getattr(source, attr)) + + +def param_is_not_gtp_duplicate(param): + """True if the param's grad is counted once across the GTP_remat/EGTP_remat axis. + + GTP_remat/EGTP_remat shards are unique per peer (kept); replicated params counted only on + rank 0 of the gtp_remat/egtp_remat axis (else counted N times). When GTP_remat is off rank is 0, + so every param is kept. + """ + if getattr(param, "is_gtp_weight_remat", False): + return True + is_expert = not getattr(param, "allreduce", True) + if is_expert: + return get_expert_gtp_weight_remat_rank() == 0 + return get_gtp_weight_remat_rank() == 0 + + def set_tensor_model_parallel_attributes(tensor, is_parallel, dim, stride): """Sets tp attributes to tensor""" # Make sure the attributes are not set. @@ -281,6 +307,20 @@ def __init__( tensor=self.weight, is_parallel=True, dim=0, stride=1 ) + self.gtp_remat_size = 1 + gtp_remat_group = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat"] + ).gtp_remat + if gtp_remat_group is not None and gtp_remat_group.size() > 1: + from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp + + wrap_module_params_gtp(self, ["weight"], gtp_remat_group) + self.gtp_remat_size = gtp_remat_group.size() + # Nothing prefetches embedding — it is head of the UNGRAPHED + # chain in fwd, and its bwd bypasses all_gather_and_prefetch_bwd + # via GTPEmbeddingWeight.backward. + self.weight._need_weight_prefetch = False + def forward(self, input_): """Forward. @@ -295,12 +335,19 @@ def forward(self, input_): masked_input[input_mask] = 0 else: masked_input = input_ + + weight = self.weight + if self.gtp_remat_size > 1: + from megatron.core.tensor_parallel.gtp_api import GTPEmbeddingWeight + + weight = GTPEmbeddingWeight.apply(self.weight) + # Get the embeddings. if self.deterministic_mode: - output_parallel = self.weight[masked_input] + output_parallel = weight[masked_input] else: # F.embedding currently has a non-deterministic backward function - output_parallel = F.embedding(masked_input, self.weight) + output_parallel = F.embedding(masked_input, weight) # Mask the output embedding. if self.tp_group.size() > 1: output_parallel[input_mask, :] = 0.0 @@ -400,6 +447,7 @@ def linear_with_frozen_weight( tp_group: Optional[torch.distributed.ProcessGroup], grad_output_buffer: Optional[List[torch.Tensor]] = None, wgrad_deferral_limit: None = None, + gtp_remat_size: int = 1, ) -> torch.Tensor: """Linear layer execution with weight.requires_grad == False. @@ -436,6 +484,10 @@ def linear_with_frozen_weight( wgrad_deferral_limit (int optional): dummy argument, used to keep the API unified between all forward implementation functions. + + gtp_remat_size (int): GTP shard count. When > 1 the weight is GTP-sharded and must be + all-gathered to its full shape before the matmul, mirroring the trainable path. + Defaults to 1 (no-op) for the common non-GTP / non-sharded case. """ assert grad_output_buffer is None, ( @@ -456,6 +508,9 @@ def linear_with_frozen_weight( else: input = input + if gtp_remat_size > 1: + weight = weight.all_gather_and_prefetch(fwd=True) + args = [input, weight, bias, allreduce_dgrad, tp_group] return LinearWithFrozenWeight.apply(*args) @@ -477,6 +532,7 @@ def forward( grad_output_buffer, wgrad_deferral_limit, tp_group, + gtp_remat_size, ): """Forward.""" if gradient_accumulation_fusion and hasattr(weight, "main_grad"): @@ -484,6 +540,10 @@ def forward( else: main_grad = None ctx.save_for_backward(input, weight) + + if gtp_remat_size > 1: + weight = weight.all_gather_and_prefetch(fwd=True) + # We can't save main_grad in save_for_backward as this module would be # reused across layers like MTP logits. So, to prevent in-place modification # checks we save the tensor in ctx. @@ -495,6 +555,7 @@ def forward( ctx.wgrad_deferral_limit = wgrad_deferral_limit ctx.grad_output_buffer = grad_output_buffer ctx.tp_group = tp_group + ctx.gtp_remat_size = gtp_remat_size if sequence_parallel: dim_size = list(input.size()) @@ -518,6 +579,13 @@ def backward(ctx, grad_output): input, weight = ctx.saved_tensors main_grad = ctx.main_grad use_bias = ctx.use_bias + + # GTP: re-gather weight for dgrad + if ctx.gtp_remat_size > 1: + sharded_weight = weight + weight = sharded_weight.all_gather_and_prefetch_bwd() + ctx.gradient_accumulation_fusion = False + grad_output_buffer = ctx.grad_output_buffer wgrad_deferral_limit = ctx.wgrad_deferral_limit handle = None @@ -651,16 +719,31 @@ def backward(ctx, grad_output): grad_weight = grad_output.t().matmul(total_input) grad_bias = grad_output.sum(dim=0) if use_bias else None + # GTP: reduce-scatter wgrad + if ctx.gtp_remat_size > 1 and grad_weight is not None: + grad_weight = sharded_weight.wgrad_reduce_scatter(grad_weight) + if ctx.sequence_parallel: handle.wait() # Need to return None's as gradient has to flow for all the input arguments # provided during forward - return (sub_grad_input, grad_weight, grad_bias, None, None, None, None, None, None) + return ( + sub_grad_input, + grad_weight, + grad_bias, + None, + None, + None, + None, + None, + None, + None, + ) if ctx.allreduce_dgrad: handle.wait() - return grad_input, grad_weight, grad_bias, None, None, None, None, None, None + return grad_input, grad_weight, grad_bias, None, None, None, None, None, None, None def linear_with_grad_accumulation_and_async_allreduce( @@ -673,6 +756,7 @@ def linear_with_grad_accumulation_and_async_allreduce( grad_output_buffer: Optional[List[torch.Tensor]] = None, wgrad_deferral_limit: Optional[int] = 0, tp_group: Optional[torch.distributed.ProcessGroup] = None, + gtp_remat_size: int = 1, ) -> torch.Tensor: """Linear layer execution with asynchronous communication and gradient accumulation fusion in backprop. @@ -749,6 +833,7 @@ def linear_with_grad_accumulation_and_async_allreduce( grad_output_buffer, wgrad_deferral_limit, tp_group, + gtp_remat_size, ] if not linear_with_grad_accumulation_and_async_allreduce.warned: @@ -923,6 +1008,17 @@ def __init__( else: self.weight = None + self.gtp_remat_size = 1 + _pg = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = _pg.expt_gtp_remat if self.is_expert else _pg.gtp_remat + if gtp_remat_group is not None and gtp_remat_group.size() > 1: + from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp + + wrap_module_params_gtp(self, ["weight"], gtp_remat_group) + self.gtp_remat_size = gtp_remat_group.size() + if bias: if config.use_cpu_initialization: self.bias = Parameter( @@ -1075,6 +1171,7 @@ def forward( else None ), tp_group=self.tp_group, + gtp_remat_size=self.gtp_remat_size, ) gather_output = self.gather_output @@ -1271,6 +1368,17 @@ def __init__( ) setattr(self.weight, "allreduce", not (self.is_expert and self.expert_parallel)) + self.gtp_remat_size = 1 + _pg = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = _pg.expt_gtp_remat if self.is_expert else _pg.gtp_remat + if gtp_remat_group is not None and gtp_remat_group.size() > 1: + from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp + + wrap_module_params_gtp(self, ["weight"], gtp_remat_group) + self.gtp_remat_size = gtp_remat_group.size() + if bias: if config.use_cpu_initialization: self.bias = Parameter(torch.empty(self.output_size, dtype=config.params_dtype)) @@ -1343,6 +1451,7 @@ def forward(self, input_: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: sequence_parallel=False, tp_group=None, grad_output_buffer=None, + gtp_remat_size=self.gtp_remat_size, ) # All-reduce across all the partitions. diff --git a/megatron/core/tensor_parallel/random.py b/megatron/core/tensor_parallel/random.py index 4cf945dd8bb..eb726c2eaf4 100644 --- a/megatron/core/tensor_parallel/random.py +++ b/megatron/core/tensor_parallel/random.py @@ -18,8 +18,12 @@ from typing_extensions import TypeVarTuple, Unpack from megatron.core.parallel_state import ( + get_expert_gtp_weight_remat_rank, + get_expert_gtp_weight_remat_world_size, get_expert_model_parallel_rank, get_expert_tensor_parallel_rank, + get_gtp_weight_remat_rank, + get_gtp_weight_remat_world_size, get_tensor_model_parallel_rank, ) from megatron.core.utils import is_te_min_version, safely_set_viewless_tensor_data @@ -91,6 +95,10 @@ def _get_share_storage(): _MODEL_PARALLEL_RNG_TRACKER_NAME = 'model-parallel-rng' _EXPERT_PARALLEL_RNG_TRACKER_NAME = 'expert-parallel-rng' _DATA_PARALLEL_RNG_TRACKER_NAME = 'data-parallel-rng' +# GTP_remat weight-init trackers: shards init per-rank, so each peer must draw DIFFERENT values; +# registered only when the axis is active (see model_parallel_cuda_manual_seed). +_GTP_REMAT_RNG_TRACKER_NAME = 'gtp-remat-rng' +_EXPERT_GTP_REMAT_RNG_TRACKER_NAME = 'egtp-remat-rng' def _get_cuda_rng_state( @@ -213,6 +221,11 @@ def get_data_parallel_rng_tracker_name(): return _DATA_PARALLEL_RNG_TRACKER_NAME +def get_gtp_remat_rng_tracker_name(is_expert=False): + """Get the (E)GTP_remat weight-init rng tracker name (per-(E)GTP-rank distinct draws).""" + return _EXPERT_GTP_REMAT_RNG_TRACKER_NAME if is_expert else _GTP_REMAT_RNG_TRACKER_NAME + + class CudaRNGStatesTracker: """Tracker for the cuda RNG states. @@ -483,6 +496,19 @@ def model_parallel_cuda_manual_seed( expert_parallel_seed = seed + 1024 + 100 * ep_rank + etp_rank _CUDA_RNG_STATE_TRACKER.add(_EXPERT_PARALLEL_RNG_TRACKER_NAME, expert_parallel_seed) + # GTP_remat weight-init states: shards are initialized per-rank (GTP-agnostic init), so peers + # must draw DIFFERENT values (everything above is identical across peers by design). The 65536 + # stride keeps these disjoint from the tp/ep/etp seeds. Added only when the axis is active, so + # non-GTP runs keep a byte-identical tracker set (and checkpoint rng payload). + gtp_remat_rank = get_gtp_weight_remat_rank() + if get_gtp_weight_remat_world_size() > 1: + gtp_remat_seed = tensor_model_parallel_seed + 65536 * (1 + gtp_remat_rank) + _CUDA_RNG_STATE_TRACKER.add(_GTP_REMAT_RNG_TRACKER_NAME, gtp_remat_seed) + egtp_remat_rank = get_expert_gtp_weight_remat_rank() + if get_expert_gtp_weight_remat_world_size() > 1: + egtp_remat_seed = expert_parallel_seed + 32768 + 65536 * (1 + egtp_remat_rank) + _CUDA_RNG_STATE_TRACKER.add(_EXPERT_GTP_REMAT_RNG_TRACKER_NAME, egtp_remat_seed) + def is_graph_safe_cuda_rng_tracker(cuda_rng_tracker): """Check if the cuda rng tracker is graph safe version.""" diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 210f39fa217..a64347e00f8 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -57,6 +57,31 @@ except: HAVE_TE_GRAPHS = False +try: + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP +except ImportError: + # GTP requires TransformerEngine with the GTP hook registry; treat it as + # unavailable when that import path cannot be resolved. + HAVE_GTP = False + +if HAVE_GTP: + from megatron.core.tensor_parallel.gtp_api import ( + GTPChain, + get_ag_stream, + get_rs_stream, + set_cuda_graph_mempool, + wait_async_comms, + ) +else: + # Placeholders so static analysis does not flag these GTP-only symbols as + # possibly-used-before-assignment; every use site is guarded by HAVE_GTP / + # gtp_remat at runtime. + GTPChain = None + get_ag_stream = None + get_rs_stream = None + set_cuda_graph_mempool = None + wait_async_comms = None + try: from tqdm import tqdm @@ -69,6 +94,16 @@ logger = logging.getLogger(__name__) +_GTP_RUNNER_STREAMS: List[torch.cuda.Stream] = [] + + +def get_gtp_runner_streams() -> List[torch.cuda.Stream]: + """Replay streams of all GTP CG runners; finalize_model_grads waits on these + (tail = captured Phase 2 main_grad.add_) before reading main_grad. + """ + return _GTP_RUNNER_STREAMS + + def _set_skip_fp8_weight_update_tensor(skip: bool) -> None: """Toggle TE's FP8 "skip weight refresh" flag between microbatches. @@ -340,6 +375,36 @@ def create_strong_ref(ten: torch.Tensor): bwd_buffer_reuse_ref_count = 0 +def _backup_grads_before_capture(runner): + """Snapshot main_grad so create_fwd_graph's eager warmup can't corrupt the finalized grads; + restore with ``_restore_grads_after_capture``. + """ + backup = {} + for p in runner.base_module.parameters(): + mg = getattr(p, "main_grad", None) + if mg is not None: + backup[id(p)] = (p, mg.clone()) + + if runner.gtp_remat: + # GTP only: also protect the cross-graph next_w the cascade accumulates into. + for p in runner.base_module.parameters(): + nw = getattr(p, "next_w", None) if getattr(p, "is_gtp_weight_remat", False) else None + if nw is None: + continue + shards = nw.weight_list if getattr(nw, "is_routed_expert", False) else [nw] + for w in shards or []: + mg = getattr(w, "main_grad", None) + if mg is not None and id(w) not in backup: + backup[id(w)] = (w, mg.clone()) + return backup + + +def _restore_grads_after_capture(backup): + """Restore the main_grad snapshots taken by ``_backup_grads_before_capture``.""" + for p, saved in backup.values(): + p.main_grad.copy_(saved) + + class _CudagraphGlobalRecord: """A global datastructure that records of the ordering of all _CudaGraphRunner's first fwd or bwd passes. 'create_cudagraphs' will use this to create @@ -527,6 +592,7 @@ def delete_cuda_graphs(): _CudagraphGlobalRecord.cudagraph_created = False _CudagraphGlobalRecord.cudagraph_record = [] _CudagraphGlobalRecord.cudagraph_inference_record = [] + _GTP_RUNNER_STREAMS.clear() # TODO: Optional?: Force garbage collection to clean up memory gc.collect() @@ -623,7 +689,13 @@ def forward(ctx, runner, is_first_microbatch, *inputs): _set_skip_fp8_weight_update_tensor(not is_first_microbatch) runner.fp8_param_cache_updated = is_first_microbatch - runner.fwd_graph.replay() + if runner.use_stream: + runner.stream.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(runner.stream): + runner.fwd_graph.replay() + torch.cuda.current_stream().wait_event(runner.fwd_completion_event) + else: + runner.fwd_graph.replay() if runner.is_last_layer: outputs = tuple(torch.clone(t) for t in runner.fwd_graph_output_surface) @@ -654,7 +726,14 @@ def backward(ctx, *grads): if user_output_grad.data_ptr() != cudagraph_output_grad.data_ptr(): cudagraph_output_grad.copy_(user_output_grad) - runner.bwd_graph.replay() + if runner.use_stream: + runner.stream.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(runner.stream): + runner.bwd_graph.replay() + torch.cuda.current_stream().wait_event(runner.bwd_completion_event) + else: + runner.bwd_graph.replay() + runner.bwd_graph_replay_complete_event.record(torch.cuda.current_stream()) for param in runner.params_to_backprop: param._cudagraph_wgrad_ready_event = runner.bwd_graph_replay_complete_event @@ -666,6 +745,20 @@ def backward(ctx, *grads): ): FP8GlobalStateManager.reduce_and_update_fp8_tensors(forward=False) + # DDP grad-ready hook is silenced at capture/replay, so fire it here (on each param's + # rs_stream, after wait_stream(runner.stream) fences Phase 2) to let DDP RS overlap bwd. + if runner.gtp_remat: + for gtp_rs_stream, params in runner._gtp_finalize_hook_plan: + gtp_rs_stream.wait_stream(runner.stream) + with torch.cuda.stream(gtp_rs_stream): + for param in params: + param.grad = None + if hasattr(param, 'grad_added_to_main_grad'): + param.grad_added_to_main_grad = True + hook = getattr(param, '_grad_accum_hook', None) + if hook is not None: + hook() + return None, None, *runner.static_grad_inputs, *(None,) * len(runner.params_to_backprop) @@ -713,6 +806,16 @@ def __init__( self.fp4_runtime_enabled = None self.deallocate_pipeline_outputs = False self.num_warmup_steps = 0 + self.use_stream = False + self.gtp_remat = False + self.fwd_side_streams = [] + self.bwd_side_streams = [] + # Populated by create_bwd_graph: GTP params whose main_grad.add_ was captured in THIS + # graph. Used in Graphed.backward's post-replay hook loop to fire DDP hooks only in the + # graph whose replay populates main_grad. + self.finalized_during_bwd_capture = [] + # (rs_stream, params) DDP grad-ready hook plan; built in create_bwd_graph. + self._gtp_finalize_hook_plan = [] self.grad_enabled = need_backward and torch.is_grad_enabled() self.func = super(MegatronModule, self.base_module).__call__ if func is None else func @@ -735,6 +838,31 @@ def __init__( self.fp4_enabled = self.base_module.config.fp4 is not None self.fp8_runtime_enabled = None self.fp4_runtime_enabled = None + self.gtp_remat = self.base_module.config.gtp_weight_remat_size > 1 + + if self.gtp_remat: + # Ensure internal warmup (inside create_fwd_graph) has >= 2 steps + # for GTP: 1st builds chain + tickets, 2nd exercises prefetch path. + self.num_warmup_steps = max(self.num_warmup_steps, 2) + + self.use_stream = True + self.stream = torch.cuda.Stream() + self.fwd_completion_event = torch.cuda.Event(external=True, interprocess=True) + self.bwd_completion_event = torch.cuda.Event(external=True, interprocess=True) + # Register (chain, group) side streams before the first forward. + # Dense for mamba/attn/shared_experts; expert (below) for routed + # experts captured when "moe" is in cuda_graph_modules. + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + self._register_gtp_side_streams(pg_collection.gtp_remat) + # EGTP_remat streams: required so _wait/_sync_side_streams drain EGTP_remat + # NCCL into runner_stream before bwd_completion_event fires. + egtp_remat_group = pg_collection.expt_gtp_remat + if egtp_remat_group is not None and egtp_remat_group.size() > 1: + self._register_gtp_side_streams(egtp_remat_group) + # Registered for finalize_model_grads to wait on (Phase 2 fence). + _GTP_RUNNER_STREAMS.append(self.stream) if self.fp8_enabled: self.fp8_recipe = FP8GlobalStateManager.get_fp8_recipe() @@ -746,6 +874,56 @@ def __init__( self.fp4_recipe = get_fp4_recipe(self.base_module.config) _set_skip_fp8_weight_update_tensor(False) + def _register_gtp_side_streams(self, group): + """Register a GTP (chain, group)'s GRAPHED AG/RS side streams for capture/replay sync: the + AG stream on both fwd and bwd, the RS stream on bwd only.""" + ag = get_ag_stream(GTPChain.GRAPHED.value, group) + rs = get_rs_stream(GTPChain.GRAPHED.value, group) + self.fwd_side_streams.append(ag) + self.bwd_side_streams.append(ag) + self.bwd_side_streams.append(rs) + + def _sync_against_side_streams(self, side_streams): + """Make registered side streams wait for the current stream. + Also injects a dummy kernel into each stream to ensure it is non-empty, + which is required for CUDA graph capture (joining an empty captured + stream is a CUDA error).""" + for s in side_streams: + s.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(s): + torch.cuda._sleep(1) + + def _wait_side_streams(self, side_streams): + """Make the current stream wait for all registered side streams.""" + for s in side_streams: + torch.cuda.current_stream().wait_stream(s) + + def _compute_finalized_during_bwd_capture(self): + """Return GTP params whose DDP grad-ready hook fires post-replay + of THIS bwd_graph. + + A param's hook must fire in the graph that physically populates its + main_grad. Rules, given the cascade walk in wgrad_reduce_scatter + finalizes p.next_w on behalf of p: + - p.prev_w is None → p is sync-finalized in p's own graph; add p. + - p.next_w is not None → p.next_w's main_grad.add_ is captured here + via p's cascade; add p.next_w. (For cross-graph chain tails the + wait was captured in the producer's Phase 2, but the add lives + here regardless, bridged by external rs_event.) + """ + finalized = {} # id → param + for p in self.params_to_backprop: + if not getattr(p, 'is_gtp_weight_remat', False): + continue + if getattr(p, "prev_w", None) is None: + for w in getattr(p, "_weights", [p]): + finalized[id(w)] = w + next_w = getattr(p, "next_w", None) + if next_w is not None: + for w in getattr(next_w, "_weights", [next_w]): + finalized[id(w)] = w + return list(finalized.values()) + def __str__(self): return "%s; hid %s" % ( self.base_module.__class__.__name__, @@ -811,9 +989,7 @@ def create_fwd_graph(self, args, kwargs, outputs=None, clone_inputs=True): for buf in self.base_module.buffers(): buffer_backup.append(buf.clone()) - grad_backup = [] - for param in self.base_module.parameters(): - grad_backup.append(param.main_grad.clone() if hasattr(param, "main_grad") else None) + grad_backup = _backup_grads_before_capture(self) saved_fp8_tensors = None if self.fp8_enabled: @@ -941,6 +1117,10 @@ def clone_ten(ten): allow_unused=True, ) + if self.gtp_remat: + wait_async_comms(GTPChain.GRAPHED.value) + self._sync_against_side_streams(self.bwd_side_streams) + _set_warmup_end() with self.get_quantization_context(): @@ -961,10 +1141,23 @@ def clone_ten(ten): with torch.cuda.graph( self.fwd_graph, pool=self.mempool, capture_error_mode="thread_local" ): + + self._sync_against_side_streams(self.fwd_side_streams) + fwd_graph_outputs = self.func( *self.fwd_graph_input_args, **self.fwd_graph_input_kwargs ) + if self.gtp_remat: + # Forward only issues AG prefetches (no wgrad RS), so drain AG and skip RS. + wait_async_comms(GTPChain.GRAPHED.value, skip_rs=True) + + if self.fwd_side_streams: + self._wait_side_streams(self.fwd_side_streams) + + if self.use_stream: + self.fwd_completion_event.record() + # Unfreeze GC. if FREEZE_GC: gc.unfreeze() @@ -1023,9 +1216,7 @@ def clone_ten(ten): if self.fp8_enabled: restore_fp8_tensors([self.base_module], saved_fp8_tensors) # restore cached grads - for main_grad_copy, param in zip(grad_backup, self.base_module.parameters()): - if main_grad_copy is not None: - param.main_grad.copy_(main_grad_copy) + _restore_grads_after_capture(grad_backup) # restore cached buffers for buf_copy, buf in zip(buffer_backup, self.base_module.buffers()): @@ -1080,6 +1271,9 @@ def create_bwd_graph(self): gc.freeze() with torch.cuda.graph(self.bwd_graph, pool=self.mempool): + + self._sync_against_side_streams(self.bwd_side_streams) + grad_inputs = torch.autograd.grad( outputs=tuple(o for o in self.fwd_graph_output_surface if o.requires_grad), inputs=tuple(i for i in self.fwd_graph_input_surface if i.requires_grad), @@ -1096,10 +1290,71 @@ def create_bwd_graph(self): if wgrad is not None and not getattr(param, 'grad_added_to_main_grad', False): param.main_grad.add_(wgrad) + # GTP cross-graph RS overlap, two phases: + # Phase 1 — drain AG, fence runner_stream past ag_stream's tail, + # then record bwd_completion_event so main_stream can + # release the next runner while RS is still in flight. + # Phase 2 — drain RS wait on rs_stream. For cross-graph chain + # tails the wait is captured here, the add in the + # consumer's cascade; for within-graph tails both + # happen here (see wait_async_comms). + if self.gtp_remat: + # Phase 1: drain AG; fence runner_stream past dense + EGTP AG + # so bwd_completion_event records AFTER NCCL_AG completion. + wait_async_comms(GTPChain.GRAPHED.value, skip_rs=True) + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + gtp_remat_group = pg_collection.gtp_remat + graphed_ag = get_ag_stream(GTPChain.GRAPHED.value, gtp_remat_group) + torch.cuda.current_stream().wait_stream(graphed_ag) + egtp_remat_group = pg_collection.expt_gtp_remat + if egtp_remat_group is not None and egtp_remat_group.size() > 1: + egtp_graphed_ag = get_ag_stream(GTPChain.GRAPHED.value, egtp_remat_group) + torch.cuda.current_stream().wait_stream(egtp_graphed_ag) + + # Record completion AFTER AG drain + fence but BEFORE RS drain, + # so main_stream can trigger the next runner while RS is still + # in flight on rs_stream. + self.bwd_completion_event.record() + + # Phase 2: in-graph RS drain + finalize. + wait_async_comms(GTPChain.GRAPHED.value, finalize_after_drain=True) + + if self.bwd_side_streams: + self._wait_side_streams(self.bwd_side_streams) + + if self.use_stream and not self.gtp_remat: + # Non-GTP path: record after the side-stream join. + self.bwd_completion_event.record() + # Unfreeze GC. if FREEZE_GC: gc.unfreeze() + # See _compute_finalized_during_bwd_capture for what's in this set and why. + self.finalized_during_bwd_capture = ( + self._compute_finalized_during_bwd_capture() if self.gtp_remat else [] + ) + + # Precompute the (rs_stream, params) DDP grad-ready hook plan once — it's + # replay-invariant — so Graphed.backward avoids per-replay group lookups. + self._gtp_finalize_hook_plan = [] + if self.gtp_remat and self.finalized_during_bwd_capture: + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + dense_group = pg_collection.gtp_remat + expert_group = pg_collection.expt_gtp_remat + params_by_group = defaultdict(list) + for param in self.finalized_during_bwd_capture: + is_expert = not getattr(param, 'allreduce', True) + params_by_group[expert_group if is_expert else dense_group].append(param) + self._gtp_finalize_hook_plan = [ + (get_rs_stream(GTPChain.GRAPHED.value, group), params) + for group, params in params_by_group.items() + ] + for arg in args_to_clear_buffers: arg.cg_buffer_metadata.bwd_cudagraph_buffer = None bwd_buffer_reuse_ref_count -= 1 @@ -1430,6 +1685,10 @@ def wrapped_func(*args, eager=False, cache_key=None, **kwargs): self.reuse_cudagraphs = self.pg_collection.pp.size() == 1 if CudaGraphManager.global_mempool is None: CudaGraphManager.global_mempool = torch.cuda.graph_pool_handle() + # Register the pool so GTP allocates GRAPHED-chain buffers + quantized + # storage directly into it (created before the first graphed forward). + if HAVE_GTP: + set_cuda_graph_mempool(torch.cuda.current_device(), CudaGraphManager.global_mempool) # Cudagraph stream capture requires no operations on the default stream prior to the # capture, so change to a side stream. torch.cuda.set_stream(torch.cuda.Stream()) @@ -1616,7 +1875,7 @@ def __call__(self, megatron_module, args, kwargs, cache_key=None): self.is_first_microbatch = False # If forward only, next replay should be a forward pass as well - if is_inference_mode or not torch.is_grad_enabled(): + if is_inference_mode or not torch.is_grad_enabled() or not runner.fwd_graph_recorded: runner.status = _GraphStatus.FWD_READY else: runner.status = _GraphStatus.BWD_READY diff --git a/megatron/core/transformer/mlp.py b/megatron/core/transformer/mlp.py index 4c84e271ad3..f5659d21a6e 100644 --- a/megatron/core/transformer/mlp.py +++ b/megatron/core/transformer/mlp.py @@ -402,6 +402,14 @@ def as_mlp_submodule( assert hasattr( pg_collection, 'tp' ), 'TP process group is required for MLP in TransformerLayer' + + # fc1/fc2 resolve GTP_remat at the leaf; the TE op-fused MLP ignores shards, so fail fast. + if hasattr(cls, '_make_fused_impl'): + assert config.gtp_weight_remat_size <= 1, ( + f"{cls.__name__}: GTP sharding of the dense MLP is not supported with the " + "TE fused MLP / GroupedLinear path (_make_fused_impl ignores GTP shards). " + "Use the non-fused MLP submodule, or do not enable GTP for dense MLP layers." + ) return cls( config=config, submodules=submodules, diff --git a/megatron/core/transformer/moe/moe_logging.py b/megatron/core/transformer/moe/moe_logging.py index b1f2b27000b..68d359741ec 100644 --- a/megatron/core/transformer/moe/moe_logging.py +++ b/megatron/core/transformer/moe/moe_logging.py @@ -255,12 +255,17 @@ def _sync_metrics( """ if pg_collection is None: pp_group = parallel_state.get_pipeline_model_parallel_group() + dp_group = None + else: + pp_group = pg_collection.pp + dp_group = getattr(pg_collection, 'dp_cp_gtp_remat', None) + # The metric DP-average must span gtp_remat peers (they hold distinct tokens), else the + # displayed value is a 1/gtp_remat subsample and looks noisy. Use the gtp_remat-inclusive + # group; CP ranks (already summed in reduce_group) average as a no-op. + if dp_group is None: dp_group = parallel_state.get_data_parallel_group( with_context_parallel=False, partial_data_parallel=False ) - else: - pp_group = pg_collection.pp - dp_group = pg_collection.dp for name in metric_names: if name not in self._metrics: diff --git a/megatron/core/transformer/moe/moe_utils.py b/megatron/core/transformer/moe/moe_utils.py index 5c053adb6b1..9b7cf177c79 100644 --- a/megatron/core/transformer/moe/moe_utils.py +++ b/megatron/core/transformer/moe/moe_utils.py @@ -1443,7 +1443,10 @@ def get_default_pg_collection() -> ProcessGroupCollection: pg_collection.tp = parallel_state.get_tensor_model_parallel_group() pg_collection.cp = parallel_state.get_context_parallel_group() pg_collection.expt_tp = parallel_state.get_expert_tensor_parallel_group() - pg_collection.expt_dp = parallel_state.get_expert_data_parallel_group() + pg_collection.expt_dp = parallel_state.get_expert_data_parallel_group(with_gtp_remat=False) + pg_collection.expt_dp_gtp_remat = parallel_state.get_expert_data_parallel_group( + check_initialized=False + ) pg_collection.tp_ep = parallel_state.get_expert_tensor_and_model_parallel_group() pg_collection.tp_cp = parallel_state.get_tensor_and_context_parallel_group() pg_collection.tp_dp_cp = parallel_state.get_tensor_and_data_parallel_group( diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index ac08b69751d..0b94e8eac66 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -1021,7 +1021,9 @@ class TransformerConfig(ModelParallelConfig): more details, see: https://pytorch.org/docs/stable/generated/torch.Tensor.backward.html.""" cuda_graph_warmup_steps: int = 3 - """Number of warmup steps for CUDA graphs""" + """Number of warmup steps for CUDA graphs. Note: GTP (``gtp_weight_remat_size > 1``) forces a + minimum of 2 per-graph warmup steps regardless of this value, because the first warmup builds + the weight-prefetch chain and the second exercises the prefetch path before capture.""" external_cuda_graph: bool = False """DEPRECATED and replaced by cuda_graph_impl. @@ -2570,6 +2572,27 @@ def _scope_to_str(s): "moe_input_jitter_eps is not supported with graphed moe recomputation." ) + if ( + self.gtp_weight_remat_size > 1 + and self.cuda_graph_impl == "local" + and (self.fp8 is not None or self.fp4 is not None) + and self.moe_shared_expert_intermediate_size is not None + and not self.moe_shared_expert_overlap + and ( + full_cudagraph + or CudaGraphModule.moe in self.cuda_graph_modules + or CudaGraphModule.moe_router in self.cuda_graph_modules + ) + ): + assert "shared_experts" not in self.recompute_modules, ( + "GTP + local CUDA graphs that capture shared_experts " + "(moe_router/moe scope) cannot recompute it under fp8/fp4: " + "te_checkpoint requires .backward(), but the local fwd-graph " + "warmup uses .grad(). Drop 'shared_experts' from " + "--recompute-modules (GTP-shard + offload instead), or use " + "--cuda-graph-impl full_iteration." + ) + if self.fine_grained_activation_offloading: offload_modules = set(self.offload_modules or []) if self.cuda_graph_impl == "local": diff --git a/megatron/core/transformer/utils.py b/megatron/core/transformer/utils.py index 2249c79a2bd..aee4e961b9e 100644 --- a/megatron/core/transformer/utils.py +++ b/megatron/core/transformer/utils.py @@ -132,6 +132,28 @@ def make_sharded_tensors_for_checkpoint( tp_group = get_tensor_model_parallel_group_if_none(tp_group) dp_cp_group = parallel_state.get_data_parallel_group(with_context_parallel=True) + # GTP-sharded weights need the GTP axis layered onto the TP/DP offsets. The GTP helper + # is a no-op for non-GTP state_dicts, but importing it eagerly would be circular, so + # gate on HAVE_GTP and the presence of a GTP param before delegating. + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if HAVE_GTP: + from megatron.core.tensor_parallel.gtp_api import ( + is_gtp_param, + make_sharded_tensors_for_checkpoint_with_gtp_remat, + ) + + if any(is_gtp_param(t) for t in state_dict.values()): + return make_sharded_tensors_for_checkpoint_with_gtp_remat( + state_dict, + prefix, + tensor_parallel_layers_axis_map, + sharded_offsets, + extra_state_suffix=extra_state_suffix, + tp_group=tp_group, + dp_cp_group=dp_cp_group, + ) + sharded_state_dict = {} for layer_name in state_dict.keys(): tensor = state_dict[layer_name] diff --git a/megatron/core/utils.py b/megatron/core/utils.py index 2e2f6184733..ad9692e8aa5 100644 --- a/megatron/core/utils.py +++ b/megatron/core/utils.py @@ -934,7 +934,10 @@ def check_param_hashes_across_dp_replicas( for params, local_param_hashes, all_gather_group in zip( [non_expert_params, expert_params], [local_non_expert_param_hashes, local_expert_param_hashes], - [parallel_state.get_data_parallel_group(), parallel_state.get_expert_data_parallel_group()], + [ + parallel_state.get_data_parallel_group(with_gtp_remat=False), + parallel_state.get_expert_data_parallel_group(with_gtp_remat=False), + ], ): # Collect per-parameter hashes across all ranks in group. assert len(params) == len(local_param_hashes) @@ -1025,6 +1028,50 @@ def make_tp_sharded_tensor_for_checkpoint( # FSDP2 shards axis 0 and TP shards some other axis new_offsets.append((prepend_axis_num, dp_rank, dp_size)) + # GTP: a GTP param additionally shards out_features (axis 0) by 1/gtp_remat. Layer that + # split onto TP offset — mirrors make_sharded_tensors_for_checkpoint_with_gtp_remat so direct + # callers (e.g. VocabParallelEmbedding, which can't use that wrapper because it needs + # allow_shape_mismatch) still save GTP weights with correct global offsets/shape. + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if HAVE_GTP: + from megatron.core.fp8_utils import is_float8tensor + from megatron.core.tensor_parallel.gtp_api import dequantize_gtp_native_fp8, is_gtp_param + + if is_gtp_param(tensor): + gtp_rank = get_pg_rank(tensor.group) + gtp_remat_size = get_pg_size(tensor.group) + if tp_axis == 0: + # same axis as TP → one composite axis-0 offset + new_offsets[0] = ( + prepend_axis_num, + tp_rank * gtp_remat_size + gtp_rank, + tp_size * gtp_remat_size, + ) + else: + # GTP shards axis 0, TP shards a different axis → add a separate axis-0 offset + new_offsets.append((prepend_axis_num, gtp_rank, gtp_remat_size)) + # Elect the writer over the gtp_remat-EXCLUDED DP group (its true replicas). + dp_replica_id = parallel_state.get_data_parallel_rank( + with_context_parallel=True, with_gtp_remat=False + ) + # Saved global is the padded shape when GTP padded out_features for alignment. + if getattr(tensor, "pad_length", 0): + kwargs.setdefault("allow_shape_mismatch", True) + # Native-FP8 GTP shard: the param IS a QuantizedTensor (reports a fake BF16 dtype + # over FP8 bytes). Dequantize to real BF16 so the checkpoint stores portable + # high-precision values, not raw FP8 bytes mislabeled as BF16. Offsets above were + # already read from the FP8 param's GTP attrs; shape is preserved by dequantize. + # (dequantize_gtp_native_fp8 restores the base FP8 class for the dequantize call — + # TE's tex.dequantize does not recognize the dynamic GTP_ subclass.) + if is_float8tensor(tensor): + fp8_param = tensor + tensor = dequantize_gtp_native_fp8(tensor) + # Backlink to the live FP8 param: optimizer sharded_state_dict matches params + # to model entries by id(entry.data), which this dequantized copy would break + # (see _backfill_gtp_sharded_param_map in optimizer.py). + tensor._gtp_dequant_src = fp8_param + if replica_id is None: replica_id = (0, 0, dp_replica_id) @@ -1059,6 +1106,18 @@ def make_sharded_tensor_for_checkpoint(tensor, key, prepend_offsets=(), replica_ - dp_cp_group: Data parallel + context parallel group (default: None, falls back to parallel_state) """ + # Sanity guard. + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if HAVE_GTP: + from megatron.core.tensor_parallel.gtp_api import is_gtp_param + + assert not is_gtp_param(tensor), ( + f"GTP weight-remat param '{key}' reached make_sharded_tensor_for_checkpoint (the " + "replicated path); route GTP-sharded weights through " + "make_tp_sharded_tensor_for_checkpoint or make_sharded_tensors_for_checkpoint instead." + ) + # Pop group parameters from kwargs tp_group = kwargs.pop('tp_group', None) dp_cp_group = kwargs.pop('dp_cp_group', None) diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index c209aaa9cda..235fc95ec35 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -408,7 +408,23 @@ def validate_args(args, defaults={}): update_use_dist_ckpt(args) - total_model_size = args.tensor_model_parallel_size * args.pipeline_model_parallel_size * args.context_parallel_size + # GTP_remat counts toward total_model_size (an independent weight-shard axis), so the + # args.data_parallel_size below is the replicate degree (matches + # parallel_state). gtp_weight_remat_size is derived from --tensor-parallel-num-weight-shards. + from megatron.core.model_parallel_config import resolve_tensor_parallel_weight_shards + (args.tensor_parallel_num_weight_shards, args.gtp_weight_remat_size) = ( + resolve_tensor_parallel_weight_shards( + args.tensor_model_parallel_size, + args.tensor_parallel_num_weight_shards, + getattr(args, "gtp_weight_remat_size", 1), + ) + ) + total_model_size = ( + args.tensor_model_parallel_size + * args.pipeline_model_parallel_size + * args.context_parallel_size + * args.gtp_weight_remat_size + ) # Total model size. assert args.world_size % total_model_size == 0, ( @@ -421,7 +437,12 @@ def validate_args(args, defaults={}): # Pipeline model parallel size. args.transformer_pipeline_model_parallel_size = args.pipeline_model_parallel_size - total_model_size = args.tensor_model_parallel_size * args.pipeline_model_parallel_size * args.context_parallel_size + total_model_size = ( + args.tensor_model_parallel_size + * args.pipeline_model_parallel_size + * args.context_parallel_size + * args.gtp_weight_remat_size + ) args.data_parallel_size = args.world_size // total_model_size if args.perform_rl_step: @@ -1409,6 +1430,105 @@ def validate_args(args, defaults={}): if args.expert_model_parallel_size > 1 and 'ep_dp' not in args.high_priority_stream_groups: args.high_priority_stream_groups.append('ep_dp') + + # Derive the internal gtp_weight_remat_size from the user-facing + # --tensor-parallel-num-weight-shards. gtp_weight_remat_size has no CLI flag (it is excluded + # from argument generation), so it is set here as a fresh attribute on args before it is + # consumed below (and in initialize/training, which read args.gtp_weight_remat_size directly). + # Mirrors ModelParallelConfig.__post_init__. + from megatron.core.model_parallel_config import resolve_tensor_parallel_weight_shards + (args.tensor_parallel_num_weight_shards, args.gtp_weight_remat_size) = ( + resolve_tensor_parallel_weight_shards( + args.tensor_model_parallel_size, + args.tensor_parallel_num_weight_shards, + getattr(args, "gtp_weight_remat_size", 1), + ) + ) + # Same for the expert layers: derive the internal expert_gtp_weight_remat_size from the + # user-facing --expert-tensor-parallel-num-weight-shards (expert_tensor_parallel_size is + # defaulted earlier in validate_args). expert_gtp_weight_remat_size has no CLI flag. + (args.expert_tensor_parallel_num_weight_shards, args.expert_gtp_weight_remat_size) = ( + resolve_tensor_parallel_weight_shards( + args.expert_tensor_parallel_size, + args.expert_tensor_parallel_num_weight_shards, + getattr(args, "expert_gtp_weight_remat_size", 1), + ) + ) + + if args.gtp_weight_remat_size > 1 or args.expert_gtp_weight_remat_size > 1: + if args.fp4 and not args.fp4_param_gather: + raise ValueError( + "GTP (--tensor-parallel-num-weight-shards / " + "--expert-tensor-parallel-num-weight-shards > 1) with --fp4-format requires " + "--fp4-param-gather so NVFP4 weights are all-gathered as native NVFP4." + ) + gtp_weight_remat_size = args.gtp_weight_remat_size + egtp_weight_remat_size = args.expert_gtp_weight_remat_size + if get_device_arch_version() >= 10: + # Setting GTP communication groups for high priority streams for Blackwell and later + # architectures. Assigning high priority to communication streams ensures that + # communication kernels are scheduled with higher priority, minimizing the exposed + # communication when it is overlapped with other computation kernels. + if 'gtp_remat' not in args.high_priority_stream_groups: + args.high_priority_stream_groups.append('gtp_remat') + warn_rank_0("Setting 'gtp_remat' group for high priority streams.") + if ( + egtp_weight_remat_size > 1 + and 'expt_gtp_remat' not in args.high_priority_stream_groups + ): + args.high_priority_stream_groups.append('expt_gtp_remat') + warn_rank_0("Setting 'expt_gtp_remat' group for high priority streams.") + + # Sanity check for 'CUDA_GRAPHS_USE_NODE_PRIORITY'. + if args.cuda_graph_impl != "none": + assert os.environ.get('CUDA_GRAPHS_USE_NODE_PRIORITY') == "1", \ + 'GTP requires CUDA_GRAPHS_USE_NODE_PRIORITY=1 to make sure fine-grained GTP ' \ + 'comms can be well overlapped with GEMMs when CudaGraph is enabled for ' \ + 'Blackwell and later architecture.' + + # Sanity check for 'NCCL_PROTO'. + if os.environ.get('NCCL_PROTO', '').lower() == "simple": + warn_rank_0( + "Generally GTP prefers 'NCCL_PROTO=LL128 or LL' while get 'NCCL_PROTO=simple', " + "force setting NCCL_PROTO=Simple might introduce bad perf." + ) + + assert not args.ddp_average_in_collective, ( + "GTP requires --ddp-average-in-collective off (the default); averaged collectives " + "would need per-buffer 1/gtp_remat scaling." + ) + + assert args.ckpt_format in ('torch', 'torch_dist'), ( + f"GTP supports only --ckpt-format 'torch' (legacy) or 'torch_dist', got " + f"'{args.ckpt_format}'." + ) + assert not ( + getattr(args, 'dist_ckpt_optim_fully_reshardable', False) + and getattr(args, 'distrib_optim_fully_reshardable_mem_efficient', False) + ), ( + "GTP does not support the distributed-optimizer fully-reshardable + " + "mem-efficient checkpoint mode. Disable " + "--distrib-optim-fully-reshardable-mem-efficient (or " + "--dist-ckpt-optim-fully-reshardable)." + ) + + # GTP with the mxfp8 recipe requires --fp8-param-gather: GTP keeps no bf16 weight and + # relies on the optimizer maintaining the fp8 shard (the forward all-gathers fp8 and does + # not re-quantize). Without fp8-param-gather the fp8 forward weight would never be updated. + if getattr(args, 'fp8_recipe', None) == 'mxfp8': + assert getattr(args, 'fp8_param_gather', False), ( + "GTP + mxfp8 requires --fp8-param-gather (the optimizer maintains the fp8 shard; " + "GTP does not keep or re-quantize a bf16 weight)." + ) + # MXFP8 params cannot be mapped into the contiguous param buffer (TE's + # replace_raw_data does not support the MXFP8 tile-scaling layout), so the param + # all-gather must reuse the grad buffer instead. + assert getattr(args, 'reuse_grad_buf_for_mxfp8_param_ag', False), ( + "GTP + mxfp8 + --fp8-param-gather requires --reuse-grad-buf-for-mxfp8-param-ag " + "(MXFP8 params keep their own quantized storage; mapping them into the param " + "buffer via replace_raw_data is unsupported)." + ) + # Disable bias gelu fusion if we are disabling bias altogether if not args.add_bias_linear: args.bias_gelu_fusion = False @@ -2147,6 +2267,10 @@ def _add_network_size_args(parser): "bias_dropout_fusion", "apply_rope_fusion", "mamba_training_ssm_states_dtype", + # internal/derived: controlled only via --tensor-parallel-num-weight-shards + "gtp_weight_remat_size", + # internal/derived: controlled only via --expert-tensor-parallel-num-weight-shards + "expert_gtp_weight_remat_size", ] transformer_factory = ArgumentGroupFactory(TransformerConfig, exclude=exclude) transformer_group = transformer_factory.build_group(parser, "transformer configuration") diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index ccc7ececfd7..8a96ad7a1e2 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -838,6 +838,8 @@ def _rank_and_size(explicit_rank, group, mpu_rank_fn, mpu_size_fn): pipeline_rank, pp_group, mpu.get_pipeline_model_parallel_rank, mpu.get_pipeline_model_parallel_world_size, ) + gtp_remat_rank = mpu.get_gtp_weight_remat_rank() + 1 + gtp_remat_size_to_print = mpu.get_gtp_weight_remat_world_size() def iter_finalize_fn(): prev_iteration = 0 @@ -851,6 +853,7 @@ def iter_finalize_fn(): print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] successfully saved " f"checkpoint from iteration {int(iteration):7d} to {args.save} " f"[ t {tensor_mp_rank}/{tp_size_to_print}, " + f"gtp_remat {gtp_remat_rank}/{gtp_remat_size_to_print}, " f"p {pipeline_mp_rank}/{pp_size_to_print} ]") if args.log_progress and args.async_save: append_to_progress_log(args.save, f'Saved async checkpoint\tIteration: {iteration}', @@ -2060,12 +2063,26 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', def load_model_state_dict(module, state_dict, strict: bool): """Helper function to load state dict with fallback for missing extra states.""" + # GTP native-FP8 weights: load_state_dict's copy_ re-quantizes into the FP8 param, which + # TE's IsMXFP8Tensor check rejects for our subclass. Present the base FP8 class for it. + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if HAVE_GTP: + from megatron.core.tensor_parallel.gtp_api import gtp_native_fp8_load_context + + load_ctx = lambda: gtp_native_fp8_load_context(module) + else: + from contextlib import nullcontext + + load_ctx = nullcontext try: - module.load_state_dict(state_dict, strict=strict) + with load_ctx(): + module.load_state_dict(state_dict, strict=strict) except Exception as e: if strict: # Fallback support for backward compatibility breaking changes in TransformerEngine - load_return = module.load_state_dict(state_dict, strict=False) + with load_ctx(): + load_return = module.load_state_dict(state_dict, strict=False) print(f"load_return: {load_return}") # Model. if not skip_load_to_model_and_opt: @@ -2202,8 +2219,11 @@ def load_model_state_dict(module, state_dict, strict: bool): _tp_w = get_pg_size(tp_group) if tp_group is not None else mpu.get_tensor_model_parallel_world_size() _pp_r = get_pg_rank(pp_group) if pp_group is not None else mpu.get_pipeline_model_parallel_rank() _pp_w = get_pg_size(pp_group) if pp_group is not None else mpu.get_pipeline_model_parallel_world_size() + _gtp_remat_r = mpu.get_gtp_weight_remat_rank() + _gtp_remat_w = mpu.get_gtp_weight_remat_world_size() print_rank_0(f' successfully loaded checkpoint from {load_dir} ' f'[ t {_tp_r + 1}/{_tp_w}, ' + f'gtp_remat {_gtp_remat_r + 1}/{_gtp_remat_w}, ' f'p {_pp_r + 1}/{_pp_w} ] ' f'at iteration {iteration}') diff --git a/megatron/training/global_vars.py b/megatron/training/global_vars.py index ec0bc532f59..aba204603a6 100644 --- a/megatron/training/global_vars.py +++ b/megatron/training/global_vars.py @@ -128,7 +128,8 @@ def set_global_variables(args, build_tokenizer=True): rank=args.rank, global_batch_size=args.global_batch_size, micro_batch_size=args.micro_batch_size, - data_parallel_size=args.data_parallel_size, + # Full DP x gtp_remat degree (args.data_parallel_size is the gtp_remat-excluded replicate). + data_parallel_size=args.data_parallel_size * args.gtp_weight_remat_size, decrease_batch_size_if_needed=args.decrease_batch_size_if_needed, step_batch_size_schedule=args.step_batch_size_schedule, seq_length=args.seq_length, diff --git a/megatron/training/initialize.py b/megatron/training/initialize.py index 53c030f39bf..87d6aa65b03 100644 --- a/megatron/training/initialize.py +++ b/megatron/training/initialize.py @@ -359,12 +359,24 @@ def _initialize_distributed(get_embedding_ranks, get_position_embedding_ranks, s if mpu.model_parallel_is_initialized(): print("model parallel is already initialized") else: + if args.gtp_weight_remat_size > 1 or args.expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + assert HAVE_GTP, ( + "GTP requires TransformerEngine >= 2.19. " + "Set both --gtp_remat-weight-remat-size and " + "--expert-generalized-tensor-parallel-remat-size to 1 to disable GTP." + ) mpu.initialize_model_parallel( args.tensor_model_parallel_size, args.pipeline_model_parallel_size, args.virtual_pipeline_model_parallel_size, pipeline_model_parallel_comm_backend=args.pipeline_model_parallel_comm_backend, use_sharp=args.use_sharp, + # GTP_remat/EGTP_remat need world divisible by TP*PP*CP*GTP_remat (expert grid + # by ETP*EP*PP*EGTP_remat). Inactive when the remat sizes are 1. + gtp_remat_size=args.gtp_weight_remat_size, + expert_gtp_remat_size=args.expert_gtp_weight_remat_size, context_parallel_size=args.context_parallel_size, hierarchical_context_parallel_sizes=args.hierarchical_context_parallel_sizes, hybrid_context_parallel=args.hybrid_context_parallel, @@ -384,6 +396,10 @@ def _initialize_distributed(get_embedding_ranks, get_position_embedding_ranks, s f"> initialized tensor model parallel with size " f"{mpu.get_tensor_model_parallel_world_size()}" ) + print_rank_0( + f"> initialized gtp weight remat with size " + f"{mpu.get_gtp_weight_remat_world_size()}" + ) print_rank_0( f"> initialized pipeline model parallel with size " f"{mpu.get_pipeline_model_parallel_world_size()}" diff --git a/megatron/training/models/dist_utils.py b/megatron/training/models/dist_utils.py index 5853f2eff6f..ac4575deb68 100644 --- a/megatron/training/models/dist_utils.py +++ b/megatron/training/models/dist_utils.py @@ -27,7 +27,7 @@ from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.transformer import MegatronModule, TransformerConfig from megatron.core.transformer.module import Float16Module -from megatron.core.utils import get_model_config +from megatron.core.utils import get_model_config, get_pg_rank try: @@ -203,7 +203,7 @@ def _print_num_params(model: list[MegatronModule], pg_collection: ProcessGroupCo """Print the number of parameters in the model on rank 0. Only prints on data parallel rank 0 to avoid duplicate output. - Shows parameter count per (tensor parallel, pipeline parallel) rank. + Shows parameter count per (tensor parallel, gtp_remat, pipeline parallel) rank. Args: model: List of model modules to count parameters from @@ -211,8 +211,9 @@ def _print_num_params(model: list[MegatronModule], pg_collection: ProcessGroupCo """ if (pg_collection.dp.rank() == 0) and (pg_collection.cp.rank() == 0): print( - " > number of parameters on (tensor, pipeline) model parallel rank ({}, {}): {}".format( + " > number of parameters on (tensor, gtp_remat, pipeline) model parallel rank ({}, {}, {}): {}".format( pg_collection.tp.rank(), + get_pg_rank(pg_collection.gtp_remat), pg_collection.pp.rank(), sum([sum([p.nelement() for p in model_module.parameters()]) for model_module in model]), ), diff --git a/megatron/training/training.py b/megatron/training/training.py index 44e24acd4ae..66e9bfed9f8 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -114,6 +114,7 @@ get_rerun_state_machine, ) from megatron.core.resharding.refit import swap_model_weights +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP from megatron.core.transformer.cuda_graphs import TECudaGraphHelper from megatron.core.transformer.experimental_attention_variant.dsa import DSAIndexerLossLoggingHelper from megatron.core.transformer.module import Float16Module @@ -149,7 +150,7 @@ set_jit_fusion_options, write_args_to_tensorboard, ) -from megatron.training.utils import is_hybrid_model +from megatron.training.utils import is_gtp_remat_active, is_hybrid_model # Local. from . import ft_integration, one_logger_utils @@ -1782,9 +1783,10 @@ def build_model(): ) if get_pg_rank(pg_collection.dp) == 0 and get_pg_rank(pg_collection.cp) == 0: print( - ' > number of parameters on (tensor, pipeline) ' - 'model parallel rank ({}, {}): {}'.format( + ' > number of parameters on (tensor, gtp_weight_remat, pipeline) ' + 'model parallel rank ({}, {}, {}): {}'.format( get_pg_rank(pg_collection.tp), + get_pg_rank(pg_collection.gtp_remat), get_pg_rank(pg_collection.pp), num_parameters, ), @@ -2042,9 +2044,35 @@ def _build_model_wrapper(wrap_with_ddp: bool): assert model_provider_func is not None, "Must provide a model config via config_container or a model_provider_func." return get_model(model_provider_func, model_type, wrap_with_ddp=wrap_with_ddp, pg_collection=pg_collection) + # Configure GTP weight-remat padding/loss reduction before model construction (pad + # alignment governs how dim-0 shards are built). Placed here (not in get_model) so it + # also covers the config-container builder path, which does not call get_model. + if is_gtp_remat_active(args): + from megatron.core.tensor_parallel.gtp_api import configure_gtp_remat_from_recipe + + configure_gtp_remat_from_recipe( + fp4=getattr(args, 'fp4', None) is not None, + fp8_recipe=getattr(args, 'fp8_recipe', None), + fp8=getattr(args, 'fp8', None) is not None, + calculate_per_token_loss=getattr(args, 'calculate_per_token_loss', False), + ) + model = _build_model_wrapper(wrap_with_ddp) unwrapped_model = unwrap_model(model) + # Classify each GTP param's prefetch chain after model build + DDP wrap, before the + # first forward. Placed here (not in get_model) so it also covers the config-container + # builder path. + if is_gtp_remat_active(args): + from megatron.core.tensor_parallel.gtp_api import classify_gtp_remat_chains + + classify_gtp_remat_chains( + model, + cuda_graph_modules=getattr(args, 'cuda_graph_modules', None), + moe_shared_expert_overlap=getattr(args, 'moe_shared_expert_overlap', False), + cuda_graph_impl=getattr(args, 'cuda_graph_impl', 'none'), + ) + if args.logits_save_dir is not None: from megatron.training.distillation import LogitsSaverHooks @@ -2162,7 +2190,9 @@ def _build_model_wrapper(wrap_with_ddp: bool): and args.ckpt_format == "torch_dist", tp_group=ckpt_pgc.tp if ckpt_pgc is not None else None, pp_group=ckpt_pgc.pp if ckpt_pgc is not None else None, - dp_cp_group=ckpt_pgc.dp_cp if ckpt_pgc is not None else None, + # Replica_id must match the save path (see save_checkpoint_and_time): use the + # gtp_remat-inclusive group, not replicate dp_cp, or gtp_remat peers collide. + dp_cp_group=getattr(ckpt_pgc, "dp_cp_gtp_remat", None), dp_group=ckpt_pgc.dp if ckpt_pgc is not None else None, expt_dp_group=ckpt_pgc.expt_dp if ckpt_pgc is not None else None, rng_state_key_prefix=getattr(unwrapped_model[0], "rng_state_key_prefix", ""), @@ -2448,7 +2478,9 @@ def _save_state_dict(attr_name, label): f"model pg_collection used by train_step must define {_required}" ) mp_group = pg_collection.mp - dp_cp_group = pg_collection.dp_cp + # gtp_remat-inclusive: the reported global per-token loss must cover gtp_remat peers' distinct + # tokens (replicate dp_cp would report a 1/gtp_remat subsample -> per-step noisy). Display-only. + dp_cp_group = getattr(pg_collection, 'dp_cp_gtp_remat', None) or pg_collection.dp_cp is_last_stage = is_pp_last_stage(pg_collection.pp) # when freezing sub-models we may have a mixture of successful and unsucessful ranks, # so we must gather across mp ranks @@ -2468,7 +2500,15 @@ def _save_state_dict(attr_name, label): # Update learning rate. if update_successful: - increment = get_num_microbatches() * args.micro_batch_size * args.data_parallel_size + # data_parallel_size excludes the GTP-remat axis (it's folded into total_model_size at + # arguments.py:446); each gtp-remat peer consumes a distinct microbatch, so multiply it + # back in for the sample count. + increment = ( + get_num_microbatches() + * args.micro_batch_size + * args.data_parallel_size + * args.gtp_weight_remat_size + ) opt_param_scheduler.step(increment=increment) skipped_iter = 0 else: @@ -2599,8 +2639,15 @@ def training_log( if args.perform_rl_step: timers_to_log.extend(RL_LOGGABLE_TIMER_NAMES) - # Calculate batch size. - batch_size = args.micro_batch_size * args.data_parallel_size * get_num_microbatches() + # Calculate batch size. data_parallel_size excludes the GTP-remat axis (it's folded into + # total_model_size at arguments.py:446); each gtp-remat peer consumes a distinct microbatch, + # so multiply it back in for the global sample count. + batch_size = ( + args.micro_batch_size + * args.data_parallel_size + * args.gtp_weight_remat_size + * get_num_microbatches() + ) # Track app tag & app tag ID one_logger_utils.track_app_tag(batch_size, args.world_size, args.seq_length) @@ -3006,7 +3053,8 @@ def save_checkpoint_and_time( tp_group = getattr(ckpt_pgc, "tp", None) if ckpt_pgc is not None else None pp_group = getattr(ckpt_pgc, "pp", None) if ckpt_pgc is not None else None dp_group = getattr(ckpt_pgc, "dp", None) if ckpt_pgc is not None else None - dp_cp_group = getattr(ckpt_pgc, "dp_cp", None) if ckpt_pgc is not None else None + # Replica_id needs the gtp_remat-inclusive group (dp_cp_gtp_remat), not replicate dp_cp. + dp_cp_group = getattr(ckpt_pgc, "dp_cp_gtp_remat", None) if ckpt_pgc is not None else None expt_dp_group = getattr(ckpt_pgc, "expt_dp", None) if ckpt_pgc is not None else None # Per-grid rng key namespace set by a multi-grid model; '' for stock single-grid. rng_state_key_prefix = getattr(unwrap_model(model)[0], "rng_state_key_prefix", "") @@ -3376,12 +3424,15 @@ def train( ) def _dp_world_size(): + # Full DP x gtp_remat degree (num_microbatches spans the full data-distribution axis). + gtp_remat = args.gtp_weight_remat_size if lang_pgc is not None: - return lang_pgc.dp.size() + return lang_pgc.dp.size() * gtp_remat if mpu.model_parallel_is_initialized(): return mpu.get_data_parallel_world_size() - # args.data_parallel_size equals the language (llm) dp on all ranks (entry validate_args). - return args.data_parallel_size + # args.data_parallel_size is the language (llm) dp on all ranks (set in validate_args) and + # excludes gtp_remat, so scale by gtp_remat to span the full data-distribution axis. + return args.data_parallel_size * gtp_remat # IMPORTANT FIX: For RL training, reinitialize the microbatch calculator with the correct configuration if args.perform_rl_step: @@ -4122,7 +4173,12 @@ def evaluate( # make validation batch size independent from training batch size eval_batch_size = args.eval_global_batch_size eval_micro_batch_size = args.eval_micro_batch_size - eval_num_microbatches = eval_batch_size // (eval_micro_batch_size * args.data_parallel_size) + # data_parallel_size excludes the GTP-remat axis (it's folded into total_model_size at + # arguments.py:446); each gtp-remat peer consumes a distinct microbatch, so include it in the + # global sample breadth we divide out to recover the microbatch count. + eval_num_microbatches = eval_batch_size // ( + eval_micro_batch_size * args.data_parallel_size * args.gtp_weight_remat_size + ) forward_backward_func = get_forward_backward_func(schedule_pg_collection=pg_collection) # Reductions source per-rank groups from the model (encoder rank -> encoder groups). eval_pgc = get_attr_wrapped_model(model[0], "pg_collection") diff --git a/megatron/training/utils/__init__.py b/megatron/training/utils/__init__.py index 15cd4b26d4f..04992e26652 100644 --- a/megatron/training/utils/__init__.py +++ b/megatron/training/utils/__init__.py @@ -16,6 +16,7 @@ is_last_rank, print_rank_last, is_hybrid_model, + is_gtp_remat_active, is_first_or_last_pipeline_stage, get_device_arch_version, get_blend_and_blend_per_split, diff --git a/megatron/training/utils/common_utils.py b/megatron/training/utils/common_utils.py index 27046ab4b31..4e7d55a4577 100644 --- a/megatron/training/utils/common_utils.py +++ b/megatron/training/utils/common_utils.py @@ -48,6 +48,35 @@ from megatron.training import get_adlr_autoresume, get_args, get_timers + +def _compute_norm_2(params_list): + """Compute squared L2 norm of a list of tensors. Returns a CUDA scalar.""" + if len(params_list) > 0: + dummy_overflow_buf = torch.tensor([0], dtype=torch.int, device='cuda') + norm, _ = multi_tensor_applier( + multi_tensor_l2norm, dummy_overflow_buf, [params_list], False, + ) + return norm * norm + return torch.zeros((1,), dtype=torch.float32, device='cuda') + + +def _get_param_data(param, force_create_fp32_copy, bf16): + """Extract the appropriate data tensor from a param for norm computation. + + Returns (data_tensor, is_sharded) where is_sharded indicates the param has + a sharded main_param from the distributed optimizer. + """ + if bf16: + if not force_create_fp32_copy and hasattr(param, 'main_param'): + if getattr(param, 'main_param_sharded', False): + if param.main_param is not None: + return param.main_param, True + return None, True + return param.main_param, False + return param.data.float(), False + return param.data, False + + def calc_params_l2_norm(model, force_create_fp32_copy=False): """Calculate l2 norm of parameters""" args = get_args() @@ -70,129 +99,112 @@ def calc_params_l2_norm(model, force_create_fp32_copy=False): return calc_dtensor_params_l2_norm(params) - # Seperate moe and dense params - params_data = [] - moe_params_data = [] - sharded_params_data = [] - data_parallel_group = None + # 8 buckets: 4 categories × (non-sharded, sharded optimizer main_param). + # Each category needs different reduction groups. + params_data = [] # Dense, non-sharded + sharded_params_data = [] # Dense, sharded → reduce over dp_cp + gtp_params_data = [] # GTP_remat, non-sharded + gtp_sharded_params_data = [] # GTP_remat, sharded → reduce over dp_cp + moe_params_data = [] # MoE, non-sharded + moe_sharded_params_data = [] # MoE, sharded → reduce over expert_dp + moe_gtp_params_data = [] # MoE-GTP_remat, non-sharded + moe_gtp_sharded_params_data = [] # MoE-GTP_remat sharded → expert_dp + + gtp_rank = mpu.get_gtp_weight_remat_rank() + egtp_rank = mpu.get_expert_gtp_weight_remat_rank() for model_chunk in model: for param in model_chunk.parameters(): - data_parallel_group = get_data_parallel_group_if_dtensor(param, data_parallel_group) - is_not_tp_duplicate = param_is_not_tensor_parallel_duplicate(param) - if not is_not_tp_duplicate: + is_gtp = getattr(param, 'is_gtp_weight_remat', False) + + # Filter TP duplicates. GTP_remat params are always unique across TP ranks + # so skip this check for them. + if not is_gtp and not param_is_not_tensor_parallel_duplicate(param): continue - assert is_not_tp_duplicate - if not getattr(param, 'allreduce', True): + is_expert = not getattr(param, 'allreduce', True) + + # Filter GTP_remat duplicates: non-GTP_remat params replicate across GTP_remat ranks. + if is_expert: + if not is_gtp and egtp_rank != 0: + continue + else: + if not is_gtp and gtp_rank != 0: + continue + + # Route to the correct bucket. + if is_expert: assert param_is_not_shared(param) param = to_local_if_dtensor(param) - if args.bf16: - if not force_create_fp32_copy and hasattr(param, 'main_param'): - if getattr(param, 'main_param_sharded', False): - if param.main_param is not None: - sharded_params_data.append(param.main_param) - else: - moe_params_data.append(param.main_param) - else: - # Fallback to original logic of making a fp32 copy of the - # parameter if `.main_param` attribute is not available. - moe_params_data.append(param.data.float()) + data, is_sharded = _get_param_data(param, force_create_fp32_copy, args.bf16) + if data is None: + continue + if is_gtp: + (moe_gtp_sharded_params_data if is_sharded else moe_gtp_params_data).append(data) else: - moe_params_data.append(param.data) + (moe_sharded_params_data if is_sharded else moe_params_data).append(data) else: if param_is_not_shared(param): param = to_local_if_dtensor(param) - if args.bf16: - if not force_create_fp32_copy and hasattr(param, 'main_param'): - if getattr(param, 'main_param_sharded', False): - if param.main_param is not None: - sharded_params_data.append(param.main_param) - else: - params_data.append(param.main_param) - else: - # Fallback to original logic of making a fp32 copy of the - # parameter if `.main_param` attribute is not available. - params_data.append(param.data.float()) + data, is_sharded = _get_param_data(param, force_create_fp32_copy, args.bf16) + if data is None: + continue + if is_gtp: + (gtp_sharded_params_data if is_sharded else gtp_params_data).append(data) else: - params_data.append(param.data) - - # Calculate norm. - dummy_overflow_buf = torch.tensor([0], dtype=torch.int, device='cuda') - if len(params_data) > 0: - norm, _ = multi_tensor_applier( - multi_tensor_l2norm, dummy_overflow_buf, [params_data], False # no per-parameter norm. - ) - norm_2 = norm * norm - else: - norm_2 = torch.zeros((1,), dtype=torch.float32, device='cuda') - - if data_parallel_group is not None: - torch.distributed.all_reduce( - norm_2, op=torch.distributed.ReduceOp.SUM, group=data_parallel_group - ) - - # Add norm contribution from params with sharded main_params. These norms need to be - # accumulated across the DP group since the main parameters are sharded because - # of distributed optimizer. - if len(sharded_params_data) > 0: - dummy_overflow_buf = torch.tensor([0], dtype=torch.int, device='cuda') - sharded_norm, _ = multi_tensor_applier( - multi_tensor_l2norm, - dummy_overflow_buf, - [sharded_params_data], - False, # no per-parameter norm. - ) - sharded_norm_2 = sharded_norm * sharded_norm - else: - sharded_norm_2 = torch.zeros((1,), dtype=torch.float32, device='cuda') - # Sum over all DP groups, including CP since distributed optimizer state is - # sharded jointly over DP+CP. - torch.distributed.all_reduce( + (sharded_params_data if is_sharded else params_data).append(data) + + # --- Compute local norm^2 for each bucket --- + params_norm_2 = _compute_norm_2(params_data) + sharded_norm_2 = _compute_norm_2(sharded_params_data) + gtp_norm_2 = _compute_norm_2(gtp_params_data) + gtp_sharded_norm_2 = _compute_norm_2(gtp_sharded_params_data) + moe_norm_2 = _compute_norm_2(moe_params_data) + moe_sharded_norm_2 = _compute_norm_2(moe_sharded_params_data) + moe_gtp_norm_2 = _compute_norm_2(moe_gtp_params_data) + moe_gtp_sharded_norm_2 = _compute_norm_2(moe_gtp_sharded_params_data) + + def _sum_reduce(tensor, group): + torch.distributed.all_reduce(tensor, op=torch.distributed.ReduceOp.SUM, group=group) + + # --- Sharded optimizer DP reductions (each category uses its own group) --- + # Reduce over the gtp_remat-EXCLUDED replicate group (with_gtp_remat=False): the model-parallel + # reduce below already spans the gtp_remat axis, so a gtp_remat-inclusive group here would + # over-count by gtp_remat. No-op for non-GTP_remat runs. + _sum_reduce( sharded_norm_2, - op=torch.distributed.ReduceOp.SUM, - group=mpu.get_data_parallel_group(with_context_parallel=True) + mpu.get_data_parallel_group(with_context_parallel=True, with_gtp_remat=False), ) - norm_2 += sharded_norm_2 - - # Add norm contribution from expert layers in MoEs. - if len(moe_params_data) > 0: - moe_norm, _ = multi_tensor_applier( - multi_tensor_l2norm, - dummy_overflow_buf, - [moe_params_data], - False, # no per-parameter norm. - ) - moe_norm_2 = moe_norm * moe_norm + _sum_reduce( + gtp_sharded_norm_2, + mpu.get_data_parallel_group(with_context_parallel=True, with_gtp_remat=False), + ) + _sum_reduce(moe_sharded_norm_2, mpu.get_expert_data_parallel_group(with_gtp_remat=False)) + _sum_reduce(moe_gtp_sharded_norm_2, mpu.get_expert_data_parallel_group(with_gtp_remat=False)) - # Account for MoE norm even if current rank doesn't have any expert params to prevent - # hang in models with un-even numbers of MoE layers. - # See details in https://gitlab-master.nvidia.com/ADLR/megatron-lm/-/issues/409 - else: - moe_norm_2 = torch.zeros_like(norm_2) + # --- Combine dense + GTP_remat norms --- + # model_parallel group = TP×GTP_remat×PP, so GTP_remat reduction is implicit. + norm_2 = params_norm_2 + sharded_norm_2 + gtp_norm_2 + gtp_sharded_norm_2 - # Reduce norm across model parallel groups (dense and expert). - # Dense params should sum across all model-parallel GPUs (tensor + pipeline). + # --- Combine MoE + MoE-GTP_remat norms --- + # expert_model_parallel = TP×EP×PP (does NOT include EGTP_remat), so we need + # an explicit EGTP_remat reduction for MoE-GTP_remat before the model-parallel reduce. + moe_gtp_combined_norm_2 = moe_gtp_norm_2 + moe_gtp_sharded_norm_2 + _sum_reduce(moe_gtp_combined_norm_2, mpu.get_expert_gtp_weight_remat_group()) + moe_total_norm_2 = moe_norm_2 + moe_sharded_norm_2 + moe_gtp_combined_norm_2 + + # --- Model-parallel reductions --- dense_reduce_group = mpu.get_model_parallel_group() - ranks_in_dense_reduce_group = torch.distributed.get_process_group_ranks(dense_reduce_group) - # Expert params should sum across all model-parallel GPUs (expert + tensor + pipeline). expert_reduce_group = mpu.get_expert_tensor_model_pipeline_parallel_group() + ranks_in_dense_reduce_group = torch.distributed.get_process_group_ranks(dense_reduce_group) ranks_in_expert_reduce_group = torch.distributed.get_process_group_ranks(expert_reduce_group) - # If dense and expert reduce groups are the same, sum then reduce. if ranks_in_dense_reduce_group == ranks_in_expert_reduce_group: - norm_2 += moe_norm_2 - torch.distributed.all_reduce( - norm_2, op=torch.distributed.ReduceOp.SUM, group=dense_reduce_group - ) - # If dense and expert reduce groups are different, reduce then sum. + norm_2 += moe_total_norm_2 + _sum_reduce(norm_2, dense_reduce_group) else: - torch.distributed.all_reduce( - norm_2, op=torch.distributed.ReduceOp.SUM, group=dense_reduce_group - ) - torch.distributed.all_reduce( - moe_norm_2, op=torch.distributed.ReduceOp.SUM, group=expert_reduce_group - ) - norm_2 += moe_norm_2 + _sum_reduce(norm_2, dense_reduce_group) + _sum_reduce(moe_total_norm_2, expert_reduce_group) + norm_2 += moe_total_norm_2 return norm_2.item() ** 0.5 @@ -463,6 +475,14 @@ def is_hybrid_model(args): return args.hybrid_layer_pattern is not None +def is_gtp_remat_active(args): + """Returns True if GTP weight-remat is enabled on the decoder or expert axis.""" + return ( + getattr(args, 'gtp_weight_remat_size', 1) > 1 + or getattr(args, 'expert_gtp_weight_remat_size', 1) > 1 + ) + + def is_first_or_last_pipeline_stage(vp_stage): """Return True if on first or last pipeline stage, taking into account virtual pipeline parallelism.""" diff --git a/tests/unit_tests/distributed/test_param_and_grad_buffer.py b/tests/unit_tests/distributed/test_param_and_grad_buffer.py index 445f9f0bcda..e00509067a5 100644 --- a/tests/unit_tests/distributed/test_param_and_grad_buffer.py +++ b/tests/unit_tests/distributed/test_param_and_grad_buffer.py @@ -936,6 +936,55 @@ def test_nvfp4_varied_param_sizes(self): assert buffer.param_index_map[params[0]] == (small_unpacked_start, small_unpacked_end, 0) +@pytest.mark.parametrize("num_distributed_optimizer_instances", [1, 2]) +def test_optimizer_shards_cover_every_param(num_distributed_optimizer_instances: int): + """Every parameter must be owned by exactly one rank of the buffer's data-parallel group. + + ``DistributedOptimizer._build_model_gbuf_range`` splits each bucket into N shards and gives + rank ``r`` the r-th one, while the reduce-scatter/all-gather runs over the buffer's + ``data_parallel_group``. N must therefore equal that group's size. If N is larger (e.g. taken + from a layout sized by the full DP world while the group is intra-optimizer-instance), the + trailing shards belong to no rank: those params are never updated by the optimizer and vanish + from grad-norm, num-zeros and params-norm, which are summed over owned shards only. + """ + Utils.initialize_model_parallel( + num_distributed_optimizer_instances=num_distributed_optimizer_instances + ) + + _, param_and_grad_buffer, _ = get_model_and_buffers( + input_dim=100, + output_dim=100, + num_layers=2, + bias=True, + shared_embedding=False, + bucket_size=None, + use_distributed_optimizer=True, + overlap_grad_reduce=False, + average_in_collective=False, + num_distributed_optimizer_instances=num_distributed_optimizer_instances, + ) + + # Sum the parameter elements this rank owns across every bucket. + owned_numel = 0 + for bucket_index in range(len(param_and_grad_buffer.buckets)): + param_map = DistributedOptimizer._build_model_gbuf_range( + param_and_grad_buffer, bucket_index + )["param_map"] + for param_ranges in param_map.values(): + owned_numel += param_ranges["param"].size + + owned_total = torch.tensor([owned_numel], dtype=torch.long, device='cuda') + torch.distributed.all_reduce(owned_total, group=param_and_grad_buffer.data_parallel_group) + + expected_numel = sum(param.numel() for param in param_and_grad_buffer.params) + assert owned_total.item() == expected_numel, ( + f"Optimizer shards cover {owned_total.item()} of {expected_numel} param elements; " + f"{expected_numel - owned_total.item()} elements are owned by no rank" + ) + + Utils.destroy_model_parallel() + + @pytest.mark.parametrize("use_distributed_optimizer", [False, True]) def test_expert_parallel_params_get_separate_buffers(use_distributed_optimizer: bool): """Verify that expert-parallel params (allreduce=False) land in separate buffers diff --git a/tests/unit_tests/generalized_tensor_parallel/__init__.py b/tests/unit_tests/generalized_tensor_parallel/__init__.py new file mode 100644 index 00000000000..b5dff7b5663 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/__init__.py @@ -0,0 +1 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. diff --git a/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py b/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py new file mode 100644 index 00000000000..259cc6ed0d5 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Shared fixtures and helpers for all GTP unit tests. +""" + +import pytest +import torch +import transformer_engine.pytorch as te +from transformer_engine.pytorch import is_mxfp8_available, is_nvfp4_available +from transformer_engine.pytorch.quantization import FP8GlobalStateManager + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.test_utilities import Utils + +# --------------------------------------------------------------------------- +# Fixtures (import into each test module so pytest discovers them) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module", autouse=True) +def _torchrun_dist_init(): + """Initialize the torchrun-managed dist group once per module.""" + Utils.initialize_model_parallel() + yield + Utils.destroy_model_parallel() + + +@pytest.fixture(autouse=True) +def reset_fp8_state(): + yield + FP8GlobalStateManager.reset() + + +@pytest.fixture(autouse=True) +def reset_gtp_globals(): + """Reset GTP mutable class-level state between tests.""" + yield + GTPShardedParam._chain_state = {} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_distributed(fn, required_world_size: int, *args) -> None: + """Run ``fn(rank, world_size, port, *args)`` on every torchrun rank. + + ``port`` is unused (dist already initialized by torchrun) but kept so + worker signatures don't need editing. + """ + actual_world_size = torch.distributed.get_world_size() + if actual_world_size != required_world_size: + pytest.skip( + f"Requires world_size={required_world_size}, " + f"got {actual_world_size} (launch with torchrun --nproc-per-node={required_world_size})" + ) + fn(torch.distributed.get_rank(), actual_world_size, None, *args) + + +def _requires_multi_gpu(n: int = 4): + if torch.cuda.device_count() < n: + pytest.skip(f"Requires at least {n} CUDA devices") + + +def _requires_mxfp8(): + available, reason = is_mxfp8_available(return_reason=True) + if not available: + pytest.skip(f"MXFP8 not available: {reason}") + + +def _requires_nvfp4(): + if not is_nvfp4_available(): + pytest.skip("NVFP4 not available (requires compute capability >= 10.0)") + + +def _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype=torch.bfloat16, **kwargs): + """Construct a bias-free GTP-sharded te.Linear on CUDA. + + Mirrors the production integration (extensions/transformer_engine.py): TE has no GTP + construction hooks, so the module is built stock, ``module.gtp_remat_size`` (the forward-gather + gate) is stamped post-init, and the BF16 weight is sliced by ``wrap_module_params_gtp``. + """ + from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp + + layer = te.Linear( + in_features=in_f, + out_features=out_f, + bias=False, + params_dtype=dtype, + device="cuda", + **kwargs, + ) + layer.gtp_remat_size = gtp_remat_group.size() + wrap_module_params_gtp(layer, layer.weight_names, gtp_remat_group) + return layer + + +def _make_gtp_remat_grouped_linear( + num_gemms, in_f, out_f, gtp_remat_group, dtype=torch.bfloat16, **kwargs +): + """Construct a bias-free GTP-sharded te.GroupedLinear on CUDA (post-init slice, see + _make_gtp_linear).""" + from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp + + layer = te.GroupedLinear( + num_gemms=num_gemms, + in_features=in_f, + out_features=out_f, + bias=False, + params_dtype=dtype, + device="cuda", + **kwargs, + ) + layer.gtp_remat_size = gtp_remat_group.size() + # GroupedLinear exposes per-expert weight0..weight{num_gemms-1} (it no longer declares + # weight_names); build the names here to match attach_gtp_to_presharded_module. + weight_names = [f"weight{idx}" for idx in range(num_gemms)] + wrap_module_params_gtp(layer, weight_names, gtp_remat_group, is_grouped=True) + return layer + + +def _restore_gtp_shards_and_init_main_grad(module, saved_weights, gtp_rank, dtype=torch.bfloat16): + """Load saved full weights into a GTP_remat_size>1 module and prep it for backward. + + GTPShardedParams receive their ``gtp_rank`` axis-0 shard; replicated params get the full + tensor. Then pre-allocate ``main_grad`` on every GTPShardedParam (required before the first + backward). Used by the dense two-phase baseline-vs-GTP tests. + """ + for name, p in module.named_parameters(): + full = saved_weights[name] + if isinstance(p, GTPShardedParam): + shard_size = p.shape[0] + p.data.copy_(full[gtp_rank * shard_size : (gtp_rank + 1) * shard_size]) + else: + p.data.copy_(full) + for p in module.parameters(): + if isinstance(p, GTPShardedParam): + p.main_grad = torch.zeros(p.shape, dtype=dtype, device='cuda') + + +def _assert_loss_trajectories_match(baseline_losses, test_losses, steps, label="gtp_remat"): + """On rank 0: print and assert two per-step loss trajectories match. + + GTP (ZeRO-3-like) reduces grads with a reduce-scatter-sum while the no-GTP baseline + all-reduces; in BF16 these differ only in reduction order, so the trajectories track to + BF16 precision (observed max |diff| ~1e-2 over 10 steps) rather than bitwise. The tolerance + is set for that BF16 noise floor and still trips on any real GTP sharding bug, which diverges + by O(loss). (The sibling grad-correctness test likewise checks ~1e-2-scale grad error.) + """ + assert ( + len(baseline_losses) == len(test_losses) == steps + ), f"loss counts: baseline={len(baseline_losses)} {label}={len(test_losses)} want {steps}" + for step, (lb, lt) in enumerate(zip(baseline_losses, test_losses)): + print(f"Step {step:2d}: baseline={lb:.6f} {label}={lt:.6f}", flush=True) + torch.testing.assert_close( + torch.tensor(test_losses), torch.tensor(baseline_losses), atol=5e-2, rtol=5e-2 + ) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_attention_gtp.py b/tests/unit_tests/generalized_tensor_parallel/test_attention_gtp.py new file mode 100644 index 00000000000..bfa27e9ae46 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_attention_gtp.py @@ -0,0 +1,232 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Integration tests for GTP + Attention (TransformerLayer) correctness. + +Test groups +----------- +TestAttentionGTPCorrectness - GTP TransformerLayer loss trajectory matches baseline (no-GTP) + over 10 training steps using MXFP8 and Nemotron3-Super proxy + hyperparameters. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from transformer_engine.pytorch import fp8_autocast + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _assert_loss_trajectories_match, + _restore_gtp_shards_and_init_main_grad, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +# --------------------------------------------------------------------------- +# Attention GTP_remat correctness: per-step loss trajectory baseline vs GTP_remat=4 +# --------------------------------------------------------------------------- + + +def _worker_attention_gtp_correctness(rank, world_size, port): + """Verify GTP TransformerLayer produces the same per-step loss as a no-GTP baseline. + + Phase 1 — GTP_remat_size=1, DP=4: + All 4 ranks hold the full model and process identical inputs. Gradients + are identical across ranks (no all-reduce needed). Weight update: + param.data -= lr * param.grad + + Phase 2 — GTP_remat_size=4, DP=1: + All linear weights (QKV proj, output proj, MLP fc1/fc2) sharded across + 4 ranks. After backward, wgrad reduce-scatter sums each shard's wgrad: + main_grad[rank_i] = gtp_remat_size * dW[shard_i] + The optimizer divides by gtp_remat_size to recover the per-element gradient: + param.data -= (lr / gtp_remat_size) * param.main_grad + + Both phases use identical initial weights (synced from rank 0 in Phase 1, + restored as shards in Phase 2) and identical step-by-step inputs. + + Nemotron3-Super proxy hyperparameters: + hidden=4096, num_heads=32 (head_dim=128), ffn_hidden_size=16384 (=4xhidden) + MXFP8 alignment with GTP_remat_size=4: + QKV shard: 3x4096/4=3072, 3072%32=0 ✓; proj shard: 4096/4=1024, 1024%32=0 ✓ + fc1 shard: 16384/4=4096, 4096%32=0 ✓; fc2 shard: 4096/4=1024, 1024%32=0 ✓ + """ + from transformer_engine.pytorch.quantization import FP8GlobalStateManager + + from megatron.core import parallel_state as ps + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + from megatron.core.transformer.transformer_config import TransformerConfig + + HIDDEN = 4096 + NUM_HEADS = 32 # head_dim = HIDDEN / NUM_HEADS = 128 + FFN_HIDDEN = 16384 # = 4 x HIDDEN (default GPT FFN ratio) + NUM_LAYERS = 2 + SEQ = 32 + BATCH = 1 + LR = 0.01 + STEPS = 10 + dtype = torch.bfloat16 + + def make_config(): + return TransformerConfig( + num_attention_heads=NUM_HEADS, + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + ffn_hidden_size=FFN_HIDDEN, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + + def make_transformer_stack(config, pg_collection): + spec = get_gpt_layer_with_transformer_engine_spec() + return torch.nn.ModuleList( + [ + spec.module( + config, spec.submodules, layer_number=i + 1, pg_collection=pg_collection + ) + for i in range(NUM_LAYERS) + ] + ) + + def run_step(layers, x): + with fp8_autocast(enabled=False): + for layer in layers: + x, _ = layer(x, attention_mask=None) + return x.mean() + + # ------------------------------------------------------------------------- + # Phase 1: Baseline — GTP_remat=1 (DP=4) + # ------------------------------------------------------------------------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + model_parallel_cuda_manual_seed(42) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + layers = make_transformer_stack(config, pg_collection) + for layer in layers: + layer.cuda() + + # Verify baseline has no GTP_remat sharding (gtp_remat_size=1 should leave plain parameters). + assert not any( + isinstance(p, GTPShardedParam) for p in layers.parameters() + ), "Baseline GTP_remat_size=1 stack should have no GTPShardedParam" + + # Synchronize weights from rank 0 across all DP ranks. + for p in layers.parameters(): + dist.broadcast(p.data, src=0) + + # Save initial weights; will be used to initialize the GTP_remat model identically. + saved_weights = {n: p.data.clone() for n, p in layers.named_parameters()} + + baseline_losses = [] + for step in range(STEPS): + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + loss = run_step(layers, x) + if rank == 0: + baseline_losses.append(loss.item()) + + loss.backward() + with torch.no_grad(): + for p in layers.parameters(): + if p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + FP8GlobalStateManager.reset() + + # ------------------------------------------------------------------------- + # Phase 2: GTP_remat=4 (DP=1) + # ------------------------------------------------------------------------- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=4 + ) + model_parallel_cuda_manual_seed(42) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + layers_gtp = make_transformer_stack(config, pg_collection) + for layer in layers_gtp: + layer.cuda() + + gtp_remat_group = ps.get_gtp_weight_remat_group() + gtp_remat_size = gtp_remat_group.size() + gtp_rank = gtp_remat_group.rank() + + # Verify GTP_remat is truly active: linear weights must be GTPShardedParam instances. + gtp_params = [p for p in layers_gtp.parameters() if isinstance(p, GTPShardedParam)] + assert ( + len(gtp_params) > 0 + ), "GTP is not active: no GTPShardedParam found in GTP_remat_size=4 transformer stack" + + # Restore initial weights into shards and pre-allocate main_grad for the backward. + _restore_gtp_shards_and_init_main_grad(layers_gtp, saved_weights, gtp_rank, dtype) + + gtp_losses = [] + for step in range(STEPS): + for p in layers_gtp.parameters(): + if isinstance(p, GTPShardedParam): + p.main_grad.zero_() + + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + loss = run_step(layers_gtp, x) + if rank == 0: + gtp_losses.append(loss.item()) + + loss.backward() + + # After RS, main_grad = gtp_remat_size * dW_shard. Divide by gtp_remat_size for baseline. + with torch.no_grad(): + for p in layers_gtp.parameters(): + if isinstance(p, GTPShardedParam): + p.data.sub_((LR / gtp_remat_size) * p.main_grad) + elif p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + # ------------------------------------------------------------------------- + # Compare per-step loss trajectories on rank 0 + # ------------------------------------------------------------------------- + if rank == 0: + _assert_loss_trajectories_match(baseline_losses, gtp_losses, STEPS) + + +class TestAttentionGTPCorrectness: + def test_attention_gtp_loss_trajectory_matches_baseline(self): + """GTP TransformerLayer per-step losses must match no-GTP baseline (atol=1e-5, rtol=1e-5; MXFP8, Nemotron3-Super proxy).""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires at least 4 CUDA devices") + _run_distributed(_worker_attention_gtp_correctness, 4) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py new file mode 100644 index 00000000000..dd044f63adc --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py @@ -0,0 +1,1105 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for Generalized Tensor Parallelism (GTP). + +Scope: sharding math, module wiring, and behavior/regression guards. End-to-end fwd/bwd/loss/grad, +fp8, and checkpoint correctness live in the integration tests (test_gtp_loss_correctness, +test_gtp_grad_correctness, test_attention_gtp, test_mamba_gtp, test_moe_egtp, +test_gtp_fp8_param_gather, test_gtp_dcp), so low-level plumbing smoke tests are not duplicated here. + +Test groups +----------- +- TestGTPSharding - wrap_module_params_gtp: shard content + padding +- TestWrapModuleParams - wrap_module_params_gtp: param replacement + weight_list +- TestLinearGTP / TestLayerNormLinearGTP / TestGroupedLinearGTP - single-layer fwd/bwd +- TestGTPPrefetchChain - linked-list next_w/prev_w wiring +- TestGTPWgradRS - wgrad reduce-scatter shape + multi-layer deferred path +- TestGTPMicrobatches - output consistency across microbatches +- TestMXFP8LinearGTP - Linear + MXFP8 recipe: quantized shard setup, fwd/bwd, padding +- TestGTPGroupSizeOne - wrap_module_params_gtp no-op when gtp_remat_group.size()==1 +- TestGTPPrefetchDisabled - weight_prefetch=False single-pass forward +- TestFuseWgradAccumulation - fuse_wgrad_accumulation=True: wgrad -> main_grad +- TestGTPGradAccumHook - main_grad updated after reduce-scatter backward +- TestWaitAsyncCommsFallback - inline-accumulation fallback when _wgrad_rs_handle is None +- TestGTPDDPBucketAlignment - GTP/regular DDP bucket ends padded for dist-opt alignment +- TestGTPDDPGradReadyWiring - GTP params drive DDP grad-ready via the manual hook, not autograd + +Multi-GPU tests skip when ``torch.distributed.get_world_size()`` != the required world size (4). +""" + +import pytest +import torch +import torch.distributed as dist +import torch.nn as nn + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +import transformer_engine.pytorch as te +from transformer_engine.pytorch import fp8_autocast +from transformer_engine.pytorch.quantized_tensor import QuantizedTensor + +import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module +from megatron.core import parallel_state +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTPShardedParam, + wrap_module_params_gtp, +) +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _make_gtp_linear, + _make_gtp_remat_grouped_linear, + _requires_multi_gpu, + _requires_mxfp8, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + + +class _FakeGroup: + """Minimal mock for a dist process group — used in single-process unit tests.""" + + def __init__(self, size=1, rank=0): + self._size = size + self._rank = rank + + def size(self): + return self._size + + def rank(self): + return self._rank + + +def _worker_sharding_aligned(rank, world_size, port): + K, M = world_size * 32, 16 # K divisible by 16*world_size → no padding + full_weight = torch.arange(K * M, dtype=torch.float32).reshape(K, M).cuda() + dist.broadcast(full_weight, src=0) + + gtp_remat_group = dist.new_group(list(range(world_size))) + mod = nn.Module() + mod.weight = nn.Parameter(full_weight.clone(), requires_grad=False) + wrap_module_params_gtp(mod, ["weight"], gtp_remat_group) + shard = mod.weight + + rows_per_rank = K // world_size + assert shard.shape == (rows_per_rank, M), f"rank {rank}: unexpected shape {shard.shape}" + assert shard.pad_length == 0 + expected = full_weight[rank * rows_per_rank : (rank + 1) * rows_per_rank] + assert torch.allclose(shard.data, expected), f"rank {rank}: shard content mismatch" + + +def _worker_sharding_padding(rank, world_size, port): + alignment = 16 * world_size + K = alignment - 1 # deliberately unaligned + M = 16 + full_weight = torch.ones(K, M, dtype=torch.float32).cuda() + dist.broadcast(full_weight, src=0) + + gtp_remat_group = dist.new_group(list(range(world_size))) + mod = nn.Module() + mod.weight = nn.Parameter(full_weight.clone(), requires_grad=False) + wrap_module_params_gtp(mod, ["weight"], gtp_remat_group) + shard = mod.weight + + padded_K = alignment + rows_per_rank = padded_K // world_size + + if rank == world_size - 1: + assert shard.pad_length > 0 + # The shard tensor holds only the real rows; get_padded_shard() appends zero rows. + padded = shard.get_padded_shard() + assert ( + padded.shape[0] == rows_per_rank + ), f"rank {rank}: expected padded shard {rows_per_rank} rows, got {padded.shape[0]}" + n_real = K - rank * rows_per_rank + assert torch.all(padded[n_real:] == 0), "Padding rows must be zero" + else: + # pad_length is set globally on every rank's shard (slicer attaches the + # global padding amount), so we don't assert anything about it here — + # only the last rank's shard contains the actual padding rows. + assert ( + shard.shape[0] == rows_per_rank + ), f"rank {rank}: expected {rows_per_rank} rows, got {shard.shape[0]}" + + +class TestGTPSharding: + def test_aligned_shard_content(self): + _requires_multi_gpu(4) + _run_distributed(_worker_sharding_aligned, 4) + + def test_unaligned_shard_padding(self): + _requires_multi_gpu(4) + _run_distributed(_worker_sharding_padding, 4) + + +# --------------------------------------------------------------------------- +# wrap_module_params_gtp: param replacement and GroupedLinear weight_list +# --------------------------------------------------------------------------- + + +def _worker_linear_param_replaced(rank, world_size, port): + in_f, out_f = 64, 128 + gtp_remat_group = dist.new_group(list(range(world_size))) + layer = _make_gtp_linear(in_f, out_f, gtp_remat_group) + w = layer.weight + assert isinstance(w, GTPShardedParam), "weight must be GTPShardedParam" + assert w.shape == (out_f // world_size, in_f), f"unexpected shard shape {w.shape}" + assert w.group is gtp_remat_group + + +def _worker_grouped_weight_list(rank, world_size, port): + num_gemms, in_f, out_f = 3, 32, 64 + gtp_remat_group = dist.new_group(list(range(world_size))) + layer = _make_gtp_remat_grouped_linear(num_gemms, in_f, out_f, gtp_remat_group) + w0 = layer.weight0 + assert isinstance(w0, GTPShardedParam) + assert w0.weight_list is not None + assert len(w0.weight_list) == num_gemms + assert [w.expert_idx for w in w0.weight_list] == list(range(num_gemms)) + + +class TestWrapModuleParams: + def test_linear_weight_replaced(self): + _requires_multi_gpu(4) + _run_distributed(_worker_linear_param_replaced, 4) + + def test_grouped_linear_weight_list(self): + _requires_multi_gpu(4) + _run_distributed(_worker_grouped_weight_list, 4) + + +# --------------------------------------------------------------------------- +# Linear forward/backward numerical correctness +# --------------------------------------------------------------------------- + + +def _worker_linear_correctness(rank, world_size, port): + """GTP output == (all-gathered weight) @ input, and dX matches.""" + torch.manual_seed(0) + batch, in_f, out_f = 16, 64, 128 # out_f % (16*world_size)==0 → no padding + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + + # Reconstruct full weight from shards (all-gather) + shard = layer.weight.data.clone() + all_shards = [torch.zeros_like(shard) for _ in range(world_size)] + dist.all_gather(all_shards, shard, group=gtp_remat_group) + full_weight = torch.cat(all_shards, dim=0).float()[:out_f] # strip any padding + + # Shared input across ranks + inp = torch.randn(batch, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + + inp_gtp = inp.clone().requires_grad_(True) + inp_ref = inp.clone().requires_grad_(True) + + # GTP_remat forward + out_gtp = layer(inp_gtp, is_first_microbatch=True) + + # Reference forward + out_ref = inp_ref.float() @ full_weight.T + out_ref = out_ref.to(dtype) + + assert out_gtp.shape == out_ref.shape, f"Shape mismatch {out_gtp.shape} vs {out_ref.shape}" + assert torch.allclose( + out_gtp.float(), out_ref.float(), atol=1e-5, rtol=1e-5 + ), f"Output mismatch max_diff={(out_gtp.float()-out_ref.float()).abs().max():.4f}" + + # wgrad RS path always accumulates into main_grad; allocate before backward. + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + + # Backward: compare input gradient + grad_out = torch.randn_like(out_gtp) + dist.broadcast(grad_out, src=0) + out_gtp.backward(grad_out) + out_ref.backward(grad_out.float()) + + assert inp_gtp.grad is not None + assert torch.allclose( + inp_gtp.grad.float(), inp_ref.grad.float(), atol=1e-5, rtol=1e-5 + ), f"dX mismatch max_diff={(inp_gtp.grad.float()-inp_ref.grad.float()).abs().max():.4f}" + + +class TestLinearGTP: + def test_forward_backward_correctness(self): + _requires_multi_gpu(4) + _run_distributed(_worker_linear_correctness, 4) + + +# --------------------------------------------------------------------------- +# LayerNormLinear forward/backward smoke test +# --------------------------------------------------------------------------- + + +def _worker_layernorm_linear(rank, world_size, port): + torch.manual_seed(0) + seq, batch, in_f, out_f = 4, 2, 64, 128 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = te.LayerNormLinear( + in_features=in_f, out_features=out_f, bias=False, params_dtype=dtype, device="cuda" + ) + # TE construction is GTP-agnostic: gtp_remat_size (forward-gather gate) is stamped and the + # BF16 weight is sliced post-init (Megatron side). + layer.gtp_remat_size = gtp_remat_group.size() + wrap_module_params_gtp(layer, layer.weight_names, gtp_remat_group) + assert isinstance(layer.weight, GTPShardedParam) + + inp = torch.randn(seq, batch, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + out = layer(inp, is_first_microbatch=True) + assert out.shape == (seq, batch, out_f), f"unexpected output shape {out.shape}" + + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + out.sum().backward() + assert inp.grad is not None and inp.grad.shape == inp.shape + + +class TestLayerNormLinearGTP: + def test_forward_backward(self): + _requires_multi_gpu(4) + _run_distributed(_worker_layernorm_linear, 4) + + +# --------------------------------------------------------------------------- +# GroupedLinear forward/backward smoke test +# --------------------------------------------------------------------------- + + +def _worker_grouped_linear(rank, world_size, port, num_gemms): + torch.manual_seed(0) + in_f, out_f, total_tokens = 32, 64, num_gemms * 4 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = _make_gtp_remat_grouped_linear(num_gemms, in_f, out_f, gtp_remat_group, dtype) + assert isinstance(layer.weight0, GTPShardedParam) + + m_splits = [total_tokens // num_gemms] * num_gemms + m_splits[-1] += total_tokens - sum(m_splits) + + inp = torch.randn(total_tokens, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + out = layer(inp, m_splits=m_splits, is_first_microbatch=True) + assert out.shape == (total_tokens, out_f), f"unexpected output shape {out.shape}" + + for i in range(num_gemms): + w = getattr(layer, f"weight{i}") + w.main_grad = torch.zeros(w.shape, dtype=dtype, device="cuda") + out.sum().backward() + assert inp.grad is not None and inp.grad.shape == inp.shape + + +class TestGroupedLinearGTP: + @pytest.mark.parametrize("num_gemms", [2, 4]) + def test_forward_backward(self, num_gemms): + _requires_multi_gpu(4) + _run_distributed(_worker_grouped_linear, 4, num_gemms) + + +def _worker_ops_grouped_linear(rank, world_size, port, num_gemms): + """GTP on the fusible-op ``te.ops.GroupedLinear`` -- the unfused fallback path (run standalone, + so no op fusion). Exercises materialize (fwd/bwd all-gather) + wgrad reduce-scatter wiring in + transformer_engine/pytorch/ops/basic/grouped_linear.py.""" + torch.manual_seed(0) + in_f, out_f, total_tokens = 32, 64, num_gemms * 4 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + op = te.ops.GroupedLinear(num_gemms, in_f, out_f, bias=False, device="cuda", dtype=dtype) + op.gtp_remat_size = gtp_remat_group.size() + wrap_module_params_gtp( + op, [f"weight{i}" for i in range(num_gemms)], gtp_remat_group, is_grouped=True + ) + assert isinstance(op.weight0, GTPShardedParam) + + m_splits = [total_tokens // num_gemms] * num_gemms + m_splits[-1] += total_tokens - sum(m_splits) + split_sizes = torch.tensor(m_splits, dtype=torch.int64, device="cuda") + + inp = torch.randn(total_tokens, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + out = op(inp, split_sizes) + assert out.shape == (total_tokens, out_f), f"unexpected output shape {out.shape}" + + for i in range(num_gemms): + w = getattr(op, f"weight{i}") + w.main_grad = torch.zeros(w.shape, dtype=dtype, device="cuda") + # DDP initializes this on every param; the backward wgrad-fusion path sets it True and + # returns a throwaway dummy .grad (real grad is reduce-scattered into main_grad). + w.grad_added_to_main_grad = False + out.sum().backward() + assert inp.grad is not None and inp.grad.shape == inp.shape + # The wgrad reduce-scatter wrote each per-expert shard's gradient into main_grad (the gradient + # of record for GTP), and flagged it so DDP won't double-add the dummy .grad. + for i in range(num_gemms): + w = getattr(op, f"weight{i}") + assert w.grad_added_to_main_grad is True + assert w.main_grad.shape == w.shape + assert torch.count_nonzero(w.main_grad) > 0, f"weight{i} main_grad not populated by RS" + + +class TestOpsGroupedLinearGTP: + """GTP on the fusible-op ``te.ops.GroupedLinear`` (the unfused fallback for grouped-MLP).""" + + @pytest.mark.parametrize("num_gemms", [2, 4]) + def test_forward_backward(self, num_gemms): + _requires_multi_gpu(4) + _run_distributed(_worker_ops_grouped_linear, 4, num_gemms) + + +# --------------------------------------------------------------------------- +# Prefetch chain: next_w / prev_w wiring after first forward pass +# --------------------------------------------------------------------------- + + +def _worker_chain_wired(rank, world_size, port): + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + l0 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + l1 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + + inp = torch.randn(4, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + + # First forward pass builds the linked list + l0(inp, is_first_microbatch=True) + l1(inp, is_first_microbatch=True) + + w0, w1 = l0.weight, l1.weight + assert w0.next_w is w1, "w0.next_w should point to w1" + assert w1.prev_w is w0, "w1.prev_w should point back to w0" + assert w1.next_w is None + assert w0.prev_w is None + + +def _worker_chain_async_prefetch(rank, world_size, port): + """On the second forward pass, w1 should be in DATA_READY before its forward runs.""" + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + l0 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + l1 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + + inp = torch.randn(4, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + + # First pass builds chain, second pass uses async prefetch + for _ in range(2): + out = l0(inp, is_first_microbatch=True) + l1(inp, is_first_microbatch=True) + assert torch.isfinite(out).all(), "Non-finite output on second pass" + + +class TestGTPPrefetchChain: + def test_chain_wired_after_first_pass(self): + _requires_multi_gpu(4) + _run_distributed(_worker_chain_wired, 4) + + def test_async_prefetch_second_pass(self): + _requires_multi_gpu(4) + _run_distributed(_worker_chain_async_prefetch, 4) + + +# --------------------------------------------------------------------------- +# Wgrad reduce-scatter: shape and deferred async path +# --------------------------------------------------------------------------- + + +def _worker_wgrad_shape(rank, world_size, port): + """After backward, weight.grad shape must match the local shard shape.""" + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype, fuse_wgrad_accumulation=False) + inp = torch.randn(8, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + layer(inp, is_first_microbatch=True).sum().backward() + + w = layer.weight + if w.grad is not None: + assert w.grad.shape == w.shape, f"wgrad shape {w.grad.shape} != shard shape {w.shape}" + + +def _worker_multilayer_deferred_rs(rank, world_size, port): + """Two-layer GTP: async RS deferred for layer0 (non-last), sync for layer1 (last in bwd).""" + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + l0 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + l1 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + + inp = torch.randn(8, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + # wgrad RS path always accumulates into main_grad; allocate before backward. + l0.weight.main_grad = torch.zeros(l0.weight.shape, dtype=dtype, device="cuda") + l1.weight.main_grad = torch.zeros(l1.weight.shape, dtype=dtype, device="cuda") + + out = l0(inp, is_first_microbatch=True) + l1(inp, is_first_microbatch=True) + out.sum().backward() + + # Both weights' main_grad should have been updated + for lyr in [l0, l1]: + w = lyr.weight + assert w.main_grad is not None, f"No main_grad on {lyr.__class__.__name__}.weight" + + +class TestGTPWgradRS: + def test_wgrad_shape_matches_shard(self): + _requires_multi_gpu(4) + _run_distributed(_worker_wgrad_shape, 4) + + def test_multilayer_deferred_rs(self): + _requires_multi_gpu(4) + _run_distributed(_worker_multilayer_deferred_rs, 4) + + +# --------------------------------------------------------------------------- +# Multiple microbatches: output must be consistent when weight unchanged +# --------------------------------------------------------------------------- + + +def _worker_microbatches(rank, world_size, port): + torch.manual_seed(0) + batch, in_f, out_f = 8, 64, 128 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + inp = torch.randn(batch, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + + # First microbatch + out1 = layer(inp, is_first_microbatch=True).detach().clone() + + # Second microbatch with same weight (skip_weight_cast=True path) + out2 = layer(inp, is_first_microbatch=False).detach() + + assert torch.allclose( + out1, out2 + ), f"Microbatch outputs differ; max_diff={(out1-out2).abs().max():.6f}" + + +class TestGTPMicrobatches: + def test_consistent_across_microbatches(self): + _requires_multi_gpu(4) + _run_distributed(_worker_microbatches, 4) + + +# --------------------------------------------------------------------------- +# MXFP8 + GTP_remat: Linear forward/backward, quantized shard setup +# --------------------------------------------------------------------------- + + +def _make_native_fp8_gtp_linear(in_f, out_f, gtp_remat_group, dtype, recipe): + """Build a native-FP8 GTP te.Linear the gtp-agnostic way. + + Mirrors megatron/core/extensions/transformer_engine.py: pass the pre-sharded + out_features to a STOCK te.Linear under fp8_model_init (TE inits+quantizes a native + MXFP8 shard with no GTP awareness), then attach the GTP wiring post-init. + """ + from transformer_engine.pytorch import fp8_model_init + + from megatron.core.tensor_parallel.gtp_api import ( + attach_gtp_to_presharded_module, + gtp_remat_shard_dim0, + ) + + shard_out, pad_length = gtp_remat_shard_dim0(out_f, gtp_remat_group) + with fp8_model_init(enabled=True, recipe=recipe): + layer = te.Linear( + in_features=in_f, out_features=shard_out, bias=False, params_dtype=dtype, device="cuda" + ) + layer.gtp_remat_size = gtp_remat_group.size() + attach_gtp_to_presharded_module(layer, gtp_remat_group, pad_length) + return layer + + +def _worker_mxfp8_linear(rank, world_size, port): + """Verify GTP Linear with a native MXFP8 param: all-gather + GEMM + backward. + + mxfp8 always implies --fp8-param-gather: the weight is built as a native FP8 shard at + construction (no BF16 source, no per-forward cast). + """ + from transformer_engine.common.recipe import MXFP8BlockScaling + + from megatron.core.tensor_parallel.generalized_tensor_parallelism import update_gtp_config + + torch.manual_seed(0) + # batch=32: MXFP8 wgrad GEMM (K=batch) requires K divisible by MXFP8_BLOCK_SCALING_SIZE=32 + batch, in_f, out_f = 32, 64, 128 # out_f % (16*world_size)==0 → no padding + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + recipe = MXFP8BlockScaling() + layer = _make_native_fp8_gtp_linear(in_f, out_f, gtp_remat_group, dtype, recipe) + + # The weight IS the native FP8 shard: a QuantizedTensor with the GTP surface attached. + w = layer.weight + assert isinstance(w, QuantizedTensor), f"weight should be QuantizedTensor, got {type(w)}" + assert w.quantized is w, "native-FP8 GTP: self.quantized must be the param itself" + assert getattr(w, "is_gtp_weight_remat", False), "GTP surface missing on native param" + assert w.shape[0] * world_size == out_f, "weight must be dim-0 sharded" + + inp = torch.randn(batch, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + with fp8_autocast(enabled=True, fp8_recipe=recipe): + out = layer(inp, is_first_microbatch=True) + + assert out.shape == (batch, out_f), f"unexpected output shape {out.shape}" + assert torch.isfinite(out).all(), "MXFP8 GTP output has non-finite values" + + # Backward should complete without error + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + out.sum().backward() + assert inp.grad is not None + assert inp.grad.shape == inp.shape + + # Second microbatch reuses the same native FP8 weight + with fp8_autocast(enabled=True, fp8_recipe=recipe): + out2 = layer(inp.detach(), is_first_microbatch=False) + assert torch.isfinite(out2).all(), "MXFP8 GTP second-microbatch output has non-finite" + + +def _worker_mxfp8_linear_unaligned(rank, world_size, port): + """Verify native-FP8 MXFP8 GTP when out_features needs padding. + + MXFP8 requires tensor dims divisible by 32, so shard_size (= M_padded / world_size) + must be a multiple of 32. With world_size=4 this requires M_padded % 128 == 0. + out_f=120 gives M_padded=128, shard_size=32 (32 % 32 == 0). The last rank's shard + holds 24 real rows zero-padded to 32. After all-gather, _strip_padding removes the + padded rows before the GEMM, so the output has the original out_f columns. + """ + from transformer_engine.common.recipe import MXFP8BlockScaling + + from megatron.core.tensor_parallel.generalized_tensor_parallelism import update_gtp_config + + torch.manual_seed(0) + # out_f=120: M_padded=128, shard_size=32, last rank has 24 rows padded to 32. + out_f = 120 + in_f = 64 + batch = 32 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + recipe = MXFP8BlockScaling() + layer = _make_native_fp8_gtp_linear(in_f, out_f, gtp_remat_group, dtype, recipe) + assert layer.weight.pad_length == 8, f"expected pad 8, got {layer.weight.pad_length}" + + inp = torch.randn(batch, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + with fp8_autocast(enabled=True, fp8_recipe=recipe): + out = layer(inp, is_first_microbatch=True) + + # After _strip_padding removes the padded rows, output has out_f (not padded) cols. + assert out.shape == (batch, out_f), f"unexpected output shape {out.shape}" + assert torch.isfinite(out).all(), "MXFP8 GTP (unaligned) output has non-finite values" + + +class TestMXFP8LinearGTP: + def test_forward_backward(self): + _requires_mxfp8() + _requires_multi_gpu(4) + _run_distributed(_worker_mxfp8_linear, 4) + + def test_forward_unaligned_padding(self): + _requires_mxfp8() + _requires_multi_gpu(4) + _run_distributed(_worker_mxfp8_linear_unaligned, 4) + + +# --------------------------------------------------------------------------- +# wrap_module_params_gtp is a no-op when gtp_remat_group.size() == 1 +# --------------------------------------------------------------------------- + + +class TestGTPGroupSizeOne: + + def test_no_sharding_when_gtp_remat_size_one(self): + """wrap_module_params_gtp must be a no-op for a singleton GTP group.""" + mod = nn.Linear(32, 64, bias=False) + original_weight = mod.weight + wrap_module_params_gtp(mod, ["weight"], _FakeGroup()) + assert ( + mod.weight is original_weight + ), "gtp_remat_group.size()==1 should leave parameters unchanged" + assert not isinstance(mod.weight, GTPShardedParam) + + +class TestGTPRematPgCollectionWithoutParallelState: + """Resolving the GTP shard group with GTP off must return None, not assert. + + TE linear ``__init__`` calls ``use_mpu_process_groups(["gtp_remat", "expt_gtp_remat"])``; both + getters use ``check_initialized=False``, so an uninitialized GTP axis must yield None groups + rather than break construction of every non-GTP module. + """ + + def test_gtp_remat_pgs_are_none_and_do_not_raise(self, mocker): + """The exact call in the TE extension returns None groups, no assert, when GTP is off.""" + # Force the uninitialized GTP state deterministically (independent of suite ordering). + mocker.patch.object(parallel_state, "_GTP_WEIGHT_REMAT_GROUP", None) + mocker.patch.object(parallel_state, "_EXPERT_GTP_WEIGHT_REMAT_GROUP", None) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["gtp_remat", "expt_gtp_remat"] + ) + + # Mirror the downstream selection in the TE extension; both branches are None, so + # _init_gtp_remat_context takes the no-op (GTP-inactive) path. + assert pg_collection.gtp_remat is None + assert pg_collection.expt_gtp_remat is None + for is_expert in (False, True): + gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat + assert gtp_remat_group is None + + +# --------------------------------------------------------------------------- +# weight_prefetch=False: forward still produces correct output +# --------------------------------------------------------------------------- + + +def _worker_prefetch_disabled(rank, world_size, port): + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + gtp_module.update_gtp_config(weight_prefetch=False) + try: + l0 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + l1 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + + inp = torch.randn(4, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + + # Single forward pass: builds chain and verifies output is correct + out = l0(inp, is_first_microbatch=True) + l1(inp, is_first_microbatch=True) + + # Chain should still be wired even with prefetch disabled + assert l0.weight.next_w is l1.weight + assert torch.isfinite(out).all(), "Non-finite output with prefetch disabled" + finally: + gtp_module.update_gtp_config(weight_prefetch=True) + + +class TestGTPPrefetchDisabled: + def test_forward_works_without_prefetch(self): + _requires_multi_gpu(4) + _run_distributed(_worker_prefetch_disabled, 4) + + +# --------------------------------------------------------------------------- +# fuse_wgrad_accumulation=True: wgrad is accumulated into main_grad +# --------------------------------------------------------------------------- + + +def _worker_fuse_wgrad(rank, world_size, port): + torch.manual_seed(0) + in_f, out_f = 32, 128 # out_f % (16*world_size)==0, no padding + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype, fuse_wgrad_accumulation=True) + + # Allocate main_grad on the local shard shape + w = layer.weight + w.main_grad = torch.zeros(w.shape, dtype=dtype, device="cuda") + + inp = torch.randn(8, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + layer(inp, is_first_microbatch=True).sum().backward() + + # With fused accumulation, wgrad was added into main_grad + assert torch.any( + w.main_grad != 0 + ), "main_grad should have been updated by fused wgrad accumulation" + + +class TestFuseWgradAccumulation: + def test_wgrad_accumulated_into_main_grad(self): + _requires_multi_gpu(4) + _run_distributed(_worker_fuse_wgrad, 4) + + +# --------------------------------------------------------------------------- +# _grad_accum_hook is called after reduce-scatter +# --------------------------------------------------------------------------- + + +def _worker_main_grad_updated_after_bwd(rank, world_size, port): + """After backward, the wgrad RS path must have accumulated wgrad into main_grad.""" + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + layer = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + + # wgrad RS path always accumulates into main_grad; allocate before backward. + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + + inp = torch.randn(8, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + layer(inp, is_first_microbatch=True).sum().backward() + + assert torch.any( + layer.weight.main_grad != 0 + ), "main_grad should have been updated after the reduce-scatter accumulation" + + +class TestGTPGradAccumHook: + def test_main_grad_updated_after_backward(self): + _requires_multi_gpu(4) + _run_distributed(_worker_main_grad_updated_after_bwd, 4) + + +# --------------------------------------------------------------------------- +# wait_async_comms(finalize_after_drain=True) inline-accumulation fallback +# --------------------------------------------------------------------------- + + +class TestWaitAsyncCommsFallback: + """Exercises the inline-accumulation fallback inside + ``wait_async_comms(finalize_after_drain=True)``: when a param is in + ``_inflight_comm_params`` (async AG was issued) but its ``_wgrad_rs_handle`` + is ``None`` (no async RS handle to drain), the inner + ``_wait_reduce_scatter`` call no-ops and the outer loop must inline the + accumulation itself (main_grad.add_ + ticket release + flag set). + + Production flows rarely hit this combination — chain-interior params have + both async AG and async RS, and chain-head sync RS doesn't enter + ``_inflight_comm_params`` via bwd AG. We construct the state by hand to + pin down the fallback's contract. + """ + + @staticmethod + def _make_inflight_param(main_grad_fill=0.0, already_finalized=False): + """Build a minimal GTPShardedParam wired for wait_async_comms testing.""" + dtype = torch.bfloat16 + p = GTPShardedParam(torch.zeros(8, 4, dtype=dtype, device="cuda")) + p.group = _FakeGroup() + p.expert_idx = None + p.pad_length = 0 + p.chain_id = gtp_module.GTPChain.UNGRAPHED.value + p._quantizer = None + p.is_routed_expert = False # ⇒ self._weights property returns [self] + p.main_grad = torch.full((8, 4), main_grad_fill, dtype=dtype, device="cuda") + p._prefetch_handle = None # _wait_param_gather is no-op + p._wgrad_rs_handle = None # _wait_reduce_scatter is no-op → fallback fires + p._cached_ag_stream = None + p._cached_rs_stream = None + p.ag_event = torch.cuda.Event(external=True) + p.rs_event = torch.cuda.Event(external=True) + p.rs_event.record() # so rs_event.wait() in fallback doesn't block + p._already_finalized = already_finalized + p.grad_added_to_main_grad = False + return p + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") + def test_fallback_accumulates_when_no_rs_handle(self): + dtype = torch.bfloat16 + p = self._make_inflight_param(main_grad_fill=0.0) + + # Place a known wgrad in the cache for the fallback to read. + cache = gtp_module.get_global_GTP_cache() + p._rs_ticket = cache.reserve(p, dtype, fwd=False, reduce_scatter=True) + cache.get(p._rs_ticket).fill_(2.0) + + # Save + replace _inflight_comm_params so we don't trip over leftover + # params from earlier tests in the loop. + saved = set(gtp_module._inflight_comm_params) + gtp_module._inflight_comm_params.clear() + gtp_module._inflight_comm_params.add(p) + try: + gtp_module.wait_async_comms( + chain_id=p.chain_id, skip_rs=False, finalize_after_drain=True + ) + finally: + gtp_module._inflight_comm_params.clear() + gtp_module._inflight_comm_params.update(saved) + + torch.cuda.synchronize() + assert torch.all( + p.main_grad == 2.0 + ), f"main_grad should be 2.0 after fallback accumulation; got {p.main_grad}" + assert p._already_finalized is True, "_already_finalized must be set" + assert p.grad_added_to_main_grad is True, "grad_added_to_main_grad must be set" + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") + def test_fallback_skipped_when_already_finalized(self): + """When _already_finalized=True, the fallback must NOT re-accumulate.""" + p = self._make_inflight_param(main_grad_fill=5.0, already_finalized=True) + # No _rs_ticket: if the fallback ran it would AttributeError on cache.get(None). + p._rs_ticket = None + + saved = set(gtp_module._inflight_comm_params) + gtp_module._inflight_comm_params.clear() + gtp_module._inflight_comm_params.add(p) + try: + gtp_module.wait_async_comms( + chain_id=p.chain_id, skip_rs=False, finalize_after_drain=True + ) + finally: + gtp_module._inflight_comm_params.clear() + gtp_module._inflight_comm_params.update(saved) + + torch.cuda.synchronize() + assert torch.all( + p.main_grad == 5.0 + ), "main_grad must be untouched when _already_finalized=True" + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") + def test_fallback_skipped_for_pure_ag_param(self): + """Regression: cross-graph fwd-AG prefetch in flight + finalize_after_drain=True. + + A param can be in _inflight_comm_params because of an outstanding async + all-gather (e.g. a cross-graph forward prefetch reaching the + bwd→optimizer boundary). No reduce-scatter was ever issued for that + param, so _rs_ticket is None on every weight. Previously the fallback + called cache.get(None) and crashed with KeyError; the guard now skips + the inline accumulation entirely when no weight has an RS ticket. + """ + p = self._make_inflight_param(main_grad_fill=7.0) + # Critical: simulates a pure-AG prefetch — no RS ever issued, ticket is None. + p._rs_ticket = None + + saved = set(gtp_module._inflight_comm_params) + gtp_module._inflight_comm_params.clear() + gtp_module._inflight_comm_params.add(p) + try: + # Must NOT raise KeyError(None) from cache.get(None). + gtp_module.wait_async_comms( + chain_id=p.chain_id, skip_rs=False, finalize_after_drain=True + ) + finally: + gtp_module._inflight_comm_params.clear() + gtp_module._inflight_comm_params.update(saved) + + torch.cuda.synchronize() + assert torch.all( + p.main_grad == 7.0 + ), "main_grad must be untouched for a pure-AG param (no wgrad to accumulate)" + assert ( + p._already_finalized is False + ), "_already_finalized must stay False — no finalize happened for a pure-AG param" + + +# --------------------------------------------------------------------------- +# GTP_remat DDP bucket alignment: distributed optimizer bucket-end assertion +# --------------------------------------------------------------------------- + + +def _worker_gtp_ddp_bucket_alignment(rank, world_size, port): + """GTP param buffers in DDP must use padded bucket layout with use_distributed_optimizer=True. + + Bug: DDP used param_layout=None for GTP buffers, falling through to + _compute_default_per_buffer_param_layout, which packs params without padding bucket ends. + The distributed optimizer requires every bucket end to be divisible by + intra_dp_cp_group.size() (asserted at param_and_grad_buffer.py:1427). + + Trigger: + GTP_remat_size=2, DP=4 → intra_dp_cp_group.size()=2 + pad_for_alignment=0, weight [out=2,in=3] → GTP shard=[1,3]=3 elements (odd) + Two GTP params: total=6, 6%2==0 (total check passes); bucket_size=3 forces + bucket-0 to contain only the first param, end=3, 3%2≠0 → AssertionError + """ + from megatron.core import parallel_state as ps + from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig + from megatron.core.transformer.transformer_config import TransformerConfig + + # The module fixture initialized model_parallel without GTP_remat; re-init with GTP_remat=2. + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + + orig_pad = gtp_module.GTP_CONFIG.pad_for_alignment + gtp_module.GTP_CONFIG.pad_for_alignment = 0 + try: + gtp_remat_group = ps.get_gtp_weight_remat_group() + + class _TwoLayerModel(torch.nn.Module): + def __init__(self): + super().__init__() + self.fc0 = te.Linear(3, 2, bias=False, device="cuda") + self.fc1 = te.Linear(3, 2, bias=False, device="cuda") + + model = _TwoLayerModel() + wrap_module_params_gtp(model.fc0, ["weight"], gtp_remat_group) + wrap_module_params_gtp(model.fc1, ["weight"], gtp_remat_group) + + config = TransformerConfig( + num_attention_heads=1, num_layers=1, hidden_size=4, tensor_model_parallel_size=1 + ) + ddp_config = DistributedDataParallelConfig( + use_distributed_optimizer=True, overlap_grad_reduce=True, bucket_size=3 + ) + + # Without the fix this raises AssertionError at param_and_grad_buffer.py:1427: + # assert end_index % self.data_parallel_world_size == 0 + DistributedDataParallel(config, ddp_config, model) + finally: + gtp_module.GTP_CONFIG.pad_for_alignment = orig_pad + ps.destroy_model_parallel() + ps.initialize_model_parallel() # restore default for remaining tests + + +def _worker_regular_buffer_padded_when_gtp_params_present(rank, world_size, port): + """Regular (non-GTP) param buffers in DDP must also use padded layout when GTP is active. + + Bug: when gtp_params is non-empty, full_param_layout.layouts contains stale GTP entries + that don't belong to the regular buffer, causing KeyErrors in DistOpt's param map. + DDP avoided this by forcing param_layout=None for regular buffers, but that falls through + to _compute_default_per_buffer_param_layout, which produces unpadded bucket ends, again + violating param_and_grad_buffer.py:1427 (end_index % data_parallel_world_size == 0). + + Trigger: + GTP_remat_size=2, DP=4 → intra_dp_cp_group.size()=4 + (regular params reduce over the full DP group) + bias=True → each bias has 2 elements (not divisible by 4) + Two layers: total regular numel=4, 4%4==0 (total check passes); bucket_size=2 forces + bucket-0 to contain only the first bias, end=2, 2%4≠0 → AssertionError + """ + from megatron.core import parallel_state as ps + from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig + from megatron.core.transformer.transformer_config import TransformerConfig + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + + orig_pad = gtp_module.GTP_CONFIG.pad_for_alignment + gtp_module.GTP_CONFIG.pad_for_alignment = 0 + try: + gtp_remat_group = ps.get_gtp_weight_remat_group() + + class _TwoLayerModelWithBias(torch.nn.Module): + def __init__(self): + super().__init__() + # bias=True: weight → GTPShardedParam (gtp_buffer), bias → regular param + self.fc0 = te.Linear(3, 2, bias=True, device="cuda") + self.fc1 = te.Linear(3, 2, bias=True, device="cuda") + + model = _TwoLayerModelWithBias() + wrap_module_params_gtp(model.fc0, ["weight"], gtp_remat_group) + wrap_module_params_gtp(model.fc1, ["weight"], gtp_remat_group) + + config = TransformerConfig( + num_attention_heads=1, num_layers=1, hidden_size=4, tensor_model_parallel_size=1 + ) + # bucket_size=2: each 2-element bias fills one bucket in the regular buffer. + # Without the fix: regular buffer uses param_layout=None → bucket-0 ends at 2, + # 2 % intra_dp_cp_group.size()(=4) != 0 → AssertionError at line 1427. + ddp_config = DistributedDataParallelConfig( + use_distributed_optimizer=True, overlap_grad_reduce=True, bucket_size=2 + ) + + DistributedDataParallel(config, ddp_config, model) + finally: + gtp_module.GTP_CONFIG.pad_for_alignment = orig_pad + ps.destroy_model_parallel() + ps.initialize_model_parallel() + + +class TestGTPDDPBucketAlignment: + def test_gtp_buffers_use_padded_layout_with_distributed_optimizer(self): + """GTP buffer bucket ends must be padded to intra_dp_cp_group.size().""" + _requires_multi_gpu(4) + _run_distributed(_worker_gtp_ddp_bucket_alignment, 4) + + def test_regular_buffers_use_padded_layout_when_gtp_params_present(self): + """Regular buf bucket ends must be padded even when gtp_params forces layoutrecompute.""" + _requires_multi_gpu(4) + _run_distributed(_worker_regular_buffer_padded_when_gtp_params_present, 4) + + +# --------------------------------------------------------------------------- +# GTP_remat DDP grad-ready wiring: register_grad_ready must fire AFTER the wgrad add +# --------------------------------------------------------------------------- + + +def _worker_gtp_ddp_grad_ready_wiring(rank, world_size, port): + """GTP params must drive DDP grad-ready from GTP's manual hook, not autograd. + + GTP defers the main_grad accumulation to a later backward node, so autograd's AccumulateGrad can + fire register_grad_ready before the grad lands and dispatch the bucket reduce-scatter on stale + grad_data (corrupts reduce_scatter_with_fp32_accumulation). The fix routes grad-ready through + register_grad_accum_hook (fired after the add) and skips the autograd hook. This pins that + wiring: every GTP weight has _grad_accum_hook set and none falls through to the autograd list. + """ + from megatron.core import parallel_state as ps + from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig + from megatron.core.transformer.transformer_config import TransformerConfig + + # The module fixture initialized model_parallel without GTP_remat; re-init with GTP_remat=2. + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + try: + gtp_remat_group = ps.get_gtp_weight_remat_group() + + class _TwoLayerModel(torch.nn.Module): + def __init__(self): + super().__init__() + # bias=False -> all params are GTP_remat weights, so grad_accs must end up empty. + self.fc0 = te.Linear(64, 128, bias=False, device="cuda") + self.fc1 = te.Linear(64, 128, bias=False, device="cuda") + + model = _TwoLayerModel() + wrap_module_params_gtp(model.fc0, ["weight"], gtp_remat_group) + wrap_module_params_gtp(model.fc1, ["weight"], gtp_remat_group) + + config = TransformerConfig( + num_attention_heads=1, num_layers=1, hidden_size=4, tensor_model_parallel_size=1 + ) + ddp_config = DistributedDataParallelConfig( + use_distributed_optimizer=True, overlap_grad_reduce=True + ) + ddp_model = DistributedDataParallel(config, ddp_config, model) + + for name, w in [("fc0", model.fc0.weight), ("fc1", model.fc1.weight)]: + assert isinstance(w, GTPShardedParam), f"{name}.weight should be a GTP param" + # Manual hook set -> grad-ready fires after the add; None -> early autograd path (bug). + assert ( + getattr(w, "_grad_accum_hook", None) is not None + ), f"{name}.weight must have _grad_accum_hook set (manual grad-ready, not autograd)" + + # bias=False -> all params are GTP_remat -> none took the autograd path. + assert len(ddp_model.grad_accs) == 0, ( + "GTP params must not register an autograd AccumulateGrad hook " + f"(grad_accs has {len(ddp_model.grad_accs)} entries)" + ) + finally: + ps.destroy_model_parallel() + ps.initialize_model_parallel() # restore default for remaining tests + + +class TestGTPDDPGradReadyWiring: + def test_gtp_params_use_manual_grad_ready_hook(self): + """GTP params route DDP grad-ready through register_grad_accum_hook, not autograd.""" + _requires_multi_gpu(4) + _run_distributed(_worker_gtp_ddp_grad_ready_wiring, 4) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_cudagraph_grad.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_cudagraph_grad.py new file mode 100644 index 00000000000..d7c2f4e53e4 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_cudagraph_grad.py @@ -0,0 +1,95 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Regression test for the GTP + CUDA-graph capture-step grad-norm bug. + +Bug: create_cudagraphs() runs after finalize_model_grads, so main_grad already holds the finalized +(reduced + per-token-scaled) grads. create_fwd_graph then runs an eager warmup backward (graph +capture only records ops, it doesn't run them), and that eager backward executes GTP's wgrad +main_grad.add_ -- including the cascade add into a param's cross-graph ``next_w`` (in another +module, via a stale RS ticket) -- clobbering the finalized grads and spiking the step's grad norm. + +Fix: create_fwd_graph snapshots the grads its warmup touches via ``_backup_grads_before_capture`` +and restores them after. This test exercises that helper pair directly: the module's own params +and their cross-graph ``next_w`` must survive a simulated warmup clobber. +""" + +import pytest +import torch + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP +from megatron.core.transformer.cuda_graphs import ( + _backup_grads_before_capture, + _restore_grads_after_capture, +) + +if not HAVE_GTP: + pytest.skip("GTP requires TE with hook registry", allow_module_level=True) + + +def _gtp_param(value: float, numel: int = 8) -> torch.nn.Parameter: + """A param with a finalized (reduced + scaled) main_grad, flagged as a GTP weight.""" + p = torch.nn.Parameter(torch.zeros(numel, device="cuda")) + p.is_gtp_weight_remat = True + p.main_grad = torch.full((numel,), value, device="cuda") + return p + + +class _Mod(torch.nn.Module): + def __init__(self, weight: torch.nn.Parameter): + super().__init__() + self.weight = weight + + +class _StubRunner: + """The ``base_module`` and ``gtp_remat`` attrs that ``_backup_grads_before_capture`` reads.""" + + def __init__(self, base_module: torch.nn.Module, gtp_remat: bool = True): + self.base_module = base_module + self.gtp_remat = gtp_remat + + +class TestGTPCaptureGradSnapshot: + def test_preserves_own_and_cross_graph_next_w(self): + """Snapshot/restore must keep both the module's own grad and its cross-graph next_w grad + (in another module) intact across a capture that clobbers them.""" + own = _gtp_param(0.0125) + cross = _gtp_param(0.02) # next_w lives in a different module/graph + own.next_w = cross + runner = _StubRunner(_Mod(own)) + + backup = _backup_grads_before_capture(runner) + own.main_grad.add_(410.0) # simulate the capture-time main_grad.add_ clobber + cross.main_grad.add_(99.0) + _restore_grads_after_capture(backup) + + torch.testing.assert_close(own.main_grad, torch.full((8,), 0.0125, device="cuda")) + torch.testing.assert_close(cross.main_grad, torch.full((8,), 0.02, device="cuda")) + + def test_routed_expert_next_w_via_weight_list(self): + """A routed-expert next_w exposes its shards via ``weight_list`` (read directly, since the + ``_weights`` property raises on non-leaders before capture).""" + own = _gtp_param(0.0125) + shard0, shard1 = _gtp_param(0.03), _gtp_param(0.04) + routed = torch.nn.Parameter(torch.zeros(8, device="cuda")) # leader wrapper (no own grad) + routed.is_routed_expert = True + routed.weight_list = [shard0, shard1] + own.next_w = routed + runner = _StubRunner(_Mod(own)) + + backup = _backup_grads_before_capture(runner) + shard0.main_grad.add_(50.0) + shard1.main_grad.add_(60.0) + _restore_grads_after_capture(backup) + + torch.testing.assert_close(shard0.main_grad, torch.full((8,), 0.03, device="cuda")) + torch.testing.assert_close(shard1.main_grad, torch.full((8,), 0.04, device="cuda")) + + def test_non_gtp_backs_up_own_params_only(self): + """Non-GTP runner: own params are snapshotted, but the GTP cross-graph next_w walk is + skipped (the bwd capture doesn't touch main_grad on the non-GTP path).""" + own = _gtp_param(0.0125) + cross = _gtp_param(0.02) + own.next_w = cross + backup = _backup_grads_before_capture(_StubRunner(_Mod(own), gtp_remat=False)) + assert id(own) in backup + assert id(cross) not in backup diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py new file mode 100644 index 00000000000..da42a086a91 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py @@ -0,0 +1,1089 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for GTP_remat + distributed checkpointing. + +Verifies that ``make_sharded_tensors_for_checkpoint_with_gtp_remat`` emits +ShardedTensor offsets that correctly encode TP × GTP_remat sharding, and that +the helper is a no-op (delegates to vanilla) when no ``GTPShardedParam`` +is present in the input state_dict. + +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core import parallel_state as ps +from megatron.core.dist_checkpointing import ShardedTensor +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TE with hook registry", allow_module_level=True) + +import transformer_engine.pytorch as te # noqa: E402 +from transformer_engine.common.recipe import MXFP8BlockScaling # noqa: E402 +from transformer_engine.pytorch import fp8_autocast, fp8_model_init # noqa: E402 + +from megatron.core.dist_checkpointing.mapping import ( # noqa: E402 + ShardedObject, + ShardedTensorFactory, + is_main_replica, +) +from megatron.core.extensions.transformer_engine import ( # noqa: E402 + TELayerNormColumnParallelLinear, + TERowParallelLinear, +) +from megatron.core.fp8_utils import is_float8tensor # noqa: E402 +from megatron.core.fusions.fused_bias_dropout import get_bias_dropout_add # noqa: E402 +from megatron.core.process_groups_config import ProcessGroupCollection # noqa: E402 +from megatron.core.ssm.mamba_layer import MambaLayer, MambaLayerSubmodules # noqa: E402 +from megatron.core.ssm.mamba_mixer import MambaMixer, MambaMixerSubmodules # noqa: E402 +from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( # noqa: E402 + GTP_CONFIG, + GTPShardedParam, + make_sharded_tensors_for_checkpoint_with_gtp_remat, + update_gtp_config, + wrap_module_params_gtp, +) +from megatron.core.tensor_parallel.gtp_api import ( # noqa: E402 + attach_gtp_to_presharded_module, + dequantize_gtp_native_fp8, + gtp_native_fp8_load_context, + gtp_remat_shard_dim0, +) +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed # noqa: E402 +from megatron.core.transformer.spec_utils import ModuleSpec # noqa: E402 +from megatron.core.transformer.transformer_config import TransformerConfig # noqa: E402 +from megatron.core.transformer.utils import make_sharded_tensors_for_checkpoint # noqa: E402 +from megatron.core.utils import get_pg_size, make_tp_sharded_tensor_for_checkpoint # noqa: E402 +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( # noqa: E402,F401 + _requires_mxfp8, + _torchrun_dist_init, +) + + +@pytest.fixture(autouse=True) +def _no_pad_alignment(): + """Disable GTP_remat padding for the duration of each test so local shard sizes + are exactly ``per_tp_out / gtp_remat_size`` and the test math stays simple. + DCP semantics with padding are exercised by the integration tests. + """ + orig = GTP_CONFIG.pad_for_alignment + update_gtp_config(pad_for_alignment=0) + yield + update_gtp_config(pad_for_alignment=orig) + + +def _require_world_size(n): + if dist.get_world_size() != n: + pytest.skip( + f"Requires world_size={n}, got {dist.get_world_size()} " + f"(launch with torchrun --nproc-per-node={n})" + ) + + +# Many workers need the same TP/GTP subgroups. Memoize by rank-set so the process holds a +# handful of communicators instead of re-creating (and leaking) one per worker. +_GROUP_CACHE = {} + + +def _cached_new_group(ranks): + """Memoized ``dist.new_group`` keyed by rank-set (see note above).""" + key = tuple(ranks) + if key not in _GROUP_CACHE: + _GROUP_CACHE[key] = dist.new_group(list(ranks)) + return _GROUP_CACHE[key] + + +@pytest.fixture(scope="module", autouse=True) +def _precreate_subgroups(_torchrun_dist_init): + """Pre-create the shared TP/GTP subgroups once, on all ranks, in a fixed order. + + ``dist.new_group`` is a world-collective (all ranks must call it in the same order); the + per-member ``new_group([0,1]) if rank in (0,1) else ...`` idiom collides disjoint groups on + the call-order tag and hangs NCCL. Pre-creating makes every later ``_cached_new_group`` a hit. + """ + if dist.is_initialized() and dist.get_world_size() == 4: + for ranks in ([0, 1], [2, 3], [0, 2], [1, 3], [0, 1, 2, 3]): + _cached_new_group(ranks) + yield + + +def _make_gtp_shard(out_features, in_features, gtp_remat_group, dtype=torch.bfloat16): + """Build a small GTPShardedParam by wrapping a one-param dummy module.""" + + class _Dummy(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter( + torch.arange(out_features * in_features, dtype=dtype, device="cuda").reshape( + out_features, in_features + ) + ) + + mod = _Dummy() + wrap_module_params_gtp(mod, ["weight"], gtp_remat_group) + return mod.weight # now a GTPShardedParam + + +def _make_native_fp8_gtp_shard(per_tp_out, in_f, gtp_remat_group, recipe): + """Build a native-FP8 GTP weight the production way (extensions/transformer_engine.py): + pass the pre-sharded out_features into a stock ``fp8_model_init`` ``te.Linear`` so TE inits a + native MXFP8 shard, attach the GTP surface post-init, then run one FP8 forward to populate the + rowwise/columnwise FP8 data. Returns the reclassed ``GTP_`` weight.""" + shard_out, pad = gtp_remat_shard_dim0(per_tp_out, gtp_remat_group) + with fp8_model_init(enabled=True, recipe=recipe): + lin = te.Linear(in_f, shard_out, bias=False, params_dtype=torch.bfloat16, device="cuda") + lin.gtp_remat_size = gtp_remat_group.size() + attach_gtp_to_presharded_module(lin, gtp_remat_group, pad) + with fp8_autocast(enabled=True, fp8_recipe=recipe): + _ = lin(torch.randn(32, in_f, dtype=torch.bfloat16, device="cuda")) + return lin.weight + + +def _worker_native_fp8_dcp_save(rank, world_size, port): + """Native-FP8 GTP weight: DCP save must emit a dequantized BF16 ShardedTensor with the full + (TP x GTP_remat) global shape and correct composite axis-0 offset -- not raw FP8 bytes under + a fake BF16 dtype (a55b save-crash guard: recognition gates / TE tex.dequantize miss the + native-FP8 GTP_ subclass; dequantize_gtp_native_fp8 restores the base class). + """ + _requires_mxfp8() + + # TP=2, GTP_remat=2 (4 ranks). MXFP8 needs dims % 32, so use fp8-valid sizes. + gtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + tp_group = _cached_new_group([0, 2]) if rank in (0, 2) else _cached_new_group([1, 3]) + full_out, in_f = 128, 128 + tp_size, gtp_remat_size = 2, 2 + per_tp_out = full_out // tp_size # 64 + per_shard_out = per_tp_out // gtp_remat_size # 32 (== MXFP8 block size) + + recipe = MXFP8BlockScaling() + w = _make_native_fp8_gtp_shard(per_tp_out, in_f, gtp_remat_group, recipe) + # The live weight is a native FP8 GTP param (a QuantizedTensor subclass), sharded. + assert is_float8tensor(w), "weight should be a native FP8 tensor" + assert getattr(w, "is_gtp_weight_remat", False), "GTP surface missing" + assert type(w).__name__.startswith("GTP_"), type(w).__name__ + assert tuple(w.shape) == (per_shard_out, in_f) + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": w}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=tp_group, + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + assert isinstance(st, ShardedTensor), type(st) + + # Saved data must be dequantized BF16 — not raw FP8 bytes under a fake dtype. + assert st.data.dtype == torch.bfloat16, f"expected bf16 saved data, got {st.data.dtype}" + assert not is_float8tensor(st.data), "checkpoint data must be dequantized, not FP8" + assert tuple(st.data.shape) == (per_shard_out, in_f) + + # Full (TP x GTP) global shape + composite axis-0 offset (not sharded-as-full). + assert st.global_shape[0] == full_out, (st.global_shape, full_out) + tp_rank, gtp_rank = rank // 2, rank % 2 + assert st.global_offset[0] == (tp_rank * gtp_remat_size + gtp_rank) * per_shard_out, ( + rank, + st.global_offset, + ) + + # The live param must be untouched by the save (class restored, still native FP8). + assert is_float8tensor(w) and type(w).__name__.startswith( + "GTP_" + ), "dequantize must not mutate the live param's class" + + +def _worker_native_fp8_dcp_load_copy(rank, world_size, port): + """Copying a BF16 checkpoint value back into a live native-FP8 GTP weight must go through + ``gtp_native_fp8_load_context`` (a55b load-crash guard: TE's exact-class MXFP8 check rejects + the dynamic ``GTP_`` subclass). Assert the raw copy raises but succeeds under the + context, and the reclassed weight dequantizes to the loaded values. + """ + _requires_mxfp8() + + # This test exercises a single-rank concern (the __class__ swap during copy_), so use the + # default WORLD group as the gtp_remat_group rather than dist.new_group subgroups — the + # latter's secondary NCCL socket bootstrap is flaky on some multi-node allocations and would + # mask the fp8 copy behavior under test. + gtp_remat_group = dist.group.WORLD + per_tp_out, in_f = 128, 128 # MXFP8 needs dims % 32; shard = 128/world(4) = 32 + recipe = MXFP8BlockScaling() + + shard_out, pad = gtp_remat_shard_dim0(per_tp_out, gtp_remat_group) + with fp8_model_init(enabled=True, recipe=recipe): + lin = te.Linear(in_f, shard_out, bias=False, params_dtype=torch.bfloat16, device="cuda") + lin.gtp_remat_size = gtp_remat_group.size() + attach_gtp_to_presharded_module(lin, gtp_remat_group, pad) + with fp8_autocast(enabled=True, fp8_recipe=recipe): + _ = lin(torch.randn(32, in_f, dtype=torch.bfloat16, device="cuda")) + + assert is_float8tensor(lin.weight) and type(lin.weight).__name__.startswith("GTP_") + + # The dequantized BF16 payload a DCP load would hand back for this shard. + target_bf16 = torch.randn(shard_out, in_f, dtype=torch.bfloat16, device="cuda") + + # (1) Without the context, copy_ into the subclass raises in TE's C++ quantizer. + # Mirror production's _load_from_state_dict, which copies under no_grad. + raised = False + try: + with torch.no_grad(): + lin.weight.copy_(target_bf16) + except Exception as e: # noqa: BLE001 + raised = True + assert "MXFP8" in str(e) or "IsMXFP8Tensor" in str(e), str(e) + assert raised, "copy_ into GTP_ unexpectedly succeeded without the load context" + + # (2) Under the context the copy succeeds; the reclassed weight holds the loaded values. + with torch.no_grad(), gtp_native_fp8_load_context(lin): + lin.weight.copy_(target_bf16) + assert is_float8tensor(lin.weight) and type(lin.weight).__name__.startswith( + "GTP_" + ), "load context must reclass back to the GTP subclass" + loaded = dequantize_gtp_native_fp8(lin.weight) + # MXFP8 round-trip is lossy; check it tracks the target (not the pre-copy garbage). + rel = (loaded - target_bf16).abs().max() / target_bf16.abs().max().clamp_min(1e-6) + assert rel < 0.2, f"loaded weight does not match checkpoint values (max rel {rel:.3f})" + + +def _worker_helper_offsets_tp_eq_gtp_axis(rank, world_size, port): + """TP=2, GTP_remat=2 (4 ranks total). Weight is GTPShardedParam. + + Production flow: Mcore TE constructs the Linear with already-TP-sliced + out_features (i.e. full / tp_size). GTP_remat then slices that further by + gtp_remat_size. We mimic that by starting with a per-TP-rank tensor of size + ``full // tp_size`` and letting wrap_module_params_gtp slice it. + """ + gtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + tp_group = _cached_new_group([0, 2]) if rank in (0, 2) else _cached_new_group([1, 3]) + + full_out_features = 8 + tp_size, gtp_remat_size = 2, 2 + per_tp_out = full_out_features // tp_size # 4 + per_shard_out = per_tp_out // gtp_remat_size # 2 + in_features = 4 + + weight = _make_gtp_shard(per_tp_out, in_features, gtp_remat_group) + assert weight.shape == (per_shard_out, in_features), ( + f"rank={rank} local shard shape {tuple(weight.shape)} != " + f"({per_shard_out}, {in_features})" + ) + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=tp_group, + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + assert isinstance(st, ShardedTensor), f"Expected ShardedTensor, got {type(st)}" + + # Composite offset: (axis=0, tp_rank*gtp_remat_size+gtp_rank, tp_size*gtp_remat_size) + # rank → (tp_rank, gtp_rank): 0→(0,0), 1→(0,1), 2→(1,0), 3→(1,1) + tp_rank = rank // 2 + gtp_rank = rank % 2 + expected_offset = (tp_rank * gtp_remat_size + gtp_rank) * per_shard_out + assert ( + st.global_offset[0] == expected_offset + ), f"rank={rank} expected axis-0 offset {expected_offset}, got {st.global_offset[0]}" + assert ( + st.global_shape[0] == full_out_features + ), f"rank={rank} expected global axis-0 size {full_out_features}, got {st.global_shape[0]}" + + +def _worker_helper_offsets_tp_neq_gtp_axis(rank, world_size, port): + """Row-parallel: TP=2 shards axis 1, GTP_remat=2 shards axis 0. + + Per-TP-rank tensor: (full_out, full_in/tp_size). GTP_remat further shards + axis 0 to (full_out/gtp_remat_size, full_in/tp_size). + """ + gtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + tp_group = _cached_new_group([0, 2]) if rank in (0, 2) else _cached_new_group([1, 3]) + + full_out, full_in = 8, 4 + tp_size, gtp_remat_size = 2, 2 + per_tp_in = full_in // tp_size # 2 + per_shard_out = full_out // gtp_remat_size # 4 + + weight = _make_gtp_shard(full_out, per_tp_in, gtp_remat_group) + assert weight.shape == (per_shard_out, per_tp_in) + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 1}, # row-parallel + sharded_offsets=(), + tp_group=tp_group, + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + tp_rank = rank // 2 + gtp_rank = rank % 2 + assert ( + st.global_offset[0] == gtp_rank * per_shard_out + ), f"rank={rank} axis-0 offset wrong: {st.global_offset[0]}" + assert ( + st.global_offset[1] == tp_rank * per_tp_in + ), f"rank={rank} axis-1 offset wrong: {st.global_offset[1]}" + assert st.global_shape == ( + full_out, + full_in, + ), f"rank={rank} global shape {st.global_shape} != ({full_out}, {full_in})" + + +def _worker_helper_no_op_no_gtp_remat(rank, world_size, port): + """Helper must delegate to vanilla when state_dict has no GTPShardedParam. + + Per-TP-rank shape under column-parallel TP=2: (full_out//tp_size, in). + """ + tp_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + + full_out, in_features, tp_size = 8, 4, 2 + per_tp_out = full_out // tp_size + + plain = torch.nn.Parameter( + torch.zeros(per_tp_out, in_features, dtype=torch.bfloat16, device="cuda") + ) + bias = torch.nn.Parameter(torch.zeros(per_tp_out, dtype=torch.bfloat16, device="cuda")) + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": plain, "bias": bias}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0, "bias": 0}, + sharded_offsets=(), + tp_group=tp_group, + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + # tp_group is [0,1] for ranks 0,1 and [2,3] for ranks 2,3 here — local tp_rank = rank % 2 + tp_rank = rank % 2 + assert sharded["weight"].global_offset[0] == tp_rank * per_tp_out, ( + f"rank={rank} fallback path produced wrong offset for weight: " + f"{sharded['weight'].global_offset[0]}" + ) + assert sharded["weight"].global_shape == (full_out, in_features) + + +def _worker_helper_padded_inproj_no_pad_case(rank, world_size, port): + """``in_proj.weight`` shape modeled after the production case (z|x|B|C|dt + concat along dim 0). With GTP_remat=4 and these dim-0 sizes the alignment + constraint ``dim0 % (gtp_remat_size * pad_for_alignment) == 0`` is satisfied — + *no* padding fires. Verify the helper emits the expected offsets. + """ + update_gtp_config(pad_for_alignment=16) + # dim0 = 512+512+64+64+8 = 1160 → 1160 % (4*16=64) = 8 ⇒ NOT aligned. + # Pick sizes that ARE aligned to 64 to exercise the no-pad path: + dim0 = 1152 # = 18 * 64; alignment-clean for gtp_remat_size=4, pad=16 + in_features = 4 + + # All 4 ranks form a single GTP_remat group. + gtp_remat_group = _cached_new_group(list(range(world_size))) + weight = _make_gtp_shard(dim0, in_features, gtp_remat_group) + + # No padding ⇒ local shape is exactly dim0 / 4 = 288 + expected_local = dim0 // 4 + assert weight.shape == (expected_local, in_features), ( + f"rank={rank}: padding should NOT have fired (dim0 aligned); " + f"got local shape {tuple(weight.shape)}, expected ({expected_local}, {in_features})" + ) + assert getattr(weight, "pad_length", 0) == 0 + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=_cached_new_group([rank]), # trivial 1-rank TP group + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + assert ( + st.global_shape[0] == dim0 + ), f"rank={rank} no-pad case: global_shape[0] {st.global_shape[0]} != {dim0}" + assert st.global_offset[0] == rank * expected_local + + +def _worker_helper_padded_inproj_pad_case(rank, world_size, port): + """in_proj with a dim-0 size needing GTP_remat padding (dim0=1160, gtp_remat_size=4, + pad_for_alignment=16 -> 56 pad rows -> padded 1216, per-rank shard 304). Pins that the + padded global shape round-trips when save_gtp_remat_size == load_gtp_remat_size. + """ + update_gtp_config(pad_for_alignment=16) + dim0_unpadded = 1160 # z(512) + x(512) + B(64) + C(64) + dt(8) + in_features = 4 + gtp_remat_size = world_size + alignment_block = 16 * gtp_remat_size # = 64 + pad = (alignment_block - dim0_unpadded % alignment_block) % alignment_block + dim0_padded = dim0_unpadded + pad + per_shard = dim0_padded // gtp_remat_size + + gtp_remat_group = _cached_new_group(list(range(world_size))) + weight = _make_gtp_shard(dim0_unpadded, in_features, gtp_remat_group) + + assert weight.shape == ( + per_shard, + in_features, + ), f"rank={rank}: post-pad shard shape {tuple(weight.shape)} != ({per_shard}, {in_features})" + # Only rank-3 (the last GTP_remat rank) carries the trailing pad rows; all ranks + # report the same pad_length (an invariant set by _gtp_slice_one_param). + assert ( + getattr(weight, "pad_length", 0) == pad + ), f"rank={rank}: pad_length {getattr(weight, 'pad_length', 0)} != {pad}" + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=_cached_new_group([rank]), + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + # Helper saves the padded global. ``allow_shape_mismatch=True`` is what + # makes the saved tensor portable to a different load-time GTP_remat topology + # (different alignment choice yields a different padded size). + assert ( + st.global_shape[0] == dim0_padded + ), f"rank={rank} pad case: global_shape[0] {st.global_shape[0]} != {dim0_padded}" + assert st.global_offset[0] == rank * per_shard + assert st.allow_shape_mismatch is True, ( + f"rank={rank} pad case: allow_shape_mismatch must be True when GTP_remat padding fires; " + f"otherwise the ckpt cannot be loaded at a different GTP_remat topology." + ) + + +def _worker_helper_cross_topology_reshard_metadata(rank, world_size, port): + """Pin the cross-topology reshard contract via ShardedTensor metadata. + + We can't run a real DCP save/load against itself within a single torchrun + (need separate worlds), but we can verify the saved ShardedTensor carries + everything DCP needs to do the reshard: ``allow_shape_mismatch=True`` and + a global_shape large enough to cover any compatible load-side topology + (≥ unpadded original). + """ + update_gtp_config(pad_for_alignment=16) + dim0_unpadded = 1160 + in_features = 4 + gtp_remat_size = world_size + alignment_block = 16 * gtp_remat_size # 64 + dim0_padded = ( + dim0_unpadded + (alignment_block - dim0_unpadded % alignment_block) % alignment_block + ) + per_shard = dim0_padded // gtp_remat_size + + gtp_remat_group = _cached_new_group(list(range(world_size))) + weight = _make_gtp_shard(dim0_unpadded, in_features, gtp_remat_group) + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=_cached_new_group([rank]), + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + # 1. The saved global covers >= unpadded original size. + assert st.global_shape[0] >= dim0_unpadded, ( + f"rank={rank} saved global_shape ({st.global_shape[0]}) < unpadded ({dim0_unpadded}); " + f"would lose valid data on cross-topology reshard." + ) + # 2. ``allow_shape_mismatch=True`` lets DCP tolerate that the load-side + # padded size may differ. + assert st.allow_shape_mismatch is True + # 3. Each rank's offset+local_shape covers a contiguous slice of the + # padded global; together the ranks cover [0, padded_global). + assert st.global_offset[0] + st.local_shape[0] <= st.global_shape[0] + assert st.global_offset[0] + st.local_shape[0] == (rank + 1) * per_shard + + +def _worker_save_then_load_offsets_symmetric(rank, world_size, port): + """Save-side and load-side ShardedTensors must produce identical offsets + and global_shape so DCP can correctly resharded between them. + + We don't run the real DCP save (avoids filesystem / async-writer issues + in CI); we just verify the symmetry property the load path relies on. + """ + update_gtp_config(pad_for_alignment=0) + dim0 = 16 + in_features = 4 + gtp_remat_group = _cached_new_group(list(range(world_size))) + + def _build(prefix): + weight = _make_gtp_shard(dim0, in_features, gtp_remat_group) + return make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix=prefix, + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=_cached_new_group([rank]), + dp_cp_group=_cached_new_group(list(range(world_size))), + )["layer.weight"] + + save_st = _build("layer.") + load_st = _build("layer.") + assert save_st.global_shape == load_st.global_shape + assert save_st.global_offset == load_st.global_offset + assert save_st.local_shape == load_st.local_shape + assert save_st.replica_id == load_st.replica_id + + +def _worker_helper_offsets_ep_egtp(rank, world_size, port): + """EP=2, EGTP_remat=2 (4 ranks): routed-expert weight. + + Mirrors ``TEGroupedLinear.sharded_state_dict``: expert parallelism prepends a + global-expert axis through ``sharded_offsets``, and EGTP_remat shards each expert's + ``out_features`` (axis 0). The GTP_remat-aware checkpoint helper layers the EGTP_remat + axis-0 split on top of the prepended expert offset. + + rank → (ep_rank, egtp_rank): 0→(0,0) 1→(0,1) 2→(1,0) 3→(1,1). + """ + egtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + + ep_size, egtp_remat_size, num_gemms = 2, 2, 1 + ep_rank = rank // 2 + egtp_rank = rank % 2 + per_expert_out = 4 + per_shard_out = per_expert_out // egtp_remat_size # 2 + in_features = 4 + num_global_experts = ep_size * num_gemms # 2 + global_expert_idx = ep_rank * num_gemms # + gemm_idx (0) + + weight = _make_gtp_shard(per_expert_out, in_features, egtp_remat_group) + assert weight.shape == ( + per_shard_out, + in_features, + ), f"rank={rank} EGTP_remat shape {tuple(weight.shape)} != ({per_shard_out}, {in_features})" + + sharded = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {"weight": weight}, + prefix="", + tensor_parallel_layers_axis_map={"weight": 0}, + # EP prepends the global-expert axis; EGTP_remat shards out_features below it. + sharded_offsets=((0, global_expert_idx, num_global_experts),), + tp_group=_cached_new_group([rank]), # no TP in this case + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["weight"] + assert isinstance(st, ShardedTensor), f"Expected ShardedTensor, got {type(st)}" + # global shape = (num_global_experts, full_out_features, in_features) + assert st.global_shape == (num_global_experts, per_expert_out, in_features), ( + f"rank={rank} global_shape {st.global_shape} != " + f"({num_global_experts}, {per_expert_out}, {in_features})" + ) + # Prepended expert axis (axis 0): offset == this rank's global expert index. + assert ( + st.global_offset[0] == global_expert_idx + ), f"rank={rank} expert-axis offset {st.global_offset[0]} != {global_expert_idx}" + # EGTP_remat axis (weight axis 0, shifted to global axis 1): offset == egtp_rank · per_shard. + assert ( + st.global_offset[1] == egtp_rank * per_shard_out + ), f"rank={rank} EGTP_remat axis-1 offset {st.global_offset[1]} != {egtp_rank * per_shard_out}" + + +def _worker_helper_embedding_offsets(rank, world_size, port): + """Embedding / output_layer path: ``VocabParallelEmbedding.sharded_state_dict`` calls + ``make_tp_sharded_tensor_for_checkpoint`` DIRECTLY (it needs allow_shape_mismatch for + vocab padding), bypassing the GTP_remat-aware wrapper. So that helper itself must layer the + GTP_remat axis-0 split. TP=2, GTP_remat=2, tp_axis=0 → composite axis-0 offset, same as the + column-parallel case. + """ + gtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + tp_group = _cached_new_group([0, 2]) if rank in (0, 2) else _cached_new_group([1, 3]) + + full_vocab, hidden = 8, 4 + tp_size, gtp_remat_size = 2, 2 + per_tp = full_vocab // tp_size # 4 + per_shard = per_tp // gtp_remat_size # 2 + + weight = _make_gtp_shard(per_tp, hidden, gtp_remat_group) + assert weight.shape == (per_shard, hidden) + + st = make_tp_sharded_tensor_for_checkpoint( + tensor=weight, + key="embedding.word_embeddings.weight", + tp_axis=0, + allow_shape_mismatch=True, # how VocabParallelEmbedding calls it + prepend_offsets=(), + tp_group=tp_group, + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + assert isinstance(st, ShardedTensor), f"Expected ShardedTensor, got {type(st)}" + tp_rank = rank // 2 + gtp_rank = rank % 2 + expected_offset = (tp_rank * gtp_remat_size + gtp_rank) * per_shard + assert ( + st.global_offset[0] == expected_offset + ), f"rank={rank} embedding axis-0 offset {st.global_offset[0]} != {expected_offset}" + assert ( + st.global_shape[0] == full_vocab + ), f"rank={rank} embedding global axis-0 {st.global_shape[0]} != {full_vocab}" + + +def _worker_helper_public_wrapper_delegates(rank, world_size, port): + """The public ``make_sharded_tensors_for_checkpoint`` (the entry point most layers call, + e.g. ColumnParallelLinear / output_layer) must detect a GTPShardedParam and produce the + GTP_remat-composite offset — i.e. it delegates to the GTP_remat-aware path not the vanilla + TP-only one. TP=2, GTP_remat=2, column-parallel (tp_axis=0). + """ + gtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + tp_group = _cached_new_group([0, 2]) if rank in (0, 2) else _cached_new_group([1, 3]) + + full_out, in_features = 8, 4 + tp_size, gtp_remat_size = 2, 2 + per_tp_out = full_out // tp_size # 4 + per_shard_out = per_tp_out // gtp_remat_size # 2 + + weight = _make_gtp_shard(per_tp_out, in_features, gtp_remat_group) + + sharded = make_sharded_tensors_for_checkpoint( + {"weight": weight}, + prefix="layer.", + tensor_parallel_layers_axis_map={"weight": 0}, + sharded_offsets=(), + tp_group=tp_group, + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + st = sharded["layer.weight"] + assert isinstance(st, ShardedTensor), f"Expected ShardedTensor, got {type(st)}" + tp_rank = rank // 2 + gtp_rank = rank % 2 + expected_offset = (tp_rank * gtp_remat_size + gtp_rank) * per_shard_out + assert st.global_offset[0] == expected_offset, ( + f"rank={rank} public wrapper did not produce the GTP_remat-composite offset: " + f"{st.global_offset[0]} != {expected_offset} (delegation to the GTP_remat path failed?)" + ) + assert ( + st.global_shape[0] == full_out + ), f"rank={rank} global axis-0 {st.global_shape[0]} != {full_out}" + + +def _worker_helper_replicated_sink_rejects_gtp(rank, world_size, port): + """Sanity guard: a GTPShardedParam must NEVER be saved via the replicated + make_sharded_tensor_for_checkpoint (it would record a shard-sized global shape). + The helper asserts; this pins that behaviour. + """ + from megatron.core.utils import make_sharded_tensor_for_checkpoint + + gtp_remat_group = _cached_new_group([0, 1]) if rank in (0, 1) else _cached_new_group([2, 3]) + weight = _make_gtp_shard(4, 4, gtp_remat_group) + with pytest.raises(AssertionError): + make_sharded_tensor_for_checkpoint( + weight, + "weight", + tp_group=_cached_new_group([rank]), + dp_cp_group=_cached_new_group(list(range(world_size))), + ) + + +def _worker_mamba_replicated_param_replica_ids(rank, world_size, port): + """MambaMixer.sharded_state_dict under GTP_remat: replicated directly-owned params + (A_log / dt_bias / D / conv1d.*) must get conflict-free replica_ids -- unique across the + peers holding each chunk, exactly one writer -- so DCP elects a single writer per chunk. + """ + GTP_remat = 2 # world=4 -> tp1 * gtp2 * dp2 (exercises both gtp_remat peers and replicate DP) + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=GTP_remat + ) + model_parallel_cuda_manual_seed(42) + pg = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + + config = TransformerConfig( + num_attention_heads=32, + num_layers=1, + hidden_size=4096, + mamba_num_heads=128, + mamba_head_dim=64, + mamba_state_dim=128, + mamba_num_groups=8, + use_mamba_mem_eff_path=True, + params_dtype=torch.bfloat16, + hidden_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + submodules = MambaLayerSubmodules( + mixer=ModuleSpec( + module=MambaMixer, + submodules=MambaMixerSubmodules( + in_proj=TELayerNormColumnParallelLinear, out_proj=TERowParallelLinear + ), + ), + mamba_bda=get_bias_dropout_add, + ) + layer = MambaLayer(config, submodules, layer_number=1, pg_collection=pg).cuda() + assert any( + isinstance(p, GTPShardedParam) for p in layer.parameters() + ), "GTP_remat not active: no GTPShardedParam in the GTP_remat=2 Mamba layer" + + # Checkpoint replica election for gtp_remat-REPLICATED params needs the gtp_remat-INCLUSIVE + # group so gtp_remat peers get distinct replica_ids (matches production's get_default + # metadata, which uses the gtp_remat-inclusive default). The replicate group would collide. + metadata = {'dp_cp_group': ps.get_data_parallel_group(with_context_parallel=True)} + sd = layer.mixer.sharded_state_dict(prefix='mixer.', metadata=metadata) + + target_bases = {'A_log', 'dt_bias', 'D', 'conv1d.weight', 'conv1d.bias'} + local = {} + for key, val in sd.items(): + base = key.split('mixer.', 1)[-1] + if base in target_bases and isinstance( + val, (ShardedTensor, ShardedTensorFactory, ShardedObject) + ): + rid = val.replica_id + if isinstance(rid, tuple): + local[base] = tuple(rid) + + gathered = [None] * world_size + dist.all_gather_object(gathered, local) + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + if rank == 0: + bases = set(gathered[0]) + assert bases, "no GTP_remat-replicated tiny params found in MambaMixer sharded_state_dict" + for base in sorted(bases): + rids = [g[base] for g in gathered] + assert ( + len(set(rids)) == world_size + ), f"{base}: replica_id collision across ranks -> DCP write conflict: {rids}" + n_writers = sum(is_main_replica(r) for r in rids) + assert n_writers == 1, f"{base}: expected exactly 1 writer, got {n_writers}: {rids}" + + +def _worker_replicated_param_needs_gtp_inclusive_dp_cp(rank, world_size, port): + """Regression for the checkpoint-save duplicate-writer bug in save_checkpoint_and_time. + + A REPLICATED param's replica_id must use the gtp_remat-INCLUSIVE group (``pg.dp_cp_gtp_remat``); + the gtp-excluded ``pg.dp_cp`` collapses gtp_remat peers to one replica_id -> multiple writers + -> save validation failure. world=4 -> tp1*gtp2*dp2 (replicate=2 ranks, inclusive=4). + """ + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + pg = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'dp_cp', 'dp_cp_gtp_remat'] + ) + + # The two attributes save_checkpoint_and_time may read must differ under GTP_remat, else the + # group choice would be moot: full = replicate x gtp_remat(2). + assert ( + get_pg_size(pg.dp_cp_gtp_remat) == get_pg_size(pg.dp_cp) * 2 + ), f"full={get_pg_size(pg.dp_cp_gtp_remat)} replicate={get_pg_size(pg.dp_cp)}" + + replicated = torch.nn.Parameter(torch.zeros(8, 4, dtype=torch.bfloat16, device="cuda")) + + def _gather_replica_ids(dp_cp_group): + sd = make_sharded_tensors_for_checkpoint( + {"w": replicated}, + prefix="", + tensor_parallel_layers_axis_map={}, + tp_group=pg.tp, + dp_cp_group=dp_cp_group, + ) + out = [None] * world_size + dist.all_gather_object(out, tuple(sd["w"].replica_id)) + return out + + rids_replicate = _gather_replica_ids(pg.dp_cp) # the bug + rids_full = _gather_replica_ids(pg.dp_cp_gtp_remat) # the fix + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + + if rank == 0: + # Replicate (gtp-excluded) group: gtp_remat peers collapse -> >1 writer (reproduces bug). + assert ( + sum(is_main_replica(r) for r in rids_replicate) > 1 + ), f"replicate dp_cp should collide across gtp_remat peers, got {rids_replicate}" + # gtp_remat-inclusive group: every holder distinct, exactly one writer. + assert len(set(rids_full)) == world_size, f"full-group replica_id collision: {rids_full}" + assert ( + sum(is_main_replica(r) for r in rids_full) == 1 + ), f"full group must elect exactly one writer, got {rids_full}" + + +def _worker_embedding_writer_election_gtp_inclusive_default(rank, world_size, port): + """VocabParallelEmbedding calls make_tp_sharded_tensor_for_checkpoint directly (needs + allow_shape_mismatch), so its GTP writer election must use the gtp_remat-EXCLUDED DP group. + Assert every axis-0 offset has exactly one main-replica writer and the offsets tile the vocab. + + world=4 -> tp1 * gtp2 * dp2: vocab split in 2 (gtp), each half replicated on 2 dp ranks. + """ + from collections import defaultdict + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + gtp_group = ps.get_gtp_weight_remat_group() + assert gtp_group.size() == 2, f"expected gtp_remat_size=2, got {gtp_group.size()}" + + full_vocab, hidden = 8, 4 + per_shard = full_vocab // gtp_group.size() # 4 (tp=1 -> per_tp == full_vocab) + weight = _make_gtp_shard(full_vocab, hidden, gtp_group) + assert weight.shape == (per_shard, hidden) + + st = make_tp_sharded_tensor_for_checkpoint( + tensor=weight, + key="embedding.word_embeddings.weight", + tp_axis=0, + allow_shape_mismatch=True, # how VocabParallelEmbedding calls it + prepend_offsets=(), + tp_group=ps.get_tensor_model_parallel_group(), + dp_cp_group=ps.get_data_parallel_group(with_context_parallel=True), + ) + mine = (int(st.global_offset[0]), tuple(st.replica_id)) + gathered = [None] * world_size + dist.all_gather_object(gathered, mine) + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + if rank == 0: + by_offset = defaultdict(list) + for off, rid in gathered: + by_offset[off].append(rid) + assert set(by_offset) == {0, per_shard}, f"vocab offsets must tile: {sorted(by_offset)}" + for off, rids in by_offset.items(): + n_writers = sum(is_main_replica(r) for r in rids) + assert n_writers == 1, ( + f"vocab offset {off}: expected exactly 1 checkpoint writer, got {n_writers} " + f"(replica_ids {rids}); a gtp_remat-inclusive default DP group leaves a shard " + f"with no main-replica writer -> 'Invalid access pattern' at save" + ) + + +def _worker_mamba_inproj_optim_param_map(rank, world_size, port): + """GTP_remat+Muon ckpt fix: in_proj's gathered+split model entry does NOT id-match the + per-shard optimizer param, so get_param_id_to_sharded_param_map misses it (the KeyError seen in + Float16OptimizerWithFloat16Params.sharded_state_dict). Verify the per-shard fallback used by the + fix restores a ShardedTensor with local_shape == the optimizer param shape, which + make_sharded_optimizer_tensor then accepts. + """ + from megatron.core.dist_checkpointing.optimizer import ( + get_param_id_to_sharded_param_map, + make_sharded_optimizer_tensor, + ) + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + tag_gtp_params_with_names, + ) + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + model_parallel_cuda_manual_seed(42) + pg = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + config = TransformerConfig( + num_attention_heads=32, + num_layers=1, + hidden_size=4096, + mamba_num_heads=128, + mamba_head_dim=64, + mamba_state_dim=128, + mamba_num_groups=8, + use_mamba_mem_eff_path=True, + params_dtype=torch.bfloat16, + hidden_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + submodules = MambaLayerSubmodules( + mixer=ModuleSpec( + module=MambaMixer, + submodules=MambaMixerSubmodules( + in_proj=TELayerNormColumnParallelLinear, out_proj=TERowParallelLinear + ), + ), + mamba_bda=get_bias_dropout_add, + ) + layer = MambaLayer(config, submodules, layer_number=1, pg_collection=pg).cuda() + tag_gtp_params_with_names(layer) # set _debug_name (mirrors production setup) + + in_proj_w = layer.mixer.in_proj.weight + assert isinstance(in_proj_w, GTPShardedParam), "in_proj.weight should be GTP_remat-sharded" + + metadata = {'dp_cp_group': ps.get_data_parallel_group(with_context_parallel=True)} + model_sd = layer.mixer.sharded_state_dict(prefix='mixer.', metadata=metadata) + + # Reproduce the gap: in_proj's per-shard optim param has no id-match in the model dict. + id_map = get_param_id_to_sharded_param_map(model_sd, [in_proj_w]) + assert 0 not in id_map, "expected in_proj to be MISSING from id map (the KeyError gap)" + + # The fix's per-shard fallback restores a matching entry. + key = in_proj_w._debug_name or '_gtp_optim_param_0' + entry = make_sharded_tensors_for_checkpoint_with_gtp_remat( + {key: in_proj_w}, + prefix='', + tensor_parallel_layers_axis_map={key: 0}, + tp_group=ps.get_tensor_model_parallel_group(), + dp_cp_group=ps.get_data_parallel_group(with_context_parallel=True), + )[key] + assert tuple(entry.local_shape) == tuple(in_proj_w.shape), ( + f"per-shard entry local_shape {tuple(entry.local_shape)} != param shape " + f"{tuple(in_proj_w.shape)}" + ) + # make_sharded_optimizer_tensor must accept it for a same-shape optimizer state tensor. + opt_state = torch.zeros_like(in_proj_w) + osh = make_sharded_optimizer_tensor(entry, opt_state, prefix='optimizer.state.exp_avg') + assert osh is not None + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + +def _worker_save_load_roundtrip_needs_gtp_inclusive_group(rank, world_size, ckpt_base): + """Save->load roundtrip: save and load must use the gtp_remat-INCLUSIVE replica group. + + Loading with the gtp_remat-EXCLUDING ``pg.dp_cp`` collides replica_ids across gtp_remat + peers -> DCP 'Invalid access pattern' (the a55b load failure). world=4 -> tp1*gtp2*dp2. + """ + from megatron.core.dist_checkpointing import load, save + from tests.unit_tests.dist_checkpointing import TempNamedDir + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + try: + pg = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'dp_cp', 'dp_cp_gtp_remat'] + ) + # The two group choices must differ under GTP_remat, else the test is moot. + assert ( + get_pg_size(pg.dp_cp_gtp_remat) == get_pg_size(pg.dp_cp) * 2 + ), f"full={get_pg_size(pg.dp_cp_gtp_remat)} replicate={get_pg_size(pg.dp_cp)}" + + # A GTP-replicated param: byte-identical on every rank (like decoder.final_norm.weight). + replicated = torch.nn.Parameter( + torch.arange(32, dtype=torch.bfloat16, device="cuda").reshape(8, 4) + ) + + def _sd(dp_cp_group): + return make_sharded_tensors_for_checkpoint( + {"w": replicated}, + prefix="", + tensor_parallel_layers_axis_map={}, + tp_group=pg.tp, + dp_cp_group=dp_cp_group, + ) + + # Negative invariant (uniform, no failed collective): under the gtp_remat-EXCLUDING + # replicate group the gtp_remat peers collapse to the same replica_id -> >1 main writer + # for the identical element. That is exactly what makes DCP load-validation raise the + # a55b 'Invalid access pattern'. (validate_sharding_integrity raises on rank 0 only, so a + # real failed load() can't be asserted cleanly across ranks; we assert the root condition.) + rids_excl = [None] * world_size + dist.all_gather_object(rids_excl, tuple(_sd(pg.dp_cp)["w"].replica_id)) + rids_incl = [None] * world_size + dist.all_gather_object(rids_incl, tuple(_sd(pg.dp_cp_gtp_remat)["w"].replica_id)) + if rank == 0: + assert ( + sum(is_main_replica(r) for r in rids_excl) > 1 + ), f"gtp-excluding dp_cp must collide across gtp_remat peers (the bug): {rids_excl}" + assert ( + sum(is_main_replica(r) for r in rids_incl) == 1 + ), f"gtp-inclusive group must elect exactly one writer: {rids_incl}" + + # Positive end-to-end roundtrip through the real DCP save/load with the gtp_remat-inclusive + # group (what save_checkpoint_and_time and the fixed load_checkpoint both thread): save and + # load must agree on this group, and the replicated data must round-trip intact. + with TempNamedDir(ckpt_base / 'gtp_dcp_roundtrip', sync=True) as ckpt_dir: + save(_sd(pg.dp_cp_gtp_remat), ckpt_dir) + loaded = load(_sd(pg.dp_cp_gtp_remat), ckpt_dir) + assert torch.equal(loaded["w"].cpu(), replicated.detach().cpu()), loaded["w"] + finally: + ps.destroy_model_parallel() + ps.initialize_model_parallel() + + +# --------------------------------------------------------------------------- +# Test class wrappers (4-GPU) +# --------------------------------------------------------------------------- + + +@pytest.mark.run_only_on_devices_with_compute_capability(compute_capability=(10, 0)) +class TestGtpDcpHelper: + def test_mamba_replicated_param_replica_ids(self): + _require_world_size(4) + _worker_mamba_replicated_param_replica_ids(dist.get_rank(), 4, None) + + def test_mamba_inproj_optim_param_map(self): + _require_world_size(4) + _worker_mamba_inproj_optim_param_map(dist.get_rank(), 4, None) + + def test_replicated_param_needs_gtp_inclusive_dp_cp(self): + _require_world_size(4) + _worker_replicated_param_needs_gtp_inclusive_dp_cp(dist.get_rank(), 4, None) + + def test_save_load_roundtrip_needs_gtp_inclusive_group(self, tmp_path_dist_ckpt): + _require_world_size(4) + _worker_save_load_roundtrip_needs_gtp_inclusive_group( + dist.get_rank(), 4, tmp_path_dist_ckpt + ) + + def test_composite_offset_same_axis(self): + _require_world_size(4) + _worker_helper_offsets_tp_eq_gtp_axis(dist.get_rank(), 4, None) + + def test_native_fp8_dcp_save(self): + _require_world_size(4) + _worker_native_fp8_dcp_save(dist.get_rank(), 4, None) + + def test_native_fp8_dcp_load_copy(self): + _require_world_size(4) + _worker_native_fp8_dcp_load_copy(dist.get_rank(), 4, None) + + def test_dual_offsets_cross_axis(self): + _require_world_size(4) + _worker_helper_offsets_tp_neq_gtp_axis(dist.get_rank(), 4, None) + + def test_ep_egtp_offsets(self): + _require_world_size(4) + _worker_helper_offsets_ep_egtp(dist.get_rank(), 4, None) + + def test_embedding_offsets(self): + _require_world_size(4) + _worker_helper_embedding_offsets(dist.get_rank(), 4, None) + + def test_embedding_writer_election(self): + _require_world_size(4) + _worker_embedding_writer_election_gtp_inclusive_default(dist.get_rank(), 4, None) + + def test_public_wrapper_delegates(self): + _require_world_size(4) + _worker_helper_public_wrapper_delegates(dist.get_rank(), 4, None) + + def test_replicated_sink_rejects_gtp(self): + _require_world_size(4) + _worker_helper_replicated_sink_rejects_gtp(dist.get_rank(), 4, None) + + def test_no_op_no_gtp_remat(self): + _require_world_size(4) + _worker_helper_no_op_no_gtp_remat(dist.get_rank(), 4, None) + + def test_inproj_no_pad(self): + _require_world_size(4) + _worker_helper_padded_inproj_no_pad_case(dist.get_rank(), 4, None) + + def test_inproj_with_pad(self): + _require_world_size(4) + _worker_helper_padded_inproj_pad_case(dist.get_rank(), 4, None) + + def test_cross_topology_reshard_metadata(self): + _require_world_size(4) + _worker_helper_cross_topology_reshard_metadata(dist.get_rank(), 4, None) + + def test_save_then_load_offsets_symmetric(self): + _require_world_size(4) + _worker_save_then_load_offsets_symmetric(dist.get_rank(), 4, None) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_fp8_param_gather.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_fp8_param_gather.py new file mode 100644 index 00000000000..127a2fc1761 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_fp8_param_gather.py @@ -0,0 +1,201 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""GTP + MXFP8 --fp8-param-gather / --reuse-grad-buf-for-mxfp8-param-ag correctness. + +Asserts the two MXFP8 param-gather knobs don't change training: a GTP (weight-remat=2) loss +trajectory with the knobs on must match the same run with them off. Reuses the full DDP + +DistributedOptimizer harness from ``test_fp8_param.py::TestFP8Param`` by composition (imported +under a non-``Test*`` alias so pytest doesn't re-collect it), flipping GTP on via +``tensor_parallel_num_weight_shards`` (= tp x gtp_weight_remat_size). +""" + +import pytest +import torch + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from megatron.core.utils import is_te_min_version +from megatron.training.utils import get_device_arch_version + +# Non-"Test*" alias so pytest does not re-collect the whole TestFP8Param suite here (wrong +# world/DP config + global-state pollution); reused by composition only. +from tests.unit_tests.test_fp8_param import TestFP8Param as _FP8ParamHarness +from tests.unit_tests.test_fp8_param import fp8_available, reason_for_no_fp8 + + +class TestGTPFp8ParamGather: + """GTP weight-remat=2 loss-trajectory parity for the MXFP8 param-gather knobs.""" + + @pytest.mark.skipif( + get_device_arch_version() < 10, reason="MXFP8 is supported since Blackwell architecture" + ) + @pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8) + @pytest.mark.skipif(not is_te_min_version("2.3.0.dev0"), reason="TE 2.3.0.dev0 is required") + @pytest.mark.parametrize("dp_overlap", [(False, False), (True, True)]) + # (tp_size, num_weight_shards, min_gpus): tp2 case guards a TP/GTP axis-order inversion in + # native-FP8 init (real TE TP divide + sequence_parallel x GTP2). + @pytest.mark.parametrize("tp_case", [(1, 2, 2), (2, 4, 4)]) + def test_gtp_mxfp8_fp8_param_gather(self, dp_overlap, tp_case): + """GTP weight-remat=2: fp8-param loss must track pure-BF16 loss within MXFP8 noise. + + A frozen fp8 forward weight (optimizer updates not reaching the native fp8 shard) instead + leaves fp8 flat while BF16 descends (~1+ gap). dp_overlap=(overlap_param_gather, + overlap_grad_reduce); the overlap leg exercises the ``_copy_main_params_to_param_buffer`` + path GTP hooks for --reuse-grad-buf-for-mxfp8-param-ag. + """ + tp_size, num_shards, min_gpus = tp_case + if torch.cuda.device_count() < min_gpus: + pytest.skip(f"Requires {min_gpus} CUDA devices for TP{tp_size} x GTP weight-remat=2") + + harness = _FP8ParamHarness() + harness.setup_method(None) + # num-microbatches uses data_parallel_size = world/tp (gtp is a DP sub-axis). + harness.micro_batch_size = 1 + try: + common = dict( + tp_size=tp_size, + global_batch_size=4, + overlap_param_gather=dp_overlap[0], + overlap_grad_reduce=dp_overlap[1], + tensor_parallel_num_weight_shards=num_shards, # tp * N => gtp_weight_remat_size=N + # Untie: the tied path feeds the GTP-sharded embedding into a Megatron-native + # ColumnParallelLinear, which does no GTP all-gather (TE-only) and fails its check. + untie_embeddings_and_output_weights=True, + ) + loss_fp8 = harness._run_test_helper(recipe="mxfp8", fp8_param_gather=True, **common) + # Pure BF16 GTP reference: fp8=None overrides the harness default (recipe inert). + loss_bf16 = harness._run_test_helper( + recipe="delayed", fp8_param_gather=False, fp8=None, **common + ) + # Max drift ~0.03 over 100 steps (MXFP8 noise); 0.05 stays above it and trips on the + # ~1+ frozen-weight gap. + diff = (loss_fp8 - loss_bf16).abs().max().item() + assert diff < 0.05, ( + f"GTP+mxfp8 fp8-param-gather loss diverges from pure-BF16 GTP baseline " + f"(max per-step |diff|={diff:.4f}; fp8: {loss_fp8[0]:.3f}->{loss_fp8[-1]:.3f}, " + f"bf16: {loss_bf16[0]:.3f}->{loss_bf16[-1]:.3f})." + ) + finally: + harness.teardown_method(None) + # Restore GTP_CONFIG defaults mutated by the mxfp8 arg setup. + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + update_gtp_config, + ) + + update_gtp_config(pad_for_alignment=16, calculate_per_token_loss=False) + + @pytest.mark.skipif( + get_device_arch_version() < 10, reason="MXFP8 is supported since Blackwell architecture" + ) + @pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8) + @pytest.mark.skipif(not is_te_min_version("2.3.0.dev0"), reason="TE 2.3.0.dev0 is required") + def test_gtp_mxfp8_moe_fp8_param_gather(self): + """MoE grouped-expert (TEGroupedLinear) native-FP8 GTP: loss must track pure-BF16 GTP. + + Covers the EGTP-sharded expert weights built as native MXFP8 shards under + --fp8-param-gather — the gap the dense test (attention + dense MLP) leaves. Same parity + assertion as the dense case. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices for EP=2 x GTP weight-remat=2 MoE") + + harness = _FP8ParamHarness() + harness.setup_method(None) + harness.micro_batch_size = 1 + try: + common = dict( + tp_size=1, + global_batch_size=4, + overlap_param_gather=True, + overlap_grad_reduce=True, + tensor_parallel_num_weight_shards=2, # tp=1 * 2 => gtp_weight_remat_size=2 (EGTP) + untie_embeddings_and_output_weights=True, + # MoE grouped experts (mirror test_mxfp8_moe), EP=2. + num_experts=2, + moe_grouped_gemm=True, + expert_model_parallel_size=2, + moe_token_dispatcher_type="alltoall", + moe_router_topk=1, + moe_router_pre_softmax=True, + moe_router_load_balancing_type="none", + moe_aux_loss_coeff=0.0, + moe_ffn_hidden_size=128, + ) + loss_fp8 = harness._run_test_helper(recipe="mxfp8", fp8_param_gather=True, **common) + loss_bf16 = harness._run_test_helper( + recipe="delayed", fp8_param_gather=False, fp8=None, **common + ) + diff = (loss_fp8 - loss_bf16).abs().max().item() + assert diff < 0.05, ( + f"GTP+mxfp8 MoE fp8-param-gather loss diverges from pure-BF16 GTP baseline " + f"(max per-step |diff|={diff:.4f}; fp8: {loss_fp8[0]:.3f}->{loss_fp8[-1]:.3f}, " + f"bf16: {loss_bf16[0]:.3f}->{loss_bf16[-1]:.3f})." + ) + finally: + harness.teardown_method(None) + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + update_gtp_config, + ) + + update_gtp_config(pad_for_alignment=16, calculate_per_token_loss=False) + + @pytest.mark.skipif( + get_device_arch_version() < 10, reason="MXFP8 is supported since Blackwell architecture" + ) + @pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8) + @pytest.mark.skipif(not is_te_min_version("2.3.0.dev0"), reason="TE 2.3.0.dev0 is required") + def test_gtp_mxfp8_save_does_not_perturb_training(self): + """A checkpoint save must NOT mutate the live weights. + + Runs GTP+mxfp8+fp8-param-gather twice with identical seeds — once driving the production + save path mid-training (force_param_sync + sharded_state_dict), once without — and requires + matching loss trajectories. overlap_param_gather=True makes should_disable_forward_pre_hook + True so force_param_sync actually runs; passing the optimizer copies FP32 masters into the + param buffer first, so the copy-back re-quantizes the GTP native-FP8 shard from masters (not + stale grad scratch). Guards the historical post-save loss spike (seen at a55b) — a save + side-effect test_gtp_dcp can't see (it never trains after saving). + """ + if torch.cuda.device_count() < 2: + pytest.skip("Requires at least 2 CUDA devices for GTP weight-remat=2") + + common = dict( + tp_size=1, + recipe="mxfp8", + fp8_param_gather=True, + overlap_param_gather=True, + overlap_grad_reduce=True, + global_batch_size=4, + tensor_parallel_num_weight_shards=2, + untie_embeddings_and_output_weights=True, + ) + try: + h1 = _FP8ParamHarness() + h1.setup_method(None) + h1.micro_batch_size = 1 + loss_baseline = h1._run_test_helper(**common) + h1.teardown_method(None) + + h2 = _FP8ParamHarness() + h2.setup_method(None) + h2.micro_batch_size = 1 + loss_saved = h2._run_test_helper(save_at_steps=(5, 10, 15), **common) + h2.teardown_method(None) + + diff = (loss_baseline - loss_saved).abs() + worst = diff.max().item() + # Save runs a real MXFP8 force_param_sync (not bit-exact vs no-save), so allow re-gather + # noise (~0.03/100 steps); 0.1 clears it and still catches the pre-fix O(10) spike. + assert worst < 0.1, ( + f"Checkpoint save perturbed training (max per-step |diff|={worst:.4f} at step " + f"{int(diff.argmax())}); the forced pre-save param-sync is corrupting live FP8 " + f"weights. saved-run around first save: {loss_saved[4:8].tolist()}" + ) + finally: + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + update_gtp_config, + ) + + update_gtp_config(pad_for_alignment=16, calculate_per_token_loss=False) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py new file mode 100644 index 00000000000..b969e5e5bf8 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py @@ -0,0 +1,559 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Numeric repro: GTP_remat gradient correctness through the REAL +DDP + distributed-optimizer + finalize path, with replicate (DP) > 1. + +The validated loss-trajectory test uses DP=1 (replicate=1) and manual +SGD on main_grad, so it cannot catch a gradient-reduction error that only shows +up when the dist-opt shards over a replicate group of size > 1 (the new-at-64-GPU +condition: DP2 x GTP16). This test reproduces that condition at small scale +(world=4 = GTP2 x DP2) and checks the gradient end-to-end against a trusted +no-GTP_remat DP=4 baseline. + +Decisive choices: + * SGD lr=1.0 (NOT Adam): the step is scale-SENSITIVE, so a gtp_remat x gradient + under-scale shows up directly as a gtp_remat x smaller weight delta. Adam would + normalize a uniform scale error away and mask the bug. + * Distinct input per rank (seed=rank): each data-parallel position sees a + different batch (the HSDP guarantee), so the correct reduced grad is the + MEAN over all 4 positions. Baseline (DP4) and GTP_remat (GTP2xDP2) both + span the same 4 positions, so their reduced grads -- and thus post-step + weights and grad-norm -- must match. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( # noqa: F401 + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +HIDDEN = 256 +NUM_HEADS = 8 +FFN_HIDDEN = 512 +NUM_LAYERS = 1 +SEQ = 16 +BATCH = 1 +LR = 1.0 # scale-sensitive SGD step +dtype = torch.bfloat16 + + +def _make_config(calculate_per_token_loss=False): + from megatron.core.transformer.transformer_config import TransformerConfig + + return TransformerConfig( + num_attention_heads=NUM_HEADS, + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + ffn_hidden_size=FFN_HIDDEN, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + calculate_per_token_loss=calculate_per_token_loss, + ) + + +def _make_stack(config, pg_collection): + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + + spec = get_gpt_layer_with_transformer_engine_spec() + return torch.nn.ModuleList( + [ + spec.module(config, spec.submodules, layer_number=i + 1, pg_collection=pg_collection) + for i in range(NUM_LAYERS) + ] + ) + + +def _build_ddp(stack, calculate_per_token_loss=False): + """Wrap the stack in a NON-distributed-optimizer DDP so main_grad holds the + full all-reduced gradient (no optimizer needed; no Adam scale-invariance to + mask a scaling error).""" + from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig + + config = _make_config(calculate_per_token_loss=calculate_per_token_loss) + ddp_config = DistributedDataParallelConfig( + use_distributed_optimizer=False, overlap_grad_reduce=False + ) + module = torch.nn.Sequential() + for i, layer in enumerate(stack): + module.add_module(str(i), layer) + return DistributedDataParallel(config, ddp_config, module) + + +def _run_one_backward(ddp_model, rank, calculate_per_token_loss=False): + ddp_model.zero_grad_buffer() + # Distinct input per rank => the correct reduced grad is the MEAN over ranks. + torch.manual_seed(1000 + rank) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + out = x + for layer in ddp_model.module.children(): + out, _ = layer(out, attention_mask=None) + loss = out.float().mean() + loss.backward() + # Sync ONCE: finish_grad_sync() triggers the (single) grad reduction for + # overlap_grad_reduce=False. Do NOT also call start_grad_sync() — that double- + # reduces, which is idempotent at full-DP size but halves at replicate size. + ddp_model.finish_grad_sync() + from megatron.core.distributed.finalize_model_grads import ( + _allreduce_replicated_grads_over_gtp_remat_group, + ) + + _allreduce_replicated_grads_over_gtp_remat_group( + [ddp_model], calculate_per_token_loss=calculate_per_token_loss + ) + return float(loss.item()) + + +def _full_main_grads(stack): + """Reconstruct full (unsharded) reduced gradients keyed by param name. + + GTPShardedParam.main_grad is the local gtp_remat shard -> all-gather over the gtp_remat + group. Non-GTP_remat params are replicated -> take the local (already gtp_remat-summed) copy. + """ + from megatron.core import parallel_state as ps + + out = {} + for layer in stack: + for name, p in layer.named_parameters(): + g_attr = 'main_grad' if hasattr(p, 'main_grad') else 'grad' + mg = getattr(p, g_attr) + if isinstance(p, GTPShardedParam): + g = ps.get_gtp_weight_remat_group() + shards = [torch.empty_like(mg) for _ in range(g.size())] + dist.all_gather(shards, mg.contiguous(), group=g) + out[name] = torch.cat(shards, dim=0).float().cpu() + else: + out[name] = mg.detach().float().cpu() + return out + + +def _worker(rank, world_size, port, calculate_per_token_loss=False): + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + # ---------- Phase A: baseline, GTP_remat=1 DP=4 (trusted standard path) ---------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + base_stack = _make_stack(_make_config(calculate_per_token_loss=calculate_per_token_loss), pgc) + for layer in base_stack: + layer.cuda() + for p in base_stack.parameters(): + dist.broadcast(p.data, src=0) + saved = {n: p.data.clone() for n, p in base_stack.named_parameters()} + + base_ddp = _build_ddp(base_stack, calculate_per_token_loss=calculate_per_token_loss) + _run_one_backward(base_ddp, rank, calculate_per_token_loss=calculate_per_token_loss) + base_grads = _full_main_grads(base_stack) + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + # ---------- Phase B: GTP_remat=2 DP=2 (replicate>1!) ---------- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + gtp_stack = _make_stack(_make_config(calculate_per_token_loss=calculate_per_token_loss), pgc) + for layer in gtp_stack: + layer.cuda() + + g = ps.get_gtp_weight_remat_group() + gtp_rank = g.rank() + assert g.size() == 2, f"expected gtp_remat shard group size 2, got {g.size()}" + + # Load the SAME init weights as baseline: GTP_remat params get their gtp_remat shard. + for name, p in gtp_stack.named_parameters(): + full = saved[name] + if isinstance(p, GTPShardedParam): + ss = p.shape[0] + p.data.copy_(full[gtp_rank * ss : (gtp_rank + 1) * ss]) + else: + p.data.copy_(full) + + gtp_ddp = _build_ddp(gtp_stack, calculate_per_token_loss=calculate_per_token_loss) + _run_one_backward(gtp_ddp, rank, calculate_per_token_loss=calculate_per_token_loss) + gtp_grads = _full_main_grads(gtp_stack) + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + # ---------- Compare reduced gradients on rank 0 ---------- + if rank == 0: + max_err = 0.0 + worst = None + for name in base_grads: + bg, gg = base_grads[name], gtp_grads[name] + assert bg.shape == gg.shape, f"{name}: {bg.shape} vs {gg.shape}" + err = (bg - gg).abs().max().item() + denom = bg.abs().max().item() + 1e-8 + rel = err / denom + ratio = (gg.norm() / (bg.norm() + 1e-12)).item() + print( + f"[grad] {name:55s} rel_max_err={rel:.3e} norm_ratio(orth/base)={ratio:.4f}", + flush=True, + ) + if rel > max_err: + max_err, worst = rel, name + print( + f"[summary] max relative grad error GTP_remat-vs-DP4-baseline = {max_err:.3e} " + f"(worst: {worst})", + flush=True, + ) + assert max_err < 2e-2, ( + f"GTP_remat2xDP2 reduced gradient does not match the no-GTP_remat DP4 baseline " + f"(max rel err {max_err:.3e} on {worst}) -> gtp_remat-axis grad reduce/scaling error." + ) + + +# --------------------------------------------------------------------------- +# Distributed-optimizer + grad-norm path (the production 64-GPU path) +# --------------------------------------------------------------------------- + + +def _build_ddp_distopt_and_optim(stack): + """Real distributed-optimizer setup (Adam), matching the 64-GPU production path.""" + from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig + from megatron.core.optimizer import OptimizerConfig, get_megatron_optimizer + + config = _make_config() + ddp_config = DistributedDataParallelConfig( + use_distributed_optimizer=True, overlap_grad_reduce=False + ) + module = torch.nn.Sequential() + for i, layer in enumerate(stack): + module.add_module(str(i), layer) + ddp_model = DistributedDataParallel(config, ddp_config, module) + opt_config = OptimizerConfig( + optimizer='adam', + lr=0.01, + bf16=True, + use_distributed_optimizer=True, + use_precision_aware_optimizer=False, + main_params_dtype=torch.float32, + main_grads_dtype=torch.float32, + exp_avg_dtype=torch.float32, + exp_avg_sq_dtype=torch.float32, + clip_grad=1.0, # reported grad-norm is computed pre-clip, so this is just for the step + ) + optim = get_megatron_optimizer(opt_config, [ddp_model]) + return ddp_model, optim + + +def _run_step_distopt(ddp_model, optim, rank): + """Mirror production finalize order: finish_grad_sync -> gtp_remat-finalize -> optim.step(). + Returns the optimizer-reported grad-norm (computed pre-clip from the reduced grads).""" + optim.zero_grad() + ddp_model.zero_grad_buffer() + torch.manual_seed(1000 + rank) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + out = x + for layer in ddp_model.module.children(): + out, _ = layer(out, attention_mask=None) + loss = out.float().mean() + loss.backward() + # Production order (finalize_model_grads): reduce across DP first, THEN the gtp_remat finalize. + ddp_model.finish_grad_sync() + from megatron.core.distributed.finalize_model_grads import ( + _allreduce_replicated_grads_over_gtp_remat_group, + ) + + _allreduce_replicated_grads_over_gtp_remat_group([ddp_model]) + _, grad_norm, _ = optim.step() + return float(grad_norm) + + +def _worker_distopt(rank, world_size, port): + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + # ---------- Phase A: baseline, GTP_remat=1 DP=4, dist-opt + Adam ---------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + base_stack = _make_stack(_make_config(), pgc) + for layer in base_stack: + layer.cuda() + for p in base_stack.parameters(): + dist.broadcast(p.data, src=0) + saved = {n: p.data.clone() for n, p in base_stack.named_parameters()} + base_ddp, base_optim = _build_ddp_distopt_and_optim(base_stack) + base_gn = _run_step_distopt(base_ddp, base_optim, rank) + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + # ---------- Phase B: GTP_remat=2 DP=2, dist-opt + Adam ---------- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + gtp_stack = _make_stack(_make_config(), pgc) + for layer in gtp_stack: + layer.cuda() + g = ps.get_gtp_weight_remat_group() + gtp_rank = g.rank() + for name, p in gtp_stack.named_parameters(): + full = saved[name] + if isinstance(p, GTPShardedParam): + ss = p.shape[0] + p.data.copy_(full[gtp_rank * ss : (gtp_rank + 1) * ss]) + else: + p.data.copy_(full) + gtp_ddp, gtp_optim = _build_ddp_distopt_and_optim(gtp_stack) + gtp_gn = _run_step_distopt(gtp_ddp, gtp_optim, rank) + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + if rank == 0: + ratio = gtp_gn / max(base_gn, 1e-12) + print( + f"\n[distopt grad-norm] baseline={base_gn:.6f} GTP_remat={gtp_gn:.6f} " + f"ratio={ratio:.4f}", + flush=True, + ) + # Same model, same data, gradients proven equal -> grad-norm must match. + torch.testing.assert_close(torch.tensor(gtp_gn), torch.tensor(base_gn), atol=0, rtol=3e-2) + + +# --------------------------------------------------------------------------- +# MoE + EGTP_remat dist-opt grad-norm path (EGTP_remat shards expert weights) +# --------------------------------------------------------------------------- + +NUM_EXPERTS = 4 +MOE_FFN = 256 + + +def _make_moe_config(): + from megatron.core.transformer.transformer_config import TransformerConfig + + return TransformerConfig( + num_attention_heads=NUM_HEADS, + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + ffn_hidden_size=FFN_HIDDEN, + num_moe_experts=NUM_EXPERTS, + moe_router_topk=2, + moe_ffn_hidden_size=MOE_FFN, + moe_grouped_gemm=True, + moe_token_dispatcher_type="alltoall", + moe_aux_loss_coeff=0.0, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + + +def _make_moe_stack(config, pg_collection): + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + + spec = get_gpt_layer_with_transformer_engine_spec( + num_experts=NUM_EXPERTS, moe_grouped_gemm=True + ) + return torch.nn.ModuleList( + [ + spec.module(config, spec.submodules, layer_number=i + 1, pg_collection=pg_collection) + for i in range(NUM_LAYERS) + ] + ) + + +def _is_expert_param(name, p): + return ('experts' in name) or (not getattr(p, 'allreduce', True)) + + +def _worker_moe_distopt(rank, world_size, port): + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + pgs = ['tp', 'cp', 'gtp_remat', 'ep'] + + # ---------- Phase A: baseline GTP1/EGTP1, EP2 (DP2 dense / expert_dp2) ---------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=2, + gtp_remat_size=1, + expert_gtp_remat_size=1, + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=pgs) + base_stack = _make_moe_stack(_make_moe_config(), pgc) + for layer in base_stack: + layer.cuda() + # Broadcast only NON-expert (dense) params; expert weights are EP-local and must + # stay rank-distinct. Save all params per-rank for the GTP_remat phase to mirror. + for name, p in base_stack.named_parameters(): + if not _is_expert_param(name, p): + dist.broadcast(p.data, src=0) + saved = {n: p.data.clone() for n, p in base_stack.named_parameters()} + base_ddp, base_optim = _build_ddp_distopt_and_optim(base_stack) + base_gn = _run_step_distopt(base_ddp, base_optim, rank) + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + # ---------- Phase B: GTP2/EGTP2, EP2 (EGTP_remat actually shards experts) ---------- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=2, + gtp_remat_size=2, + expert_gtp_remat_size=2, + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=pgs) + moe_stack = _make_moe_stack(_make_moe_config(), pgc) + for layer in moe_stack: + layer.cuda() + g = ps.get_gtp_weight_remat_group() + eg = ps.get_expert_gtp_weight_remat_group() + gtp_rank, egtp_rank = g.rank(), eg.rank() + n_egtp_sharded = 0 + for name, p in moe_stack.named_parameters(): + full = saved[name] # EP2 layout identical to baseline -> rank-local match + if isinstance(p, GTPShardedParam): + # dense GTP_remat shards over gtp_remat group; expert (EGTP_remat) over egtp_remat. + is_expert = _is_expert_param(name, p) + r = egtp_rank if is_expert else gtp_rank + ss = p.shape[0] + p.data.copy_(full[r * ss : (r + 1) * ss]) + if is_expert: + n_egtp_sharded += 1 + else: + p.data.copy_(full) + if rank == 0: + print( + f"[moe-egtp] egtp-sharded expert params = {n_egtp_sharded} (must be >0 to be a " + f"faithful EGTP_remat test)", + flush=True, + ) + moe_ddp, moe_optim = _build_ddp_distopt_and_optim(moe_stack) + moe_gn = _run_step_distopt(moe_ddp, moe_optim, rank) + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + if rank == 0: + ratio = moe_gn / max(base_gn, 1e-12) + print( + f"\n[moe distopt grad-norm] baseline={base_gn:.6f} GTP_remat={moe_gn:.6f} " + f"ratio={ratio:.4f}", + flush=True, + ) + torch.testing.assert_close(torch.tensor(moe_gn), torch.tensor(base_gn), atol=0, rtol=3e-2) + + +def _worker_idog_span(rank, world_size, port): + """Dist-opt grad-stats group (intra_dist_opt) must span the FULL world for both + dense-only and MoE(EP2/EGTP2) configs. A naive build collapses the MoE case to a sub-world + group (egtp factored out of expert_data_parallel_size), under-counting the grad-norm.""" + from megatron.core import parallel_state as ps + + # MoE EP2 EGTP2 GTP2 expert config. + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=2, + gtp_remat_size=2, + expert_gtp_remat_size=2, + ) + moe_idog = ps.get_intra_distributed_optimizer_instance_group().size() + ps.destroy_model_parallel() + # Dense-only GTP2 (must remain world too). + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + dense_idog = ps.get_intra_distributed_optimizer_instance_group().size() + ps.destroy_model_parallel() + if rank == 0: + print( + f"[idog] MoE intra_dist_opt.size={moe_idog} dense.size={dense_idog} " + f"(world={world_size})", + flush=True, + ) + assert moe_idog == world_size, ( + f"MoE grad-stats group = {moe_idog}, expected world {world_size} " + f"-> grad-norm would under-count gtp_remat/egtp_remat-sharded params" + ) + assert dense_idog == world_size, f"dense grad-stats group = {dense_idog}" + + +class TestGTPGradCorrectness: + def test_distopt_gradstats_group_spans_world(self): + """intra_dist_opt_group (grad-stats) must span the full world.""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_idog_span, 4) + + @pytest.mark.parametrize("per_token_loss", [False, True]) + def test_gtp2_dp2_grad_matches_dp4_baseline(self, per_token_loss): + """GTP2xDP2 reduced grad must match no-GTP_remat DP4 (non-dist-opt main_grad). + + per_token_loss=True disables DDP's 1/dp pre-scaling and normalizes by + 1/total_global_tokens, so the gtp_remat axis must be SUM-reduced (plain reduce-scatter + + SUM finalize), NOT the 1/gtp MEAN used otherwise. A regression to an unconditional mean + shrinks every gtp grad by 1/gtp and the per_token_loss case catches it (GTP2xDP2 sum-grad + must still match the DP4 sum-grad). GTP_CONFIG is a process-global, so set it for the run + and always reset it. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + from megatron.core.tensor_parallel.generalized_tensor_parallelism import update_gtp_config + + update_gtp_config(calculate_per_token_loss=per_token_loss) + try: + _run_distributed(_worker, 4, per_token_loss) + finally: + update_gtp_config(calculate_per_token_loss=False) + + def test_gtp2_dp2_distopt_grad_norm_matches_dp4_baseline(self): + """GTP2xDP2 dist-opt grad-norm must match no-GTP_remat DP4 (the 64-GPU path).""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_distopt, 4) + + @pytest.mark.skip( + reason="EP=2 (engages EGTP_remat) but the minimal test dims (SEQ16 BATCH1 hidden256) hit a " + "token-dispatcher shape error in the alltoall path (RuntimeError shape [2,1,4]). Needs a " + "larger MoE config to run; left as a stub. The real EGTP_remat path is validated at scale " + "(loss matches the GTP1/EGTP1 baseline after the is_gtp/allreduce master-param fix)." + ) + def test_moe_egtp_distopt_grad_norm_matches_baseline(self): + """GTP2/EGTP2 MoE dist-opt grad-norm must match GTP1/EGTP1 baseline (EP=2 both).""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_moe_distopt, 4) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_loss_correctness.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_loss_correctness.py new file mode 100644 index 00000000000..cfc6ff8a492 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_loss_correctness.py @@ -0,0 +1,183 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Integration test for GTP correctness. + +Validates that GTP run as a first-class parallelism axis +(world_size = TP * GTP * CP * DP) produces the same per-step loss as a no-GTP +baseline. This is the end-to-end proof that the standalone-GTP rank grid built +in parallel_state trains correctly. + +Mirrors TestAttentionGTPCorrectness. With world=4 and gtp_remat_size=4, GTP +yields dp_replicate=1 and a single shard group [0,1,2,3], so the loss must match +the GTP_remat_size=1 baseline. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from transformer_engine.pytorch import fp8_autocast + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( # noqa: F401 (autouse, module-scoped: initializes the dist PG); noqa: F401 (autouse) + _assert_loss_trajectories_match, + _restore_gtp_shards_and_init_main_grad, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + + +def _worker_gtp_loss_correctness(rank, world_size, port): + """Baseline (GTP_remat_size=1, DP=4) vs GTP_remat_size=4 (world=TP1*GTP4*CP1*DP1).""" + from transformer_engine.pytorch.quantization import FP8GlobalStateManager + + from megatron.core import parallel_state as ps + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + from megatron.core.transformer.transformer_config import TransformerConfig + + HIDDEN = 4096 + NUM_HEADS = 32 + FFN_HIDDEN = 16384 + NUM_LAYERS = 2 + SEQ = 32 + BATCH = 1 + LR = 0.01 + STEPS = 10 + dtype = torch.bfloat16 + + def make_config(): + return TransformerConfig( + num_attention_heads=NUM_HEADS, + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + ffn_hidden_size=FFN_HIDDEN, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + + def make_transformer_stack(config, pg_collection): + spec = get_gpt_layer_with_transformer_engine_spec() + return torch.nn.ModuleList( + [ + spec.module( + config, spec.submodules, layer_number=i + 1, pg_collection=pg_collection + ) + for i in range(NUM_LAYERS) + ] + ) + + def run_step(layers, x): + with fp8_autocast(enabled=False): + for layer in layers: + x, _ = layer(x, attention_mask=None) + return x.mean() + + # ---- Phase 1: Baseline — GTP_remat=1 (DP=4) ---- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + model_parallel_cuda_manual_seed(42) + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + layers = make_transformer_stack(config, pg_collection) + for layer in layers: + layer.cuda() + for p in layers.parameters(): + dist.broadcast(p.data, src=0) + saved_weights = {n: p.data.clone() for n, p in layers.named_parameters()} + + baseline_losses = [] + for step in range(STEPS): + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + loss = run_step(layers, x) + if rank == 0: + baseline_losses.append(loss.item()) + loss.backward() + with torch.no_grad(): + for p in layers.parameters(): + if p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + FP8GlobalStateManager.reset() + + # ---- Phase 2: GTP_remat=4 (world = TP1 * GTP4 * CP1 * DP1) ---- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + gtp_remat_size=4, # standalone-axis GTP_remat under test + ) + model_parallel_cuda_manual_seed(42) + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + layers_gtp = make_transformer_stack(config, pg_collection) + for layer in layers_gtp: + layer.cuda() + + gtp_remat_group = ps.get_gtp_weight_remat_group() + gtp_remat_size = gtp_remat_group.size() + gtp_rank = gtp_remat_group.rank() + assert gtp_remat_size == 4, f"GTP shard group size should be 4, got {gtp_remat_size}" + + gtp_params = [p for p in layers_gtp.parameters() if isinstance(p, GTPShardedParam)] + assert len(gtp_params) > 0, "GTP not active: no GTPShardedParam found" + + _restore_gtp_shards_and_init_main_grad(layers_gtp, saved_weights, gtp_rank, dtype) + + gtp_losses = [] + for step in range(STEPS): + for p in layers_gtp.parameters(): + if isinstance(p, GTPShardedParam): + p.main_grad.zero_() + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + loss = run_step(layers_gtp, x) + if rank == 0: + gtp_losses.append(loss.item()) + loss.backward() + with torch.no_grad(): + for p in layers_gtp.parameters(): + if isinstance(p, GTPShardedParam): + p.data.sub_((LR / gtp_remat_size) * p.main_grad) + elif p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + if rank == 0: + _assert_loss_trajectories_match(baseline_losses, gtp_losses, STEPS) + + +class TestGTPLossCorrectness: + def test_gtp_loss_trajectory_matches_baseline(self): + """GTP_remat_size=4 per-step losses must match no-GTP baseline (atol=1e-5, rtol=1e-5).""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires at least 4 CUDA devices") + _run_distributed(_worker_gtp_loss_correctness, 4) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py new file mode 100644 index 00000000000..b26d8a974ce --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py @@ -0,0 +1,327 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for GTP + Muon (LayerWise) distributed checkpointing. + +Covers the optimizer-state checkpoint roundtrip for the +:class:`LayerWiseDistributedOptimizer` (Muon) under GTP, where GTP-replicated +matrix params (e.g. the MoE router) are kept whole and must be disambiguated +by ``replica_id`` so DCP does not see multiple writers for the same shard. +""" + +import torch + +from megatron.core.dist_checkpointing import load, save +from tests.unit_tests.dist_checkpointing import TempNamedDir, setup_model_and_optimizer +from tests.unit_tests.test_utilities import Utils + + +def check_equal(input_1, input_2): + """Check if two inputs are equal, used for checking checkpointing.""" + if isinstance(input_1, dict) and isinstance(input_2, dict): + assert input_1.keys() == input_2.keys() + for key in input_1.keys(): + check_equal(input_1[key], input_2[key]) + elif isinstance(input_1, list) and isinstance(input_2, list): + assert len(input_1) == len(input_2) + for i in range(len(input_1)): + check_equal(input_1[i], input_2[i]) + elif isinstance(input_1, torch.Tensor) and isinstance(input_2, torch.Tensor): + assert torch.all(input_1 == input_2), f"Input 1: {input_1} != Input 2: {input_2}" + elif type(input_1) != type(input_2): + assert False, f"Input 1 type: {type(input_1)} != Input 2 type: {type(input_2)}" + else: + assert input_1 == input_2, f"Input 1: {input_1} != Input 2: {input_2}" + + +def _initialize_native_fp8_moe_model( + pre_process=True, + post_process=True, + seed=0, + use_glu=True, + use_sp=False, + use_te=True, + use_grouped_mlp=False, + **config_kwargs, +): + """``initialize_moe_model`` variant for native-FP8 (fp8_model_init / mxfp8) weights. + + ``params_dtype`` is set up front and the ``.bfloat16()`` / ``.random_()`` post-passes skip FP8 + params — a dtype cast or in-place ``random_`` would replace/destroy the native FP8 storage. + """ + from megatron.core.fp8_utils import is_float8tensor + from megatron.core.models.gpt import GPTModel + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + from megatron.core.tensor_parallel import model_parallel_cuda_manual_seed + from megatron.core.transformer import TransformerConfig + + # Passed through training.get_model but not part of TransformerConfig. + config_kwargs.pop("pg_collection", None) + config_kwargs.pop("config", None) + torch.manual_seed(seed) + model_parallel_cuda_manual_seed(seed) + expert_num = 8 + + # Dims sized so every GTP4 / EGTP2 FP8 shard dim stays a multiple of the MXFP8 block (32). + default_config_kwargs = dict( + num_layers=2, + hidden_size=128, + num_attention_heads=8, + kv_channels=16, + ffn_hidden_size=256, + use_cpu_initialization=False, + params_dtype=torch.bfloat16, + num_moe_experts=expert_num, + sequence_parallel=use_sp, + moe_grouped_gemm=use_grouped_mlp, + add_bias_linear=False, + fp8='e4m3', + fp8_recipe='mxfp8', + fp8_param=True, + ) + default_config_kwargs.update(**config_kwargs) + transformer_config = TransformerConfig(**default_config_kwargs, gated_linear_unit=use_glu) + spec = get_gpt_layer_with_transformer_engine_spec( + num_experts=expert_num, moe_grouped_gemm=use_grouped_mlp + ) + model = GPTModel( + config=transformer_config, + transformer_layer_spec=spec, + vocab_size=128, + max_sequence_length=4, + pre_process=pre_process, + post_process=post_process, + ) + with torch.no_grad(): + for p in model.parameters(): + if not is_float8tensor(p): + p.random_() + return model + + +class TestGTPMuonDCP: + """GTP + Muon (LayerWise) distributed checkpointing tests.""" + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def test_gtp_muon_moe_save_load(self, tmp_path_dist_ckpt): + """GTP + Muon (LayerWise) optimizer-state checkpoint roundtrip. + + GTP-REPLICATED, Muon-managed matrix params (e.g. the MoE router, held identically on every + GTP peer) must not collide on GTP peers during checkpoint save: LayerWise keeps each such + param whole, so its optimizer-state ShardedTensor has the same key+offset on all GTP peers + and the replica_id must distinguish them, or DCP validate_sharding_integrity reports 2 + writers ('Invalid access pattern ... [[2]]'). Adam dodges this by sharding the state. + """ + import os + from functools import partial + + import pytest + + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if not HAVE_GTP: + pytest.skip("GTP requires TE with hook registry") + if int(os.environ.get('WORLD_SIZE', '1')) != 4: + pytest.skip("Requires world_size 4 (gtp2 x dp2)") + + os.environ['MEGATRON_GTP_FORCE_ENABLE'] = '1' + from megatron.core import parallel_state as ps + from megatron.core.tensor_parallel import model_parallel_cuda_manual_seed + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTP_CONFIG, + GTPShardedParam, + update_gtp_config, + ) + from tests.unit_tests.dist_checkpointing.utils import initialize_moe_model + + Utils.initialize_model_parallel(1, 1) # bootstrap torch.distributed + model parallel + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + model_parallel_cuda_manual_seed(2) + # Disable GTP_remat alignment padding so the tiny test dims slice cleanly by gtp_remat_size. + _orig_pad = GTP_CONFIG.pad_for_alignment + update_gtp_config(pad_for_alignment=0) + # GTP_remat dims (divisible by gtp_remat_size=2); GPU init (CPU affine not GTP_remat-aware + # for the strided QKV weight). + moe_cfg = dict( + hidden_size=64, + num_attention_heads=8, + kv_channels=8, + ffn_hidden_size=128, + use_cpu_initialization=False, + ) + meta = {'distrib_optim_sharding_type': 'dp_reshardable'} + with TempNamedDir(tmp_path_dist_ckpt / 'gtp_muon_moe_A', sync=True) as ckpt_dir_A: + with TempNamedDir(tmp_path_dist_ckpt / 'gtp_muon_moe_B', sync=True) as ckpt_dir_B: + model_A, optimizer_A = setup_model_and_optimizer( + seed=2, + tp=1, + pp=1, + bf16=True, + dist_opt=True, + use_param_layout=True, + initialize_fn=partial(initialize_moe_model, use_te=True, **moe_cfg), + optimizer='dist_muon', + ) + assert any( + isinstance(p, GTPShardedParam) for p in model_A[0].parameters() + ), "GTP not active: no GTPShardedParam in the GTP_remat_size=2 MoE model" + + model_sd_A = model_A[0].sharded_state_dict() + optim_sd_A = optimizer_A.sharded_state_dict(model_sd_A, metadata=meta) + save( + optim_sd_A, ckpt_dir_A + ) # fails (2 writers) before the LayerWise replica_id fix + + model_B, optimizer_B = setup_model_and_optimizer( + seed=3, + tp=1, + pp=1, + bf16=True, + dist_opt=True, + use_param_layout=True, + initialize_fn=partial(initialize_moe_model, use_te=True, **moe_cfg), + optimizer='dist_muon', + ) + model_sd_B = model_B[0].sharded_state_dict() + load_sharded_sd = optimizer_B.sharded_state_dict( + model_sd_B, is_loading=True, metadata=meta + ) + state_dict = load(load_sharded_sd, ckpt_dir_A) + optimizer_B.load_state_dict(state_dict) + optim_sd_B = optimizer_B.sharded_state_dict(model_sd_B, metadata=meta) + save(optim_sd_B, ckpt_dir_B) + + update_gtp_config(pad_for_alignment=_orig_pad) + + Utils.destroy_model_parallel() + Utils.initialize_model_parallel(1, 1) + from megatron.core.dist_checkpointing import load_plain_tensors + + check_equal(load_plain_tensors(ckpt_dir_A), load_plain_tensors(ckpt_dir_B)) + + def test_gtp_muon_moe_native_fp8_save_load(self, tmp_path_dist_ckpt): + """GTP + Muon (LayerWise) + native-FP8 (fp8_model_init / mxfp8) checkpoint roundtrip. + + Regression guard for the native-FP8 optimizer-state save. Native-FP8 GTP weights are + dequantized into a NEW bf16 tensor when the model builds its ShardedTensor + (make_tp_sharded_tensor_for_checkpoint), which breaks the ``id(entry.data) == id(param)`` + match every native-FP8 GTP param relies on -- so ALL of them fall into + ``_backfill_gtp_sharded_param_map``. The fix reuses each model entry (via the + ``_gtp_dequant_src`` backlink), preserving its offsets/replica_id; this exercises that + reuse path end-to-end and asserts a bit-exact save/load/re-save roundtrip. + + Uses the gtp_remat-only MoE grid (no expert-parallel), matching the sibling bf16 test. + The specific [[2],[2]] cross-expert collision from the production crash needs the GROUPED / + EGTP expert grid (shared key + per-expert offset, where the old EP-unaware rebuild dropped + that offset). That grid is intentionally avoided here: LayerWiseDistributedOptimizer's + whole-param LPT layout over EGTP-sharded expert weights leaves a coverage hole that fails + the same save even in pure BF16 (independent of native-FP8 and of this fix). + """ + import os + from functools import partial + from unittest import mock + + import pytest + + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + from tests.unit_tests.dist_checkpointing import utils as _dc_utils + from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import _requires_mxfp8 + + if not HAVE_GTP: + pytest.skip("GTP requires TE with hook registry") + if int(os.environ.get('WORLD_SIZE', '1')) != 4: + pytest.skip("Requires world_size 4 (gtp2 x dp2)") + _requires_mxfp8() + + os.environ['MEGATRON_GTP_FORCE_ENABLE'] = '1' + from megatron.core import parallel_state as ps + from megatron.core.fp8_utils import is_float8tensor + from megatron.core.tensor_parallel import model_parallel_cuda_manual_seed + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTP_CONFIG, + is_gtp_param, + tag_gtp_params_with_names, + update_gtp_config, + ) + + Utils.initialize_model_parallel(1, 1) # bootstrap torch.distributed + model parallel + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + model_parallel_cuda_manual_seed(2) + # MXFP8 needs shard dims % 32; padding off so dims slice cleanly by the remat size. + _orig_pad = GTP_CONFIG.pad_for_alignment + update_gtp_config(pad_for_alignment=0) + + # MXFP8 params can't be aliased into the DDP param buffer (replace_raw_data unsupported); + # the production native-FP8 path sets reuse_grad_buf_for_mxfp8_param_ag to skip that + # aliasing. The shared harness builds its own mock args, so wrap init_basic_mock_args to + # flip the flags before the DDP config is built from them. + _orig_init_args = _dc_utils.init_basic_mock_args + + def _init_args_fp8(args, tp, pp, bf16=True): + _orig_init_args(args, tp, pp, bf16=bf16) + args.fp8_param_gather = True + args.reuse_grad_buf_for_mxfp8_param_ag = True + return args + + init_fn = partial(_initialize_native_fp8_moe_model, use_te=True, use_grouped_mlp=False) + meta = {'distrib_optim_sharding_type': 'dp_reshardable'} + with ( + mock.patch.object(_dc_utils, 'init_basic_mock_args', _init_args_fp8), + TempNamedDir(tmp_path_dist_ckpt / 'gtp_muon_fp8_A', sync=True) as ckpt_dir_A, + TempNamedDir(tmp_path_dist_ckpt / 'gtp_muon_fp8_B', sync=True) as ckpt_dir_B, + ): + model_A, optimizer_A = setup_model_and_optimizer( + seed=2, + tp=1, + pp=1, + bf16=True, + dist_opt=True, + use_param_layout=True, + initialize_fn=init_fn, + optimizer='dist_muon', + ) + tag_gtp_params_with_names(model_A[0]) + assert any( + is_gtp_param(p) and is_float8tensor(p) for p in model_A[0].parameters() + ), "no native-FP8 GTP param present; test is not exercising the FP8 path" + + model_sd_A = model_A[0].sharded_state_dict() + # Every native-FP8 GTP param is unmatched (dequantized copy) and reuses its model + # entry via _backfill_gtp_sharded_param_map; save validates the composed sharding. + optim_sd_A = optimizer_A.sharded_state_dict(model_sd_A, metadata=meta) + save(optim_sd_A, ckpt_dir_A) + + model_B, optimizer_B = setup_model_and_optimizer( + seed=3, + tp=1, + pp=1, + bf16=True, + dist_opt=True, + use_param_layout=True, + initialize_fn=init_fn, + optimizer='dist_muon', + ) + tag_gtp_params_with_names(model_B[0]) + model_sd_B = model_B[0].sharded_state_dict() + load_sharded_sd = optimizer_B.sharded_state_dict( + model_sd_B, is_loading=True, metadata=meta + ) + state_dict = load(load_sharded_sd, ckpt_dir_A) + optimizer_B.load_state_dict(state_dict) + optim_sd_B = optimizer_B.sharded_state_dict(model_sd_B, metadata=meta) + save(optim_sd_B, ckpt_dir_B) + + update_gtp_config(pad_for_alignment=_orig_pad) + + Utils.destroy_model_parallel() + Utils.initialize_model_parallel(1, 1) + from megatron.core.dist_checkpointing import load_plain_tensors + + check_equal(load_plain_tensors(ckpt_dir_A), load_plain_tensors(ckpt_dir_B)) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_mamba_gtp.py b/tests/unit_tests/generalized_tensor_parallel/test_mamba_gtp.py new file mode 100644 index 00000000000..152b551e78d --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_mamba_gtp.py @@ -0,0 +1,343 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Integration tests for GTP + Mamba correctness. + +Test groups +----------- +TestMambaGTPCorrectness - GTP Mamba loss trajectory matches baseline (no-GTP) over 10 + training steps using MXFP8 and Nemotron3-Super Mamba hyperparameters. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from transformer_engine.pytorch import fp8_autocast, fp8_model_init + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _assert_loss_trajectories_match, + _requires_mxfp8, + _restore_gtp_shards_and_init_main_grad, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +# --------------------------------------------------------------------------- +# Mamba GTP_remat correctness: per-step loss trajectory baseline vs GTP_remat=4 +# --------------------------------------------------------------------------- + + +def _worker_mamba_gtp_correctness(rank, world_size, port): + """Verify GTP Mamba produces the same per-step loss as a no-GTP baseline. + + Phase 1 — GTP_remat_size=1, DP=4: + All 4 ranks hold the full model and process identical inputs. Gradients + are identical across ranks (no all-reduce needed). Weight update: + param.data -= lr * param.grad + + Phase 2 — GTP_remat_size=4, DP=1: + Weights sharded across 4 ranks. After backward, wgrad reduce-scatter + sums each shard's identical wgrad over all ranks, so: + main_grad[rank_i] = gtp_remat_size * dW[shard_i] + The optimizer divides by gtp_remat_size to recover the per-element gradient: + param.data -= (lr / gtp_remat_size) * param.main_grad + + Both phases use identical initial weights (synced from rank 0 in phase 1, + restored as shards in phase 2) and identical step-by-step inputs. The + per-step loss trajectories must agree within 0.1% relative error. + """ + from transformer_engine.common.recipe import MXFP8BlockScaling + from transformer_engine.pytorch.quantization import FP8GlobalStateManager + + from megatron.core import parallel_state as ps + from megatron.core.extensions.transformer_engine import ( + TELayerNormColumnParallelLinear, + TERowParallelLinear, + ) + from megatron.core.fusions.fused_bias_dropout import get_bias_dropout_add + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.ssm.mamba_layer import MambaLayer, MambaLayerSubmodules + from megatron.core.ssm.mamba_mixer import MambaMixer, MambaMixerSubmodules + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + from megatron.core.transformer.spec_utils import ModuleSpec + from megatron.core.transformer.transformer_config import TransformerConfig + + # Nemotron3-Super Proxy Mamba hyperparameters. + # in_proj_out = 2*8192 + 2*8*128 + 128 = 18560; 18560/4 = 4640, 4640%16 = 0 (MXFP8-aligned). + HIDDEN = 4096 + NHEADS = 128 # mamba_num_heads; d_inner = nheads * headdim = 128 * 64 = 8192 + NGROUPS = 8 # mamba_num_groups (default) + D_STATE = 128 # mamba_state_dim (default) + NUM_LAYERS = 2 + SEQ = 32 + BATCH = 1 + LR = 0.01 + STEPS = 10 + dtype = torch.bfloat16 + recipe = MXFP8BlockScaling() # native-FP8 Phase 3 (Phases 1-2 run in BF16) + + def make_config(): + return TransformerConfig( + num_attention_heads=32, + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + mamba_num_heads=NHEADS, + mamba_head_dim=64, + mamba_state_dim=D_STATE, + mamba_num_groups=NGROUPS, + use_mamba_mem_eff_path=True, + params_dtype=dtype, + hidden_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + + def make_mamba_stack(config, pg_collection): + submodules = MambaLayerSubmodules( + mixer=ModuleSpec( + module=MambaMixer, + submodules=MambaMixerSubmodules( + in_proj=TELayerNormColumnParallelLinear, out_proj=TERowParallelLinear + ), + ), + mamba_bda=get_bias_dropout_add, + ) + return torch.nn.ModuleList( + [ + MambaLayer(config, submodules, layer_number=i + 1, pg_collection=pg_collection) + for i in range(NUM_LAYERS) + ] + ) + + def run_step(layers, x): + with fp8_autocast(enabled=False): + for layer in layers: + x = layer(x) + return x.mean() + + # ------------------------------------------------------------------------- + # Phase 1: Baseline — GTP_remat=1 (DP=4) + # ------------------------------------------------------------------------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + model_parallel_cuda_manual_seed(42) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + layers = make_mamba_stack(config, pg_collection) + for layer in layers: + layer.cuda() + + # Verify baseline has no GTP_remat sharding (gtp_remat_size=1 should leave plain parameters). + assert not any( + isinstance(p, GTPShardedParam) for p in layers.parameters() + ), "Baseline GTP_remat_size=1 stack should have no GTPShardedParam" + + # Synchronize weights from rank 0 across all DP ranks. + for p in layers.parameters(): + dist.broadcast(p.data, src=0) + + # Save initial weights; will be used to initialize the GTP_remat model identically. + saved_weights = {n: p.data.clone() for n, p in layers.named_parameters()} + + baseline_losses = [] + for step in range(STEPS): + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + loss = run_step(layers, x) + if rank == 0: + baseline_losses.append(loss.item()) + + loss.backward() + with torch.no_grad(): + for p in layers.parameters(): + if p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + FP8GlobalStateManager.reset() + + # ------------------------------------------------------------------------- + # Phase 2: GTP_remat=4 (DP=1) + # ------------------------------------------------------------------------- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=4 + ) + model_parallel_cuda_manual_seed(42) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + layers_gtp = make_mamba_stack(config, pg_collection) + for layer in layers_gtp: + layer.cuda() + + gtp_remat_group = ps.get_gtp_weight_remat_group() + gtp_remat_size = gtp_remat_group.size() + gtp_rank = gtp_remat_group.rank() + + # Verify GTP_remat is truly active: at least one param must be a GTPShardedParam. + gtp_params = [p for p in layers_gtp.parameters() if isinstance(p, GTPShardedParam)] + assert ( + len(gtp_params) > 0 + ), "GTP is not active: no GTPShardedParam found in GTP_remat_size=4 Mamba stack" + + # Restore initial weights into shards and pre-allocate main_grad for the backward. + _restore_gtp_shards_and_init_main_grad(layers_gtp, saved_weights, gtp_rank, dtype) + + gtp_losses = [] + for step in range(STEPS): + for p in layers_gtp.parameters(): + if isinstance(p, GTPShardedParam): + p.main_grad.zero_() + + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + loss = run_step(layers_gtp, x) + if rank == 0: + gtp_losses.append(loss.item()) + + loss.backward() + + # After RS, main_grad = gtp_remat_size * dW_shard (sum over ranks, all ranks hold the same + # full wgrad after all-gathering the weight in fwd). Divide by gtp_remat_size so the weight + # update is equivalent to the baseline. + with torch.no_grad(): + for p in layers_gtp.parameters(): + if isinstance(p, GTPShardedParam): + p.data.sub_((LR / gtp_remat_size) * p.main_grad) + elif p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + FP8GlobalStateManager.reset() + + # ------------------------------------------------------------------------- + # Phase 3: GTP_remat=4 (DP=1), NATIVE MXFP8 weights (--fp8-param-gather path). + # in_proj/out_proj are built under fp8_model_init -> native MXFP8 GTP shards. FP8 params can't + # be updated in place, so this leg keeps an FP32 master and re-quantizes each step via + # gtp_native_fp8_load_context (same copy_ mechanism as checkpoint load). Loss must track the + # BF16 baseline within MXFP8 noise; a frozen/miswired FP8 weight flattens or diverges it. + # ------------------------------------------------------------------------- + from megatron.core.fp8_utils import is_float8tensor + from megatron.core.tensor_parallel.gtp_api import gtp_native_fp8_load_context, is_gtp_param + + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=4 + ) + model_parallel_cuda_manual_seed(42) + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'gtp_remat'] + ) + config = make_config() + with fp8_model_init(enabled=True, recipe=recipe): + layers_fp8 = make_mamba_stack(config, pg_collection) + for layer in layers_fp8: + layer.cuda() + + # Verify native-FP8 GTP is truly active: some weight must be a native FP8 GTP shard. + native_fp8 = [p for p in layers_fp8.parameters() if is_gtp_param(p) and is_float8tensor(p)] + assert len(native_fp8) > 0, "No native-FP8 GTP weight found in fp8_model_init mamba stack" + + # Init: FP32 master per param = the gtp_rank shard of the saved baseline weights (padded to the + # native-FP8 shard size); FP8 params re-quantized from their master via the load context. + masters = {} + with torch.no_grad(), gtp_native_fp8_load_context(layers_fp8): + for name, p in layers_fp8.named_parameters(): + full = saved_weights[name] + if is_gtp_param(p): + shard = p.shape[0] # native-FP8 shard may include GTP alignment pad rows + aligned = shard * gtp_remat_size + if full.shape[0] < aligned: + full = torch.nn.functional.pad(full, (0, 0, 0, aligned - full.shape[0])) + m = full[gtp_rank * shard : (gtp_rank + 1) * shard].float().clone() + else: + m = full.float().clone() + masters[name] = m + p.copy_(m.to(dtype)) # BF16->FP8 (inside the load context) or plain BF16 copy + for p in layers_fp8.parameters(): + if is_gtp_param(p): + p.main_grad = torch.zeros(p.shape, dtype=dtype, device='cuda') + + fp8_losses = [] + for step in range(STEPS): + for p in layers_fp8.parameters(): + if is_gtp_param(p): + p.main_grad.zero_() + + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + with fp8_autocast(enabled=True, fp8_recipe=recipe): + y = x + for layer in layers_fp8: + y = layer(y) + loss = y.mean() + if rank == 0: + fp8_losses.append(loss.item()) + loss.backward() + + # Update FP32 masters (same math as bf16 Phase 2), then re-quantize FP8 shards from them. + with torch.no_grad(): + for name, p in layers_fp8.named_parameters(): + if is_gtp_param(p): + masters[name].sub_((LR / gtp_remat_size) * p.main_grad.float()) + elif p.grad is not None: + masters[name].sub_(LR * p.grad.float()) + p.grad.zero_() + with gtp_native_fp8_load_context(layers_fp8): + for name, p in layers_fp8.named_parameters(): + p.copy_(masters[name].to(dtype)) + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + FP8GlobalStateManager.reset() + + # ------------------------------------------------------------------------- + # Compare per-step loss trajectories on rank 0 + # ------------------------------------------------------------------------- + if rank == 0: + _assert_loss_trajectories_match(baseline_losses, gtp_losses, STEPS) + # Native-FP8 leg tracks the baseline within MXFP8 noise (looser than the bf16-vs-bf16 tol). + import torch as _torch + + diff = (_torch.tensor(fp8_losses) - _torch.tensor(baseline_losses)).abs().max().item() + assert diff < 0.2, ( + f"Native-FP8 GTP mamba loss diverges from BF16 baseline " + f"(max per-step |diff|={diff:.4f}; fp8: {fp8_losses[0]:.3f}->{fp8_losses[-1]:.3f}, " + f"bf16: {baseline_losses[0]:.3f}->{baseline_losses[-1]:.3f})." + ) + + +class TestMambaGTPCorrectness: + def test_mamba_gtp_loss_trajectory_matches_baseline(self): + """GTP Mamba per-step losses must match the no-GTP baseline for BOTH bf16 (Phase 2) and + native-MXFP8 (Phase 3) weight-remat, within bf16 reduction / mxfp8 quantization noise.""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires at least 4 CUDA devices") + _requires_mxfp8() # Phase 3 builds native-FP8 mamba weights (fp8_model_init) + _run_distributed(_worker_mamba_gtp_correctness, 4) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_moe_egtp.py b/tests/unit_tests/generalized_tensor_parallel/test_moe_egtp.py new file mode 100644 index 00000000000..da4e957fccd --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_moe_egtp.py @@ -0,0 +1,342 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Integration tests for EGTP_remat + MoE correctness. + +Test groups +----------- +TestMoEEGTPCorrectness - EGTP_remat MoE loss trajectory matches baseline (no-EGTP_remat) over 10 + training steps using MXFP8 and Nemotron3-Super MoE hyperparameters. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from transformer_engine.pytorch import fp8_autocast + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from megatron.core.transformer.moe.moe_utils import get_default_pg_collection +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _assert_loss_trajectories_match, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +# --------------------------------------------------------------------------- +# MoE EGTP_remat correctness: per-step loss trajectory EP=4 baseline vs EP=2+EGTP_remat=2 +# --------------------------------------------------------------------------- + + +def _worker_moe_egtp_correctness(rank, world_size, port): + """Verify EP=2+EGTP_remat=2 MoE matches per-step loss of EP=4 no-EGTP_remat baseline. + + Phase 1 — EP=4, EGTP_remat=1: + All 4 ranks form one EP group; each rank holds 2 full expert weights (8 total). + All ranks receive the same MoE-layer input; alltoall dispatch routes each token + to its assigned expert rank, so each rank computes a different token subset. + Gradients are local to each expert's rank. Weight update: + param.data -= lr * param.grad + + Phase 2 — EP=2, EGTP_remat=2: + Two EP groups of 2 ranks, each EGTP_remat-sharded over 2 ranks. Expert weights + are sharded along dim 0 within each EGTP_remat group (shard = full_dim0 / egtp_remat_size). + After backward, wgrad reduce-scatter sums each shard's identical wgrad: + main_grad[rank_i] = egtp_remat_size * dW[shard_i] + The optimizer divides by egtp_remat_size: + param.data -= (lr / egtp_remat_size) * param.main_grad + + Weight sharing (test-only): + To ensure both phases start from identical expert weights, an all-gather + collects the full 8-expert table from the EP=4 group (where each rank holds + only 2 experts) onto every rank. Phase 2 then slices each rank's local + experts and EGTP_remat shard from that global table. + + Nemotron3-Super Proxy MoE hyperparameters (scaled for unit-test speed): + hidden=4096, ffn_hidden_size=2688, num_experts=8, topk=2 + MXFP8 alignment with EGTP_remat=2: + 2688/2=1344, 1344%16=0 (fc1 shard); 4096/2=2048, 2048%16=0 (fc2 shard) + """ + from transformer_engine.pytorch.quantization import FP8GlobalStateManager + + from megatron.core import parallel_state as ps + from megatron.core.models.gpt.moe_module_specs import get_moe_module_spec + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + from megatron.core.transformer.transformer_config import TransformerConfig + + # Nemotron3-Super MoE hyperparameters (num_experts scaled from 512 to 8 for test speed). + HIDDEN = 4096 + FFN_HIDDEN = 2688 + NUM_EXPERTS = 8 + TOPK = 2 + SEQ = 32 + BATCH = 1 + LR = 0.01 + STEPS = 10 + dtype = torch.bfloat16 + + def make_config(): + return TransformerConfig( + num_attention_heads=32, + num_layers=1, + hidden_size=HIDDEN, + num_moe_experts=NUM_EXPERTS, + moe_router_topk=TOPK, + moe_ffn_hidden_size=FFN_HIDDEN, + moe_grouped_gemm=True, + moe_token_dispatcher_type="alltoall", + moe_aux_loss_coeff=0.0, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + + def make_moe_layer(config, pg_collection): + moe_spec = get_moe_module_spec(use_te=True, num_experts=NUM_EXPERTS, moe_grouped_gemm=True) + return moe_spec(config, layer_number=1, pg_collection=pg_collection) + + def run_step(layer, x): + with fp8_autocast(enabled=False): + output, _ = layer(x) + return output.mean() + + # ------------------------------------------------------------------------- + # Phase 1: Baseline — EP=4, EGTP_remat=1 (DP=1) + # ------------------------------------------------------------------------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=4, + expert_gtp_remat_size=1, + ) + model_parallel_cuda_manual_seed(42) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['ep']) + ep_group = pg_collection.ep + num_local_experts_baseline = NUM_EXPERTS // 4 # = 2 + + config = make_config() + layer = make_moe_layer(config, None) # MoELayer uses get_default_pg_collection() + layer.cuda() + + # Verify baseline has no GTP_remat sharding (EGTP_remat=1 should leave plain parameters). + assert not any( + isinstance(p, GTPShardedParam) for p in layer.parameters() + ), "Baseline EP=4 layer should have no GTPShardedParam (EGTP_remat=1)" + + # Synchronize non-expert weights from rank 0; expert weights are rank-local. + for name, p in layer.named_parameters(): + if 'linear_fc1.weight' not in name and 'linear_fc2.weight' not in name: + dist.broadcast(p.data, src=0) + + # Collect the full expert weight table so Phase 2 can restore identical init weights. + # EP=4: each rank holds 2 experts; all-gather gives every rank the complete [8, dim, ...] table. + local_fc1 = torch.stack( + [ + dict(layer.named_parameters())[f'experts.linear_fc1.weight{i}'].data + for i in range(num_local_experts_baseline) + ] + ) # [2, FFN_HIDDEN, HIDDEN] + global_fc1 = torch.zeros(NUM_EXPERTS, FFN_HIDDEN, HIDDEN, dtype=dtype, device='cuda') + dist.all_gather_into_tensor(global_fc1, local_fc1, group=ep_group) + + local_fc2 = torch.stack( + [ + dict(layer.named_parameters())[f'experts.linear_fc2.weight{i}'].data + for i in range(num_local_experts_baseline) + ] + ) # [2, HIDDEN, FFN_HIDDEN] + global_fc2 = torch.zeros(NUM_EXPERTS, HIDDEN, FFN_HIDDEN, dtype=dtype, device='cuda') + dist.all_gather_into_tensor(global_fc2, local_fc2, group=ep_group) + + # Save non-expert param values (router, norms, etc.) from rank 0. + non_expert_weights = {} + for name, p in layer.named_parameters(): + if 'linear_fc1.weight' not in name and 'linear_fc2.weight' not in name: + non_expert_weights[name] = p.data.clone() + + baseline_losses = [] + for step in range(STEPS): + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + loss = run_step(layer, x) + if rank == 0: + baseline_losses.append(loss.item()) + + loss.backward() + with torch.no_grad(): + for p in layer.parameters(): + if p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + FP8GlobalStateManager.reset() + + # ------------------------------------------------------------------------- + # Phase 2: EP=2, EGTP_remat=2 (DP=1 effective) + # ------------------------------------------------------------------------- + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + expert_model_parallel_size=2, + expert_gtp_remat_size=2, + ) + model_parallel_cuda_manual_seed(42) + + pg_collection = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['expt_gtp_remat']) + egtp_remat_group = pg_collection.expt_gtp_remat + egtp_remat_size = egtp_remat_group.size() + egtp_rank = egtp_remat_group.rank() + ep_rank_egtp = dist.get_rank(ps.get_expert_model_parallel_group()) + num_local_experts_egtp = NUM_EXPERTS // 2 # = 4 + + config = make_config() + # Build full pg_collection for MoELayer: default groups + expt_gtp for EGTP_remat sharding. + moe_pg = get_default_pg_collection() + moe_pg.expt_gtp_remat = egtp_remat_group + layer_egtp = make_moe_layer(config, moe_pg) + layer_egtp.cuda() + + # Verify EGTP_remat is truly active: expert weight params must be GTPShardedParam instances. + egtp_params = [p for p in layer_egtp.parameters() if isinstance(p, GTPShardedParam)] + assert len(egtp_params) > 0, "EGTP_remat inactive: no GTPShardedParam in EP=2+EGTP_remat=2" + + # Restore weights from saved global tables. + # Expert local index j → global expert id = ep_rank_egtp * num_local_experts_egtp + j. + fc1_shard = FFN_HIDDEN // egtp_remat_size # 2688/2 = 1344 + fc2_shard = HIDDEN // egtp_remat_size # 4096/2 = 2048 + for name, p in layer_egtp.named_parameters(): + if 'linear_fc1.weight' in name: + j = int(name.rsplit('weight', 1)[1]) + gid = ep_rank_egtp * num_local_experts_egtp + j + p.data.copy_(global_fc1[gid, egtp_rank * fc1_shard : (egtp_rank + 1) * fc1_shard]) + elif 'linear_fc2.weight' in name: + j = int(name.rsplit('weight', 1)[1]) + gid = ep_rank_egtp * num_local_experts_egtp + j + p.data.copy_(global_fc2[gid, egtp_rank * fc2_shard : (egtp_rank + 1) * fc2_shard]) + elif name in non_expert_weights: + p.data.copy_(non_expert_weights[name]) + + # Pre-allocate main_grad for EGTP_remat params (required before the first backward). + for p in layer_egtp.parameters(): + if isinstance(p, GTPShardedParam): + p.main_grad = torch.zeros(p.shape, dtype=dtype, device='cuda') + + egtp_losses = [] + for step in range(STEPS): + for p in layer_egtp.parameters(): + if isinstance(p, GTPShardedParam): + p.main_grad.zero_() + + torch.manual_seed(step) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) + + loss = run_step(layer_egtp, x) + if rank == 0: + egtp_losses.append(loss.item()) + + loss.backward() + + # After RS, main_grad = egtp_remat_size * dW_shard. Divide by egtp_remat_size for baseline. + with torch.no_grad(): + for p in layer_egtp.parameters(): + if isinstance(p, GTPShardedParam): + p.data.sub_((LR / egtp_remat_size) * p.main_grad) + elif p.grad is not None: + p.data.sub_(LR * p.grad) + p.grad.zero_() + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + # ------------------------------------------------------------------------- + # Compare per-step loss trajectories on rank 0 + # ------------------------------------------------------------------------- + if rank == 0: + _assert_loss_trajectories_match(baseline_losses, egtp_losses, STEPS, label="egtp_remat") + + +def _worker_expert_bias_gtp_inclusive(rank, world_size, port): + """Router expert-bias must stay identical across gtp_remat peers. + + The aux-loss-free balancer (``get_updated_expert_bias``) all-reduces per-expert token counts + over the router's tp_dp_cp group, then sign-updates the replicated expert_bias. gtp_remat peers + hold DISTINCT tokens but share the replicated router, so the reduction must span gtp_remat. + ``get_tensor_and_data_parallel_group`` therefore spans gtp_remat (like dp); over a gtp-EXCLUDED + group the peers reduce different token sums and the bias diverges -> routing instability + (loss spikes). world=4 -> tp1 * gtp_remat2 * dp2. + """ + import torch.distributed as dist + + from megatron.core import parallel_state as ps + from megatron.core.transformer.moe.moe_utils import get_updated_expert_bias + from megatron.core.utils import get_pg_size + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + gtp_group = ps.get_gtp_weight_remat_group() + tp_dp_cp = ps.get_tensor_and_data_parallel_group(with_context_parallel=True) # spans gtp_remat + # gtp-EXCLUDED replicate group (explicit, since get_data_parallel_group now defaults inclusive). + replicate = ps.get_data_parallel_group(with_context_parallel=True, with_gtp_remat=False) + + num_experts = 8 + torch.manual_seed(1000 + rank) # distinct per-rank tokens (distinct data on each rank) + base = torch.randint(0, 100, (num_experts,), device="cuda").float() + + def _max_bias_diff_across_gtp(group): + bias = torch.zeros(num_experts, device="cuda") # identical start on every rank + updated = get_updated_expert_bias(base.clone(), bias, 0.01, tp_dp_cp_group=group) + buf = [torch.zeros_like(updated) for _ in range(gtp_group.size())] + dist.all_gather(buf, updated, group=gtp_group) + return max((buf[i] - buf[0]).abs().max().item() for i in range(gtp_group.size())) + + # tp_dp_cp must span gtp_remat (size = replicate_dp_cp x gtp_remat at tp=1). + spans_gtp = get_pg_size(tp_dp_cp) == get_pg_size(replicate) * get_pg_size(gtp_group) + diff_excluded = _max_bias_diff_across_gtp(replicate) + diff_included = _max_bias_diff_across_gtp(tp_dp_cp) + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + + if rank == 0: + assert spans_gtp, "tp_dp_cp group must span the gtp_remat axis (like dp)" + # gtp-excluded reduction reproduces the bug; tp_dp_cp (spans gtp_remat) keeps peers in sync. + assert ( + diff_excluded > 0 + ), f"gtp-excluded group should diverge across gtp_remat peers, got {diff_excluded}" + assert ( + diff_included == 0 + ), f"tp_dp_cp must keep expert_bias identical across gtp_remat peers, got {diff_included}" + + +class TestMoEEGTPCorrectness: + def test_moe_egtp_loss_trajectory_matches_baseline(self): + """EP=2+EGTP_remat=2 MoE per-step losses match EP=4 baseline: atol=rtol=1e-5; MXFP8""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires at least 4 CUDA devices") + _run_distributed(_worker_moe_egtp_correctness, 4) + + def test_expert_bias_gtp_inclusive(self): + """expert_bias stays synced across gtp_remat peers only with the gtp-inclusive group.""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires at least 4 CUDA devices") + _run_distributed(_worker_expert_bias_gtp_inclusive, 4) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_tp_gtp.py b/tests/unit_tests/generalized_tensor_parallel/test_tp_gtp.py new file mode 100644 index 00000000000..961ab070556 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_tp_gtp.py @@ -0,0 +1,397 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for combined Tensor Parallelism + Generalized Tensor Parallelism (TP+GTP). + +Process group layout (world_size = tp_size x gtp_remat_size): + + rank = gtp_rank x tp_size + tp_rank + + TP group: all ranks that share the same gtp_rank (size = tp_size) + GTP group: all ranks that share the same tp_rank (size = gtp_remat_size) + +Test groups +----------- +1. TestTPGTPProcessGroups - verify TP/GTP group sizes and rank assignment +2. TestTPGTPColumnParallelLinear - column-parallel Linear: fwd/bwd correctness (weight shape verified inline) +3. TestTPGTPRowParallelLinear - row-parallel Linear: fwd/bwd smoke test + numerical correctness +4. TestTPGTPLayerNormLinear - LayerNormLinear column-parallel smoke test + +Tests use (tp_size, gtp_remat_size) = (2, 2) → world_size = 4 (runs on 4-GPU machines). + +Multi-GPU tests skip automatically when ``torch.distributed.get_world_size()`` does not match +the requested combination of tp_size x gtp_remat_size. +""" + +import types + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +import transformer_engine.pytorch as te + +from megatron.core.extensions.transformer_engine import _gtp_pre_init +from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTP_CONFIG, + GTPShardedParam, + update_gtp_config, + wrap_module_params_gtp, +) +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _make_gtp_linear, + _requires_multi_gpu, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + + +def _build_groups(rank: int, world_size: int, tp_size: int, gtp_remat_size: int): + """Create TP and GTP process groups for a 2D parallelism grid. + + Layout: rank = gtp_rank x tp_size + tp_rank + TP group: contiguous block [gtp_rank*tp_size, (gtp_rank+1)*tp_size) + GTP group: strided set {tp_rank, tp_rank+tp_size, tp_rank+2*tp_size, ...} + + Every rank must call new_group for ALL groups (PyTorch distributed requirement). + + Returns: + tp_group: this rank's TP process group + gtp_remat_group: this rank's GTP process group + tp_rank: this rank's index within its TP group + gtp_rank: this rank's index within its GTP group + """ + assert tp_size * gtp_remat_size == world_size + tp_rank = rank % tp_size + gtp_rank = rank // tp_size + + tp_group = None + for er in range(gtp_remat_size): + ranks = list(range(er * tp_size, (er + 1) * tp_size)) + grp = dist.new_group(ranks) + if er == gtp_rank: + tp_group = grp + + gtp_remat_group = None + for tr in range(tp_size): + ranks = list(range(tr, world_size, tp_size)) + grp = dist.new_group(ranks) + if tr == tp_rank: + gtp_remat_group = grp + + return tp_group, gtp_remat_group, tp_rank, gtp_rank + + +# --------------------------------------------------------------------------- +# 1. TestTPGTPProcessGroups - group sizes and rank membership +# --------------------------------------------------------------------------- + + +def _worker_groups(rank, world_size, port, tp_size, gtp_remat_size): + tp_group, gtp_remat_group, tp_rank, gtp_rank = _build_groups( + rank, world_size, tp_size, gtp_remat_size + ) + + assert tp_group.size() == tp_size, f"rank {rank}: TP group size {tp_group.size()} != {tp_size}" + assert ( + gtp_remat_group.size() == gtp_remat_size + ), f"rank {rank}: GTP group size {gtp_remat_group.size()} != {gtp_remat_size}" + assert ( + dist.get_rank(tp_group) == tp_rank + ), f"rank {rank}: TP rank {dist.get_rank(tp_group)} != expected {tp_rank}" + assert ( + dist.get_rank(gtp_remat_group) == gtp_rank + ), f"rank {rank}: GTP rank {dist.get_rank(gtp_remat_group)} != expected {gtp_rank}" + + +class TestTPGTPProcessGroups: + @pytest.mark.parametrize("tp_size,gtp_remat_size", [(2, 2)]) + def test_group_sizes_and_ranks(self, tp_size, gtp_remat_size): + world_size = tp_size * gtp_remat_size + _requires_multi_gpu(world_size) + _run_distributed(_worker_groups, world_size, tp_size, gtp_remat_size) + + +# --------------------------------------------------------------------------- +# 2. TestTPGTPColumnParallelLinear +# --------------------------------------------------------------------------- + + +def _worker_column_correctness(rank, world_size, port, tp_size, gtp_remat_size): + """Column-parallel output must equal inp @ (GTP-gathered TP-local weight)^T.""" + torch.manual_seed(0) + tp_group, gtp_remat_group, tp_rank, gtp_rank = _build_groups( + rank, world_size, tp_size, gtp_remat_size + ) + + batch, in_f = 16, 64 + out_f = tp_size * gtp_remat_size * 32 # per-rank shard = 32 rows + dtype = torch.bfloat16 + + layer = _make_gtp_linear( + in_f, out_f, gtp_remat_group, dtype, parallel_mode="column", tp_group=tp_group + ) + + # All-gather GTP_remat shards → TP-local full weight [out_f/tp_size, in_f] + shard = layer.weight.data.clone() + all_gtp_shards = [torch.zeros_like(shard) for _ in range(gtp_remat_size)] + dist.all_gather(all_gtp_shards, shard, group=gtp_remat_group) + tp_local_weight = torch.cat(all_gtp_shards, dim=0).float() # strip padding + tp_local_weight = tp_local_weight[: out_f // tp_size] + + # Same full input on all ranks (column-parallel: each rank processes full input) + inp = torch.randn(batch, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + inp_te = inp.clone().requires_grad_(True) + + # TE forward: GTP_remat all-gathers weight internally; no TP comm in column-parallel fwd + out = layer(inp_te, is_first_microbatch=True) + assert out.shape == ( + batch, + out_f // tp_size, + ), f"rank {rank}: output shape {out.shape} != ({batch}, {out_f // tp_size})" + + # Reference: this TP rank's output = inp @ tp_local_weight^T + ref = inp.float() @ tp_local_weight.T + ref = ref.to(dtype) + assert torch.allclose( + out.float(), ref.float(), atol=1e-2, rtol=1e-2 + ), f"rank {rank}: output mismatch, max_diff={(out.float() - ref.float()).abs().max():.4f}" + + # Backward: dX is all-reduced across TP group internally by TE + grad = torch.randn_like(out) + dist.broadcast(grad, src=0) + # wgrad RS path always accumulates into main_grad; allocate before backward. + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + out.backward(grad) + assert inp_te.grad is not None and inp_te.grad.shape == inp.shape + assert torch.isfinite(inp_te.grad).all(), f"rank {rank}: non-finite dX" + + +class TestTPGTPColumnParallelLinear: + @pytest.mark.parametrize("tp_size,gtp_remat_size", [(2, 2)]) + def test_forward_backward_correctness(self, tp_size, gtp_remat_size): + world_size = tp_size * gtp_remat_size + _requires_multi_gpu(world_size) + _run_distributed(_worker_column_correctness, world_size, tp_size, gtp_remat_size) + + +# --------------------------------------------------------------------------- +# 3. TestTPGTPRowParallelLinear +# --------------------------------------------------------------------------- + + +def _worker_row_forward_backward(rank, world_size, port, tp_size, gtp_remat_size): + """Row-parallel: weight shape verified; output is all-reduced [batch, out_f]; backward produces finite dX.""" + torch.manual_seed(0) + tp_group, gtp_remat_group, tp_rank, _ = _build_groups(rank, world_size, tp_size, gtp_remat_size) + + batch = 16 + in_f = tp_size * 64 # full in_features + out_f = gtp_remat_size * 64 # full out_features + dtype = torch.bfloat16 + + layer = _make_gtp_linear( + in_f, out_f, gtp_remat_group, dtype, parallel_mode="row", tp_group=tp_group + ) + + expected_shape = (out_f // gtp_remat_size, in_f // tp_size) + assert isinstance( + layer.weight, GTPShardedParam + ), f"rank {rank}: weight should be GTPShardedParam" + assert ( + layer.weight.shape == expected_shape + ), f"rank {rank}: expected {expected_shape}, got {layer.weight.shape}" + + # Row-parallel: each TP rank takes the corresponding slice of in_f + full_inp = torch.randn(batch, in_f, dtype=dtype, device="cuda") + dist.broadcast(full_inp, src=0) + local_in_f = in_f // tp_size + inp = full_inp[:, tp_rank * local_in_f : (tp_rank + 1) * local_in_f] + inp = inp.clone().requires_grad_(True) + + # TE forward: GTP_remat all-gathers weight, row-parallel all-reduces output across TP + out = layer(inp, is_first_microbatch=True) + assert out.shape == ( + batch, + out_f, + ), f"rank {rank}: output shape {out.shape} != ({batch}, {out_f})" + assert torch.isfinite(out).all(), f"rank {rank}: non-finite output" + + # wgrad RS path always accumulates into main_grad; allocate before backward. + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + out.sum().backward() + assert inp.grad is not None and inp.grad.shape == inp.shape + assert torch.isfinite(inp.grad).all(), f"rank {rank}: non-finite dX" + + +def _worker_row_correctness(rank, world_size, port, tp_size, gtp_remat_size): + """Row-parallel all-reduced output must equal inp_full @ full_weight^T.""" + torch.manual_seed(0) + tp_group, gtp_remat_group, tp_rank, _ = _build_groups(rank, world_size, tp_size, gtp_remat_size) + + batch = 16 + in_f = tp_size * 64 + out_f = gtp_remat_size * 64 + dtype = torch.bfloat16 + + layer = _make_gtp_linear( + in_f, out_f, gtp_remat_group, dtype, parallel_mode="row", tp_group=tp_group + ) + + # Reconstruct full weight: all-gather GTP_remat shards → TP-local, then all-gather TP shards + shard = layer.weight.data.clone() + all_gtp_shards = [torch.zeros_like(shard) for _ in range(gtp_remat_size)] + dist.all_gather(all_gtp_shards, shard, group=gtp_remat_group) + tp_local_weight = torch.cat(all_gtp_shards, dim=0).float() # [out_f, in_f/tp_size] + + all_tp_weights = [torch.zeros_like(tp_local_weight) for _ in range(tp_size)] + dist.all_gather(all_tp_weights, tp_local_weight, group=tp_group) + full_weight = torch.cat(all_tp_weights, dim=1).float() # [out_f, in_f] + + # Full input (same on all ranks; we slice below to simulate row-parallel) + full_inp = torch.randn(batch, in_f, dtype=dtype, device="cuda") + dist.broadcast(full_inp, src=0) + local_in_f = in_f // tp_size + inp = full_inp[:, tp_rank * local_in_f : (tp_rank + 1) * local_in_f].clone() + inp.requires_grad_(True) + + out = layer(inp, is_first_microbatch=True) + + # Reference: full input @ full weight^T — all ranks should see the same output + ref = full_inp.float() @ full_weight.T + ref = ref.to(dtype) + assert torch.allclose( + out.float(), ref.float(), atol=2e-2, rtol=1e-2 + ), f"rank {rank}: output mismatch, max_diff={(out.float() - ref.float()).abs().max():.4f}" + + +class TestTPGTPRowParallelLinear: + @pytest.mark.parametrize("tp_size,gtp_remat_size", [(2, 2)]) + def test_forward_backward(self, tp_size, gtp_remat_size): + world_size = tp_size * gtp_remat_size + _requires_multi_gpu(world_size) + _run_distributed(_worker_row_forward_backward, world_size, tp_size, gtp_remat_size) + + @pytest.mark.parametrize("tp_size,gtp_remat_size", [(2, 2)]) + def test_forward_correctness(self, tp_size, gtp_remat_size): + world_size = tp_size * gtp_remat_size + _requires_multi_gpu(world_size) + _run_distributed(_worker_row_correctness, world_size, tp_size, gtp_remat_size) + + +# --------------------------------------------------------------------------- +# 4. TestTPGTPLayerNormLinear - column-parallel smoke test +# --------------------------------------------------------------------------- + + +def _worker_layernorm_linear(rank, world_size, port, tp_size, gtp_remat_size): + torch.manual_seed(0) + tp_group, gtp_remat_group, _, _ = _build_groups(rank, world_size, tp_size, gtp_remat_size) + + seq, batch = 4, 2 + in_f = 64 + out_f = tp_size * gtp_remat_size * 32 + dtype = torch.bfloat16 + + layer = te.LayerNormLinear( + in_features=in_f, + out_features=out_f, + bias=False, + params_dtype=dtype, + parallel_mode="column", + device="cuda", + tp_group=tp_group, + ) + # TE has no GTP construction hook; gtp_remat_size (the forward-gather gate) is stamped + # post-init and the TP-sharded weight is sliced into a GTPShardedParam on the Megatron side. + layer.gtp_remat_size = gtp_remat_group.size() + wrap_module_params_gtp(layer, layer.weight_names, gtp_remat_group) + assert isinstance( + layer.weight, GTPShardedParam + ), f"rank {rank}: LayerNormLinear.weight should be GTPShardedParam" + expected_rows = out_f // (tp_size * gtp_remat_size) + assert layer.weight.shape == ( + expected_rows, + in_f, + ), f"rank {rank}: unexpected weight shape {layer.weight.shape}" + + inp = torch.randn(seq, batch, in_f, dtype=dtype, device="cuda", requires_grad=True) + dist.broadcast(inp, src=0) + + out = layer(inp, is_first_microbatch=True) + assert out.shape == (seq, batch, out_f // tp_size), f"rank {rank}: output shape {out.shape}" + assert torch.isfinite(out).all(), f"rank {rank}: non-finite output" + + # wgrad RS path always accumulates into main_grad; allocate before backward. + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=dtype, device="cuda") + out.sum().backward() + assert inp.grad is not None and inp.grad.shape == inp.shape + assert torch.isfinite(inp.grad).all(), f"rank {rank}: non-finite dX" + + +class TestTPGTPLayerNormLinear: + @pytest.mark.parametrize("tp_size,gtp_remat_size", [(2, 2)]) + def test_forward_backward(self, tp_size, gtp_remat_size): + world_size = tp_size * gtp_remat_size + _requires_multi_gpu(world_size) + _run_distributed(_worker_layernorm_linear, world_size, tp_size, gtp_remat_size) + + +# --------------------------------------------------------------------------- +# 5. TestTPGTPPaddingAlignment - GTP pre-shard must pad the *per-TP* slice so the weight +# stays MXFP8-aligned AFTER TE's tp-split (padding the full out_features would let the +# tp-split de-align it). +# --------------------------------------------------------------------------- + + +def _worker_pre_init_tp_padding(rank, world_size, port, tp_size, gtp_remat_size): + """`_gtp_pre_init` pads ``output_size // out_split_size`` (the per-TP slice), so the shard + TE hands each rank after its own tp-split is a multiple of ``pad_for_alignment`` (32 for MXFP8). + + tp2 x gtp2, pad=32, out_features=192: + correct: per-TP slice 96 -> pad 128 -> /gtp2 = 64 -> TE tp-split /2 = 64 (32-aligned). + pre-fix: pad full 192 -> /gtp2 = 96 -> TE tp-split /2 = 48 (NOT 32-aligned). + """ + tp_group, gtp_remat_group, tp_rank, gtp_rank = _build_groups( + rank, world_size, tp_size, gtp_remat_size + ) + out_features = 192 + orig_pad = GTP_CONFIG.pad_for_alignment + update_gtp_config(pad_for_alignment=32) + try: + extra_kwargs = {} + shard_out, gtp_ctx = _gtp_pre_init( + types.SimpleNamespace(), + out_features, + gtp_remat_group, + extra_kwargs, + out_split_size=tp_size, + ) + # shard_out is what TE receives as out_features; TE splits it again across tp_size. + per_gpu = shard_out // tp_size + assert per_gpu % 32 == 0, ( + f"rank {rank}: per-GPU shard {per_gpu} not 32-aligned -- TE tp-split de-aligned the " + "GTP pad (pad the per-TP slice, not the full out_features)" + ) + assert per_gpu == 64, f"rank {rank}: expected per-GPU 64, got {per_gpu}" + # pad_length is measured on the per-TP slice: 128 - 96 = 32. + gtp_remat_group_ctx, pad_length, logical = gtp_ctx + assert pad_length == 32, f"rank {rank}: expected pad_length 32, got {pad_length}" + assert gtp_remat_group_ctx.size() == gtp_remat_size and logical == out_features + finally: + update_gtp_config(pad_for_alignment=orig_pad) + + +class TestTPGTPPaddingAlignment: + @pytest.mark.parametrize("tp_size,gtp_remat_size", [(2, 2)]) + def test_pre_init_pads_per_tp_slice(self, tp_size, gtp_remat_size): + world_size = tp_size * gtp_remat_size + _requires_multi_gpu(world_size) + _run_distributed(_worker_pre_init_tp_padding, world_size, tp_size, gtp_remat_size) diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index 5648d72532c..b63c094b790 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -104,6 +104,8 @@ "experimental_attention_variant": None, "experimental_attention_variant_loss_scale_func": None, "expert_model_parallel_size": 4, + "expert_gtp_weight_remat_size": 1, + "expert_tensor_parallel_num_weight_shards": 1, "expert_tensor_parallel_size": 1, "external_cuda_graph": False, "ffn_hidden_size": 1856, @@ -132,6 +134,7 @@ "fused_residual_rmsnorm": False, "fused_single_qkv_rope": False, "gated_linear_unit": False, + "gtp_weight_remat_size": 1, "glu_linear_offset": 0.0, "grad_scale_func": None, "mtp_grad_scale_func": None, @@ -282,6 +285,7 @@ "softmax_type": "vanilla", "symmetric_ar_type": None, "tensor_model_parallel_size": 2, + "tensor_parallel_num_weight_shards": 2, "test_mode": False, "timers": None, "tp_comm_atomic_ag": False, diff --git a/tests/unit_tests/test_fp8_param.py b/tests/unit_tests/test_fp8_param.py index abb3095e5b5..a8bb324f373 100644 --- a/tests/unit_tests/test_fp8_param.py +++ b/tests/unit_tests/test_fp8_param.py @@ -26,7 +26,7 @@ set_args, set_global_variables, ) -from megatron.training.training import get_model, setup_model_and_optimizer +from megatron.training.training import force_param_sync, get_model, setup_model_and_optimizer from megatron.training.utils import get_device_arch_version from tests.unit_tests.test_utilities import Utils @@ -223,6 +223,9 @@ def _run_test_helper( **kwargs, ): """Test fp8_param with a small GPT model.""" + # Test-only knob: not a model arg, so pop before create_test_args (which asserts every + # kwarg is a real arg attribute). + save_at_steps_kw = kwargs.pop("save_at_steps", ()) args = self.create_test_args( tp_size, recipe, @@ -244,6 +247,9 @@ def _run_test_helper( Utils.initialize_model_parallel( tensor_model_parallel_size=tp_size, expert_model_parallel_size=args.expert_model_parallel_size, + # Enable GTP weight-remat when the test requested it (default 1 => no GTP, so + # non-GTP fp8 tests are unaffected). + gtp_remat_size=getattr(args, "gtp_weight_remat_size", 1), ) input_ids, labels, position_ids, attention_mask, loss_mask = self.get_batch( @@ -332,11 +338,27 @@ def _run_test_helper( loss_list = [] eval_loss_list = [] + # Optional: generate the sharded_state_dict (the checkpoint-save metadata path) at these + # steps to catch save side-effects on the live weights — a correct save must not perturb + # the subsequent training step (regression guard for GTP native-FP8 save corruption). + save_at_steps = set(save_at_steps_kw or ()) + for i in range(100): if not inference: gpt_model[0].zero_grad_buffer() optimizer.zero_grad() + if i in save_at_steps: + # Mirror production save_checkpoint_and_time: when the forward pre-hook is disabled + # for the save, a forced param-sync runs first. Passing the optimizer makes it copy + # the FP32 masters into the param buffer before the copy-back re-quantizes, so + # native-FP8 GTP shards are refreshed from masters (not stale grad scratch). + # Exercise it so the save-perturbation test is a real regression test for the + # post-save loss spike. + if should_disable_forward_pre_hook(args): + force_param_sync(gpt_model, optimizer=optimizer) + _ = gpt_model[0].sharded_state_dict() + # Capture CUDA graphs after warmup if helper is provided. # Hard coded cuda_graph_warmup_steps = 0. cuda_graph_warmup_steps = 0 diff --git a/tests/unit_tests/test_process_groups_config.py b/tests/unit_tests/test_process_groups_config.py index b49962b1a5a..a61936bd132 100644 --- a/tests/unit_tests/test_process_groups_config.py +++ b/tests/unit_tests/test_process_groups_config.py @@ -29,7 +29,7 @@ def test_transformer_process_groups(self, mocker): # Test attribute existence assert hasattr(model_pgs, 'tp') assert hasattr(model_pgs, 'pp') - assert not hasattr(model_pgs, 'cp') # Not set yet + assert model_pgs.cp is None # Not set yet def test_grad_comm_process_groups(self, mocker): """Test basic functionality of ProcessGroupCollection.""" @@ -47,7 +47,7 @@ def test_grad_comm_process_groups(self, mocker): # Test attribute existence assert hasattr(grad_pgs, 'dp') - assert not hasattr(grad_pgs, 'dp_cp') # Not set yet + assert grad_pgs.dp_cp is None # Not set yet def test_hierarchical_context_parallel_groups(self, mocker): """Test setting and accessing the hierarchical context parallel list.""" @@ -129,7 +129,7 @@ def test_default_initialization(self): assert hasattr(model_pgs, 'tp') assert hasattr(model_pgs, 'pp') assert hasattr(model_pgs, 'cp') - assert not hasattr(model_pgs, 'dp') + assert model_pgs.dp is None # Not requested, so not set # Test that an error is raised if an invalid process group is requested with pytest.raises(ValueError, match=r"Invalid process groups requested"): From cd4afffa648426a959dc7cb1e24b5ce7d0c3ff54 Mon Sep 17 00:00:00 2001 From: Philip Monk Date: Sat, 25 Jul 2026 14:41:59 -0700 Subject: [PATCH 112/290] Fix gradient-norm undercounting when using EP and TP (#5916) Signed-off-by: Philip Monk --- .../core/distributed/param_and_grad_buffer.py | 5 +- .../core/extensions/transformer_engine.py | 86 +- megatron/core/optimizer/__init__.py | 17 +- megatron/core/optimizer/clip_grads.py | 5 +- megatron/core/optimizer/optimizer.py | 9 +- .../core/post_training/modelopt/layers.py | 7 +- megatron/core/tensor_parallel/layers.py | 28 +- megatron/training/utils/common_utils.py | 6 +- .../golden_values_dev_dgx_h100.json | 475 ++++--- .../golden_values_dev_dgx_gb200.json | 471 ++++--- .../golden_values_dev_dgx_h100.json | 475 ++++--- .../golden_values_dev_dgx_gb200.json | 473 ++++--- .../golden_values_dev_dgx_h100.json | 499 ++++--- .../golden_values_dev_dgx_h100.json | 995 +++++++------- .../golden_values_dev_dgx_gb200.json | 797 ++++++----- .../golden_values_dev_dgx_h100.json | 1189 ++++++++--------- .../test_grad_sync_with_expert_parallel.py | 121 ++ .../test_tp_attrs_without_init.py | 58 + tests/unit_tests/test_optimizer.py | 11 + tests/unit_tests/training/test_param_norm.py | 202 +++ .../test_transformer_engine_grouped_linear.py | 38 + 21 files changed, 3232 insertions(+), 2735 deletions(-) create mode 100644 tests/unit_tests/training/test_param_norm.py diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index e2e60adfadf..15247c2be53 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -887,8 +887,9 @@ def group_params_for_buffers( Each distinct buffer is identified by a BufferKey with three dimensions: - param_dtype: storage dtype (torch.uint8 for FP8/NVFP4 parameters, else param.dtype). - grad_dtype: gradient reduction dtype (torch.float if grad_reduce_in_fp32, else param.dtype). - - is_expert_parallel: whether the parameter is expert-parallel (param.allreduce == False), - which requires a separate buffer with a different data-parallel group. + - is_expert_parallel: whether the parameter uses the expert topology (param.allreduce == False), + which requires a separate buffer for the expert data-parallel group. This is true for experts + when expert-parallelism > 1 or expert-tensor-parallelism != tensor-parallelism. The param_indices track each parameter's position among same-dtype params (using the "fake" high-precision dtype for FP8/NVFP4 params), needed for loading non-native-fp8 diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 51a9457883a..8f0117c68ec 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -8,6 +8,7 @@ import io import os import pickle +import re import warnings from contextlib import contextmanager, nullcontext from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, cast @@ -84,6 +85,42 @@ HAVE_TE = False _TE_CONFIG_TYPE_KEY = "transformer_engine_config_type" +_EXPERT_PARAMETER_NAME_PATTERN = re.compile(r"(weight|bias)\d*") + + +def _set_expert_parameter_attributes( + module: torch.nn.Module, parallel_mode: Optional[str], use_expert_pgs: bool +) -> None: + """Set process-group and tensor-partition metadata on an expert TE module. + + ``allreduce=False`` selects EDP for gradient reduction. + + Weights and biases, including TEGroupedLinear's numbered parameters, are also marked as + TP-partitioned according to ``parallel_mode``; row-parallel biases remain replicated. + + Any parameter which is partitioned along TP or ETP is marked with ``tensor_model_parallel``, + which ensures that all shards contribute to the gradient norm. + + Args: + module: Transformer Engine module whose direct parameters should be marked. + parallel_mode: Tensor-parallel mode used by the module (``"column"``, ``"row"``, or None). + use_expert_pgs: Whether to use EP/ETP/EDP process groups instead of TP/CP/DP. + """ + for name, param in module.named_parameters(recurse=False): + param.allreduce = not use_expert_pgs + + name_match = _EXPERT_PARAMETER_NAME_PATTERN.fullmatch(name) + parameter_kind = name_match.group(1) if name_match else None + is_weight = parameter_kind == "weight" + is_bias = parameter_kind == "bias" + is_partitioned = parallel_mode in ("column", "row") and ( + is_weight or (parallel_mode == "column" and is_bias) + ) + if is_weight or is_bias: + param.tensor_model_parallel = is_partitioned + if is_partitioned: + param.partition_dim = 1 if parallel_mode == "row" else 0 + param.partition_stride = 1 class TransformerEngineConfigType(enum.Enum): @@ -942,6 +979,10 @@ def __init__( tp_size = get_pg_size(tp_group) self.expert_parallel = self.config.expert_model_parallel_size > 1 + use_expert_pgs = is_expert and ( + self.expert_parallel + or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + ) if is_expert: rng_tracker_name = get_expert_parallel_rng_tracker_name() else: @@ -1007,11 +1048,10 @@ def __init__( **extra_kwargs, ) - for param in self.parameters(): - if is_expert: - # Reduce the gradient on the expert_data_parallel group for expert linear layers - setattr(param, "allreduce", not self.expert_parallel) - else: + if is_expert: + _set_expert_parameter_attributes(self, parallel_mode, use_expert_pgs) + else: + for param in self.parameters(): # Reduce the gradient on DP group setattr(param, "allreduce", True) if parallel_mode == "duplicated": @@ -1431,6 +1471,13 @@ def __init__( self.bias.zero_() setattr(self.bias, "allreduce", True) + if is_expert: + use_expert_pgs = ( + config.expert_model_parallel_size > 1 + or config.expert_tensor_parallel_size != config.tensor_model_parallel_size + ) + _set_expert_parameter_attributes(self, "column", use_expert_pgs) + def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): """Sharding along axis 0, bias sharded""" state_dict = self.state_dict(prefix="", keep_vars=True) @@ -1680,6 +1727,13 @@ def __init__( setattr(self.bias, "allreduce", True) setattr(self.bias, "sequence_parallel", config.sequence_parallel) + if is_expert: + use_expert_pgs = ( + config.expert_model_parallel_size > 1 + or config.expert_tensor_parallel_size != config.tensor_model_parallel_size + ) + _set_expert_parameter_attributes(self, "row", use_expert_pgs) + def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): """Sharding along axis 1, bias not sharded""" state_dict = self.state_dict(prefix="", keep_vars=True) @@ -2106,6 +2160,10 @@ def __init__( extra_kwargs["ub_name"] = tp_comm_buffer_name self.expert_parallel = self.config.expert_model_parallel_size > 1 + use_expert_pgs = is_expert and ( + self.expert_parallel + or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + ) if is_expert: extra_kwargs["rng_tracker_name"] = get_expert_parallel_rng_tracker_name() @@ -2182,23 +2240,7 @@ def __init__( **extra_kwargs, ) - for param in self.parameters(): - setattr(param, "allreduce", not (is_expert and self.expert_parallel)) - - # Explicitly stamp partition_dim and partition_stride on expert weight - # tensors when explicit_expert_comm cleared parallel_mode. TE ≤2.12 - # set these internally; TE ≥2.13 no longer does (parallel_mode=None - # is passed due to explicit_expert_comm). The resharding/refit planner - # relies on partition_dim to correctly plan TP gather/scatter operations. - # NOTE: we intentionally do NOT stamp tensor_model_parallel here — - # doing so would change num-zeros gradient counting. - if self.explicit_expert_comm and original_parallel_mode in ("column", "row"): - part_dim = 0 if original_parallel_mode == "column" else 1 - for i in range(num_gemms): - weight = getattr(self, f"weight{i}", None) - if weight is not None: - setattr(weight, "partition_dim", part_dim) - setattr(weight, "partition_stride", 1) + _set_expert_parameter_attributes(self, original_parallel_mode, use_expert_pgs) self._register_load_state_dict_pre_hook( type(self)._normalize_grouped_parameter_keys, with_module=True diff --git a/megatron/core/optimizer/__init__.py b/megatron/core/optimizer/__init__.py index b7ea0bbe069..70f757f2889 100644 --- a/megatron/core/optimizer/__init__.py +++ b/megatron/core/optimizer/__init__.py @@ -47,7 +47,6 @@ if HAVE_EMERGING_OPTIMIZERS: from emerging_optimizers.scalar_optimizers import Lion -from megatron.core import parallel_state from megatron.core.optimizer.cpu_offloading.hybrid_optimizer import HybridDeviceOptimizer from megatron.core.optimizer_param_scheduler import ( ParamGroupOverride, @@ -686,11 +685,12 @@ def init_state_fn(opt, config=None): setattr(optimizer, 'grad_stats_parallel_group', model_parallel_group) if pg_collection is None or not hasattr(pg_collection, 'tp'): - tp_group = parallel_state.get_tensor_model_parallel_group() - else: - tp_group = pg_collection.tp - # TODO(M4): plumb tp_group through optimizer constructors so this setattr disappears. + pg_collection = ProcessGroupCollection.use_mpu_process_groups() + tp_group = pg_collection.tp + expert_tp_group = getattr(pg_collection, 'expt_tp', tp_group) + # TODO(M4): plumb TP groups through optimizer constructors so these setattrs disappear. setattr(optimizer, 'tp_group', tp_group) + setattr(optimizer, 'expert_tp_group', expert_tp_group) return optimizer @@ -898,11 +898,10 @@ def _get_megatron_emerging_optimizer( else: optimizer = FP32Optimizer(optimizer, config, init_state_fn) setattr(optimizer, 'grad_stats_parallel_group', model_parallel_group) - if pg_collection is None or not hasattr(pg_collection, 'tp'): - tp_group = parallel_state.get_tensor_model_parallel_group() - else: - tp_group = pg_collection.tp + tp_group = pg_collection.tp + expert_tp_group = getattr(pg_collection, 'expt_tp', tp_group) setattr(optimizer, 'tp_group', tp_group) + setattr(optimizer, 'expert_tp_group', expert_tp_group) results.append(optimizer) continue else: diff --git a/megatron/core/optimizer/clip_grads.py b/megatron/core/optimizer/clip_grads.py index 55848e104ae..ad2064ede36 100644 --- a/megatron/core/optimizer/clip_grads.py +++ b/megatron/core/optimizer/clip_grads.py @@ -197,6 +197,7 @@ def count_zeros_fp32( grad_stats_parallel_group: torch.distributed.ProcessGroup, use_decoupled_grad: bool = False, tp_group: Optional[torch.distributed.ProcessGroup] = None, + expert_tp_group: Optional[torch.distributed.ProcessGroup] = None, ) -> float: """Counts the number of zero values in the gradients of the given parameters. @@ -242,7 +243,9 @@ def count_zeros_fp32( total_num_zeros += num_zeros continue is_not_shared = param_is_not_shared(param) - is_not_tp_duplicate = param_is_not_tensor_parallel_duplicate(param, tp_group=tp_group) + is_not_tp_duplicate = param_is_not_tensor_parallel_duplicate( + param, tp_group=tp_group, expert_tp_group=expert_tp_group + ) is_not_gtp_duplicate = param_is_not_gtp_duplicate(param) if grad_not_none and is_not_shared and is_not_tp_duplicate and is_not_gtp_duplicate: grad_obj = getattr(param, grad_attr) diff --git a/megatron/core/optimizer/optimizer.py b/megatron/core/optimizer/optimizer.py index 77cd20de10e..dac16f4a2ee 100644 --- a/megatron/core/optimizer/optimizer.py +++ b/megatron/core/optimizer/optimizer.py @@ -176,6 +176,8 @@ def _is_separate_grad_norm_group(grad_norm_group: Optional[str]) -> bool: def copy_optimizer_param_metadata(destination: torch.Tensor, source: torch.Tensor) -> None: """Copy optimizer-relevant metadata when creating param views/copies.""" + if hasattr(source, 'allreduce'): + destination.allreduce = source.allreduce if hasattr(source, 'shared'): destination.shared = source.shared if hasattr(source, GRAD_NORM_GROUP_ATTR): @@ -266,7 +268,9 @@ def _filter_grads_for_norm( grad_not_none = grad is not None is_not_shared = param_is_not_shared(param) is_not_tp_duplicate = tensor_parallel.param_is_not_tensor_parallel_duplicate( - param, getattr(self, 'tp_group', None) + param, + tp_group=getattr(self, 'tp_group', None), + expert_tp_group=getattr(self, 'expert_tp_group', None), ) is_not_gtp_duplicate = tensor_parallel.param_is_not_gtp_duplicate(param) if grad_not_none and is_not_shared and is_not_tp_duplicate and is_not_gtp_duplicate: @@ -434,6 +438,7 @@ def count_zeros(self) -> float: and getattr(params[0], "__fsdp_param__", False) ), tp_group=getattr(self, 'tp_group', None), + expert_tp_group=getattr(self, 'expert_tp_group', None), ) @abstractmethod @@ -1785,6 +1790,8 @@ def count_zeros(self): self.config.use_precision_aware_optimizer and getattr(params[0], "__fsdp_param__", False) ), + tp_group=getattr(self.chained_optimizers[0], 'tp_group', None), + expert_tp_group=getattr(self.chained_optimizers[0], 'expert_tp_group', None), ) else: num_zeros_in_grad = 0 diff --git a/megatron/core/post_training/modelopt/layers.py b/megatron/core/post_training/modelopt/layers.py index 04e03a36458..5f1a746e95b 100644 --- a/megatron/core/post_training/modelopt/layers.py +++ b/megatron/core/post_training/modelopt/layers.py @@ -159,7 +159,12 @@ def __init__( for param in self.parameters(): if is_expert: # Reduce the gradient on the expert_data_parallel group for expert linear layers - setattr(param, "allreduce", self.config.expert_model_parallel_size == 1) + use_expert_groups = ( + self.config.expert_model_parallel_size > 1 + or self.config.expert_tensor_parallel_size + != self.config.tensor_model_parallel_size + ) + setattr(param, "allreduce", not use_expert_groups) else: # Reduce the gradient on DP group setattr(param, "allreduce", True) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index e6e55a96a75..3ff635b362b 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -92,11 +92,17 @@ dist_reduce_scatter_func = torch.distributed._reduce_scatter_base -def param_is_not_tensor_parallel_duplicate(param, tp_group=None): - """Returns true if the passed-in parameter is not a duplicate parameter - on another TP rank.""" +def param_is_not_tensor_parallel_duplicate(param, tp_group=None, expert_tp_group=None): + """Return whether a parameter contributes to a unique model-parallel shard. + + Parameters reduced over expert data parallel groups use the expert tensor-parallel + group for duplicate filtering. Other parameters use the regular tensor-parallel group. + """ if hasattr(param, "tensor_model_parallel") and param.tensor_model_parallel: return True + # allreduce=False marks parameters reduced over expert DP, so filter their duplicates over ETP. + if not getattr(param, "allreduce", True) and expert_tp_group is not None: + tp_group = expert_tp_group # Prefer provided tp_group when available (new explicit path). if tp_group is not None: return tp_group.rank() == 0 @@ -952,6 +958,10 @@ def __init__( world_size = get_pg_size(self.tp_group) rank = get_pg_rank(self.tp_group) self.explicit_expert_comm = self.is_expert and (world_size > 1 or self.expert_parallel) + use_expert_pgs = self.is_expert and ( + self.expert_parallel + or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + ) self.output_size_per_partition = divide(output_size, world_size) # Parameters. @@ -1004,7 +1014,7 @@ def __init__( tensor=self.weight, is_parallel=True, dim=0, stride=stride ) - setattr(self.weight, "allreduce", not (self.is_expert and self.expert_parallel)) + setattr(self.weight, "allreduce", not use_expert_pgs) else: self.weight = None @@ -1037,7 +1047,7 @@ def __init__( # Always initialize bias to zero. with torch.no_grad(): self.bias.zero_() - setattr(self.bias, "allreduce", not (self.is_expert and self.expert_parallel)) + setattr(self.bias, "allreduce", not use_expert_pgs) else: self.register_parameter("bias", None) @@ -1366,7 +1376,11 @@ def __init__( set_tensor_model_parallel_attributes( tensor=self.weight, is_parallel=True, dim=1, stride=stride ) - setattr(self.weight, "allreduce", not (self.is_expert and self.expert_parallel)) + use_expert_pgs = self.is_expert and ( + self.expert_parallel + or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + ) + setattr(self.weight, "allreduce", not use_expert_pgs) self.gtp_remat_size = 1 _pg = ProcessGroupCollection.use_mpu_process_groups( @@ -1395,7 +1409,7 @@ def __init__( # Always initialize bias to zero. with torch.no_grad(): self.bias.zero_() - setattr(self.bias, "allreduce", not (self.is_expert and self.expert_parallel)) + setattr(self.bias, "allreduce", not use_expert_pgs) setattr(self.bias, "sequence_parallel", self.sequence_parallel) else: self.register_parameter("bias", None) diff --git a/megatron/training/utils/common_utils.py b/megatron/training/utils/common_utils.py index 4e7d55a4577..30617ef9b4c 100644 --- a/megatron/training/utils/common_utils.py +++ b/megatron/training/utils/common_utils.py @@ -112,6 +112,8 @@ def calc_params_l2_norm(model, force_create_fp32_copy=False): gtp_rank = mpu.get_gtp_weight_remat_rank() egtp_rank = mpu.get_expert_gtp_weight_remat_rank() + tp_group = mpu.get_tensor_model_parallel_group() + expert_tp_group = mpu.get_expert_tensor_parallel_group() for model_chunk in model: for param in model_chunk.parameters(): @@ -119,7 +121,9 @@ def calc_params_l2_norm(model, force_create_fp32_copy=False): # Filter TP duplicates. GTP_remat params are always unique across TP ranks # so skip this check for them. - if not is_gtp and not param_is_not_tensor_parallel_duplicate(param): + if not is_gtp and not param_is_not_tensor_parallel_duplicate( + param, tp_group=tp_group, expert_tp_group=expert_tp_group + ): continue is_expert = not getattr(param, 'allreduce', True) diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/golden_values_dev_dgx_h100.json index 8a7452aa143..3eefe1b2bb3 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/golden_values_dev_dgx_h100.json @@ -6,54 +6,54 @@ "values": { "1": 10.92563, "2": 10.91638, - "3": 10.92433, - "4": 10.93217, - "5": 10.92999, - "6": 10.92662, - "7": 10.92571, - "8": 10.92333, - "9": 10.92825, - "10": 10.91605, - "11": 10.91854, - "12": 10.92399, - "13": 10.91037, - "14": 10.90685, - "15": 10.90136, - "16": 10.88661, - "17": 10.88849, - "18": 10.88662, - "19": 10.8857, - "20": 10.83765, - "21": 10.82761, - "22": 10.81538, - "23": 10.8078, - "24": 10.78018, - "25": 10.778, - "26": 10.76109, - "27": 10.74912, - "28": 10.69195, - "29": 10.66617, - "30": 10.63122, - "31": 10.62222, - "32": 10.61543, - "33": 10.57901, - "34": 10.54677, - "35": 10.54608, - "36": 10.53419, - "37": 10.50598, - "38": 10.50458, - "39": 10.47274, - "40": 10.45062, - "41": 10.42727, - "42": 10.41444, - "43": 10.40126, - "44": 10.3705, - "45": 10.38167, - "46": 10.33539, - "47": 10.32458, - "48": 10.28718, - "49": 10.28599, - "50": 10.27739 + "3": 10.92459, + "4": 10.93245, + "5": 10.93019, + "6": 10.92651, + "7": 10.92566, + "8": 10.92291, + "9": 10.92842, + "10": 10.91709, + "11": 10.9186, + "12": 10.92364, + "13": 10.91032, + "14": 10.906, + "15": 10.90106, + "16": 10.88602, + "17": 10.88857, + "18": 10.8863, + "19": 10.88563, + "20": 10.83831, + "21": 10.82801, + "22": 10.81606, + "23": 10.80814, + "24": 10.77971, + "25": 10.77746, + "26": 10.76123, + "27": 10.74966, + "28": 10.69186, + "29": 10.66597, + "30": 10.63047, + "31": 10.62182, + "32": 10.61491, + "33": 10.5784, + "34": 10.54596, + "35": 10.54573, + "36": 10.53409, + "37": 10.5049, + "38": 10.50267, + "39": 10.47204, + "40": 10.44914, + "41": 10.426, + "42": 10.41331, + "43": 10.39913, + "44": 10.36866, + "45": 10.37972, + "46": 10.3331, + "47": 10.32171, + "48": 10.28474, + "49": 10.28349, + "50": 10.27386 } }, "num-zeros": { @@ -61,56 +61,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 18949.0, - "2": 19099.0, - "3": 19050.0, - "4": 19118.0, - "5": 19083.0, - "6": 18713.0, - "7": 18983.0, - "8": 18573.0, - "9": 19074.0, - "10": 19633.0, - "11": 18864.0, - "12": 18680.0, - "13": 18983.0, - "14": 19635.0, - "15": 18534.0, - "16": 18776.0, - "17": 19027.0, - "18": 19179.0, - "19": 19256.0, - "20": 19131.0, - "21": 18678.0, - "22": 19277.0, - "23": 19003.0, - "24": 19177.0, - "25": 18328.0, - "26": 18877.0, - "27": 19140.0, - "28": 18575.0, - "29": 18512.0, - "30": 18566.0, - "31": 18581.0, - "32": 19224.0, - "33": 18078.0, - "34": 18612.0, - "35": 18599.0, - "36": 18325.0, - "37": 18458.0, - "38": 18488.0, - "39": 18900.0, - "40": 19303.0, - "41": 18755.0, - "42": 18613.0, - "43": 19044.0, - "44": 18919.0, - "45": 20364.0, - "46": 19848.0, - "47": 20037.0, - "48": 19837.0, - "49": 21694.0, - "50": 20114.0 + "1": 36671.0, + "2": 37173.0, + "3": 36899.0, + "4": 37068.0, + "5": 36679.0, + "6": 35978.0, + "7": 37031.0, + "8": 36229.0, + "9": 37090.0, + "10": 37967.0, + "11": 36941.0, + "12": 36081.0, + "13": 36942.0, + "14": 37392.0, + "15": 36186.0, + "16": 36099.0, + "17": 36904.0, + "18": 36981.0, + "19": 37455.0, + "20": 36609.0, + "21": 36447.0, + "22": 36641.0, + "23": 36893.0, + "24": 37094.0, + "25": 35824.0, + "26": 36997.0, + "27": 36498.0, + "28": 35858.0, + "29": 36103.0, + "30": 35720.0, + "31": 36188.0, + "32": 36884.0, + "33": 36092.0, + "34": 36008.0, + "35": 36767.0, + "36": 35759.0, + "37": 36357.0, + "38": 35639.0, + "39": 36859.0, + "40": 37400.0, + "41": 36473.0, + "42": 36460.0, + "43": 37019.0, + "44": 36370.0, + "45": 39844.0, + "46": 38478.0, + "47": 38793.0, + "48": 38437.0, + "49": 42666.0, + "50": 39545.0 } }, "mem-allocated-bytes": { @@ -118,56 +118,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 1027089408.0, - "2": 1027091456.0, - "3": 1027087360.0, - "4": 1027088384.0, - "5": 1027091456.0, - "6": 1027091456.0, - "7": 1027088896.0, - "8": 1027092480.0, + "1": 1027088896.0, + "2": 1027090944.0, + "3": 1027086848.0, + "4": 1027087360.0, + "5": 1027091968.0, + "6": 1027089920.0, + "7": 1027088384.0, + "8": 1027091968.0, "9": 1027091968.0, - "10": 1027089408.0, + "10": 1027089920.0, "11": 1027089920.0, - "12": 1027092480.0, + "12": 1027091456.0, "13": 1027090944.0, - "14": 1027092480.0, - "15": 1027090432.0, - "16": 1027088384.0, - "17": 1027089408.0, - "18": 1027090944.0, - "19": 1027088384.0, - "20": 1027090432.0, - "21": 1027092480.0, + "14": 1027091456.0, + "15": 1027089920.0, + "16": 1027088896.0, + "17": 1027087872.0, + "18": 1027090432.0, + "19": 1027088896.0, + "20": 1027089408.0, + "21": 1027091456.0, "22": 1027089920.0, - "23": 1027093504.0, + "23": 1027094016.0, "24": 1027092480.0, "25": 1027089408.0, - "26": 1027090944.0, - "27": 1027087360.0, - "28": 1027090432.0, - "29": 1027090432.0, - "30": 1027089920.0, - "31": 1027089408.0, - "32": 1027093504.0, - "33": 1027094016.0, - "34": 1027093504.0, - "35": 1027085824.0, - "36": 1027087872.0, + "26": 1027089408.0, + "27": 1027086848.0, + "28": 1027090944.0, + "29": 1027089920.0, + "30": 1027090432.0, + "31": 1027090432.0, + "32": 1027092992.0, + "33": 1027092480.0, + "34": 1027091968.0, + "35": 1027085312.0, + "36": 1027086336.0, "37": 1027088896.0, "38": 1027089920.0, - "39": 1027088384.0, - "40": 1027091968.0, + "39": 1027087360.0, + "40": 1027090944.0, "41": 1027088384.0, - "42": 1027089408.0, + "42": 1027087872.0, "43": 1027087872.0, - "44": 1027091456.0, + "44": 1027088384.0, "45": 1027090432.0, "46": 1027086336.0, - "47": 1027088384.0, - "48": 1027087360.0, - "49": 1027087360.0, - "50": 1027089920.0 + "47": 1027086848.0, + "48": 1027086848.0, + "49": 1027086336.0, + "50": 1027089408.0 } }, "mem-max-allocated-bytes": { @@ -175,113 +175,112 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 3058326528.0, - "2": 3298517504.0, - "3": 3298517504.0, - "4": 3298517504.0, - "5": 3300747776.0, - "6": 3300747776.0, - "7": 3300747776.0, - "8": 3300747776.0, - "9": 3300747776.0, - "10": 3300747776.0, - "11": 3300747776.0, - "12": 3300747776.0, - "13": 3300747776.0, - "14": 3300747776.0, - "15": 3300747776.0, - "16": 3300747776.0, - "17": 3300747776.0, - "18": 3300747776.0, - "19": 3300747776.0, - "20": 3300747776.0, - "21": 3300747776.0, - "22": 3300747776.0, - "23": 3300747776.0, - "24": 3300747776.0, - "25": 3300747776.0, - "26": 3300747776.0, - "27": 3300747776.0, - "28": 3300747776.0, - "29": 3300747776.0, - "30": 3300747776.0, - "31": 3300747776.0, - "32": 3300747776.0, - "33": 3300747776.0, - "34": 3300872192.0, - "35": 3300872192.0, - "36": 3300872192.0, - "37": 3300872192.0, - "38": 3300872192.0, - "39": 3300872192.0, - "40": 3300872192.0, - "41": 3300872192.0, - "42": 3300872192.0, - "43": 3300872192.0, - "44": 3300872192.0, - "45": 3300872192.0, - "46": 3300872192.0, - "47": 3300872192.0, - "48": 3300872192.0, - "49": 3300872192.0, - "50": 3300872192.0 + "1": 3059096576.0, + "2": 3298776064.0, + "3": 3298776064.0, + "4": 3298776064.0, + "5": 3298776064.0, + "6": 3298776064.0, + "7": 3298776064.0, + "8": 3299136512.0, + "9": 3299136512.0, + "10": 3299136512.0, + "11": 3299136512.0, + "12": 3299136512.0, + "13": 3299320320.0, + "14": 3299397120.0, + "15": 3299397120.0, + "16": 3299397120.0, + "17": 3299397120.0, + "18": 3299397120.0, + "19": 3299397120.0, + "20": 3299397120.0, + "21": 3299397120.0, + "22": 3299397120.0, + "23": 3300247552.0, + "24": 3300247552.0, + "25": 3300247552.0, + "26": 3300247552.0, + "27": 3300247552.0, + "28": 3300247552.0, + "29": 3300247552.0, + "30": 3300247552.0, + "31": 3300247552.0, + "32": 3300247552.0, + "33": 3300247552.0, + "34": 3300554752.0, + "35": 3300554752.0, + "36": 3300554752.0, + "37": 3300554752.0, + "38": 3300554752.0, + "39": 3300554752.0, + "40": 3300554752.0, + "41": 3300554752.0, + "42": 3300554752.0, + "43": 3300554752.0, + "44": 3300554752.0, + "45": 3300554752.0, + "46": 3300554752.0, + "47": 3300554752.0, + "48": 3300554752.0, + "49": 3300554752.0, + "50": 3300554752.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 50, "step_interval": 1, "values": { - "1": "nan", - "2": 7.37375, - "3": 0.25401, - "4": 0.23, - "5": 0.23156, - "6": 0.22618, - "7": 0.22033, - "8": 0.2124, - "9": 0.21458, - "10": 0.2112, - "11": 0.22058, - "12": 0.21214, - "13": 0.20964, - "14": 0.21773, - "15": 0.21046, - "16": 0.21558, - "17": 0.21724, - "18": 0.21042, - "19": 0.2121, - "20": 0.21156, - "21": 0.2121, - "22": 0.20983, - "23": 0.22142, - "24": 0.21088, - "25": 0.21096, - "26": 0.2105, - "27": 0.21223, - "28": 0.21432, - "29": 0.20728, - "30": 0.20861, - "31": 0.20793, - "32": 0.20812, - "33": 0.20817, - "34": 0.20922, - "35": 0.20912, - "36": 0.21051, - "37": 0.21278, - "38": 0.21391, - "39": 0.2131, - "40": 0.21335, - "41": 0.21205, - "42": 0.20975, - "43": 0.2117, - "44": 0.21456, - "45": 0.21588, - "46": 0.21062, - "47": 0.21618, - "48": 0.21235, - "49": 0.21609, - "50": 0.21536 + "2": 5.12545, + "3": 0.33231, + "4": 0.34056, + "5": 0.32602, + "6": 0.3278, + "7": 0.32667, + "8": 0.30797, + "9": 0.30763, + "10": 0.30676, + "11": 0.31256, + "12": 0.3065, + "13": 0.3017, + "14": 0.29823, + "15": 0.30407, + "16": 0.3043, + "17": 0.29941, + "18": 1.04497, + "19": 0.30398, + "20": 0.30088, + "21": 0.31032, + "22": 0.30474, + "23": 0.30372, + "24": 0.30325, + "25": 0.31021, + "26": 0.2994, + "27": 0.30871, + "28": 0.29653, + "29": 0.29361, + "30": 0.29596, + "31": 0.2957, + "32": 0.29902, + "33": 0.2982, + "34": 0.29551, + "35": 0.29378, + "36": 0.31272, + "37": 0.30903, + "38": 0.30702, + "39": 0.30131, + "40": 0.3076, + "41": 0.30128, + "42": 0.29866, + "43": 0.30149, + "44": 0.30224, + "45": 0.29648, + "46": 0.30138, + "47": 0.29993, + "48": 0.29673, + "49": 0.29749, + "50": 0.30226 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/golden_values_dev_dgx_gb200.json index 7c9dfc9c650..2ce3b9d4103 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/golden_values_dev_dgx_gb200.json +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/golden_values_dev_dgx_gb200.json @@ -6,54 +6,54 @@ "values": { "1": 10.92337, "2": 10.91184, - "3": 10.92435, - "4": 10.91391, - "5": 10.91783, - "6": 10.90822, - "7": 10.90214, - "8": 10.90968, - "9": 10.92327, - "10": 10.91324, - "11": 10.90152, - "12": 10.91168, - "13": 10.88952, - "14": 10.88978, - "15": 10.88462, - "16": 10.87656, - "17": 10.87413, - "18": 10.8627, - "19": 10.87155, - "20": 10.8213, - "21": 10.8075, - "22": 10.79454, - "23": 10.79087, - "24": 10.75698, - "25": 10.76203, - "26": 10.75144, - "27": 10.73619, - "28": 10.67458, - "29": 10.63986, - "30": 10.62269, - "31": 10.6127, - "32": 10.59666, - "33": 10.57595, - "34": 10.53962, - "35": 10.53801, - "36": 10.52144, - "37": 10.48719, - "38": 10.49901, - "39": 10.45863, - "40": 10.43984, - "41": 10.42707, - "42": 10.41355, - "43": 10.38977, - "44": 10.35254, - "45": 10.36992, - "46": 10.32982, - "47": 10.31281, - "48": 10.27777, - "49": 10.27075, - "50": 10.27166 + "3": 10.92433, + "4": 10.91442, + "5": 10.91813, + "6": 10.90832, + "7": 10.90215, + "8": 10.90903, + "9": 10.92402, + "10": 10.91272, + "11": 10.902, + "12": 10.91244, + "13": 10.88903, + "14": 10.88983, + "15": 10.88458, + "16": 10.8759, + "17": 10.87389, + "18": 10.86392, + "19": 10.87172, + "20": 10.82085, + "21": 10.80728, + "22": 10.79583, + "23": 10.79119, + "24": 10.75658, + "25": 10.76154, + "26": 10.75136, + "27": 10.73586, + "28": 10.67557, + "29": 10.64032, + "30": 10.62211, + "31": 10.61219, + "32": 10.59656, + "33": 10.57524, + "34": 10.53944, + "35": 10.53691, + "36": 10.52053, + "37": 10.48602, + "38": 10.49787, + "39": 10.45741, + "40": 10.43859, + "41": 10.42607, + "42": 10.41228, + "43": 10.38805, + "44": 10.35099, + "45": 10.36785, + "46": 10.32756, + "47": 10.31064, + "48": 10.27588, + "49": 10.26883, + "50": 10.26942 } }, "num-zeros": { @@ -61,56 +61,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 18852.0, - "2": 19201.0, - "3": 18829.0, - "4": 18799.0, - "5": 18661.0, - "6": 18606.0, - "7": 19036.0, - "8": 18992.0, - "9": 19205.0, - "10": 19409.0, - "11": 18725.0, - "12": 18412.0, - "13": 19021.0, - "14": 19235.0, - "15": 18796.0, - "16": 18661.0, - "17": 18978.0, - "18": 18747.0, - "19": 18689.0, - "20": 18999.0, - "21": 18460.0, - "22": 18943.0, - "23": 19110.0, - "24": 18724.0, - "25": 18209.0, - "26": 18944.0, - "27": 18769.0, - "28": 18225.0, - "29": 18553.0, - "30": 18154.0, - "31": 18660.0, - "32": 19011.0, - "33": 18551.0, - "34": 18477.0, - "35": 18870.0, - "36": 18407.0, - "37": 18996.0, - "38": 18270.0, - "39": 19069.0, - "40": 19467.0, - "41": 18520.0, - "42": 18295.0, - "43": 19123.0, - "44": 18827.0, - "45": 20268.0, - "46": 19722.0, - "47": 19576.0, - "48": 19950.0, - "49": 21813.0, - "50": 20205.0 + "1": 36489.0, + "2": 36983.0, + "3": 36910.0, + "4": 36780.0, + "5": 36558.0, + "6": 35770.0, + "7": 36868.0, + "8": 36266.0, + "9": 36754.0, + "10": 37570.0, + "11": 36863.0, + "12": 35673.0, + "13": 36548.0, + "14": 36969.0, + "15": 36370.0, + "16": 35940.0, + "17": 36259.0, + "18": 36835.0, + "19": 36756.0, + "20": 36499.0, + "21": 36348.0, + "22": 37016.0, + "23": 36564.0, + "24": 36769.0, + "25": 35732.0, + "26": 36536.0, + "27": 36730.0, + "28": 35823.0, + "29": 35621.0, + "30": 35794.0, + "31": 36536.0, + "32": 36948.0, + "33": 35774.0, + "34": 35891.0, + "35": 36873.0, + "36": 36000.0, + "37": 36496.0, + "38": 35759.0, + "39": 37107.0, + "40": 37664.0, + "41": 36067.0, + "42": 35632.0, + "43": 37356.0, + "44": 36500.0, + "45": 39206.0, + "46": 38653.0, + "47": 38503.0, + "48": 38931.0, + "49": 41941.0, + "50": 39489.0 } }, "mem-allocated-bytes": { @@ -121,53 +121,53 @@ "1": 1388729344.0, "2": 1388729856.0, "3": 1388729344.0, - "4": 1388727808.0, - "5": 1388728832.0, - "6": 1388729856.0, + "4": 1388728320.0, + "5": 1388728320.0, + "6": 1388729344.0, "7": 1388731392.0, - "8": 1388730880.0, - "9": 1388731392.0, - "10": 1388731392.0, - "11": 1388729344.0, - "12": 1388728320.0, - "13": 1388732928.0, - "14": 1388727808.0, - "15": 1388730880.0, + "8": 1388730368.0, + "9": 1388731904.0, + "10": 1388730368.0, + "11": 1388728832.0, + "12": 1388727808.0, + "13": 1388733952.0, + "14": 1388727296.0, + "15": 1388730368.0, "16": 1388728832.0, - "17": 1388729856.0, + "17": 1388729344.0, "18": 1388729856.0, - "19": 1388730880.0, - "20": 1388731904.0, - "21": 1388729856.0, - "22": 1388730368.0, - "23": 1388731392.0, + "19": 1388731392.0, + "20": 1388730368.0, + "21": 1388731904.0, + "22": 1388730880.0, + "23": 1388731904.0, "24": 1388730880.0, "25": 1388732416.0, - "26": 1388732416.0, - "27": 1388731904.0, - "28": 1388728832.0, - "29": 1388730880.0, - "30": 1388732928.0, - "31": 1388731392.0, - "32": 1388728832.0, + "26": 1388732928.0, + "27": 1388729856.0, + "28": 1388729856.0, + "29": 1388731392.0, + "30": 1388732416.0, + "31": 1388731904.0, + "32": 1388728320.0, "33": 1388731904.0, - "34": 1388733952.0, + "34": 1388733440.0, "35": 1388731392.0, - "36": 1388730368.0, + "36": 1388730880.0, "37": 1388731392.0, - "38": 1388730368.0, - "39": 1388731904.0, - "40": 1388731904.0, + "38": 1388729856.0, + "39": 1388733440.0, + "40": 1388732416.0, "41": 1388729344.0, "42": 1388732416.0, - "43": 1388731904.0, - "44": 1388729344.0, - "45": 1388731904.0, - "46": 1388732416.0, - "47": 1388733440.0, - "48": 1388731392.0, - "49": 1388735488.0, - "50": 1388730368.0 + "43": 1388730880.0, + "44": 1388730368.0, + "45": 1388732416.0, + "46": 1388731904.0, + "47": 1388733952.0, + "48": 1388731904.0, + "49": 1388734976.0, + "50": 1388731392.0 } }, "mem-max-allocated-bytes": { @@ -176,112 +176,111 @@ "step_interval": 1, "values": { "1": 3179891200.0, - "2": 3662152192.0, - "3": 3662164480.0, - "4": 3663250944.0, - "5": 3663250944.0, - "6": 3663250944.0, - "7": 3663250944.0, - "8": 3663250944.0, - "9": 3663250944.0, - "10": 3663250944.0, - "11": 3663250944.0, - "12": 3663250944.0, - "13": 3663250944.0, - "14": 3663250944.0, - "15": 3663250944.0, - "16": 3663250944.0, - "17": 3663250944.0, - "18": 3663250944.0, - "19": 3663250944.0, - "20": 3663250944.0, - "21": 3663250944.0, - "22": 3663250944.0, - "23": 3663250944.0, - "24": 3663250944.0, - "25": 3663250944.0, - "26": 3663250944.0, - "27": 3663250944.0, - "28": 3663250944.0, - "29": 3663250944.0, - "30": 3663250944.0, - "31": 3663250944.0, - "32": 3663250944.0, - "33": 3663250944.0, - "34": 3663250944.0, - "35": 3663897600.0, - "36": 3663897600.0, - "37": 3663897600.0, - "38": 3663897600.0, - "39": 3663897600.0, - "40": 3663897600.0, - "41": 3663897600.0, - "42": 3664121344.0, - "43": 3664121344.0, - "44": 3664593408.0, - "45": 3664593408.0, - "46": 3664593408.0, - "47": 3664593408.0, - "48": 3664593408.0, - "49": 3664994816.0, - "50": 3664994816.0 + "2": 3661529600.0, + "3": 3661897728.0, + "4": 3663147008.0, + "5": 3663147008.0, + "6": 3663147008.0, + "7": 3663147008.0, + "8": 3663147008.0, + "9": 3663147008.0, + "10": 3663147008.0, + "11": 3663147008.0, + "12": 3663147008.0, + "13": 3663147008.0, + "14": 3663147008.0, + "15": 3663147008.0, + "16": 3663147008.0, + "17": 3663147008.0, + "18": 3663147008.0, + "19": 3663147008.0, + "20": 3663147008.0, + "21": 3663147008.0, + "22": 3663147008.0, + "23": 3663147008.0, + "24": 3663147008.0, + "25": 3663147008.0, + "26": 3663147008.0, + "27": 3663147008.0, + "28": 3663147008.0, + "29": 3663147008.0, + "30": 3663147008.0, + "31": 3663147008.0, + "32": 3663147008.0, + "33": 3663147008.0, + "34": 3663147008.0, + "35": 3663633408.0, + "36": 3663633408.0, + "37": 3663633408.0, + "38": 3663633408.0, + "39": 3663633408.0, + "40": 3664276992.0, + "41": 3664276992.0, + "42": 3664276992.0, + "43": 3664276992.0, + "44": 3664562176.0, + "45": 3664562176.0, + "46": 3664562176.0, + "47": 3664562176.0, + "48": 3664941056.0, + "49": 3664941056.0, + "50": 3664941056.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 50, "step_interval": 1, "values": { - "1": "nan", - "2": 6.85618, - "3": 2.07992, - "4": 1.87215, - "5": 1.48011, - "6": 2.02399, - "7": 2.13514, - "8": 2.16634, - "9": 1.77359, - "10": 1.31073, - "11": 1.64395, - "12": 1.59434, - "13": 1.52469, - "14": 1.92437, - "15": 1.76445, - "16": 1.88589, - "17": 2.05598, - "18": 1.86971, - "19": 2.0144, - "20": 1.80886, - "21": 1.87488, - "22": 1.82297, - "23": 2.50818, - "24": 2.75346, - "25": 0.74091, - "26": 2.16027, - "27": 2.04848, - "28": 1.31746, - "29": 1.64889, - "30": 1.25634, - "31": 1.91444, - "32": 3.0018, - "33": 2.02046, - "34": 0.90031, - "35": 2.58547, - "36": 2.4573, - "37": 2.09483, - "38": 0.8511, - "39": 1.81657, - "40": 2.84934, - "41": 1.28469, - "42": 2.64455, - "43": 1.75379, - "44": 2.06723, - "45": 1.55995, - "46": 1.02072, - "47": 2.05419, - "48": 2.10853, - "49": 1.68931, - "50": 1.50254 + "2": 5.15666, + "3": 1.89051, + "4": 1.70427, + "5": 1.36821, + "6": 2.22402, + "7": 2.0206, + "8": 2.64192, + "9": 2.49779, + "10": 1.43263, + "11": 1.40881, + "12": 1.54998, + "13": 1.38339, + "14": 1.66132, + "15": 1.27345, + "16": 1.64809, + "17": 1.39588, + "18": 1.42002, + "19": 1.40276, + "20": 1.33212, + "21": 1.40375, + "22": 1.69832, + "23": 1.54171, + "24": 2.83335, + "25": 1.15593, + "26": 1.50513, + "27": 1.49099, + "28": 11.01099, + "29": 3.57548, + "30": 1.01686, + "31": 7.90208, + "32": 9.8648, + "33": 4.94174, + "34": 1.21046, + "35": 1.99545, + "36": 1.50634, + "37": 1.39215, + "38": 1.49176, + "39": 1.71133, + "40": 2.30027, + "41": 1.40087, + "42": 2.15518, + "43": 1.49416, + "44": 1.23264, + "45": 1.42777, + "46": 0.83186, + "47": 3.49767, + "48": 10.11677, + "49": 3.23631, + "50": 2.68346 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/golden_values_dev_dgx_h100.json index 83ebb282949..8267b6a3f65 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/golden_values_dev_dgx_h100.json @@ -6,54 +6,54 @@ "values": { "1": 10.92563, "2": 10.91638, - "3": 10.92433, - "4": 10.93217, - "5": 10.92999, - "6": 10.92662, - "7": 10.92571, - "8": 10.92333, - "9": 10.92825, - "10": 10.91605, - "11": 10.91854, - "12": 10.92399, - "13": 10.91037, - "14": 10.90685, - "15": 10.90136, - "16": 10.88661, - "17": 10.88849, - "18": 10.88662, - "19": 10.8857, - "20": 10.83765, - "21": 10.82761, - "22": 10.81538, - "23": 10.8078, - "24": 10.78018, - "25": 10.778, - "26": 10.76109, - "27": 10.74912, - "28": 10.69195, - "29": 10.66617, - "30": 10.63122, - "31": 10.62222, - "32": 10.61543, - "33": 10.57901, - "34": 10.54677, - "35": 10.54608, - "36": 10.53419, - "37": 10.50598, - "38": 10.50458, - "39": 10.47274, - "40": 10.45062, - "41": 10.42727, - "42": 10.41444, - "43": 10.40126, - "44": 10.3705, - "45": 10.38167, - "46": 10.33539, - "47": 10.32458, - "48": 10.28718, - "49": 10.28599, - "50": 10.27739 + "3": 10.92459, + "4": 10.93245, + "5": 10.93019, + "6": 10.92651, + "7": 10.92566, + "8": 10.92291, + "9": 10.92842, + "10": 10.91709, + "11": 10.9186, + "12": 10.92364, + "13": 10.91032, + "14": 10.906, + "15": 10.90106, + "16": 10.88602, + "17": 10.88857, + "18": 10.8863, + "19": 10.88563, + "20": 10.83831, + "21": 10.82801, + "22": 10.81606, + "23": 10.80814, + "24": 10.77971, + "25": 10.77746, + "26": 10.76123, + "27": 10.74966, + "28": 10.69186, + "29": 10.66597, + "30": 10.63047, + "31": 10.62182, + "32": 10.61491, + "33": 10.5784, + "34": 10.54596, + "35": 10.54573, + "36": 10.53409, + "37": 10.5049, + "38": 10.50267, + "39": 10.47204, + "40": 10.44914, + "41": 10.426, + "42": 10.41331, + "43": 10.39913, + "44": 10.36866, + "45": 10.37972, + "46": 10.3331, + "47": 10.32171, + "48": 10.28474, + "49": 10.28349, + "50": 10.27386 } }, "num-zeros": { @@ -61,56 +61,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 18949.0, - "2": 19099.0, - "3": 19050.0, - "4": 19118.0, - "5": 19083.0, - "6": 18713.0, - "7": 18983.0, - "8": 18573.0, - "9": 19074.0, - "10": 19633.0, - "11": 18864.0, - "12": 18680.0, - "13": 18983.0, - "14": 19635.0, - "15": 18534.0, - "16": 18776.0, - "17": 19027.0, - "18": 19179.0, - "19": 19256.0, - "20": 19131.0, - "21": 18678.0, - "22": 19277.0, - "23": 19003.0, - "24": 19177.0, - "25": 18328.0, - "26": 18877.0, - "27": 19140.0, - "28": 18575.0, - "29": 18512.0, - "30": 18566.0, - "31": 18581.0, - "32": 19224.0, - "33": 18078.0, - "34": 18612.0, - "35": 18599.0, - "36": 18325.0, - "37": 18458.0, - "38": 18488.0, - "39": 18900.0, - "40": 19303.0, - "41": 18755.0, - "42": 18613.0, - "43": 19044.0, - "44": 18919.0, - "45": 20364.0, - "46": 19848.0, - "47": 20037.0, - "48": 19837.0, - "49": 21694.0, - "50": 20114.0 + "1": 36671.0, + "2": 37173.0, + "3": 36899.0, + "4": 37068.0, + "5": 36679.0, + "6": 35978.0, + "7": 37031.0, + "8": 36229.0, + "9": 37090.0, + "10": 37967.0, + "11": 36941.0, + "12": 36081.0, + "13": 36942.0, + "14": 37392.0, + "15": 36186.0, + "16": 36099.0, + "17": 36904.0, + "18": 36981.0, + "19": 37455.0, + "20": 36609.0, + "21": 36447.0, + "22": 36641.0, + "23": 36893.0, + "24": 37094.0, + "25": 35824.0, + "26": 36997.0, + "27": 36498.0, + "28": 35858.0, + "29": 36103.0, + "30": 35720.0, + "31": 36188.0, + "32": 36884.0, + "33": 36092.0, + "34": 36008.0, + "35": 36767.0, + "36": 35759.0, + "37": 36357.0, + "38": 35639.0, + "39": 36859.0, + "40": 37400.0, + "41": 36473.0, + "42": 36460.0, + "43": 37019.0, + "44": 36370.0, + "45": 39844.0, + "46": 38478.0, + "47": 38793.0, + "48": 38437.0, + "49": 42666.0, + "50": 39545.0 } }, "mem-allocated-bytes": { @@ -118,56 +118,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 1027089408.0, - "2": 1027091456.0, - "3": 1027087360.0, - "4": 1027088384.0, - "5": 1027091456.0, - "6": 1027091456.0, - "7": 1027088896.0, - "8": 1027092480.0, + "1": 1027088896.0, + "2": 1027090944.0, + "3": 1027086848.0, + "4": 1027087360.0, + "5": 1027091968.0, + "6": 1027089920.0, + "7": 1027088384.0, + "8": 1027091968.0, "9": 1027091968.0, - "10": 1027089408.0, + "10": 1027089920.0, "11": 1027089920.0, - "12": 1027092480.0, + "12": 1027091456.0, "13": 1027090944.0, - "14": 1027092480.0, - "15": 1027090432.0, - "16": 1027088384.0, - "17": 1027089408.0, - "18": 1027090944.0, - "19": 1027088384.0, - "20": 1027090432.0, - "21": 1027092480.0, + "14": 1027091456.0, + "15": 1027089920.0, + "16": 1027088896.0, + "17": 1027087872.0, + "18": 1027090432.0, + "19": 1027088896.0, + "20": 1027089408.0, + "21": 1027091456.0, "22": 1027089920.0, - "23": 1027093504.0, + "23": 1027094016.0, "24": 1027092480.0, "25": 1027089408.0, - "26": 1027090944.0, - "27": 1027087360.0, - "28": 1027090432.0, - "29": 1027090432.0, - "30": 1027089920.0, - "31": 1027089408.0, - "32": 1027093504.0, - "33": 1027094016.0, - "34": 1027093504.0, - "35": 1027085824.0, - "36": 1027087872.0, + "26": 1027089408.0, + "27": 1027086848.0, + "28": 1027090944.0, + "29": 1027089920.0, + "30": 1027090432.0, + "31": 1027090432.0, + "32": 1027092992.0, + "33": 1027092480.0, + "34": 1027091968.0, + "35": 1027085312.0, + "36": 1027086336.0, "37": 1027088896.0, "38": 1027089920.0, - "39": 1027088384.0, - "40": 1027091968.0, + "39": 1027087360.0, + "40": 1027090944.0, "41": 1027088384.0, - "42": 1027089408.0, + "42": 1027087872.0, "43": 1027087872.0, - "44": 1027091456.0, + "44": 1027088384.0, "45": 1027090432.0, "46": 1027086336.0, - "47": 1027088384.0, - "48": 1027087360.0, - "49": 1027087360.0, - "50": 1027089920.0 + "47": 1027086848.0, + "48": 1027086848.0, + "49": 1027086336.0, + "50": 1027089408.0 } }, "mem-max-allocated-bytes": { @@ -175,113 +175,112 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 3058326528.0, - "2": 3298517504.0, - "3": 3298517504.0, - "4": 3298517504.0, - "5": 3300747776.0, - "6": 3300747776.0, - "7": 3300747776.0, - "8": 3300747776.0, - "9": 3300747776.0, - "10": 3300747776.0, - "11": 3300747776.0, - "12": 3300747776.0, - "13": 3300747776.0, - "14": 3300747776.0, - "15": 3300747776.0, - "16": 3300747776.0, - "17": 3300747776.0, - "18": 3300747776.0, - "19": 3300747776.0, - "20": 3300747776.0, - "21": 3300747776.0, - "22": 3300747776.0, - "23": 3300747776.0, - "24": 3300747776.0, - "25": 3300747776.0, - "26": 3300747776.0, - "27": 3300747776.0, - "28": 3300747776.0, - "29": 3300747776.0, - "30": 3300747776.0, - "31": 3300747776.0, - "32": 3300747776.0, - "33": 3300747776.0, - "34": 3300872192.0, - "35": 3300872192.0, - "36": 3300872192.0, - "37": 3300872192.0, - "38": 3300872192.0, - "39": 3300872192.0, - "40": 3300872192.0, - "41": 3300872192.0, - "42": 3300872192.0, - "43": 3300872192.0, - "44": 3300872192.0, - "45": 3300872192.0, - "46": 3300872192.0, - "47": 3300872192.0, - "48": 3300872192.0, - "49": 3300872192.0, - "50": 3300872192.0 + "1": 3059096576.0, + "2": 3298776064.0, + "3": 3298776064.0, + "4": 3298776064.0, + "5": 3298776064.0, + "6": 3298776064.0, + "7": 3298776064.0, + "8": 3299136512.0, + "9": 3299136512.0, + "10": 3299136512.0, + "11": 3299136512.0, + "12": 3299136512.0, + "13": 3299320320.0, + "14": 3299397120.0, + "15": 3299397120.0, + "16": 3299397120.0, + "17": 3299397120.0, + "18": 3299397120.0, + "19": 3299397120.0, + "20": 3299397120.0, + "21": 3299397120.0, + "22": 3299397120.0, + "23": 3300247552.0, + "24": 3300247552.0, + "25": 3300247552.0, + "26": 3300247552.0, + "27": 3300247552.0, + "28": 3300247552.0, + "29": 3300247552.0, + "30": 3300247552.0, + "31": 3300247552.0, + "32": 3300247552.0, + "33": 3300247552.0, + "34": 3300554752.0, + "35": 3300554752.0, + "36": 3300554752.0, + "37": 3300554752.0, + "38": 3300554752.0, + "39": 3300554752.0, + "40": 3300554752.0, + "41": 3300554752.0, + "42": 3300554752.0, + "43": 3300554752.0, + "44": 3300554752.0, + "45": 3300554752.0, + "46": 3300554752.0, + "47": 3300554752.0, + "48": 3300554752.0, + "49": 3300554752.0, + "50": 3300554752.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 50, "step_interval": 1, "values": { - "1": "nan", - "2": 7.27219, - "3": 0.24679, - "4": 0.22635, - "5": 0.23051, - "6": 0.22263, - "7": 0.21818, - "8": 0.21355, - "9": 0.21356, - "10": 0.21043, - "11": 0.21544, - "12": 0.21111, - "13": 0.21015, - "14": 0.21431, - "15": 0.21165, - "16": 0.21367, - "17": 0.21668, - "18": 0.2084, - "19": 0.20834, - "20": 0.20701, - "21": 0.21147, - "22": 0.20775, - "23": 0.2219, - "24": 0.21061, - "25": 0.20661, - "26": 0.21028, - "27": 0.2129, - "28": 0.20786, - "29": 0.20797, - "30": 0.20789, - "31": 0.20896, - "32": 0.20624, - "33": 0.20688, - "34": 0.20637, - "35": 0.20779, - "36": 0.20898, - "37": 0.20801, - "38": 0.2083, - "39": 0.20824, - "40": 0.20749, - "41": 0.20582, - "42": 0.20712, - "43": 0.21062, - "44": 0.21109, - "45": 0.21193, - "46": 0.20663, - "47": 0.21151, - "48": 0.20703, - "49": 0.21392, - "50": 0.21062 + "2": 5.16303, + "3": 0.31975, + "4": 0.32114, + "5": 0.32174, + "6": 0.31434, + "7": 0.30635, + "8": 0.30462, + "9": 0.30412, + "10": 0.30505, + "11": 0.31288, + "12": 0.29834, + "13": 0.30086, + "14": 0.29552, + "15": 0.28913, + "16": 0.29571, + "17": 0.29148, + "18": 0.2864, + "19": 0.28774, + "20": 0.28853, + "21": 0.2911, + "22": 0.29212, + "23": 0.2979, + "24": 0.29998, + "25": 0.29745, + "26": 0.29211, + "27": 0.30024, + "28": 0.29179, + "29": 0.29311, + "30": 0.29515, + "31": 0.29371, + "32": 0.29669, + "33": 0.29283, + "34": 0.29194, + "35": 0.29361, + "36": 0.2973, + "37": 0.29273, + "38": 0.29215, + "39": 0.29439, + "40": 0.295, + "41": 0.28702, + "42": 0.29175, + "43": 0.28749, + "44": 0.29187, + "45": 1.10566, + "46": 0.28901, + "47": 0.2914, + "48": 0.30221, + "49": 0.30073, + "50": 0.29095 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/golden_values_dev_dgx_gb200.json index 4e1261dee6a..594a99c55b6 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/golden_values_dev_dgx_gb200.json +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/golden_values_dev_dgx_gb200.json @@ -6,54 +6,54 @@ "values": { "1": 10.92337, "2": 10.91184, - "3": 10.92435, - "4": 10.91391, - "5": 10.91783, - "6": 10.90822, - "7": 10.90214, - "8": 10.90968, - "9": 10.92327, - "10": 10.91324, - "11": 10.90152, - "12": 10.91168, - "13": 10.88952, - "14": 10.88978, - "15": 10.88462, - "16": 10.87656, - "17": 10.87413, - "18": 10.8627, - "19": 10.87155, - "20": 10.8213, - "21": 10.8075, - "22": 10.79454, - "23": 10.79087, - "24": 10.75698, - "25": 10.76203, - "26": 10.75144, - "27": 10.73619, - "28": 10.67458, - "29": 10.63986, - "30": 10.62269, - "31": 10.6127, - "32": 10.59666, - "33": 10.57595, - "34": 10.53962, - "35": 10.53801, - "36": 10.52144, - "37": 10.48719, - "38": 10.49901, - "39": 10.45863, - "40": 10.43984, - "41": 10.42707, - "42": 10.41355, - "43": 10.38977, - "44": 10.35254, - "45": 10.36992, - "46": 10.32982, - "47": 10.31281, - "48": 10.27777, - "49": 10.27075, - "50": 10.27166 + "3": 10.92433, + "4": 10.91442, + "5": 10.91813, + "6": 10.90832, + "7": 10.90215, + "8": 10.90903, + "9": 10.92402, + "10": 10.91272, + "11": 10.902, + "12": 10.91244, + "13": 10.88903, + "14": 10.88983, + "15": 10.88458, + "16": 10.8759, + "17": 10.87389, + "18": 10.86392, + "19": 10.87172, + "20": 10.82085, + "21": 10.80728, + "22": 10.79583, + "23": 10.79119, + "24": 10.75658, + "25": 10.76154, + "26": 10.75136, + "27": 10.73586, + "28": 10.67557, + "29": 10.64032, + "30": 10.62211, + "31": 10.61219, + "32": 10.59656, + "33": 10.57524, + "34": 10.53944, + "35": 10.53691, + "36": 10.52053, + "37": 10.48602, + "38": 10.49787, + "39": 10.45741, + "40": 10.43859, + "41": 10.42607, + "42": 10.41228, + "43": 10.38805, + "44": 10.35099, + "45": 10.36785, + "46": 10.32756, + "47": 10.31064, + "48": 10.27588, + "49": 10.26883, + "50": 10.26942 } }, "num-zeros": { @@ -61,56 +61,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 18852.0, - "2": 19201.0, - "3": 18829.0, - "4": 18799.0, - "5": 18661.0, - "6": 18606.0, - "7": 19036.0, - "8": 18992.0, - "9": 19205.0, - "10": 19409.0, - "11": 18725.0, - "12": 18412.0, - "13": 19021.0, - "14": 19235.0, - "15": 18796.0, - "16": 18661.0, - "17": 18978.0, - "18": 18747.0, - "19": 18689.0, - "20": 18999.0, - "21": 18460.0, - "22": 18943.0, - "23": 19110.0, - "24": 18724.0, - "25": 18209.0, - "26": 18944.0, - "27": 18769.0, - "28": 18225.0, - "29": 18553.0, - "30": 18154.0, - "31": 18660.0, - "32": 19011.0, - "33": 18551.0, - "34": 18477.0, - "35": 18870.0, - "36": 18407.0, - "37": 18996.0, - "38": 18270.0, - "39": 19069.0, - "40": 19467.0, - "41": 18520.0, - "42": 18295.0, - "43": 19123.0, - "44": 18827.0, - "45": 20268.0, - "46": 19722.0, - "47": 19576.0, - "48": 19950.0, - "49": 21813.0, - "50": 20205.0 + "1": 36489.0, + "2": 36983.0, + "3": 36910.0, + "4": 36780.0, + "5": 36558.0, + "6": 35770.0, + "7": 36868.0, + "8": 36266.0, + "9": 36754.0, + "10": 37570.0, + "11": 36863.0, + "12": 35673.0, + "13": 36548.0, + "14": 36969.0, + "15": 36370.0, + "16": 35940.0, + "17": 36259.0, + "18": 36835.0, + "19": 36756.0, + "20": 36499.0, + "21": 36348.0, + "22": 37016.0, + "23": 36564.0, + "24": 36769.0, + "25": 35732.0, + "26": 36536.0, + "27": 36730.0, + "28": 35823.0, + "29": 35621.0, + "30": 35794.0, + "31": 36536.0, + "32": 36948.0, + "33": 35774.0, + "34": 35891.0, + "35": 36873.0, + "36": 36000.0, + "37": 36496.0, + "38": 35759.0, + "39": 37107.0, + "40": 37664.0, + "41": 36067.0, + "42": 35632.0, + "43": 37356.0, + "44": 36500.0, + "45": 39206.0, + "46": 38653.0, + "47": 38503.0, + "48": 38931.0, + "49": 41941.0, + "50": 39489.0 } }, "mem-allocated-bytes": { @@ -121,53 +121,53 @@ "1": 1388729344.0, "2": 1388729856.0, "3": 1388729344.0, - "4": 1388727808.0, - "5": 1388728832.0, - "6": 1388729856.0, + "4": 1388728320.0, + "5": 1388728320.0, + "6": 1388729344.0, "7": 1388731392.0, - "8": 1388730880.0, - "9": 1388731392.0, - "10": 1388731392.0, - "11": 1388729344.0, - "12": 1388728320.0, - "13": 1388732928.0, - "14": 1388727808.0, - "15": 1388730880.0, + "8": 1388730368.0, + "9": 1388731904.0, + "10": 1388730368.0, + "11": 1388728832.0, + "12": 1388727808.0, + "13": 1388733952.0, + "14": 1388727296.0, + "15": 1388730368.0, "16": 1388728832.0, - "17": 1388729856.0, + "17": 1388729344.0, "18": 1388729856.0, - "19": 1388730880.0, - "20": 1388731904.0, - "21": 1388729856.0, - "22": 1388730368.0, - "23": 1388731392.0, + "19": 1388731392.0, + "20": 1388730368.0, + "21": 1388731904.0, + "22": 1388730880.0, + "23": 1388731904.0, "24": 1388730880.0, "25": 1388732416.0, - "26": 1388732416.0, - "27": 1388731904.0, - "28": 1388728832.0, - "29": 1388730880.0, - "30": 1388732928.0, - "31": 1388731392.0, - "32": 1388728832.0, + "26": 1388732928.0, + "27": 1388729856.0, + "28": 1388729856.0, + "29": 1388731392.0, + "30": 1388732416.0, + "31": 1388731904.0, + "32": 1388728320.0, "33": 1388731904.0, - "34": 1388733952.0, + "34": 1388733440.0, "35": 1388731392.0, - "36": 1388730368.0, + "36": 1388730880.0, "37": 1388731392.0, - "38": 1388730368.0, - "39": 1388731904.0, - "40": 1388731904.0, + "38": 1388729856.0, + "39": 1388733440.0, + "40": 1388732416.0, "41": 1388729344.0, "42": 1388732416.0, - "43": 1388731904.0, - "44": 1388729344.0, - "45": 1388731904.0, - "46": 1388732416.0, - "47": 1388733440.0, - "48": 1388731392.0, - "49": 1388735488.0, - "50": 1388730368.0 + "43": 1388730880.0, + "44": 1388730368.0, + "45": 1388732416.0, + "46": 1388731904.0, + "47": 1388733952.0, + "48": 1388731904.0, + "49": 1388734976.0, + "50": 1388731392.0 } }, "mem-max-allocated-bytes": { @@ -175,113 +175,112 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 3179891200.0, - "2": 3662227968.0, - "3": 3662227968.0, - "4": 3663250944.0, - "5": 3663250944.0, - "6": 3663250944.0, - "7": 3663250944.0, - "8": 3663250944.0, - "9": 3663250944.0, - "10": 3663250944.0, - "11": 3663250944.0, - "12": 3663250944.0, - "13": 3663250944.0, - "14": 3663250944.0, - "15": 3663250944.0, - "16": 3663250944.0, - "17": 3663250944.0, - "18": 3663250944.0, - "19": 3663250944.0, - "20": 3663250944.0, - "21": 3663250944.0, - "22": 3663250944.0, - "23": 3663250944.0, - "24": 3663250944.0, - "25": 3663250944.0, - "26": 3663250944.0, - "27": 3663250944.0, - "28": 3663250944.0, - "29": 3663250944.0, - "30": 3663250944.0, - "31": 3663250944.0, - "32": 3663250944.0, - "33": 3663250944.0, - "34": 3664421888.0, - "35": 3664421888.0, - "36": 3664421888.0, - "37": 3664421888.0, - "38": 3664421888.0, - "39": 3664421888.0, - "40": 3664421888.0, - "41": 3664421888.0, - "42": 3664421888.0, - "43": 3664421888.0, - "44": 3664535552.0, - "45": 3664535552.0, - "46": 3664535552.0, - "47": 3664535552.0, - "48": 3664535552.0, - "49": 3664612864.0, - "50": 3664612864.0 + "1": 3179466240.0, + "2": 3662152192.0, + "3": 3662152192.0, + "4": 3663147008.0, + "5": 3663147008.0, + "6": 3663147008.0, + "7": 3663147008.0, + "8": 3663147008.0, + "9": 3663147008.0, + "10": 3663147008.0, + "11": 3663147008.0, + "12": 3663147008.0, + "13": 3663147008.0, + "14": 3663147008.0, + "15": 3663147008.0, + "16": 3663147008.0, + "17": 3663147008.0, + "18": 3663147008.0, + "19": 3663147008.0, + "20": 3663147008.0, + "21": 3663147008.0, + "22": 3663147008.0, + "23": 3663147008.0, + "24": 3663147008.0, + "25": 3663147008.0, + "26": 3663147008.0, + "27": 3663147008.0, + "28": 3663147008.0, + "29": 3663147008.0, + "30": 3663147008.0, + "31": 3663147008.0, + "32": 3663147008.0, + "33": 3663147008.0, + "34": 3663147008.0, + "35": 3663633408.0, + "36": 3663633408.0, + "37": 3663633408.0, + "38": 3663633408.0, + "39": 3663633408.0, + "40": 3664276992.0, + "41": 3664276992.0, + "42": 3664276992.0, + "43": 3664276992.0, + "44": 3664562176.0, + "45": 3664562176.0, + "46": 3664562176.0, + "47": 3664562176.0, + "48": 3664941056.0, + "49": 3664941056.0, + "50": 3664941056.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 50, "step_interval": 1, "values": { - "1": "nan", - "2": 6.76257, - "3": 2.41181, - "4": 2.14671, - "5": 1.68613, - "6": 2.35596, - "7": 1.85689, - "8": 3.55826, - "9": 2.10313, - "10": 1.69174, - "11": 2.07594, - "12": 2.11237, - "13": 2.0346, - "14": 2.34804, - "15": 1.56257, - "16": 1.96304, - "17": 2.0887, - "18": 2.24792, - "19": 1.71276, - "20": 1.64151, - "21": 2.03443, - "22": 1.86292, - "23": 1.95656, - "24": 2.17465, - "25": 2.28198, - "26": 1.89479, - "27": 1.24118, - "28": 2.07544, - "29": 1.97422, - "30": 1.20941, - "31": 1.60534, - "32": 2.43502, - "33": 2.08637, - "34": 1.84735, - "35": 2.40386, - "36": 1.40304, - "37": 2.14664, - "38": 0.89545, - "39": 2.42476, - "40": 2.25422, - "41": 1.29899, - "42": 2.6352, - "43": 1.42406, - "44": 2.03708, - "45": 2.10233, - "46": 0.7566, - "47": 1.72618, - "48": 1.51965, - "49": 1.43953, - "50": 1.66225 + "2": 5.39007, + "3": 2.22863, + "4": 1.54551, + "5": 1.86051, + "6": 1.81825, + "7": 1.83536, + "8": 2.64367, + "9": 2.04074, + "10": 1.39125, + "11": 1.58824, + "12": 1.51442, + "13": 1.55316, + "14": 2.01252, + "15": 1.07894, + "16": 1.60711, + "17": 1.8311, + "18": 1.79223, + "19": 1.69767, + "20": 2.25425, + "21": 1.10313, + "22": 1.1802, + "23": 2.17979, + "24": 1.83343, + "25": 1.4927, + "26": 1.46116, + "27": 1.40226, + "28": 2.35976, + "29": 1.44806, + "30": 1.08009, + "31": 1.20756, + "32": 2.55991, + "33": 1.71293, + "34": 2.84147, + "35": 2.40341, + "36": 1.99923, + "37": 2.07605, + "38": 1.16269, + "39": 1.45685, + "40": 2.61788, + "41": 1.76194, + "42": 2.30587, + "43": 1.5739, + "44": 1.95111, + "45": 1.65086, + "46": 0.91469, + "47": 1.7889, + "48": 1.52903, + "49": 1.16314, + "50": 1.17655 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/golden_values_dev_dgx_h100.json index 06a1d993f8b..8bcf15522c7 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/golden_values_dev_dgx_h100.json @@ -6,54 +6,54 @@ "values": { "1": 10.90791, "2": 10.90713, - "3": 10.91668, - "4": 10.90899, - "5": 10.91483, - "6": 10.89524, - "7": 10.90676, - "8": 10.90896, - "9": 10.90939, - "10": 10.91077, - "11": 10.901, - "12": 10.89922, - "13": 10.88807, - "14": 10.88197, - "15": 10.87251, - "16": 10.85287, - "17": 10.85704, - "18": 10.84826, - "19": 10.85507, - "20": 10.77687, - "21": 10.76073, - "22": 10.7605, - "23": 10.74325, - "24": 10.70838, - "25": 10.70981, - "26": 10.69235, - "27": 10.66868, - "28": 10.60599, - "29": 10.57223, - "30": 10.54151, - "31": 10.53199, - "32": 10.51634, - "33": 10.481, - "34": 10.44913, - "35": 10.44632, - "36": 10.42066, - "37": 10.40067, - "38": 10.40454, - "39": 10.36981, - "40": 10.35244, - "41": 10.33008, - "42": 10.31128, - "43": 10.29795, - "44": 10.27171, - "45": 10.28363, - "46": 10.24114, - "47": 10.23434, - "48": 10.19197, - "49": 10.19498, - "50": 10.19073 + "3": 10.91656, + "4": 10.90856, + "5": 10.91486, + "6": 10.8955, + "7": 10.90682, + "8": 10.90938, + "9": 10.90906, + "10": 10.91044, + "11": 10.90161, + "12": 10.9002, + "13": 10.88758, + "14": 10.88178, + "15": 10.87374, + "16": 10.85236, + "17": 10.85613, + "18": 10.84761, + "19": 10.85533, + "20": 10.77576, + "21": 10.76185, + "22": 10.75979, + "23": 10.74357, + "24": 10.7085, + "25": 10.70954, + "26": 10.69236, + "27": 10.66744, + "28": 10.60538, + "29": 10.57197, + "30": 10.541, + "31": 10.53121, + "32": 10.51574, + "33": 10.48005, + "34": 10.44884, + "35": 10.44593, + "36": 10.41946, + "37": 10.39946, + "38": 10.40313, + "39": 10.36823, + "40": 10.35085, + "41": 10.3285, + "42": 10.30918, + "43": 10.29587, + "44": 10.26912, + "45": 10.28118, + "46": 10.2386, + "47": 10.23166, + "48": 10.18908, + "49": 10.19252, + "50": 10.1881 } }, "num-zeros": { @@ -61,56 +61,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 16592.0, - "2": 16504.0, - "3": 16575.0, - "4": 16376.0, - "5": 16183.0, - "6": 16014.0, - "7": 16818.0, - "8": 15883.0, - "9": 16693.0, - "10": 16580.0, - "11": 16233.0, - "12": 16030.0, - "13": 16714.0, - "14": 16720.0, - "15": 15895.0, - "16": 16103.0, - "17": 16623.0, - "18": 16575.0, - "19": 16738.0, - "20": 16598.0, - "21": 16235.0, - "22": 16514.0, - "23": 16299.0, - "24": 16586.0, - "25": 15770.0, - "26": 16540.0, - "27": 16550.0, - "28": 16182.0, - "29": 16694.0, - "30": 16454.0, - "31": 17100.0, - "32": 17390.0, - "33": 17062.0, - "34": 17233.0, - "35": 17703.0, - "36": 17351.0, - "37": 18011.0, - "38": 17621.0, - "39": 18243.0, - "40": 18971.0, - "41": 18220.0, - "42": 17966.0, - "43": 18752.0, - "44": 18809.0, - "45": 20890.0, - "46": 19846.0, - "47": 19418.0, - "48": 20136.0, - "49": 22380.0, - "50": 20145.0 + "1": 32481.0, + "2": 32205.0, + "3": 31782.0, + "4": 32082.0, + "5": 31672.0, + "6": 30901.0, + "7": 32296.0, + "8": 30851.0, + "9": 32347.0, + "10": 32485.0, + "11": 31812.0, + "12": 31039.0, + "13": 32298.0, + "14": 32795.0, + "15": 31592.0, + "16": 30976.0, + "17": 32064.0, + "18": 32220.0, + "19": 32630.0, + "20": 32593.0, + "21": 31945.0, + "22": 32124.0, + "23": 32052.0, + "24": 33318.0, + "25": 31411.0, + "26": 32629.0, + "27": 32773.0, + "28": 32484.0, + "29": 32771.0, + "30": 32994.0, + "31": 34132.0, + "32": 34806.0, + "33": 33924.0, + "34": 34187.0, + "35": 35432.0, + "36": 35117.0, + "37": 35331.0, + "38": 35038.0, + "39": 36823.0, + "40": 38166.0, + "41": 36109.0, + "42": 35997.0, + "43": 37458.0, + "44": 37012.0, + "45": 40824.0, + "46": 38797.0, + "47": 38754.0, + "48": 39579.0, + "49": 43844.0, + "50": 39384.0 } }, "mem-allocated-bytes": { @@ -118,56 +118,56 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 1562452480.0, - "2": 1560886272.0, - "3": 1560936960.0, - "4": 1560987648.0, - "5": 1560990208.0, - "6": 1560987648.0, - "7": 1561697792.0, - "8": 1561797632.0, - "9": 1560936960.0, - "10": 1560987648.0, - "11": 1562030592.0, - "12": 1561056256.0, - "13": 1561038336.0, - "14": 1561974784.0, - "15": 1561152000.0, - "16": 1561089024.0, - "17": 1561038336.0, - "18": 1562183680.0, - "19": 1562019328.0, - "20": 1561089024.0, - "21": 1561960960.0, - "22": 1561631744.0, - "23": 1561836032.0, - "24": 1561089024.0, - "25": 1561139712.0, - "26": 1561089024.0, - "27": 1561139712.0, - "28": 1561190400.0, - "29": 1561772544.0, - "30": 1561604096.0, - "31": 1562013184.0, - "32": 1561406464.0, - "33": 1561139712.0, - "34": 1561755648.0, - "35": 1561589248.0, - "36": 1561190400.0, - "37": 1561662976.0, - "38": 1561190400.0, - "39": 1561241088.0, - "40": 1563022336.0, - "41": 1564137984.0, - "42": 1561291776.0, - "43": 1561952768.0, - "44": 1561291776.0, - "45": 1561715200.0, - "46": 1561291776.0, - "47": 1561342464.0, - "48": 1561291776.0, - "49": 1561342464.0, - "50": 1561291776.0 + "1": 1564053504.0, + "2": 1563238912.0, + "3": 1562403328.0, + "4": 1562403328.0, + "5": 1562403328.0, + "6": 1562403328.0, + "7": 1562403328.0, + "8": 1562513920.0, + "9": 1563245056.0, + "10": 1562403328.0, + "11": 1562484224.0, + "12": 1562403328.0, + "13": 1562433024.0, + "14": 1562863104.0, + "15": 1563126272.0, + "16": 1563629056.0, + "17": 1563629056.0, + "18": 1564133888.0, + "19": 1562616320.0, + "20": 1562403328.0, + "21": 1562403328.0, + "22": 1562895872.0, + "23": 1563697664.0, + "24": 1562403328.0, + "25": 1562640896.0, + "26": 1562403328.0, + "27": 1562403328.0, + "28": 1562403328.0, + "29": 1562691072.0, + "30": 1563299328.0, + "31": 1562403328.0, + "32": 1562403328.0, + "33": 1562915328.0, + "34": 1563009536.0, + "35": 1562771968.0, + "36": 1562403328.0, + "37": 1563407872.0, + "38": 1563094528.0, + "39": 1562403328.0, + "40": 1562403328.0, + "41": 1563132416.0, + "42": 1562603008.0, + "43": 1562563072.0, + "44": 1562528256.0, + "45": 1562403328.0, + "46": 1562589696.0, + "47": 1562403328.0, + "48": 1562632704.0, + "49": 1562403328.0, + "50": 1563123200.0 } }, "mem-max-allocated-bytes": { @@ -175,113 +175,112 @@ "end_step": 50, "step_interval": 1, "values": { - "1": 3479876608.0, - "2": 4042335744.0, - "3": 4047595520.0, - "4": 4053274112.0, - "5": 4053274112.0, - "6": 4053274112.0, - "7": 4057433088.0, - "8": 4061642240.0, - "9": 4061642240.0, - "10": 4065529344.0, - "11": 4065529344.0, - "12": 4065529344.0, - "13": 4065529344.0, - "14": 4065529344.0, - "15": 4065529344.0, - "16": 4065529344.0, - "17": 4065529344.0, - "18": 4065529344.0, - "19": 4065529344.0, - "20": 4065529344.0, - "21": 4065529344.0, - "22": 4065529344.0, - "23": 4065529344.0, - "24": 4065529344.0, - "25": 4065529344.0, - "26": 4065529344.0, - "27": 4065529344.0, - "28": 4065529344.0, - "29": 4065529344.0, - "30": 4065529344.0, - "31": 4065529344.0, - "32": 4065529344.0, - "33": 4065529344.0, - "34": 4065529344.0, - "35": 4065529344.0, - "36": 4065529344.0, - "37": 4065529344.0, - "38": 4065529344.0, - "39": 4065529344.0, - "40": 4065529344.0, - "41": 4065529344.0, - "42": 4065529344.0, - "43": 4065529344.0, - "44": 4065529344.0, - "45": 4065529344.0, - "46": 4065529344.0, - "47": 4065529344.0, - "48": 4065529344.0, - "49": 4065529344.0, - "50": 4065529344.0 + "1": 3481845248.0, + "2": 4045780992.0, + "3": 4052392960.0, + "4": 4057906176.0, + "5": 4057906176.0, + "6": 4057906176.0, + "7": 4057906176.0, + "8": 4057906176.0, + "9": 4060597248.0, + "10": 4065854464.0, + "11": 4065854464.0, + "12": 4065854464.0, + "13": 4065854464.0, + "14": 4065854464.0, + "15": 4065854464.0, + "16": 4065854464.0, + "17": 4065854464.0, + "18": 4065854464.0, + "19": 4065854464.0, + "20": 4065854464.0, + "21": 4065854464.0, + "22": 4065854464.0, + "23": 4065854464.0, + "24": 4065854464.0, + "25": 4065854464.0, + "26": 4065854464.0, + "27": 4065854464.0, + "28": 4065854464.0, + "29": 4065854464.0, + "30": 4065854464.0, + "31": 4065854464.0, + "32": 4065854464.0, + "33": 4065854464.0, + "34": 4065854464.0, + "35": 4065854464.0, + "36": 4065854464.0, + "37": 4065854464.0, + "38": 4065854464.0, + "39": 4065854464.0, + "40": 4065854464.0, + "41": 4065854464.0, + "42": 4065854464.0, + "43": 4065854464.0, + "44": 4065854464.0, + "45": 4065854464.0, + "46": 4065854464.0, + "47": 4065854464.0, + "48": 4065854464.0, + "49": 4065854464.0, + "50": 4065854464.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 50, "step_interval": 1, "values": { - "1": "nan", - "2": 11.44286, - "3": 0.39137, - "4": 0.33071, - "5": 0.32257, - "6": 0.32404, - "7": 0.30595, - "8": 0.29297, - "9": 0.29395, - "10": 0.27912, - "11": 0.30251, - "12": 0.28669, - "13": 0.28455, - "14": 0.28124, - "15": 0.2876, - "16": 0.27705, - "17": 0.28277, - "18": 0.28818, - "19": 0.29518, - "20": 0.28783, - "21": 0.28453, - "22": 0.28955, - "23": 0.27766, - "24": 0.278, - "25": 0.28149, - "26": 0.29603, - "27": 0.27934, - "28": 0.29048, - "29": 0.29607, - "30": 0.28981, - "31": 0.32857, - "32": 0.29071, - "33": 0.29613, - "34": 0.2968, - "35": 0.30616, - "36": 0.30069, - "37": 0.29431, - "38": 0.29876, - "39": 0.30582, - "40": 0.28349, - "41": 0.28535, - "42": 0.28254, - "43": 0.2788, - "44": 0.27508, - "45": 0.27863, - "46": 0.27541, - "47": 0.27561, - "48": 0.27969, - "49": 0.27721, - "50": 0.27313 + "2": 7.65129, + "3": 0.49113, + "4": 0.46732, + "5": 0.45015, + "6": 0.44098, + "7": 0.43279, + "8": 0.43532, + "9": 0.41644, + "10": 0.41447, + "11": 0.417, + "12": 1.27039, + "13": 0.42857, + "14": 0.42043, + "15": 0.43429, + "16": 0.42646, + "17": 0.41839, + "18": 0.41875, + "19": 0.42078, + "20": 0.4152, + "21": 0.41839, + "22": 0.42007, + "23": 0.40978, + "24": 0.4028, + "25": 0.40842, + "26": 0.41505, + "27": 1.15357, + "28": 0.43831, + "29": 1.14215, + "30": 0.42585, + "31": 0.42393, + "32": 0.42386, + "33": 0.41322, + "34": 0.42071, + "35": 0.4168, + "36": 1.17628, + "37": 0.42372, + "38": 0.42557, + "39": 1.14457, + "40": 0.4147, + "41": 0.41313, + "42": 0.41232, + "43": 0.41219, + "44": 0.41084, + "45": 0.40381, + "46": 0.40844, + "47": 0.40717, + "48": 1.20976, + "49": 0.42107, + "50": 0.42035 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer/golden_values_dev_dgx_h100.json index 635fcea4a97..455d3cbe1c4 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer/golden_values_dev_dgx_h100.json @@ -7,103 +7,103 @@ "1": 10.92486, "2": 10.91069, "3": 10.91839, - "4": 10.91715, - "5": 10.90503, - "6": 10.90196, - "7": 10.89732, - "8": 10.91344, - "9": 10.91625, - "10": 10.91028, - "11": 10.90163, - "12": 10.89726, - "13": 10.8879, - "14": 10.89483, - "15": 10.87506, - "16": 10.87056, - "17": 10.86912, - "18": 10.85168, - "19": 10.87022, - "20": 10.78801, - "21": 10.77233, - "22": 10.76715, - "23": 10.75842, - "24": 10.71926, - "25": 10.71997, - "26": 10.71229, - "27": 10.68551, - "28": 10.61305, - "29": 10.58637, - "30": 10.56568, - "31": 10.55773, - "32": 10.54888, - "33": 10.50977, - "34": 10.48172, - "35": 10.47011, - "36": 10.45293, - "37": 10.42772, - "38": 10.43271, - "39": 10.40299, - "40": 10.3775, - "41": 10.36875, - "42": 10.33113, - "43": 10.31542, - "44": 10.29012, - "45": 10.30282, - "46": 10.2657, - "47": 10.25567, - "48": 10.20709, - "49": 10.21058, - "50": 10.21068, + "4": 10.9172, + "5": 10.90494, + "6": 10.90213, + "7": 10.8971, + "8": 10.91319, + "9": 10.9167, + "10": 10.9103, + "11": 10.90137, + "12": 10.8968, + "13": 10.88802, + "14": 10.89532, + "15": 10.87553, + "16": 10.87001, + "17": 10.86926, + "18": 10.8521, + "19": 10.86958, + "20": 10.78798, + "21": 10.77234, + "22": 10.7674, + "23": 10.75859, + "24": 10.7193, + "25": 10.72025, + "26": 10.71224, + "27": 10.68505, + "28": 10.61329, + "29": 10.5867, + "30": 10.56566, + "31": 10.55778, + "32": 10.54894, + "33": 10.5097, + "34": 10.48124, + "35": 10.46985, + "36": 10.45295, + "37": 10.42776, + "38": 10.43249, + "39": 10.40289, + "40": 10.37722, + "41": 10.36871, + "42": 10.33138, + "43": 10.31516, + "44": 10.29024, + "45": 10.3027, + "46": 10.26552, + "47": 10.25565, + "48": 10.20692, + "49": 10.21051, + "50": 10.21041, "51": 10.21195, - "52": 10.16251, - "53": 10.16325, - "54": 10.13402, - "55": 10.10872, - "56": 10.13453, - "57": 10.13277, - "58": 10.12413, - "59": 10.06521, - "60": 10.09524, - "61": 10.04762, - "62": 10.01553, - "63": 10.08297, - "64": 10.03279, - "65": 9.99846, - "66": 10.03919, - "67": 10.01295, - "68": 9.97759, - "69": 9.99341, - "70": 9.97104, - "71": 9.9982, - "72": 9.97568, - "73": 9.95991, - "74": 9.95299, - "75": 9.9144, - "76": 9.95006, - "77": 9.94205, - "78": 9.89904, - "79": 9.89709, - "80": 9.91042, - "81": 9.93365, - "82": 9.88344, - "83": 9.83983, - "84": 9.7821, - "85": 9.76275, - "86": 9.87789, - "87": 9.9007, - "88": 9.87402, - "89": 9.82463, - "90": 9.81377, - "91": 9.8198, - "92": 9.81606, - "93": 9.74349, - "94": 9.82172, - "95": 9.81227, - "96": 9.79491, - "97": 9.74649, - "98": 9.7688, - "99": 9.81824, - "100": 9.70741 + "52": 10.16255, + "53": 10.1631, + "54": 10.13394, + "55": 10.10862, + "56": 10.13462, + "57": 10.13253, + "58": 10.12393, + "59": 10.06506, + "60": 10.09504, + "61": 10.04746, + "62": 10.01501, + "63": 10.08262, + "64": 10.03252, + "65": 9.99817, + "66": 10.03863, + "67": 10.01262, + "68": 9.97709, + "69": 9.99273, + "70": 9.97043, + "71": 9.99761, + "72": 9.97478, + "73": 9.95903, + "74": 9.95198, + "75": 9.91332, + "76": 9.9493, + "77": 9.94113, + "78": 9.89802, + "79": 9.89619, + "80": 9.90937, + "81": 9.93263, + "82": 9.88265, + "83": 9.83871, + "84": 9.78108, + "85": 9.76154, + "86": 9.87689, + "87": 9.89981, + "88": 9.87312, + "89": 9.82362, + "90": 9.81273, + "91": 9.81873, + "92": 9.81493, + "93": 9.74223, + "94": 9.82036, + "95": 9.81099, + "96": 9.79374, + "97": 9.74486, + "98": 9.76728, + "99": 9.81697, + "100": 9.70593 } }, "num-zeros": { @@ -111,106 +111,106 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 2429.0, - "2": 2591.0, - "3": 2604.0, - "4": 2665.0, - "5": 2610.0, - "6": 2487.0, - "7": 2589.0, - "8": 2535.0, - "9": 2677.0, - "10": 2474.0, - "11": 2585.0, - "12": 2605.0, - "13": 2530.0, - "14": 2651.0, - "15": 2509.0, - "16": 2506.0, - "17": 2646.0, - "18": 2601.0, - "19": 2552.0, - "20": 2518.0, - "21": 2539.0, - "22": 2594.0, - "23": 2531.0, - "24": 2604.0, - "25": 2474.0, - "26": 2505.0, - "27": 2647.0, - "28": 2551.0, - "29": 2735.0, - "30": 2709.0, - "31": 2746.0, - "32": 2729.0, - "33": 2672.0, - "34": 2741.0, - "35": 2722.0, - "36": 2761.0, - "37": 2860.0, - "38": 2827.0, - "39": 3030.0, - "40": 3060.0, - "41": 3129.0, - "42": 2813.0, - "43": 3059.0, - "44": 3088.0, - "45": 3301.0, - "46": 3239.0, - "47": 3241.0, - "48": 3278.0, - "49": 3483.0, - "50": 3340.0, - "51": 3328.0, - "52": 3384.0, - "53": 3253.0, - "54": 3558.0, - "55": 3290.0, - "56": 3548.0, - "57": 2934.0, - "58": 3989.0, - "59": 3538.0, - "60": 3638.0, - "61": 3440.0, - "62": 3763.0, - "63": 3857.0, - "64": 4201.0, - "65": 3318.0, - "66": 3743.0, - "67": 4019.0, - "68": 3853.0, - "69": 3501.0, - "70": 3812.0, - "71": 3749.0, - "72": 3597.0, - "73": 4178.0, - "74": 3676.0, - "75": 3677.0, - "76": 4080.0, - "77": 4149.0, - "78": 4236.0, - "79": 7606.0, - "80": 33298.0, - "81": 8170.0, - "82": 528771.0, - "83": 3458.0, - "84": 31908.0, - "85": 528749.0, - "86": 529194.0, - "87": 61926.0, - "88": 529038.0, - "89": 529251.0, - "90": 28836.0, - "91": 528719.0, - "92": 529072.0, - "93": 1053703.0, - "94": 529234.0, - "95": 553148.0, - "96": 560606.0, - "97": 529810.0, - "98": 529332.0, - "99": 529265.0, - "100": 529071.0 + "1": 6308.0, + "2": 6526.0, + "3": 6460.0, + "4": 6587.0, + "5": 6499.0, + "6": 6370.0, + "7": 6708.0, + "8": 6525.0, + "9": 6677.0, + "10": 6625.0, + "11": 6727.0, + "12": 6349.0, + "13": 6508.0, + "14": 6824.0, + "15": 6084.0, + "16": 6481.0, + "17": 6610.0, + "18": 6402.0, + "19": 6412.0, + "20": 6120.0, + "21": 6510.0, + "22": 6553.0, + "23": 6598.0, + "24": 6752.0, + "25": 6592.0, + "26": 6412.0, + "27": 6775.0, + "28": 6714.0, + "29": 7049.0, + "30": 6871.0, + "31": 7154.0, + "32": 7296.0, + "33": 6998.0, + "34": 7308.0, + "35": 7361.0, + "36": 7195.0, + "37": 7698.0, + "38": 7541.0, + "39": 7777.0, + "40": 7986.0, + "41": 8348.0, + "42": 7583.0, + "43": 8268.0, + "44": 7990.0, + "45": 8716.0, + "46": 8372.0, + "47": 8571.0, + "48": 8629.0, + "49": 8993.0, + "50": 8812.0, + "51": 8718.0, + "52": 9112.0, + "53": 8324.0, + "54": 9142.0, + "55": 8346.0, + "56": 9464.0, + "57": 7897.0, + "58": 10393.0, + "59": 9474.0, + "60": 9199.0, + "61": 8898.0, + "62": 9492.0, + "63": 10239.0, + "64": 10243.0, + "65": 8621.0, + "66": 9522.0, + "67": 10217.0, + "68": 9767.0, + "69": 8996.0, + "70": 9595.0, + "71": 9956.0, + "72": 9400.0, + "73": 10200.0, + "74": 14258.0, + "75": 8955.0, + "76": 10156.0, + "77": 10151.0, + "78": 10834.0, + "79": 76326.0, + "80": 132648.0, + "81": 80347.0, + "82": 1118255.0, + "83": 66887.0, + "84": 1113524.0, + "85": 2106481.0, + "86": 2107956.0, + "87": 290100.0, + "88": 2107621.0, + "89": 2107900.0, + "90": 58643.0, + "91": 2106770.0, + "92": 2107002.0, + "93": 3155613.0, + "94": 2107794.0, + "95": 2155564.0, + "96": 2170246.0, + "97": 2108416.0, + "98": 2107390.0, + "99": 2107633.0, + "100": 2107960.0 } }, "mem-allocated-bytes": { @@ -218,106 +218,106 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 628064256.0, - "2": 628065280.0, - "3": 628065280.0, - "4": 628065280.0, - "5": 628065280.0, - "6": 628065280.0, - "7": 628065280.0, - "8": 628065280.0, - "9": 628065280.0, - "10": 628065280.0, - "11": 628065280.0, - "12": 628065280.0, - "13": 628065280.0, - "14": 628065280.0, - "15": 628065280.0, - "16": 628065280.0, - "17": 628065280.0, - "18": 628065280.0, - "19": 628065280.0, - "20": 628065280.0, - "21": 628065280.0, - "22": 628065280.0, - "23": 628065280.0, - "24": 628065280.0, - "25": 628065280.0, - "26": 628065280.0, - "27": 628065280.0, - "28": 628065280.0, - "29": 628065280.0, - "30": 628065280.0, - "31": 628065280.0, - "32": 628065280.0, - "33": 628065280.0, - "34": 628065280.0, - "35": 628065280.0, - "36": 628065280.0, - "37": 628065280.0, - "38": 628065280.0, - "39": 628065280.0, - "40": 628065280.0, - "41": 628065280.0, - "42": 628065280.0, - "43": 628065280.0, - "44": 628065280.0, - "45": 628065280.0, - "46": 628065280.0, - "47": 628065280.0, - "48": 628065280.0, - "49": 628065280.0, - "50": 628065280.0, - "51": 628065280.0, - "52": 628065280.0, - "53": 628065280.0, - "54": 628065280.0, - "55": 628065280.0, - "56": 628065280.0, - "57": 628065280.0, - "58": 628065280.0, - "59": 628065280.0, - "60": 628065280.0, - "61": 628065280.0, - "62": 628065280.0, - "63": 628065280.0, - "64": 628065280.0, - "65": 628065280.0, - "66": 628065280.0, - "67": 628065280.0, - "68": 628065280.0, - "69": 628065280.0, - "70": 628065280.0, - "71": 628065280.0, - "72": 628065280.0, - "73": 628065280.0, - "74": 628065280.0, - "75": 628065280.0, - "76": 628065280.0, - "77": 628065280.0, - "78": 628065280.0, - "79": 628065280.0, - "80": 628065280.0, - "81": 628065280.0, - "82": 628065280.0, - "83": 628065280.0, - "84": 628065280.0, - "85": 628065280.0, - "86": 628065280.0, - "87": 628065280.0, - "88": 628065280.0, - "89": 628065280.0, - "90": 628065280.0, - "91": 628065280.0, - "92": 628065280.0, - "93": 628065280.0, - "94": 628065280.0, - "95": 628065280.0, - "96": 628065280.0, - "97": 628065280.0, - "98": 628065280.0, - "99": 628065280.0, - "100": 628065280.0 + "1": 628063744.0, + "2": 628064768.0, + "3": 628064768.0, + "4": 628064768.0, + "5": 628064768.0, + "6": 628064768.0, + "7": 628064768.0, + "8": 628064768.0, + "9": 628064768.0, + "10": 628064768.0, + "11": 628064768.0, + "12": 628064768.0, + "13": 628064768.0, + "14": 628064768.0, + "15": 628064768.0, + "16": 628064768.0, + "17": 628064768.0, + "18": 628064768.0, + "19": 628064768.0, + "20": 628064768.0, + "21": 628064768.0, + "22": 628064768.0, + "23": 628064768.0, + "24": 628064768.0, + "25": 628064768.0, + "26": 628064768.0, + "27": 628064768.0, + "28": 628064768.0, + "29": 628064768.0, + "30": 628064768.0, + "31": 628064768.0, + "32": 628064768.0, + "33": 628064768.0, + "34": 628064768.0, + "35": 628064768.0, + "36": 628064768.0, + "37": 628064768.0, + "38": 628064768.0, + "39": 628064768.0, + "40": 628064768.0, + "41": 628064768.0, + "42": 628064768.0, + "43": 628064768.0, + "44": 628064768.0, + "45": 628064768.0, + "46": 628064768.0, + "47": 628064768.0, + "48": 628064768.0, + "49": 628064768.0, + "50": 628064768.0, + "51": 628064768.0, + "52": 628064768.0, + "53": 628064768.0, + "54": 628064768.0, + "55": 628064768.0, + "56": 628064768.0, + "57": 628064768.0, + "58": 628064768.0, + "59": 628064768.0, + "60": 628064768.0, + "61": 628064768.0, + "62": 628064768.0, + "63": 628064768.0, + "64": 628064768.0, + "65": 628064768.0, + "66": 628064768.0, + "67": 628064768.0, + "68": 628064768.0, + "69": 628064768.0, + "70": 628064768.0, + "71": 628064768.0, + "72": 628064768.0, + "73": 628064768.0, + "74": 628064768.0, + "75": 628064768.0, + "76": 628064768.0, + "77": 628064768.0, + "78": 628064768.0, + "79": 628064768.0, + "80": 628064768.0, + "81": 628064768.0, + "82": 628064768.0, + "83": 628064768.0, + "84": 628064768.0, + "85": 628064768.0, + "86": 628064768.0, + "87": 628064768.0, + "88": 628064768.0, + "89": 628064768.0, + "90": 628064768.0, + "91": 628064768.0, + "92": 628064768.0, + "93": 628064768.0, + "94": 628064768.0, + "95": 628064768.0, + "96": 628064768.0, + "97": 628064768.0, + "98": 628064768.0, + "99": 628064768.0, + "100": 628064768.0 } }, "mem-max-allocated-bytes": { @@ -325,213 +325,212 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 974434304.0, - "2": 1143423488.0, - "3": 1143897600.0, - "4": 1146645504.0, - "5": 1147860992.0, - "6": 1147860992.0, - "7": 1148556800.0, - "8": 1148556800.0, - "9": 1148556800.0, - "10": 1148556800.0, - "11": 1148556800.0, - "12": 1148556800.0, - "13": 1148556800.0, - "14": 1148556800.0, - "15": 1148556800.0, - "16": 1148556800.0, - "17": 1148556800.0, - "18": 1148556800.0, - "19": 1148556800.0, - "20": 1148556800.0, - "21": 1148556800.0, - "22": 1148556800.0, - "23": 1148556800.0, - "24": 1148556800.0, - "25": 1148556800.0, - "26": 1149693440.0, - "27": 1149693440.0, - "28": 1149693440.0, - "29": 1149693440.0, - "30": 1149693440.0, - "31": 1149693440.0, - "32": 1149693440.0, - "33": 1149693440.0, - "34": 1149693440.0, - "35": 1149693440.0, - "36": 1149693440.0, - "37": 1149693440.0, - "38": 1149693440.0, - "39": 1149693440.0, - "40": 1149693440.0, - "41": 1149693440.0, - "42": 1149693440.0, - "43": 1149693440.0, - "44": 1149693440.0, - "45": 1149693440.0, - "46": 1149693440.0, - "47": 1149693440.0, - "48": 1149693440.0, - "49": 1149693440.0, - "50": 1149693440.0, - "51": 1149693440.0, - "52": 1149693440.0, - "53": 1149693440.0, - "54": 1149693440.0, - "55": 1149693440.0, - "56": 1149693440.0, - "57": 1149693440.0, - "58": 1149693440.0, - "59": 1149693440.0, - "60": 1149693440.0, - "61": 1149693440.0, - "62": 1149693440.0, - "63": 1149693440.0, - "64": 1149693440.0, - "65": 1149693440.0, - "66": 1149693440.0, - "67": 1149693440.0, - "68": 1149693440.0, - "69": 1149693440.0, - "70": 1149693440.0, - "71": 1149693440.0, - "72": 1149693440.0, - "73": 1149693440.0, - "74": 1149693440.0, - "75": 1149693440.0, - "76": 1149693440.0, - "77": 1149693440.0, - "78": 1149693440.0, - "79": 1149693440.0, - "80": 1149693440.0, - "81": 1149693440.0, - "82": 1149693440.0, - "83": 1149693440.0, - "84": 1149693440.0, - "85": 1149693440.0, - "86": 1149693440.0, - "87": 1149693440.0, - "88": 1149693440.0, - "89": 1149693440.0, - "90": 1149693440.0, - "91": 1149693440.0, - "92": 1149693440.0, - "93": 1149693440.0, - "94": 1149693440.0, - "95": 1149693440.0, - "96": 1149693440.0, - "97": 1149693440.0, - "98": 1149693440.0, - "99": 1149693440.0, - "100": 1149693440.0 + "1": 974433792.0, + "2": 1143422976.0, + "3": 1143894016.0, + "4": 1147345408.0, + "5": 1147816448.0, + "6": 1147816448.0, + "7": 1148524544.0, + "8": 1148524544.0, + "9": 1148524544.0, + "10": 1148524544.0, + "11": 1148524544.0, + "12": 1148524544.0, + "13": 1148524544.0, + "14": 1148524544.0, + "15": 1148524544.0, + "16": 1148524544.0, + "17": 1148524544.0, + "18": 1148524544.0, + "19": 1148524544.0, + "20": 1148524544.0, + "21": 1148524544.0, + "22": 1148524544.0, + "23": 1148524544.0, + "24": 1148524544.0, + "25": 1148524544.0, + "26": 1149626368.0, + "27": 1149626368.0, + "28": 1149626368.0, + "29": 1149626368.0, + "30": 1149626368.0, + "31": 1149626368.0, + "32": 1149626368.0, + "33": 1149626368.0, + "34": 1149626368.0, + "35": 1149626368.0, + "36": 1149626368.0, + "37": 1149626368.0, + "38": 1149626368.0, + "39": 1149626368.0, + "40": 1149626368.0, + "41": 1149626368.0, + "42": 1149626368.0, + "43": 1149626368.0, + "44": 1149626368.0, + "45": 1149626368.0, + "46": 1149626368.0, + "47": 1149626368.0, + "48": 1149626368.0, + "49": 1149626368.0, + "50": 1149626368.0, + "51": 1149626368.0, + "52": 1149626368.0, + "53": 1149626368.0, + "54": 1149626368.0, + "55": 1149626368.0, + "56": 1149626368.0, + "57": 1149626368.0, + "58": 1149626368.0, + "59": 1149626368.0, + "60": 1149626368.0, + "61": 1149626368.0, + "62": 1149626368.0, + "63": 1149626368.0, + "64": 1149626368.0, + "65": 1149626368.0, + "66": 1149626368.0, + "67": 1149626368.0, + "68": 1149626368.0, + "69": 1149626368.0, + "70": 1149626368.0, + "71": 1149626368.0, + "72": 1149626368.0, + "73": 1149626368.0, + "74": 1149626368.0, + "75": 1149626368.0, + "76": 1149626368.0, + "77": 1149626368.0, + "78": 1149626368.0, + "79": 1149626368.0, + "80": 1149626368.0, + "81": 1149626368.0, + "82": 1149626368.0, + "83": 1149626368.0, + "84": 1149626368.0, + "85": 1149626368.0, + "86": 1149626368.0, + "87": 1149626368.0, + "88": 1149626368.0, + "89": 1149626368.0, + "90": 1149626368.0, + "91": 1149626368.0, + "92": 1149626368.0, + "93": 1149626368.0, + "94": 1149626368.0, + "95": 1149626368.0, + "96": 1149626368.0, + "97": 1149626368.0, + "98": 1149626368.0, + "99": 1149626368.0, + "100": 1149626368.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 100, "step_interval": 1, "values": { - "1": "nan", - "2": 8.40786, - "3": 0.89447, - "4": 0.87487, - "5": 0.85695, - "6": 0.85548, - "7": 0.86384, - "8": 0.8398, - "9": 0.83442, - "10": 0.83457, - "11": 0.83165, - "12": 0.82049, - "13": 0.81938, - "14": 0.8372, - "15": 0.81635, - "16": 0.82269, - "17": 0.81755, - "18": 0.82139, - "19": 0.81834, - "20": 0.81571, - "21": 0.82027, - "22": 0.81783, - "23": 0.82434, - "24": 0.8179, - "25": 0.81779, - "26": 0.80609, - "27": 0.81441, - "28": 0.83081, - "29": 0.82504, - "30": 0.81873, - "31": 0.82454, - "32": 0.81663, - "33": 0.80909, - "34": 0.82198, - "35": 0.81846, - "36": 0.81614, - "37": 0.81026, - "38": 0.84604, - "39": 0.82085, - "40": 0.8318, - "41": 0.82267, - "42": 0.81837, - "43": 0.87684, - "44": 0.81896, - "45": 0.82655, - "46": 0.8241, - "47": 0.82308, - "48": 0.81433, - "49": 0.83989, - "50": 0.82395, - "51": 0.87417, - "52": 0.8737, - "53": 0.81483, - "54": 0.82825, - "55": 0.83667, - "56": 0.83546, - "57": 0.83562, - "58": 0.83505, - "59": 0.83375, - "60": 0.83021, - "61": 0.82875, - "62": 0.83214, - "63": 0.83746, - "64": 0.83687, - "65": 0.8281, - "66": 0.8317, - "67": 0.82752, - "68": 0.82693, - "69": 0.83293, - "70": 0.83375, - "71": 0.8272, - "72": 0.82716, - "73": 0.83134, - "74": 1.39559, - "75": 1.46874, - "76": 0.83059, - "77": 0.83236, - "78": 0.83428, - "79": 0.835, - "80": 0.83444, - "81": 0.83542, - "82": 0.84117, - "83": 0.83432, - "84": 0.82381, - "85": 0.831, - "86": 1.48456, - "87": 1.37924, - "88": 0.82167, - "89": 0.82408, - "90": 0.81692, - "91": 0.81059, - "92": 0.81301, - "93": 0.8096, - "94": 0.8091, - "95": 0.80549, - "96": 0.80731, - "97": 0.81231, - "98": 0.8007, - "99": 0.80887, - "100": 0.81095 + "2": 8.99025, + "3": 0.90833, + "4": 0.86431, + "5": 0.87482, + "6": 0.8573, + "7": 0.85553, + "8": 0.83513, + "9": 0.83483, + "10": 0.83557, + "11": 0.83528, + "12": 1.58117, + "13": 0.82194, + "14": 0.81823, + "15": 0.81808, + "16": 0.81282, + "17": 0.82548, + "18": 0.81502, + "19": 0.81167, + "20": 0.81094, + "21": 0.83617, + "22": 0.828, + "23": 0.82514, + "24": 0.85341, + "25": 0.81784, + "26": 0.81255, + "27": 0.81988, + "28": 0.84249, + "29": 1.54481, + "30": 0.82635, + "31": 0.81779, + "32": 0.83092, + "33": 0.82788, + "34": 0.8237, + "35": 0.83024, + "36": 0.81681, + "37": 0.81326, + "38": 1.61795, + "39": 0.84407, + "40": 0.85127, + "41": 0.82922, + "42": 0.83611, + "43": 0.81901, + "44": 2.40554, + "45": 0.81924, + "46": 0.84478, + "47": 0.8247, + "48": 1.5457, + "49": 0.81497, + "50": 0.80868, + "51": 0.95984, + "52": 0.95192, + "53": 0.8168, + "54": 0.83474, + "55": 0.82917, + "56": 0.81693, + "57": 0.81814, + "58": 0.80755, + "59": 0.80597, + "60": 0.81208, + "61": 0.81909, + "62": 0.80757, + "63": 0.8268, + "64": 0.81229, + "65": 0.80745, + "66": 0.81789, + "67": 0.79693, + "68": 0.81825, + "69": 0.82426, + "70": 0.8204, + "71": 0.80984, + "72": 0.8026, + "73": 0.80831, + "74": 0.86362, + "75": 0.82144, + "76": 0.87744, + "77": 0.81254, + "78": 0.82353, + "79": 0.85476, + "80": 0.8258, + "81": 0.81312, + "82": 0.81209, + "83": 0.80825, + "84": 0.81139, + "85": 0.80849, + "86": 0.81299, + "87": 0.81396, + "88": 0.80619, + "89": 0.79982, + "90": 0.80843, + "91": 0.81705, + "92": 0.8122, + "93": 0.8039, + "94": 0.80977, + "95": 0.81732, + "96": 0.80769, + "97": 0.81238, + "98": 0.80923, + "99": 0.80613, + "100": 0.80743 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer_1node/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer_1node/golden_values_dev_dgx_gb200.json index 03672a09acd..d1508989d13 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer_1node/golden_values_dev_dgx_gb200.json +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_resume_torch_dist_dist_optimizer_1node/golden_values_dev_dgx_gb200.json @@ -7,103 +7,103 @@ "1": 10.92169, "2": 10.90762, "3": 10.90914, - "4": 10.91959, - "5": 10.90569, - "6": 10.91601, - "7": 10.92268, - "8": 10.90935, - "9": 10.91155, - "10": 10.90713, - "11": 10.88867, - "12": 10.90462, - "13": 10.89519, - "14": 10.89552, - "15": 10.8746, - "16": 10.86175, - "17": 10.86443, - "18": 10.85034, - "19": 10.85454, - "20": 10.78432, - "21": 10.77769, - "22": 10.75615, - "23": 10.74474, - "24": 10.71765, - "25": 10.71594, - "26": 10.70996, - "27": 10.67222, - "28": 10.60454, - "29": 10.57083, - "30": 10.54221, - "31": 10.54476, - "32": 10.53155, - "33": 10.49746, - "34": 10.4747, - "35": 10.47049, - "36": 10.44085, - "37": 10.41646, - "38": 10.41713, - "39": 10.38512, - "40": 10.36837, - "41": 10.34662, - "42": 10.3344, - "43": 10.31362, - "44": 10.28901, - "45": 10.29313, - "46": 10.26547, - "47": 10.24518, - "48": 10.1985, - "49": 10.19468, - "50": 10.19581, - "51": 10.20229, - "52": 10.15471, - "53": 10.16268, - "54": 10.12733, - "55": 10.09567, - "56": 10.12483, - "57": 10.11356, - "58": 10.12404, - "59": 10.07408, - "60": 10.09371, - "61": 10.04305, - "62": 10.01618, - "63": 10.08733, - "64": 10.03798, - "65": 10.01758, - "66": 10.04082, - "67": 10.0182, - "68": 9.98006, - "69": 10.00358, - "70": 9.98391, - "71": 10.0103, - "72": 9.99161, - "73": 9.98074, - "74": 9.96226, - "75": 9.94239, - "76": 9.97139, - "77": 9.96076, - "78": 9.91543, - "79": 9.92224, - "80": 9.93093, - "81": 9.95886, - "82": 9.8954, - "83": 9.86306, - "84": 9.79599, - "85": 9.78812, - "86": 9.88186, - "87": 9.90774, - "88": 9.88268, - "89": 9.82997, - "90": 9.8221, - "91": 9.82939, - "92": 9.82008, - "93": 9.76022, - "94": 9.83152, - "95": 9.82801, - "96": 9.80754, - "97": 9.75369, - "98": 9.78408, - "99": 9.826, - "100": 9.71837 + "4": 10.91973, + "5": 10.90565, + "6": 10.91579, + "7": 10.92266, + "8": 10.90944, + "9": 10.9121, + "10": 10.90717, + "11": 10.88827, + "12": 10.90454, + "13": 10.89547, + "14": 10.89568, + "15": 10.87449, + "16": 10.86135, + "17": 10.86449, + "18": 10.85059, + "19": 10.85449, + "20": 10.78456, + "21": 10.77782, + "22": 10.75571, + "23": 10.7451, + "24": 10.71744, + "25": 10.71583, + "26": 10.70999, + "27": 10.6725, + "28": 10.60441, + "29": 10.57075, + "30": 10.54191, + "31": 10.54486, + "32": 10.53129, + "33": 10.49749, + "34": 10.47481, + "35": 10.47098, + "36": 10.4409, + "37": 10.41647, + "38": 10.41736, + "39": 10.3848, + "40": 10.36814, + "41": 10.34667, + "42": 10.33413, + "43": 10.31323, + "44": 10.28878, + "45": 10.29329, + "46": 10.26533, + "47": 10.24502, + "48": 10.1982, + "49": 10.19453, + "50": 10.19587, + "51": 10.20209, + "52": 10.15439, + "53": 10.1624, + "54": 10.12711, + "55": 10.09528, + "56": 10.12434, + "57": 10.11303, + "58": 10.12378, + "59": 10.07361, + "60": 10.09334, + "61": 10.04273, + "62": 10.01574, + "63": 10.08695, + "64": 10.03735, + "65": 10.01698, + "66": 10.0401, + "67": 10.01756, + "68": 9.97942, + "69": 10.00274, + "70": 9.98318, + "71": 10.00977, + "72": 9.99083, + "73": 9.97972, + "74": 9.96126, + "75": 9.94138, + "76": 9.97037, + "77": 9.95962, + "78": 9.91416, + "79": 9.92096, + "80": 9.92938, + "81": 9.95731, + "82": 9.89416, + "83": 9.86158, + "84": 9.79459, + "85": 9.78662, + "86": 9.8805, + "87": 9.90664, + "88": 9.88117, + "89": 9.82889, + "90": 9.82091, + "91": 9.82792, + "92": 9.81871, + "93": 9.75871, + "94": 9.82985, + "95": 9.82669, + "96": 9.80575, + "97": 9.75194, + "98": 9.78246, + "99": 9.82444, + "100": 9.71677 } }, "num-zeros": { @@ -111,106 +111,106 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 2485.0, - "2": 2410.0, - "3": 2558.0, - "4": 2521.0, - "5": 2379.0, - "6": 2355.0, - "7": 2651.0, - "8": 2390.0, - "9": 2608.0, - "10": 2524.0, - "11": 2482.0, - "12": 2420.0, - "13": 2711.0, - "14": 2610.0, - "15": 2447.0, - "16": 2348.0, - "17": 2554.0, - "18": 2448.0, - "19": 2569.0, - "20": 2425.0, - "21": 2494.0, - "22": 2534.0, - "23": 2440.0, - "24": 2576.0, - "25": 2394.0, - "26": 2550.0, - "27": 2531.0, - "28": 2635.0, - "29": 2658.0, - "30": 2646.0, - "31": 2811.0, - "32": 2768.0, - "33": 32343.0, - "34": 2854.0, - "35": 527663.0, - "36": 527633.0, - "37": 25982.0, - "38": 527719.0, - "39": 527760.0, - "40": 527781.0, - "41": 31784.0, - "42": 527721.0, - "43": 528039.0, - "44": 527836.0, - "45": 30434.0, - "46": 528026.0, - "47": 528121.0, - "48": 528129.0, - "49": 528389.0, - "50": 550759.0, - "51": 531404.0, - "52": 533197.0, - "53": 1059399.0, - "54": 554922.0, - "55": 553732.0, - "56": 1053105.0, - "57": 1052632.0, - "58": 1078718.0, - "59": 557322.0, - "60": 582238.0, - "61": 1085352.0, - "62": 1578412.0, - "63": 557697.0, - "64": 554190.0, - "65": 1578355.0, - "66": 1059050.0, - "67": 1086225.0, - "68": 1053764.0, - "69": 561544.0, - "70": 531044.0, - "71": 1054678.0, - "72": 1053885.0, - "73": 1578126.0, - "74": 1055279.0, - "75": 556894.0, - "76": 1081506.0, - "77": 552287.0, - "78": 556331.0, - "79": 1077913.0, - "80": 578071.0, - "81": 1084863.0, - "82": 1080774.0, - "83": 1577846.0, - "84": 575407.0, - "85": 558901.0, - "86": 554895.0, - "87": 575490.0, - "88": 533778.0, - "89": 1053554.0, - "90": 1578611.0, - "91": 1053399.0, - "92": 1053583.0, - "93": 529118.0, - "94": 1053702.0, - "95": 558281.0, - "96": 1055792.0, - "97": 1054543.0, - "98": 1054324.0, - "99": 554749.0, - "100": 1578781.0 + "1": 6375.0, + "2": 6416.0, + "3": 6525.0, + "4": 6457.0, + "5": 6273.0, + "6": 6109.0, + "7": 6669.0, + "8": 6430.0, + "9": 6708.0, + "10": 6443.0, + "11": 6458.0, + "12": 6151.0, + "13": 6572.0, + "14": 6699.0, + "15": 6360.0, + "16": 6322.0, + "17": 6591.0, + "18": 6232.0, + "19": 6393.0, + "20": 6142.0, + "21": 6576.0, + "22": 6560.0, + "23": 6296.0, + "24": 6476.0, + "25": 6394.0, + "26": 6513.0, + "27": 6523.0, + "28": 6752.0, + "29": 7074.0, + "30": 6676.0, + "31": 7133.0, + "32": 7218.0, + "33": 14417.0, + "34": 7490.0, + "35": 1056500.0, + "36": 1056500.0, + "37": 53811.0, + "38": 1056688.0, + "39": 1056914.0, + "40": 1057239.0, + "41": 65654.0, + "42": 1056693.0, + "43": 1057447.0, + "44": 1057197.0, + "45": 62972.0, + "46": 1057458.0, + "47": 1057702.0, + "48": 1057863.0, + "49": 1058586.0, + "50": 1061397.0, + "51": 1064572.0, + "52": 1068311.0, + "53": 2171476.0, + "54": 1111879.0, + "55": 1109170.0, + "56": 2108974.0, + "57": 2106394.0, + "58": 2159236.0, + "59": 1117431.0, + "60": 2166475.0, + "61": 2224014.0, + "62": 3161907.0, + "63": 1127153.0, + "64": 1119365.0, + "65": 4205396.0, + "66": 2119442.0, + "67": 2175193.0, + "68": 1107351.0, + "69": 2169556.0, + "70": 2152217.0, + "71": 3157239.0, + "72": 3156914.0, + "73": 3206334.0, + "74": 3199858.0, + "75": 2165206.0, + "76": 3159262.0, + "77": 1163799.0, + "78": 2162285.0, + "79": 2167416.0, + "80": 1167052.0, + "81": 2212175.0, + "82": 2175237.0, + "83": 2170605.0, + "84": 1156708.0, + "85": 2167112.0, + "86": 2159256.0, + "87": 1153212.0, + "88": 1137179.0, + "89": 2155808.0, + "90": 4206118.0, + "91": 3155614.0, + "92": 3155708.0, + "93": 2107178.0, + "94": 3155959.0, + "95": 2109446.0, + "96": 2161322.0, + "97": 3157406.0, + "98": 3156865.0, + "99": 2203211.0, + "100": 4205763.0 } }, "mem-allocated-bytes": { @@ -325,213 +325,212 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 1322636288.0, - "2": 1613951488.0, - "3": 1617380864.0, - "4": 1620309504.0, - "5": 1620309504.0, - "6": 1620309504.0, - "7": 1620309504.0, - "8": 1620309504.0, - "9": 1620309504.0, - "10": 1620309504.0, - "11": 1620309504.0, - "12": 1620309504.0, - "13": 1620309504.0, - "14": 1620309504.0, - "15": 1620309504.0, - "16": 1620309504.0, - "17": 1620309504.0, - "18": 1620309504.0, - "19": 1620309504.0, - "20": 1620309504.0, - "21": 1620309504.0, - "22": 1620309504.0, - "23": 1620309504.0, - "24": 1620309504.0, - "25": 1620309504.0, - "26": 1620309504.0, - "27": 1620309504.0, - "28": 1620309504.0, - "29": 1620309504.0, - "30": 1620309504.0, - "31": 1620309504.0, - "32": 1620309504.0, - "33": 1620309504.0, - "34": 1620309504.0, - "35": 1620309504.0, - "36": 1620309504.0, - "37": 1620309504.0, - "38": 1620309504.0, - "39": 1620309504.0, - "40": 1620309504.0, - "41": 1620309504.0, - "42": 1620309504.0, - "43": 1620309504.0, - "44": 1620309504.0, - "45": 1620309504.0, - "46": 1620309504.0, - "47": 1620309504.0, - "48": 1620309504.0, - "49": 1620309504.0, - "50": 1620309504.0, - "51": 1620309504.0, - "52": 1620309504.0, - "53": 1620309504.0, - "54": 1620309504.0, - "55": 1620309504.0, - "56": 1620309504.0, - "57": 1620309504.0, - "58": 1620309504.0, - "59": 1620309504.0, - "60": 1620309504.0, - "61": 1620309504.0, - "62": 1620309504.0, - "63": 1620309504.0, - "64": 1620309504.0, - "65": 1620309504.0, - "66": 1620309504.0, - "67": 1620309504.0, - "68": 1620309504.0, - "69": 1620309504.0, - "70": 1620309504.0, - "71": 1620309504.0, - "72": 1620309504.0, - "73": 1620309504.0, - "74": 1620309504.0, - "75": 1620309504.0, - "76": 1620309504.0, - "77": 1620309504.0, - "78": 1620309504.0, - "79": 1620309504.0, - "80": 1620309504.0, - "81": 1620309504.0, - "82": 1620309504.0, - "83": 1620309504.0, - "84": 1620309504.0, - "85": 1620309504.0, - "86": 1620309504.0, - "87": 1620309504.0, - "88": 1620309504.0, - "89": 1620309504.0, - "90": 1620309504.0, - "91": 1620309504.0, - "92": 1620309504.0, - "93": 1620309504.0, - "94": 1620309504.0, - "95": 1620309504.0, - "96": 1620309504.0, - "97": 1620309504.0, - "98": 1620309504.0, - "99": 1620309504.0, - "100": 1620309504.0 + "1": 1322703872.0, + "2": 1613727232.0, + "3": 1618076160.0, + "4": 1618448384.0, + "5": 1618448384.0, + "6": 1618448384.0, + "7": 1618448384.0, + "8": 1618448384.0, + "9": 1618448384.0, + "10": 1618448384.0, + "11": 1620705792.0, + "12": 1620705792.0, + "13": 1620705792.0, + "14": 1620705792.0, + "15": 1620705792.0, + "16": 1620705792.0, + "17": 1620705792.0, + "18": 1620705792.0, + "19": 1620705792.0, + "20": 1620705792.0, + "21": 1620705792.0, + "22": 1620705792.0, + "23": 1620705792.0, + "24": 1620705792.0, + "25": 1620705792.0, + "26": 1620705792.0, + "27": 1620705792.0, + "28": 1620705792.0, + "29": 1620705792.0, + "30": 1620705792.0, + "31": 1620705792.0, + "32": 1620705792.0, + "33": 1620705792.0, + "34": 1620705792.0, + "35": 1620705792.0, + "36": 1620705792.0, + "37": 1620705792.0, + "38": 1620705792.0, + "39": 1620705792.0, + "40": 1620705792.0, + "41": 1620705792.0, + "42": 1620705792.0, + "43": 1620705792.0, + "44": 1620705792.0, + "45": 1620705792.0, + "46": 1620705792.0, + "47": 1620705792.0, + "48": 1620705792.0, + "49": 1620705792.0, + "50": 1620705792.0, + "51": 1620705792.0, + "52": 1620705792.0, + "53": 1620705792.0, + "54": 1620705792.0, + "55": 1620705792.0, + "56": 1620705792.0, + "57": 1620705792.0, + "58": 1620705792.0, + "59": 1620705792.0, + "60": 1620705792.0, + "61": 1620705792.0, + "62": 1620705792.0, + "63": 1620705792.0, + "64": 1620705792.0, + "65": 1620705792.0, + "66": 1620705792.0, + "67": 1620705792.0, + "68": 1620705792.0, + "69": 1620705792.0, + "70": 1620705792.0, + "71": 1620705792.0, + "72": 1620705792.0, + "73": 1620705792.0, + "74": 1620705792.0, + "75": 1620705792.0, + "76": 1620705792.0, + "77": 1620705792.0, + "78": 1620705792.0, + "79": 1620705792.0, + "80": 1620705792.0, + "81": 1620705792.0, + "82": 1620705792.0, + "83": 1620705792.0, + "84": 1620705792.0, + "85": 1620705792.0, + "86": 1620705792.0, + "87": 1620705792.0, + "88": 1620705792.0, + "89": 1620705792.0, + "90": 1620705792.0, + "91": 1620705792.0, + "92": 1620705792.0, + "93": 1620705792.0, + "94": 1620705792.0, + "95": 1620705792.0, + "96": 1620705792.0, + "97": 1620705792.0, + "98": 1620705792.0, + "99": 1620705792.0, + "100": 1620705792.0 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 100, "step_interval": 1, "values": { - "1": "nan", - "2": 8.44616, - "3": 4.35946, - "4": 3.58536, - "5": 3.43258, - "6": 4.01618, - "7": 3.41532, - "8": 4.69585, - "9": 4.42146, - "10": 3.2698, - "11": 4.19498, - "12": 3.71608, - "13": 4.078, - "14": 3.7934, - "15": 2.75437, - "16": 2.90972, - "17": 3.42375, - "18": 3.12947, - "19": 3.74111, - "20": 2.77586, - "21": 3.59019, - "22": 3.15397, - "23": 4.71787, - "24": 3.61079, - "25": 3.91574, - "26": 4.22139, - "27": 3.87761, - "28": 3.93585, - "29": 3.34081, - "30": 3.02171, - "31": 3.39522, - "32": 5.21075, - "33": 2.81038, - "34": 3.41835, - "35": 4.45162, - "36": 4.20517, - "37": 3.9982, - "38": 3.03186, - "39": 6.15837, - "40": 3.06211, - "41": 3.45761, - "42": 4.42809, - "43": 3.47773, - "44": 3.66168, - "45": 3.60833, - "46": 2.92395, - "47": 2.64427, - "48": 2.86604, - "49": 2.50808, - "50": 3.04166, - "51": 2.18664, - "52": 3.25348, - "53": 3.69136, - "54": 3.55702, - "55": 3.0236, - "56": 4.7145, - "57": 2.71755, - "58": 3.34597, - "59": 3.09264, - "60": 3.14001, - "61": 3.06867, - "62": 4.22075, - "63": 2.45818, - "64": 3.80461, - "65": 2.8975, - "66": 3.83227, - "67": 3.41683, - "68": 3.28576, - "69": 3.16606, - "70": 3.69457, - "71": 3.84527, - "72": 2.94589, - "73": 2.78343, - "74": 3.33954, - "75": 3.40883, - "76": 3.35809, - "77": 3.09918, - "78": 3.74403, - "79": 3.86757, - "80": 3.56784, - "81": 3.62657, - "82": 2.52733, - "83": 3.21777, - "84": 2.77215, - "85": 3.51637, - "86": 3.14139, - "87": 4.25648, - "88": 3.21899, - "89": 3.79927, - "90": 3.25394, - "91": 3.48754, - "92": 3.57614, - "93": 2.08265, - "94": 3.45887, - "95": 3.83381, - "96": 3.66511, - "97": 3.09335, - "98": 2.62447, - "99": 4.07289, - "100": 3.1867 + "2": 8.11788, + "3": 4.18228, + "4": 2.72856, + "5": 2.74768, + "6": 3.58337, + "7": 3.05344, + "8": 3.63101, + "9": 3.55617, + "10": 3.39428, + "11": 3.27369, + "12": 3.05256, + "13": 4.19382, + "14": 3.51376, + "15": 3.33174, + "16": 3.34286, + "17": 3.28752, + "18": 3.36605, + "19": 3.34594, + "20": 3.06058, + "21": 3.73314, + "22": 2.06924, + "23": 3.19849, + "24": 2.73243, + "25": 3.48441, + "26": 5.64283, + "27": 9.3865, + "28": 2.82874, + "29": 2.66418, + "30": 3.68009, + "31": 3.99777, + "32": 4.22254, + "33": 2.35028, + "34": 2.60248, + "35": 4.32557, + "36": 2.99871, + "37": 3.06038, + "38": 2.56642, + "39": 3.5962, + "40": 2.97409, + "41": 3.18763, + "42": 3.96569, + "43": 2.75426, + "44": 3.76212, + "45": 2.68676, + "46": 2.25774, + "47": 2.65821, + "48": 3.58569, + "49": 3.10933, + "50": 2.72659, + "51": 3.10832, + "52": 2.54657, + "53": 3.5435, + "54": 3.48014, + "55": 3.02386, + "56": 4.34098, + "57": 2.76378, + "58": 3.52668, + "59": 2.98688, + "60": 3.29326, + "61": 2.79132, + "62": 20.76609, + "63": 10.52649, + "64": 4.66917, + "65": 4.40657, + "66": 4.07985, + "67": 2.93174, + "68": 2.54979, + "69": 3.25024, + "70": 2.92684, + "71": 13.6022, + "72": 6.85737, + "73": 3.00302, + "74": 2.27875, + "75": 2.94009, + "76": 3.77221, + "77": 2.98374, + "78": 3.97115, + "79": 20.85088, + "80": 2.66986, + "81": 3.20797, + "82": 3.31175, + "83": 2.6616, + "84": 2.451, + "85": 2.63993, + "86": 2.98263, + "87": 3.18667, + "88": 2.23769, + "89": 2.79162, + "90": 2.86908, + "91": 3.40123, + "92": 3.10172, + "93": 2.49279, + "94": 2.03585, + "95": 2.71749, + "96": 2.79808, + "97": 2.9601, + "98": 2.37092, + "99": 3.48981, + "100": 2.32584 } } -} \ No newline at end of file +} diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/golden_values_dev_dgx_h100.json index 1a8739a347a..745ed36d8c9 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/golden_values_dev_dgx_h100.json +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/golden_values_dev_dgx_h100.json @@ -6,104 +6,104 @@ "values": { "1": 10.95659, "2": 10.95273, - "3": 10.97293, - "4": 10.95528, - "5": 10.95882, - "6": 10.96034, - "7": 10.94954, - "8": 10.95612, - "9": 10.96238, - "10": 10.95507, - "11": 10.94969, - "12": 10.94911, - "13": 10.94368, - "14": 10.9398, - "15": 10.91315, - "16": 10.89286, - "17": 10.89445, - "18": 10.88436, - "19": 10.88801, - "20": 10.81677, - "21": 10.77929, - "22": 10.77943, - "23": 10.75103, - "24": 10.73693, - "25": 10.70864, - "26": 10.70217, - "27": 10.66685, - "28": 10.59001, - "29": 10.57517, - "30": 10.53988, - "31": 10.54916, - "32": 10.49405, - "33": 10.45568, - "34": 10.45441, - "35": 10.41606, - "36": 10.40471, - "37": 10.37388, - "38": 10.38042, - "39": 10.33633, - "40": 10.3374, - "41": 10.29239, - "42": 10.24602, - "43": 10.23775, - "44": 10.20504, - "45": 10.23936, - "46": 10.16799, - "47": 10.16516, - "48": 10.11263, - "49": 10.11908, - "50": 10.0989, - "51": 10.11379, - "52": 10.07064, - "53": 10.03526, - "54": 10.01886, - "55": 9.97027, - "56": 10.01809, - "57": 10.00228, - "58": 10.00891, - "59": 9.93698, - "60": 9.97704, - "61": 9.92067, - "62": 9.86061, - "63": 9.97239, - "64": 9.91741, - "65": 9.88037, - "66": 9.90572, - "67": 9.88892, - "68": 9.81415, - "69": 9.83834, - "70": 9.82934, - "71": 9.85471, - "72": 9.8455, - "73": 9.79839, - "74": 9.79383, - "75": 9.74221, - "76": 9.81225, - "77": 9.80864, - "78": 9.76159, - "79": 9.73676, - "80": 9.76097, - "81": 9.801, - "82": 9.72391, - "83": 9.66545, - "84": 9.62619, - "85": 9.59104, - "86": 9.73785, - "87": 9.72688, - "88": 9.73448, - "89": 9.63538, - "90": 9.62968, - "91": 9.67388, - "92": 9.63789, - "93": 9.53753, - "94": 9.65601, - "95": 9.62946, - "96": 9.63424, - "97": 9.54678, - "98": 9.59626, - "99": 9.64175, - "100": 9.53549 + "3": 10.97295, + "4": 10.95542, + "5": 10.95849, + "6": 10.96054, + "7": 10.94934, + "8": 10.95616, + "9": 10.96262, + "10": 10.95503, + "11": 10.9491, + "12": 10.9493, + "13": 10.94313, + "14": 10.93907, + "15": 10.91333, + "16": 10.89331, + "17": 10.8942, + "18": 10.8846, + "19": 10.88746, + "20": 10.8169, + "21": 10.77918, + "22": 10.78015, + "23": 10.75133, + "24": 10.73664, + "25": 10.70889, + "26": 10.7017, + "27": 10.66701, + "28": 10.5899, + "29": 10.57557, + "30": 10.5395, + "31": 10.54921, + "32": 10.49385, + "33": 10.4557, + "34": 10.45468, + "35": 10.41609, + "36": 10.40507, + "37": 10.37438, + "38": 10.38081, + "39": 10.33703, + "40": 10.33794, + "41": 10.29217, + "42": 10.2462, + "43": 10.23797, + "44": 10.20525, + "45": 10.23977, + "46": 10.16827, + "47": 10.16534, + "48": 10.11329, + "49": 10.1194, + "50": 10.09951, + "51": 10.11426, + "52": 10.07085, + "53": 10.03538, + "54": 10.01908, + "55": 9.97069, + "56": 10.0184, + "57": 10.0028, + "58": 10.00966, + "59": 9.93792, + "60": 9.97762, + "61": 9.92129, + "62": 9.86095, + "63": 9.97263, + "64": 9.9177, + "65": 9.88092, + "66": 9.90582, + "67": 9.88943, + "68": 9.81505, + "69": 9.8386, + "70": 9.82966, + "71": 9.85498, + "72": 9.84522, + "73": 9.79852, + "74": 9.79437, + "75": 9.74264, + "76": 9.81212, + "77": 9.80875, + "78": 9.76202, + "79": 9.73761, + "80": 9.7607, + "81": 9.80103, + "82": 9.72386, + "83": 9.66588, + "84": 9.6266, + "85": 9.59092, + "86": 9.73755, + "87": 9.72718, + "88": 9.73452, + "89": 9.63555, + "90": 9.62955, + "91": 9.67368, + "92": 9.63796, + "93": 9.53744, + "94": 9.65635, + "95": 9.62912, + "96": 9.63405, + "97": 9.54616, + "98": 9.59568, + "99": 9.64142, + "100": 9.53537 } }, "num-zeros": { @@ -111,106 +111,106 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 22985512.0, - "2": 22866852.0, - "3": 22718796.0, - "4": 22793114.0, - "5": 22800332.0, - "6": 22758732.0, - "7": 22889360.0, - "8": 22616950.0, - "9": 22770544.0, - "10": 22482352.0, - "11": 22768012.0, - "12": 22646638.0, - "13": 23376220.0, - "14": 23020932.0, - "15": 22728648.0, - "16": 22844252.0, - "17": 22956366.0, - "18": 23025410.0, - "19": 23121762.0, - "20": 22737712.0, - "21": 22939090.0, - "22": 22975288.0, - "23": 22636468.0, - "24": 22885640.0, - "25": 22646650.0, - "26": 23036326.0, - "27": 22820200.0, - "28": 23031704.0, - "29": 23007740.0, - "30": 22978056.0, - "31": 22931440.0, - "32": 22671854.0, - "33": 22753936.0, - "34": 23115324.0, - "35": 22764088.0, - "36": 22708302.0, - "37": 23140388.0, - "38": 22991024.0, - "39": 23018000.0, - "40": 22766666.0, - "41": 23101500.0, - "42": 22700204.0, - "43": 23019132.0, - "44": 22716592.0, - "45": 22868230.0, - "46": 22743320.0, - "47": 22871964.0, - "48": 22852496.0, - "49": 22908240.0, - "50": 22654376.0, - "51": 22713842.0, - "52": 22833092.0, - "53": 22987628.0, - "54": 22806964.0, - "55": 22950768.0, - "56": 22669906.0, - "57": 23234446.0, - "58": 22699600.0, - "59": 22862406.0, - "60": 23046738.0, - "61": 22688380.0, - "62": 22743124.0, - "63": 22644394.0, - "64": 23031802.0, - "65": 23243748.0, - "66": 22705416.0, - "67": 22986284.0, - "68": 22949944.0, - "69": 23193570.0, - "70": 22838384.0, - "71": 22750404.0, - "72": 23155280.0, - "73": 23168580.0, - "74": 22970434.0, - "75": 22903816.0, - "76": 22714100.0, - "77": 23011992.0, - "78": 23010412.0, - "79": 22845512.0, - "80": 22958324.0, - "81": 22850196.0, - "82": 22746284.0, - "83": 22741626.0, - "84": 23135720.0, - "85": 22945772.0, - "86": 23108174.0, - "87": 22369184.0, - "88": 22565120.0, - "89": 22738524.0, - "90": 22782056.0, - "91": 22941044.0, - "92": 22680736.0, - "93": 22647836.0, - "94": 23168884.0, - "95": 22702208.0, - "96": 22867360.0, - "97": 22852572.0, - "98": 22897152.0, - "99": 22645740.0, - "100": 23029680.0 + "1": 22986408.0, + "2": 22867856.0, + "3": 22719884.0, + "4": 22794008.0, + "5": 22801154.0, + "6": 22759694.0, + "7": 22890364.0, + "8": 22617896.0, + "9": 22771528.0, + "10": 22483364.0, + "11": 22768948.0, + "12": 22647494.0, + "13": 23377220.0, + "14": 23021868.0, + "15": 22729498.0, + "16": 22845248.0, + "17": 22957354.0, + "18": 23026330.0, + "19": 23122828.0, + "20": 22738700.0, + "21": 22939944.0, + "22": 22976226.0, + "23": 22637450.0, + "24": 22886616.0, + "25": 22647556.0, + "26": 23037216.0, + "27": 22821184.0, + "28": 23032704.0, + "29": 23008756.0, + "30": 22979208.0, + "31": 22932472.0, + "32": 22672852.0, + "33": 22755016.0, + "34": 23116560.0, + "35": 22765664.0, + "36": 22709672.0, + "37": 23141764.0, + "38": 22992714.0, + "39": 23022566.0, + "40": 22767830.0, + "41": 23106848.0, + "42": 23749706.0, + "43": 24068728.0, + "44": 23766336.0, + "45": 22873326.0, + "46": 23792938.0, + "47": 23922180.0, + "48": 23902652.0, + "49": 23957900.0, + "50": 23704272.0, + "51": 23763660.0, + "52": 23883720.0, + "53": 24037434.0, + "54": 23856996.0, + "55": 24001224.0, + "56": 23720200.0, + "57": 24284806.0, + "58": 23749452.0, + "59": 23914064.0, + "60": 24098812.0, + "61": 23742456.0, + "62": 22745048.0, + "63": 23693956.0, + "64": 24081672.0, + "65": 24297776.0, + "66": 23755356.0, + "67": 24036968.0, + "68": 25048580.0, + "69": 24243380.0, + "70": 23891872.0, + "71": 24848952.0, + "72": 24205908.0, + "73": 24221136.0, + "74": 25068696.0, + "75": 23952946.0, + "76": 23764776.0, + "77": 25110290.0, + "78": 24060988.0, + "79": 23897268.0, + "80": 24012984.0, + "81": 23901444.0, + "82": 23796884.0, + "83": 22742696.0, + "84": 24186118.0, + "85": 23995964.0, + "86": 24178450.0, + "87": 23419336.0, + "88": 23615672.0, + "89": 23792564.0, + "90": 23832136.0, + "91": 23991288.0, + "92": 23731182.0, + "93": 22649058.0, + "94": 24219030.0, + "95": 22703490.0, + "96": 23918500.0, + "97": 23902440.0, + "98": 22898616.0, + "99": 23695624.0, + "100": 24079520.0 } }, "mem-allocated-bytes": { @@ -218,106 +218,106 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 815727104.0, - "2": 793283584.0, - "3": 835202048.0, - "4": 807708672.0, - "5": 808458240.0, - "6": 804466688.0, - "7": 800862208.0, - "8": 808080384.0, - "9": 807653376.0, - "10": 800862208.0, - "11": 808208384.0, - "12": 807350272.0, - "13": 800862208.0, - "14": 808185856.0, - "15": 807186432.0, - "16": 800862208.0, - "17": 807448576.0, - "18": 807286784.0, - "19": 800862208.0, - "20": 807684096.0, - "21": 807890944.0, - "22": 800862208.0, - "23": 808510464.0, - "24": 808387584.0, - "25": 804035584.0, - "26": 800862208.0, - "27": 807825408.0, - "28": 806744064.0, - "29": 800862208.0, - "30": 807996416.0, - "31": 807682048.0, - "32": 803694592.0, - "33": 800862208.0, - "34": 807350272.0, - "35": 806928384.0, - "36": 800862208.0, - "37": 807727104.0, - "38": 807112704.0, - "39": 800862208.0, - "40": 807997440.0, - "41": 807677952.0, - "42": 803500032.0, - "43": 800862208.0, - "44": 807511040.0, - "45": 807274496.0, - "46": 800862208.0, - "47": 807894016.0, - "48": 807567360.0, - "49": 803500032.0, - "50": 800862208.0, - "51": 808185856.0, - "52": 804275200.0, - "53": 800862208.0, - "54": 807925760.0, - "55": 807542784.0, - "56": 803713024.0, - "57": 800862208.0, - "58": 807350272.0, - "59": 807393280.0, - "60": 800862208.0, - "61": 807858176.0, - "62": 807350272.0, - "63": 803500032.0, - "64": 800862208.0, - "65": 807711744.0, - "66": 807874560.0, - "67": 803500032.0, - "68": 807350272.0, - "69": 807612416.0, - "70": 804241408.0, - "71": 800862208.0, - "72": 807661568.0, - "73": 806744064.0, - "74": 800862208.0, - "75": 807350272.0, - "76": 807350272.0, - "77": 800862208.0, - "78": 800862208.0, - "79": 807976960.0, - "80": 808120320.0, - "81": 804230144.0, - "82": 800862208.0, - "83": 808173568.0, - "84": 807569408.0, - "85": 803500032.0, - "86": 800862208.0, - "87": 807350272.0, - "88": 807421952.0, - "89": 803729408.0, - "90": 800862208.0, - "91": 808009728.0, - "92": 808318976.0, - "93": 806807552.0, - "94": 800862208.0, - "95": 800862208.0, - "96": 808192000.0, - "97": 807350272.0, - "98": 806744064.0, - "99": 800862208.0, - "100": 807636992.0 + "1": 783287296.0, + "2": 788025856.0, + "3": 780923392.0, + "4": 807817216.0, + "5": 808268288.0, + "6": 807809536.0, + "7": 805578240.0, + "8": 802091520.0, + "9": 805330432.0, + "10": 802091520.0, + "11": 802091520.0, + "12": 805496320.0, + "13": 802091520.0, + "14": 805298688.0, + "15": 807817216.0, + "16": 802091520.0, + "17": 804969984.0, + "18": 802091520.0, + "19": 804729344.0, + "20": 804729344.0, + "21": 802091520.0, + "22": 807724032.0, + "23": 802091520.0, + "24": 802091520.0, + "25": 808419840.0, + "26": 804729344.0, + "27": 802091520.0, + "28": 808036864.0, + "29": 808786944.0, + "30": 805179392.0, + "31": 804972032.0, + "32": 802091520.0, + "33": 807858688.0, + "34": 804729344.0, + "35": 802091520.0, + "36": 808122880.0, + "37": 804729344.0, + "38": 805076480.0, + "39": 802091520.0, + "40": 807704064.0, + "41": 804729344.0, + "42": 802091520.0, + "43": 807645696.0, + "44": 802091520.0, + "45": 807986688.0, + "46": 804811264.0, + "47": 802091520.0, + "48": 802091520.0, + "49": 808298496.0, + "50": 802091520.0, + "51": 804729344.0, + "52": 807973376.0, + "53": 807367168.0, + "54": 804729344.0, + "55": 802091520.0, + "56": 802091520.0, + "57": 807793152.0, + "58": 805179392.0, + "59": 805179392.0, + "60": 804938240.0, + "61": 805179392.0, + "62": 805579264.0, + "63": 808347136.0, + "64": 804729344.0, + "65": 804729344.0, + "66": 802091520.0, + "67": 802091520.0, + "68": 802091520.0, + "69": 802091520.0, + "70": 807817216.0, + "71": 805188096.0, + "72": 802091520.0, + "73": 802091520.0, + "74": 802091520.0, + "75": 807825920.0, + "76": 805512704.0, + "77": 802091520.0, + "78": 802091520.0, + "79": 808472576.0, + "80": 802091520.0, + "81": 802091520.0, + "82": 807986688.0, + "83": 805179392.0, + "84": 802091520.0, + "85": 802091520.0, + "86": 807367168.0, + "87": 804729344.0, + "88": 802091520.0, + "89": 808003072.0, + "90": 802091520.0, + "91": 804729344.0, + "92": 807367168.0, + "93": 802091520.0, + "94": 807367168.0, + "95": 802091520.0, + "96": 802091520.0, + "97": 808018944.0, + "98": 802091520.0, + "99": 807722496.0, + "100": 805179392.0 } }, "mem-max-allocated-bytes": { @@ -325,106 +325,106 @@ "end_step": 100, "step_interval": 1, "values": { - "1": 964055552.0, - "2": 1186166272.0, - "3": 1196239360.0, - "4": 1196239360.0, - "5": 1196239360.0, - "6": 1196239360.0, - "7": 1196239360.0, - "8": 1196239360.0, - "9": 1196239360.0, - "10": 1196239360.0, - "11": 1196239360.0, - "12": 1196239360.0, - "13": 1196239360.0, - "14": 1196239360.0, - "15": 1196239360.0, - "16": 1196239360.0, - "17": 1196239360.0, - "18": 1196239360.0, - "19": 1196239360.0, - "20": 1196239360.0, - "21": 1196239360.0, - "22": 1196239360.0, - "23": 1196239360.0, - "24": 1196239360.0, - "25": 1196239360.0, - "26": 1196239360.0, - "27": 1196239360.0, - "28": 1196239360.0, - "29": 1196239360.0, - "30": 1196239360.0, - "31": 1196239360.0, - "32": 1196239360.0, - "33": 1196239360.0, - "34": 1196239360.0, - "35": 1196239360.0, - "36": 1196239360.0, - "37": 1196239360.0, - "38": 1196239360.0, - "39": 1196239360.0, - "40": 1196239360.0, - "41": 1196239360.0, - "42": 1196239360.0, - "43": 1196239360.0, - "44": 1196239360.0, - "45": 1196239360.0, - "46": 1196239360.0, - "47": 1196239360.0, - "48": 1196239360.0, - "49": 1196239360.0, - "50": 1196239360.0, - "51": 1196239360.0, - "52": 1196239360.0, - "53": 1196239360.0, - "54": 1196239360.0, - "55": 1196239360.0, - "56": 1196239360.0, - "57": 1196239360.0, - "58": 1196239360.0, - "59": 1196239360.0, - "60": 1196239360.0, - "61": 1196239360.0, - "62": 1196239360.0, - "63": 1196239360.0, - "64": 1196239360.0, - "65": 1196239360.0, - "66": 1196239360.0, - "67": 1196239360.0, - "68": 1196239360.0, - "69": 1196239360.0, - "70": 1196239360.0, - "71": 1196239360.0, - "72": 1196239360.0, - "73": 1196239360.0, - "74": 1196239360.0, - "75": 1196239360.0, - "76": 1196239360.0, - "77": 1196239360.0, - "78": 1196239360.0, - "79": 1196239360.0, - "80": 1196239360.0, - "81": 1196239360.0, - "82": 1196239360.0, - "83": 1196239360.0, - "84": 1196239360.0, - "85": 1196239360.0, - "86": 1196239360.0, - "87": 1196239360.0, - "88": 1196239360.0, - "89": 1196239360.0, - "90": 1196239360.0, - "91": 1196239360.0, - "92": 1196239360.0, - "93": 1196239360.0, - "94": 1196239360.0, - "95": 1196239360.0, - "96": 1196239360.0, - "97": 1196239360.0, - "98": 1196239360.0, - "99": 1196239360.0, - "100": 1196239360.0 + "1": 931044352.0, + "2": 1150597632.0, + "3": 1150597632.0, + "4": 1150597632.0, + "5": 1150597632.0, + "6": 1150597632.0, + "7": 1150597632.0, + "8": 1150597632.0, + "9": 1150597632.0, + "10": 1150597632.0, + "11": 1150597632.0, + "12": 1150597632.0, + "13": 1150597632.0, + "14": 1150597632.0, + "15": 1150597632.0, + "16": 1150597632.0, + "17": 1150597632.0, + "18": 1150597632.0, + "19": 1150597632.0, + "20": 1150597632.0, + "21": 1150597632.0, + "22": 1150597632.0, + "23": 1150597632.0, + "24": 1150597632.0, + "25": 1150597632.0, + "26": 1150597632.0, + "27": 1150597632.0, + "28": 1150597632.0, + "29": 1150597632.0, + "30": 1150597632.0, + "31": 1150597632.0, + "32": 1150597632.0, + "33": 1150597632.0, + "34": 1150597632.0, + "35": 1150597632.0, + "36": 1150597632.0, + "37": 1150597632.0, + "38": 1150597632.0, + "39": 1150597632.0, + "40": 1150597632.0, + "41": 1150597632.0, + "42": 1150597632.0, + "43": 1150597632.0, + "44": 1150597632.0, + "45": 1150597632.0, + "46": 1150597632.0, + "47": 1150597632.0, + "48": 1150597632.0, + "49": 1150597632.0, + "50": 1150597632.0, + "51": 1150597632.0, + "52": 1150597632.0, + "53": 1150597632.0, + "54": 1150597632.0, + "55": 1150597632.0, + "56": 1150597632.0, + "57": 1150597632.0, + "58": 1150597632.0, + "59": 1150597632.0, + "60": 1150597632.0, + "61": 1150597632.0, + "62": 1150597632.0, + "63": 1150597632.0, + "64": 1150597632.0, + "65": 1150597632.0, + "66": 1150597632.0, + "67": 1150597632.0, + "68": 1150597632.0, + "69": 1150597632.0, + "70": 1150597632.0, + "71": 1150597632.0, + "72": 1150597632.0, + "73": 1150597632.0, + "74": 1150597632.0, + "75": 1150597632.0, + "76": 1150597632.0, + "77": 1150597632.0, + "78": 1150597632.0, + "79": 1150597632.0, + "80": 1150597632.0, + "81": 1150597632.0, + "82": 1150597632.0, + "83": 1150597632.0, + "84": 1150597632.0, + "85": 1150597632.0, + "86": 1150597632.0, + "87": 1150597632.0, + "88": 1150597632.0, + "89": 1150597632.0, + "90": 1150597632.0, + "91": 1150597632.0, + "92": 1150597632.0, + "93": 1150597632.0, + "94": 1150597632.0, + "95": 1150597632.0, + "96": 1150597632.0, + "97": 1150597632.0, + "98": 1150597632.0, + "99": 1150597632.0, + "100": 1150597632.0 } }, "mtp_1 loss": { @@ -434,211 +434,210 @@ "values": { "1": 10.91546, "2": 10.92323, - "3": 10.93384, - "4": 10.92739, - "5": 10.90724, - "6": 10.91817, - "7": 10.92486, - "8": 10.92528, - "9": 10.93457, + "3": 10.93381, + "4": 10.92692, + "5": 10.90737, + "6": 10.91868, + "7": 10.92452, + "8": 10.92547, + "9": 10.93431, "10": 10.9265, - "11": 10.91896, - "12": 10.91863, - "13": 10.92814, - "14": 10.91203, - "15": 10.92041, - "16": 10.92467, - "17": 10.92235, - "18": 10.90719, - "19": 10.91438, - "20": 10.90506, - "21": 10.91161, - "22": 10.89778, - "23": 10.90483, - "24": 10.88964, - "25": 10.89765, - "26": 10.88453, - "27": 10.89849, - "28": 10.89069, + "11": 10.91849, + "12": 10.91841, + "13": 10.92772, + "14": 10.91224, + "15": 10.92052, + "16": 10.92447, + "17": 10.92184, + "18": 10.90722, + "19": 10.91489, + "20": 10.90515, + "21": 10.91178, + "22": 10.89783, + "23": 10.90484, + "24": 10.89011, + "25": 10.89839, + "26": 10.88455, + "27": 10.89857, + "28": 10.89064, "29": 10.87558, - "30": 10.88029, - "31": 10.87277, - "32": 10.87869, - "33": 10.87024, - "34": 10.8682, - "35": 10.85932, - "36": 10.86187, - "37": 10.85499, - "38": 10.85713, - "39": 10.84881, - "40": 10.86288, - "41": 10.85314, - "42": 10.8473, - "43": 10.84576, - "44": 10.83821, - "45": 10.84904, + "30": 10.88023, + "31": 10.87305, + "32": 10.87883, + "33": 10.86963, + "34": 10.86778, + "35": 10.85942, + "36": 10.86134, + "37": 10.85492, + "38": 10.85701, + "39": 10.8491, + "40": 10.86294, + "41": 10.85349, + "42": 10.84748, + "43": 10.84529, + "44": 10.83797, + "45": 10.84927, "46": 10.8381, - "47": 10.83803, - "48": 10.831, - "49": 10.82926, - "50": 10.82229, - "51": 10.82153, - "52": 10.82122, - "53": 10.80664, - "54": 10.81103, - "55": 10.79443, - "56": 10.79963, - "57": 10.78961, - "58": 10.79824, - "59": 10.78095, - "60": 10.77503, - "61": 10.77627, - "62": 10.7614, - "63": 10.78392, - "64": 10.75466, - "65": 10.75002, - "66": 10.75702, - "67": 10.73504, - "68": 10.72878, - "69": 10.72595, - "70": 10.72543, - "71": 10.72482, - "72": 10.71955, - "73": 10.71178, - "74": 10.70369, - "75": 10.68547, - "76": 10.69478, - "77": 10.69055, - "78": 10.68188, - "79": 10.66968, - "80": 10.67688, - "81": 10.66904, - "82": 10.65016, - "83": 10.6267, - "84": 10.61015, - "85": 10.60262, - "86": 10.6432, - "87": 10.63641, - "88": 10.63101, - "89": 10.59551, - "90": 10.58424, - "91": 10.60768, - "92": 10.58305, - "93": 10.56222, - "94": 10.59342, - "95": 10.57615, - "96": 10.57208, - "97": 10.55416, - "98": 10.55921, - "99": 10.55818, - "100": 10.5284 + "47": 10.83839, + "48": 10.83123, + "49": 10.82966, + "50": 10.82273, + "51": 10.82188, + "52": 10.82116, + "53": 10.80672, + "54": 10.81085, + "55": 10.79459, + "56": 10.79964, + "57": 10.78951, + "58": 10.79809, + "59": 10.78098, + "60": 10.77499, + "61": 10.77669, + "62": 10.76134, + "63": 10.78372, + "64": 10.75495, + "65": 10.75047, + "66": 10.75725, + "67": 10.73518, + "68": 10.72884, + "69": 10.72584, + "70": 10.72542, + "71": 10.72504, + "72": 10.71966, + "73": 10.7118, + "74": 10.70416, + "75": 10.68562, + "76": 10.69517, + "77": 10.69068, + "78": 10.682, + "79": 10.66958, + "80": 10.67703, + "81": 10.66911, + "82": 10.65004, + "83": 10.62667, + "84": 10.61036, + "85": 10.60259, + "86": 10.64333, + "87": 10.63617, + "88": 10.63091, + "89": 10.59516, + "90": 10.58423, + "91": 10.60752, + "92": 10.58284, + "93": 10.56206, + "94": 10.59328, + "95": 10.57611, + "96": 10.57209, + "97": 10.55386, + "98": 10.55913, + "99": 10.55814, + "100": 10.52809 } }, "iteration-time": { - "start_step": 1, + "start_step": 2, "end_step": 100, "step_interval": 1, "values": { - "1": "nan", - "2": 18.24385, - "3": 1.24992, - "4": 3.44822, - "5": 0.67115, - "6": 0.67992, - "7": 0.67235, - "8": 0.67169, - "9": 0.67242, - "10": 0.66924, - "11": 0.67194, - "12": 0.66696, - "13": 0.66783, - "14": 0.66806, - "15": 0.66792, - "16": 0.66722, - "17": 0.66884, - "18": 0.66891, - "19": 0.67057, - "20": 0.67024, - "21": 0.67476, - "22": 0.6704, - "23": 0.66892, - "24": 0.67043, - "25": 0.67258, - "26": 0.67099, - "27": 0.67203, - "28": 0.67141, - "29": 0.67162, - "30": 0.67618, - "31": 0.67022, - "32": 0.68537, - "33": 0.67019, - "34": 0.66964, - "35": 0.67288, - "36": 0.66938, - "37": 0.67603, - "38": 0.66977, - "39": 0.67445, - "40": 0.67455, - "41": 0.6717, - "42": 0.67202, - "43": 0.67216, - "44": 0.67433, - "45": 0.67073, - "46": 0.6702, - "47": 0.67187, - "48": 0.67865, - "49": 0.67059, - "50": 0.67768, - "51": 0.7684, - "52": 0.67476, - "53": 0.67246, - "54": 0.67426, - "55": 0.67523, - "56": 0.67431, - "57": 0.67379, - "58": 0.67592, - "59": 0.67389, - "60": 0.67679, - "61": 0.67409, - "62": 0.67265, - "63": 0.67543, - "64": 0.67577, - "65": 0.6745, - "66": 0.67687, - "67": 0.67327, - "68": 0.67244, - "69": 0.67241, - "70": 0.67191, - "71": 0.67044, - "72": 0.67049, - "73": 0.67597, - "74": 0.67288, - "75": 0.67123, - "76": 0.67032, - "77": 0.66955, - "78": 0.68133, - "79": 0.67997, - "80": 0.68011, - "81": 0.68168, - "82": 0.68012, - "83": 0.68054, - "84": 0.67091, - "85": 0.67421, - "86": 0.67093, - "87": 0.68073, - "88": 0.67264, - "89": 0.67707, - "90": 0.6819, - "91": 0.67945, - "92": 0.6829, - "93": 0.68217, - "94": 0.68005, - "95": 0.68097, - "96": 0.68088, - "97": 0.68296, - "98": 0.68201, - "99": 0.67603, - "100": 0.67638 + "2": 29.93393, + "3": 1.98973, + "4": 5.35658, + "5": 1.20635, + "6": 1.19444, + "7": 1.18934, + "8": 1.16351, + "9": 1.13996, + "10": 1.15149, + "11": 1.13288, + "12": 1.13898, + "13": 1.14688, + "14": 1.14899, + "15": 1.14352, + "16": 1.14048, + "17": 1.13989, + "18": 1.15969, + "19": 1.15356, + "20": 1.1122, + "21": 1.1575, + "22": 1.15642, + "23": 1.17142, + "24": 1.20778, + "25": 1.24095, + "26": 1.17461, + "27": 1.16007, + "28": 1.19187, + "29": 1.1912, + "30": 1.19282, + "31": 1.19842, + "32": 1.17999, + "33": 1.16481, + "34": 1.17327, + "35": 1.17844, + "36": 1.16523, + "37": 1.17743, + "38": 1.20861, + "39": 1.17587, + "40": 1.16604, + "41": 1.17477, + "42": 1.16262, + "43": 1.16073, + "44": 1.15779, + "45": 1.18144, + "46": 1.16745, + "47": 1.16008, + "48": 1.20221, + "49": 1.18658, + "50": 1.16544, + "51": 1.26104, + "52": 1.21112, + "53": 1.20935, + "54": 1.2683, + "55": 1.07576, + "56": 1.08833, + "57": 1.08595, + "58": 1.07682, + "59": 1.10548, + "60": 1.10443, + "61": 1.12092, + "62": 1.11365, + "63": 1.10675, + "64": 1.09502, + "65": 1.09559, + "66": 1.09977, + "67": 1.09642, + "68": 1.08417, + "69": 1.09903, + "70": 1.08224, + "71": 1.08075, + "72": 1.08284, + "73": 1.08425, + "74": 1.09211, + "75": 1.0851, + "76": 1.0861, + "77": 1.07301, + "78": 1.08354, + "79": 1.07668, + "80": 1.07698, + "81": 1.07855, + "82": 1.07964, + "83": 1.07692, + "84": 1.08014, + "85": 1.08469, + "86": 1.08449, + "87": 1.07474, + "88": 1.06482, + "89": 1.05622, + "90": 1.04827, + "91": 1.04974, + "92": 1.04658, + "93": 1.04466, + "94": 1.03782, + "95": 1.04608, + "96": 1.03914, + "97": 1.04721, + "98": 1.05712, + "99": 1.05243, + "100": 1.08418 } } -} \ No newline at end of file +} diff --git a/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py b/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py index 6461dbee751..fe56317f39a 100644 --- a/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py +++ b/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py @@ -8,9 +8,15 @@ from megatron.core import parallel_state from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig +from megatron.core.extensions.transformer_engine import ( + TEColumnParallelGroupedLinear, + TEColumnParallelLinear, +) from megatron.core.models.gpt.gpt_layer_specs import ( get_gpt_layer_with_transformer_engine_submodules, ) +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.tensor_parallel.layers import ColumnParallelLinear from megatron.core.transformer import TransformerConfig from megatron.core.transformer.moe.moe_layer import MoELayer, MoESubmodules from megatron.core.transformer.spec_utils import get_submodules @@ -104,6 +110,121 @@ def get_moe_model_and_buffers( ) +def _build_expert_linear(implementation: str, config: TransformerConfig) -> torch.nn.Module: + common_kwargs = { + "input_size": config.hidden_size, + "output_size": config.ffn_hidden_size, + "config": config, + "init_method": config.init_method, + "bias": False, + "skip_bias_add": False, + "is_expert": True, + } + if implementation == "native": + return ColumnParallelLinear( + **common_kwargs, + gather_output=False, + tp_group=parallel_state.get_expert_tensor_parallel_group(), + ) + if implementation == "transformer_engine": + return TEColumnParallelLinear( + **common_kwargs, + gather_output=False, + tp_group=parallel_state.get_expert_tensor_parallel_group(), + ) + if implementation == "transformer_engine_grouped": + return TEColumnParallelGroupedLinear( + num_gemms=config.num_moe_experts, + **common_kwargs, + pg_collection=ProcessGroupCollection.use_mpu_process_groups(), + ) + raise AssertionError(f"Unsupported implementation: {implementation}") + + +@pytest.mark.parametrize( + ("tensor_model_parallel_size", "expert_tensor_parallel_size"), [(2, 1), (1, 2)] +) +@pytest.mark.parametrize( + "implementation", ["native", "transformer_engine", "transformer_engine_grouped"] +) +def test_expert_grad_sync_uses_expert_data_parallel_group( + implementation: str, tensor_model_parallel_size: int, expert_tensor_parallel_size: int +): + """Expert gradients must not be reduced over ordinary DP when ETP differs from TP.""" + if Utils.world_size < 4 or Utils.world_size % 4 != 0: + pytest.skip("Test requires a world size divisible by four") + if Utils.world_size > 16: + pytest.skip("Rank-encoded gradients are intended for small unit-test world sizes") + + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_model_parallel_size, + expert_model_parallel_size=1, + expert_tensor_parallel_size=expert_tensor_parallel_size, + ) + try: + # Per-token loss leaves DDP's pre-collective gradient scaling at one. + config = TransformerConfig( + num_layers=1, + hidden_size=8, + num_attention_heads=4, + ffn_hidden_size=16, + num_moe_experts=2, + moe_ffn_hidden_size=16, + moe_router_topk=2, + tensor_model_parallel_size=tensor_model_parallel_size, + expert_model_parallel_size=1, + expert_tensor_parallel_size=expert_tensor_parallel_size, + calculate_per_token_loss=True, + gradient_accumulation_fusion=False, + perform_initialization=False, + bf16=True, + params_dtype=torch.bfloat16, + add_bias_linear=False, + ) + module = _build_expert_linear(implementation, config).cuda() + model = DistributedDataParallel( + config, + ddp_config=DistributedDataParallelConfig( + grad_reduce_in_fp32=True, + overlap_grad_reduce=False, + use_distributed_optimizer=False, + average_in_collective=False, + ), + module=module, + ) + + expert_dp_group = parallel_state.get_expert_data_parallel_group( + partial_expert_data_parallel=True + ) + ordinary_dp_group = parallel_state.get_data_parallel_group( + with_context_parallel=True, partial_data_parallel=True + ) + expert_dp_ranks = torch.distributed.get_process_group_ranks(expert_dp_group) + ordinary_dp_ranks = torch.distributed.get_process_group_ranks(ordinary_dp_group) + assert expert_dp_ranks != ordinary_dp_ranks + + # Powers of two give every rank set a distinct sum, exposing the wrong collective group. + rank_value = float(2 ** torch.distributed.get_rank()) + expected_value = float(sum(2**rank for rank in expert_dp_ranks)) + ordinary_dp_value = float(sum(2**rank for rank in ordinary_dp_ranks)) + assert expected_value != ordinary_dp_value + + for param in model.parameters(): + param.main_grad.fill_(rank_value) + model.finish_grad_sync() + + for param in model.parameters(): + torch.testing.assert_close( + param.main_grad, torch.full_like(param.main_grad, expected_value), rtol=0, atol=0 + ) + + assert not model.buffers + assert len(model.expert_parallel_buffers) == 1 + assert all(param.allreduce is False for param in model.parameters()) + finally: + Utils.destroy_model_parallel() + + @pytest.mark.parametrize("use_distributed_optimizer", [False, True]) @pytest.mark.parametrize("overlap_grad_reduce", [False, True]) @pytest.mark.parametrize("average_in_collective", [False, True]) diff --git a/tests/unit_tests/tensor_parallel/test_tp_attrs_without_init.py b/tests/unit_tests/tensor_parallel/test_tp_attrs_without_init.py index 44d6fa21178..a76746d7674 100644 --- a/tests/unit_tests/tensor_parallel/test_tp_attrs_without_init.py +++ b/tests/unit_tests/tensor_parallel/test_tp_attrs_without_init.py @@ -8,6 +8,7 @@ RowParallelLinear, VocabParallelEmbedding, copy_tensor_model_parallel_attributes, + param_is_not_tensor_parallel_duplicate, ) from megatron.core.transformer.transformer_config import TransformerConfig from tests.unit_tests.test_utilities import Utils @@ -100,3 +101,60 @@ def test_copy_tensor_model_parallel_attributes_preserves_qkv_split_shapes(): assert destination.is_qkv is True assert destination.qkv_split_shapes == source.qkv_split_shapes + + +def test_non_allreduce_param_uses_expert_tp_group_for_duplicate_filter(): + class RankGroup: + def __init__(self, rank): + self._rank = rank + + def rank(self): + return self._rank + + param = torch.empty(1) + regular_tp_group = RankGroup(rank=1) + expert_tp_group = RankGroup(rank=0) + assert not param_is_not_tensor_parallel_duplicate(param, regular_tp_group) + + param.allreduce = False + assert param_is_not_tensor_parallel_duplicate( + param, tp_group=regular_tp_group, expert_tp_group=expert_tp_group + ) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") +def test_expert_linear_parameters_use_expert_topology_metadata(): + Utils.initialize_model_parallel(tensor_model_parallel_size=2, expert_tensor_parallel_size=1) + cfg = TransformerConfig( + num_layers=1, + hidden_size=8, + num_attention_heads=4, + tensor_model_parallel_size=2, + expert_tensor_parallel_size=1, + use_cpu_initialization=True, + perform_initialization=False, + ) + + column = ColumnParallelLinear( + input_size=8, + output_size=8, + init_method=cfg.init_method, + bias=True, + config=cfg, + gather_output=False, + skip_bias_add=False, + is_expert=True, + ) + row = RowParallelLinear( + input_size=8, + output_size=8, + init_method=cfg.init_method, + bias=True, + input_is_parallel=True, + config=cfg, + skip_bias_add=False, + is_expert=True, + ) + + for param in (*column.parameters(), *row.parameters()): + assert param.allreduce is False diff --git a/tests/unit_tests/test_optimizer.py b/tests/unit_tests/test_optimizer.py index 94613b7096c..d822f9bbf3f 100644 --- a/tests/unit_tests/test_optimizer.py +++ b/tests/unit_tests/test_optimizer.py @@ -27,6 +27,7 @@ get_standard_config_overrides, ) from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer +from megatron.core.optimizer.optimizer import copy_optimizer_param_metadata from megatron.core.optimizer_param_scheduler import ParamGroupOverride from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed @@ -80,6 +81,16 @@ def forward(self, x): return x +def test_copy_optimizer_param_metadata_preserves_allreduce(): + source = torch.empty(1) + destination = torch.empty_like(source) + source.allreduce = False + + copy_optimizer_param_metadata(destination, source) + + assert destination.allreduce is False + + @patch('torch.distributed.get_world_size', return_value=1) @patch( 'torch.distributed.all_gather_object', lambda output_list, obj: output_list.__setitem__(0, obj) diff --git a/tests/unit_tests/training/test_param_norm.py b/tests/unit_tests/training/test_param_norm.py new file mode 100644 index 00000000000..d58eef225d3 --- /dev/null +++ b/tests/unit_tests/training/test_param_norm.py @@ -0,0 +1,202 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import math +from types import SimpleNamespace + +import pytest +import torch + +from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig +from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec +from megatron.core.models.gpt.gpt_model import GPTModel +from megatron.core.optimizer import OptimizerConfig, get_megatron_optimizer +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.training.utils import common_utils +from tests.unit_tests.test_utilities import Utils + + +def _build_tiny_moe_gpt( + tensor_parallel_size: int, + expert_parallel_size: int, + expert_tensor_parallel_size: int, + bf16: bool = False, +) -> GPTModel: + config = TransformerConfig( + num_layers=1, + hidden_size=8, + num_attention_heads=4, + ffn_hidden_size=16, + num_moe_experts=2, + moe_ffn_hidden_size=16, + moe_shared_expert_intermediate_size=16, + moe_router_topk=1, + moe_router_pre_softmax=True, + tensor_model_parallel_size=tensor_parallel_size, + expert_model_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + sequence_parallel=tensor_parallel_size > 1, + use_cpu_initialization=True, + add_bias_linear=False, + normalization="RMSNorm", + moe_grouped_gemm=True, + bf16=bf16, + params_dtype=torch.bfloat16 if bf16 else torch.float32, + ) + model = GPTModel( + config=config, + transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec( + num_experts=config.num_moe_experts, moe_grouped_gemm=True + ), + vocab_size=16, + max_sequence_length=8, + position_embedding_type="rope", + ) + assert any(".shared_experts." in name for name, _ in model.named_parameters()) + return model.cuda() + + +def _fill_parameters_with_ones(model: GPTModel) -> None: + with torch.no_grad(): + for param in model.parameters(): + param.fill_(1.0) + + +@pytest.mark.parametrize( + ("tensor_parallel_size", "expert_parallel_size", "expert_tensor_parallel_size"), + ((2, 2, 1), (2, 1, 2), (4, 1, 2), (2, 1, 4)), + ids=("expert-parallel", "expert-tensor-parallel", "tp-larger-than-etp", "etp-larger-than-tp"), +) +def test_moe_param_norm_counts_each_logical_parameter_once( + monkeypatch, + tensor_parallel_size: int, + expert_parallel_size: int, + expert_tensor_parallel_size: int, +): + """Parameter norm should be invariant to expert and expert-tensor parallelism.""" + if Utils.world_size < 4 or Utils.world_size % 4 != 0: + pytest.skip("test requires a world size divisible by four") + + monkeypatch.setattr( + common_utils, "get_args", lambda: SimpleNamespace(use_megatron_fsdp=False, bf16=False) + ) + + try: + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + expert_model_parallel_size=1, + expert_tensor_parallel_size=1, + ) + reference_model = _build_tiny_moe_gpt( + tensor_parallel_size=1, expert_parallel_size=1, expert_tensor_parallel_size=1 + ) + _fill_parameters_with_ones(reference_model) + expected_numel = sum(param.numel() for param in reference_model.parameters()) + expected_norm = math.sqrt(expected_numel) + reference_norm = common_utils.calc_params_l2_norm(reference_model) + + assert reference_norm == pytest.approx(expected_norm) + del reference_model + + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_parallel_size, + expert_model_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + ) + distributed_model = _build_tiny_moe_gpt( + tensor_parallel_size=tensor_parallel_size, + expert_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + ) + _fill_parameters_with_ones(distributed_model) + + actual_norm = common_utils.calc_params_l2_norm(distributed_model) + + assert actual_norm == pytest.approx(expected_norm) + finally: + Utils.destroy_model_parallel() + + +@pytest.mark.parametrize("use_distributed_optimizer", (False, True), ids=("optimizer", "distopt")) +@pytest.mark.parametrize( + ("tensor_parallel_size", "expert_parallel_size", "expert_tensor_parallel_size"), + ((2, 2, 1), (2, 1, 2), (4, 1, 2), (2, 1, 4)), + ids=("expert-parallel", "expert-tensor-parallel", "tp-larger-than-etp", "etp-larger-than-tp"), +) +def test_moe_grad_norm_and_clipping_count_each_logical_gradient_once( + tensor_parallel_size: int, + expert_parallel_size: int, + expert_tensor_parallel_size: int, + use_distributed_optimizer: bool, +): + """Gradient clipping should use each logical parameter's gradient exactly once.""" + if Utils.world_size < 4 or Utils.world_size % 4 != 0: + pytest.skip("test requires a world size divisible by four") + + try: + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + expert_model_parallel_size=1, + expert_tensor_parallel_size=1, + ) + reference_model = _build_tiny_moe_gpt( + tensor_parallel_size=1, expert_parallel_size=1, expert_tensor_parallel_size=1, bf16=True + ) + expected_numel = sum(param.numel() for param in reference_model.parameters()) + expected_norm = math.sqrt(expected_numel) + del reference_model + + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_parallel_size, + expert_model_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + ) + model = _build_tiny_moe_gpt( + tensor_parallel_size=tensor_parallel_size, + expert_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + bf16=True, + ) + ddp_config = DistributedDataParallelConfig( + grad_reduce_in_fp32=True, use_distributed_optimizer=use_distributed_optimizer + ) + model = DistributedDataParallel(model.config, ddp_config, model) + + max_norm = expected_norm / 2.0 + optimizer = get_megatron_optimizer( + OptimizerConfig( + optimizer="adam", + lr=0.0, + bf16=True, + clip_grad=max_norm, + use_distributed_optimizer=use_distributed_optimizer, + ), + [model], + ) + + for param in model.parameters(): + assert hasattr(param, "main_grad") + param.main_grad.fill_(1.0) + + update_successful, actual_norm, _ = optimizer.step() + + assert update_successful + actual_norm_value = ( + actual_norm.item() if isinstance(actual_norm, torch.Tensor) else actual_norm + ) + assert actual_norm_value == pytest.approx(expected_norm) + + expected_clip_coefficient = max_norm / (expected_norm + 1.0e-6) + grads_checked = 0 + for param in optimizer.get_parameters(): + if param.grad is None: + continue + torch.testing.assert_close( + param.grad, + torch.full_like(param.grad, expected_clip_coefficient), + rtol=1.0e-5, + atol=1.0e-6, + ) + grads_checked += 1 + assert grads_checked > 0 + finally: + Utils.destroy_model_parallel() diff --git a/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py b/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py index d0a411091f7..84ca109a15e 100644 --- a/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py +++ b/tests/unit_tests/transformer/test_transformer_engine_grouped_linear.py @@ -36,6 +36,44 @@ def _empty_load_args(): return {}, True, [], [], [] +@pytest.mark.parametrize(("parallel_mode", "partition_dim"), (("column", 0), ("row", 1))) +def test_expert_parameter_attributes_use_expert_topology(parallel_mode, partition_dim): + module = torch.nn.Module() + module.register_parameter("weight0", torch.nn.Parameter(torch.empty(4, 4))) + module.register_parameter("bias0", torch.nn.Parameter(torch.empty(4))) + + te_ext._set_expert_parameter_attributes( + module, parallel_mode=parallel_mode, use_expert_pgs=True + ) + + assert module.weight0.allreduce is False + assert module.weight0.tensor_model_parallel is True + assert module.weight0.partition_dim == partition_dim + assert module.bias0.allreduce is False + assert module.bias0.tensor_model_parallel is (parallel_mode == "column") + + +@pytest.mark.parametrize( + ("name", "is_partitioned"), + ( + ("weight", True), + ("weight12", True), + ("bias", True), + ("bias12", True), + ("weight_scale", False), + ("bias_extra", False), + ), +) +def test_expert_parameter_attributes_match_parameter_names(name, is_partitioned): + module = torch.nn.Module() + module.register_parameter(name, torch.nn.Parameter(torch.empty(4))) + + te_ext._set_expert_parameter_attributes(module, parallel_mode="column", use_expert_pgs=True) + + param = module.get_parameter(name) + assert getattr(param, "tensor_model_parallel", False) is is_partitioned + + def test_split_empty_extra_state_for_stateless_recipe(): module = _grouped_linear_stub(num_gemms=2) module.fp8_meta = {"fp8_checkpoint": True} From 7ec1bce60b92d85d9c0d6e2f38c42d81665ef47e Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sun, 26 Jul 2026 22:59:46 +0200 Subject: [PATCH 113/290] fix(inference): MCORE-536 report dropped prompt token lengths (#6051) Signed-off-by: svcnemo-autobot --- examples/inference/utils.py | 5 ++++- .../test_async_sched_output_metrics.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/examples/inference/utils.py b/examples/inference/utils.py index 532813ac1da..cb1a5dd11f9 100644 --- a/examples/inference/utils.py +++ b/examples/inference/utils.py @@ -359,7 +359,10 @@ def print_unique_prompts_and_outputs(results: List["DynamicInferenceRequest"]) - unique_prompt_map[req.prompt].append(idx) for unique_idx, (prompt_text, request_idxs) in enumerate(unique_prompt_map.items()): - prompt_len = len(results[request_idxs[0]].prompt_tokens) + request = results[request_idxs[0]] + prompt_len = request.prompt_length + if prompt_len is None and request.prompt_tokens is not None: + prompt_len = len(request.prompt_tokens) print( f"\n{unique_idx+1}/{len(unique_prompt_map)}" f"[n {len(request_idxs)}, l {prompt_len}] {escape_str(prompt_text)}" diff --git a/tests/unit_tests/inference/test_async_sched_output_metrics.py b/tests/unit_tests/inference/test_async_sched_output_metrics.py index 6490c9a4fde..0c12659286b 100644 --- a/tests/unit_tests/inference/test_async_sched_output_metrics.py +++ b/tests/unit_tests/inference/test_async_sched_output_metrics.py @@ -57,6 +57,25 @@ def test_inference_comparator_ignores_async_sched_counters(): assert "async_sched_compaction_step_count" in _NON_REQUEST_TOP_LEVEL_KEYS +@pytest.mark.parametrize(("prompt_tokens", "prompt_length"), [([1, 2, 3], None), (None, 3)]) +def test_print_unique_prompts_and_outputs_uses_available_prompt_length( + capsys, prompt_tokens, prompt_length +): + """Ensure reporting supports direct and coordinator inference results.""" + request = SimpleNamespace( + prompt="prompt", + prompt_tokens=prompt_tokens, + prompt_length=prompt_length, + generated_text="generated", + generated_tokens=[4], + events=[], + ) + + inference_utils.print_unique_prompts_and_outputs([request]) + + assert "[n 1, l 3] prompt" in capsys.readouterr().out + + def test_capture_engine_stats_includes_async_sched_counters(): """Ensure offline reporting captures async scheduling counters from the engine context.""" context = SimpleNamespace( From 543b8039646f2cdb7a54eacad612849080292bd6 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Mon, 27 Jul 2026 14:07:02 +0800 Subject: [PATCH 114/290] [GTP][Feat] Add one-block-ahead prefetch for GTP grouped-expert weights (#6057) Signed-off-by: Shiqing Fan --- .../core/generalized_tensor_parallel.md | 131 ++++++++++++----- .../0725_gtp_grouped_oneblock_prefetch.png | Bin 0 -> 173139 bytes .../generalized_tensor_parallelism.py | 135 +++++++++++++++--- .../test_gtp_basics.py | 103 +++++++++++++ 4 files changed, 315 insertions(+), 54 deletions(-) create mode 100644 docs/images/generalized_tensor_parallel/0725_gtp_grouped_oneblock_prefetch.png diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index 2b886d1417b..dbba305e791 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -4,40 +4,61 @@ > 📦 **Requires TransformerEngine >= 2.19** (GTP support is merged into TE main). On an older TE, GTP is disabled at import (`HAVE_GTP = False`) and enabling it raises an `ImportError` — please install TransformerEngine >= 2.19. -**At a glance.** GTP factors the weight-parallel domain into two orthogonal sub-axes — `GTP = TP × GTP_remat`. Each linear weight is sharded `1/(TP × GTP_remat)` along `out_features`: +**Generalized Tensor Parallelism (GTP)** is a lightweight, high-performance, memory-efficient distributed-training strategy implemented jointly in Megatron-LM and TransformerEngine. It **shards weight tensors across a GTP process group and reconstructs them on demand via asynchronous all-gather**, so larger models fit in the same memory without sacrificing throughput — the communication is overlapped with computation rather than added to it. -- **`TP`** slice — kept sharded through the GEMM. Ordinary tensor parallelism; the output is TP-sharded. -- **`GTP_remat`** slice — *rematerialized* just before the GEMM. Only the `GTP_remat` group all-gathers its part, so each rank's GEMM sees the full TP slice. The wgrad is reduce-scattered the same way on the way back. Both collectives overlap the previous layer's compute (forward and backward). +GTP splits the weight-parallel domain into two orthogonal sub-axes — **`GTP = TP × GTP_remat`** — so every rank stores `1/(TP × GTP_remat)` of each linear weight, together with the matching slice of its gradient and optimizer state. -This is **ZeRO-3-on-the-weight, on top of TP**. Per-GPU weight (and optimizer/grad) memory shrinks to `1/(TP × GTP_remat)`. It composes orthogonally with TP / SP / EP / DDP / CUDA Graphs. The `GTP_remat` degree is `gtp_weight_remat_size`, derived from `--tensor-parallel-num-weight-shards` (= `tensor_model_parallel_size × gtp_weight_remat_size`); when it is 1, GTP is inactive — byte-identical to plain TP+DP. +**GTP_remat is an implementation of ZeRO-3**, and obeys the same contract: shard the weight (plus grad and optimizer state), all-gather it just before it is needed, use it, free it, reduce-scatter the gradient on the way back. What distinguishes it from the familiar ZeRO-3 / FSDP implementations is *where* it shards and *how finely* it materializes: -**Scope**: a high-level summary of GTP_remat — design intent, public CLI surface, and Megatron-LM ↔ TransformerEngine integration touchpoints. +- **It shards along a model-parallel axis, not the data-parallel one.** `GTP_remat` is a sub-axis of the weight-parallel grid that sits *on top of* TP — the `TP` slice stays sharded through the GEMM, and only the `GTP_remat` slice is rebuilt. It therefore composes with TP instead of competing with it for the same weight dimension. +- **It materializes one weight at a time, not a bucket.** Each `GTPShardedParam` gathers, computes and frees on its own schedule, which is what makes the per-weight prefetch chain (§3.4) and the low-precision gather (§1.3) possible — see the FSDP contrast in §1.1. -Core implementation: `megatron/core/tensor_parallel/generalized_tensor_parallelism.py`. The public surface is re-exported from `megatron/core/tensor_parallel/gtp.py`. Low-precision tensor primitives (FP8 / MXFP8 / NVFP4) remain in TransformerEngine and are imported by `generalized_tensor_parallelism.py`. +| slice | stored | at GEMM time | +|---|---|---| +| **`TP`** | `1/TP` of the weight, permanently | **stays sharded** — ordinary tensor parallelism; the output is TP-sharded | +| **`GTP_remat`** | `1/GTP_remat` of the TP slice, permanently | **rematerialized**: all-gathered across the `GTP_remat` group just before the GEMM, so the GEMM sees the full TP slice; freed afterwards, and the wgrad is reduce-scattered back on the way out | + +Both `GTP_remat` collectives are prefetched one step ahead, so they overlap the previous layer's compute in forward *and* backward — the gather is off the critical path, not merely asynchronous. Note the two cuts do not always fall on the same axis of the weight (§1.4). + +**Turning it on.** The `GTP_remat` degree is `gtp_weight_remat_size`, derived from `--tensor-parallel-num-weight-shards` (= `tensor_model_parallel_size × gtp_weight_remat_size`). At **`gtp_weight_remat_size = 1` GTP is inactive and the path is byte-identical to plain TP + DP**, so it is safe to leave in the code path. It composes orthogonally with TP / SP / EP / DDP / CUDA Graphs. + +**Scope of this document**: a high-level summary of GTP_remat — design intent, public CLI surface, and Megatron-LM ↔ TransformerEngine integration touchpoints. + +**Source**: core implementation in `megatron/core/tensor_parallel/generalized_tensor_parallelism.py`, public surface re-exported from `megatron/core/tensor_parallel/gtp_api.py`. Low-precision tensor primitives (FP8 / MXFP8 / NVFP4) stay in TransformerEngine and are imported by the implementation module. **Outline:** -1. [Features](#1-features) - - 1.1 [Fine-grained, per-weight materialization & gradient reduction](#11-fine-grained-per-weight-materialization--gradient-reduction) - - 1.2 [CUDA graph compatibility](#12-cuda-graph-compatibility) - - 1.3 [Low-precision gather (native FP8 / NVFP4 param)](#13-low-precision-gather-native-fp8--nvfp4-param) - - 1.4 [Composability with TP / SP / EP / DDP](#14-composability-with-tp--sp--ep--ddp) - - 1.5 [Opt-in, minimally invasive integration](#15-opt-in-minimally-invasive-integration) - - 1.6 [Optimizer-agnostic (Adam + Muon)](#16-optimizer-agnostic-adam--muon) - - 1.7 [Scaling](#17-scaling) - - 1.8 [Native distributed checkpointing (DCP)](#18-native-distributed-checkpointing-dcp) -2. [Usage](#2-usage) - - 2.1 [Required flags](#21-required-flags) - - 2.2 [High-priority streams (Blackwell and later)](#22-high-priority-streams-blackwell-and-later) - - 2.3 [Minimal end-to-end example](#23-minimal-end-to-end-example) - - 2.4 [Tuning knobs](#24-tuning-knobs) -3. [Implementation details](#3-implementation-details) - - 3.1 [GTP_remat architecture (Mcore ↔ TE integration)](#31-gtp_remat-architecture-mcore--te-integration) - - [Class hierarchy: which linears shard](#class-hierarchy-which-linears-shard) - - 3.2 [DDP buckets with (E)GTP_remat](#32-ddp-buckets-with-egtp_remat) - - 3.3 [Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) - - 3.4 [Prefetch-chain construction and its design assumptions](#34-prefetch-chain-construction-and-its-design-assumptions) -4. [Testing](#4-testing) +- [Generalized Tensor Parallelism (GTP)](#generalized-tensor-parallelism-gtp) + - [1. Features](#1-features) + - [1.1 Fine-grained, per-weight materialization \& gradient reduction](#11-fine-grained-per-weight-materialization--gradient-reduction) + - [1.2 CUDA graph compatibility](#12-cuda-graph-compatibility) + - [1.3 Low-precision gather (native FP8 / NVFP4 param)](#13-low-precision-gather-native-fp8--nvfp4-param) + - [Per-microbatch schedule](#per-microbatch-schedule) + - [Communication volume breakdown](#communication-volume-breakdown) + - [GTP + NVFP4 (native NVFP4 param)](#gtp--nvfp4-native-nvfp4-param) + - [1.4 Composability with TP / SP / EP / DDP](#14-composability-with-tp--sp--ep--ddp) + - [1.5 Opt-in, minimally invasive integration](#15-opt-in-minimally-invasive-integration) + - [1.6 Optimizer-agnostic (Adam + Muon)](#16-optimizer-agnostic-adam--muon) + - [1.7 Scaling](#17-scaling) + - [1.8 Native distributed checkpointing (DCP)](#18-native-distributed-checkpointing-dcp) + - [2. Usage](#2-usage) + - [2.1 Required flags](#21-required-flags) + - [2.2 High-priority streams (Blackwell and later)](#22-high-priority-streams-blackwell-and-later) + - [2.3 Minimal end-to-end example](#23-minimal-end-to-end-example) + - [2.4 Tuning knobs](#24-tuning-knobs) + - [3. Implementation details](#3-implementation-details) + - [3.1 GTP\_remat architecture (Mcore ↔ TE integration)](#31-gtp_remat-architecture-mcore--te-integration) + - [What the flags do under the hood](#what-the-flags-do-under-the-hood) + - [Class hierarchy: which linears shard](#class-hierarchy-which-linears-shard) + - [Buffer / memory management](#buffer--memory-management) + - [Overlap design summary](#overlap-design-summary) + - [wgrad-before-dgrad schedule *(deferred to a follow-up MR)*](#wgrad-before-dgrad-schedule--deferred-to-a-follow-up-mr) + - [Recompute-forward prefetch chain *(GTP\_remat + activation recompute)*](#recompute-forward-prefetch-chain--gtp_remat--activation-recompute) + - [3.2 DDP buckets with (E)GTP\_remat](#32-ddp-buckets-with-egtp_remat) + - [3.3 Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) + - [3.4 Prefetch-chain construction and its design assumptions](#34-prefetch-chain-construction-and-its-design-assumptions) + - [Grouped-expert chains (one-block-ahead)](#grouped-expert-chains-one-block-ahead) + - [4. Testing](#4-testing) --- @@ -48,7 +69,7 @@ Core implementation: `megatron/core/tensor_parallel/generalized_tensor_paralleli Each weight is sharded 1/N across a GTP_remat group along `out_features`, stored as a `GTPShardedParam` subclass of `nn.Parameter`. Materialization and gradient reduction are both **per-weight, per-call** — not per-model or per-module: - **Independent state per param**: each has its own AG state (`state`) and RS state (`rs_state`) machines, both cycling `NONE → ASYNC_WAIT → DATA_READY → NONE` and tracked separately so fwd and bwd async ops don't interfere. -- **Prefetch chain for AG** (doubly-linked `prev_w` / `next_w`): during fwd, each weight's `all_gather_and_prefetch` issues async AG for `next_w`; during bwd, `all_gather_and_prefetch_bwd` issues async AG for `prev_w`. Layer *i*'s AG overlaps with layer *i−1*'s GEMM. For an L-layer model, L−1 all-gathers are fully hidden behind compute. When activation recompute is enabled, a **third** chain prefetches the recompute-forward gathers during backward — see §3.1 *Recompute-forward prefetch chain*. +- **Prefetch chain for AG** (doubly-linked `prev_w` / `next_w`): during fwd, each weight's `all_gather_and_prefetch` issues async AG for `next_w`; during bwd, `all_gather_and_prefetch_bwd` issues async AG for `prev_w`. Layer *i*'s AG overlaps with layer *i−1*'s GEMM. For an L-layer model, L−1 all-gathers are fully hidden behind compute. When activation recompute is enabled, a **third** chain prefetches the recompute-forward gathers during backward — see §3.1 *Recompute-forward prefetch chain*. One GEMM of runway covers a gather that stays inside the NVLink domain, but **not one that leaves it** — the case for MoE routed-expert weights, which also dominate the bytes gathered per block; those get their own *one-block-ahead* chains — see §3.4 *Grouped-expert chains*. - **Deferred RS finalize for wgrad**: `wgrad_reduce_scatter` on param *i* launches an **async** reduce-scatter (handle stashed in `_wgrad_rs_handle`) and returns `None` to autograd — the wgrad is NOT finalized into `main_grad` yet. Finalization is **deferred one step**: the next bwd step (param *i−1*'s `wgrad_reduce_scatter`) calls `self.next_w._wait_reduce_scatter()` + `_finalize_wgrad()`, which waits on the stashed handle, accumulates the reduced wgrad into `main_grad`, and fires the DDP `register_grad_ready` hook. The chain's head (first-in-fwd, last-in-bwd) uses a synchronous RS since nothing follows it. This one-step deferral is what lets layer *i*'s RS overlap with layer *i−1*'s bwd GEMMs. - **Cold start only**: every weight's very first AG is synchronous (`DATA_READY_SYNC`, no prefetch has run yet); the async prefetch chain kicks in from the second forward onward. @@ -60,7 +81,7 @@ Contrast with FSDP: FSDP gathers at module-group granularity in full precision w CG compatibility is designed-in from day one, not retrofitted. The entire sync / buffer / chain architecture is shaped around making **captured fwd/bwd replays produce identical bit-for-bit behavior** — without the usual capture-vs-eager pitfalls that force other weight-sharding schemes to either disable CG or require special handling. -- **Two chains, never cross-linked** (`GTPChain.GRAPHED` / `GTPChain.UNGRAPHED`). `prev_w` / `next_w` only connect same-chain params, so a captured traversal never reaches into eager Python and vice-versa. +- **Chains never cross-link across the capture axis** (`GTPChain.GRAPHED` / `GTPChain.UNGRAPHED`, plus the eager-only grouped-expert chains of §3.4). `prev_w` / `next_w` only connect same-chain params, so a captured traversal never reaches into eager Python and vice-versa. - **`torch.cuda.Event(external=True)`** for `ag_event` / `rs_event` — the events survive CG capture boundaries and can be waited on from replay-time streams. - **Idempotent ticket cache**: `GTPWeightCache.get(ticket)` keeps `slot.buf` set even after `release()`, so replays read the same buffer address as capture. `clear()` drops buffers while keeping tickets valid → supports CG re-capture with lazy re-allocation. - **Allocate-in-pool at creation** (`set_cuda_graph_mempool` + `_graphed_alloc`): GRAPHED-chain AG/RS buffers and quantized weight storage are allocated **directly into the CG memory pool** at first creation (during warmup, before capture), so no CUDA allocations happen inside the captured graph — and no post-hoc reallocation/clone is needed. UNGRAPHED buffers stay in regular allocator memory. @@ -126,6 +147,14 @@ NVFP4 GTP_remat keeps each shard as a native `NVFP4Tensor` and all-gathers it as ### 1.4 Composability with TP / SP / EP / DDP - **TP** (intra-layer): orthogonal axis — GTP_remat shards `out_features` regardless of TP's parallel mode (column or row). 2D grid naturally formed via `tp_group × gtp_remat_group`. + +> ⚠️ **The two cuts are not always on the same axis.** `GTP_remat` **always** slices `out_features` (dim 0) of the TP-local weight — independent of TP's `partition_dim`: +> +> | linear | TP cuts | `GTP_remat` cuts | | +> |---|---|---|---| +> | **column-parallel** (`linear_qkv`, `linear_fc1`) | `out_features` | `out_features` | same axis → `out_features/(TP × GTP_remat)` | +> | **row-parallel** (`linear_proj`, `linear_fc2`) | `in_features` | `out_features` | **perpendicular** → `in_features/TP` × `out_features/GTP_remat` | + - **SP** (sequence-parallel): transparent — GTP_remat operates at weight dim, SP at sequence dim. - **EP** (MoE): `GroupedLinear` with GTP_remat → each routed expert sharded across `EXPERT_GTP_WEIGHT_REMAT_GROUP`, independent of EP. MoE AllToAll (HybridEP/NVLink) runs independently of GTP_remat AG/RS (NCCL/IB). - **DDP**: GTP_remat bypasses autograd's grad accumulator (async RS returns `None`; `_finalize_wgrad` accumulates directly into `main_grad`). DDP registers its grad-ready hook on GTP_remat params via `register_grad_accum_hook` (not autograd's `AccumulateGrad`); GTP_remat invokes it from `_finalize_wgrad` (eager path) and `_CudagraphReplayNode.backward` (captured path) **after** the wgrad lands in `main_grad`, so a bucket's DDP reduce-scatter runs strictly after every GTP_remat param's `{RS → main_grad add}` — never over a stale `main_grad` — and DDP↔GTP_remat NIC deadlock at IB scale is avoided. See §3.2. @@ -250,7 +279,7 @@ GTP_remat enabled. GTPRematConfig(pad_for_alignment=16, check_param_states=False ### 2.4 Tuning knobs -Set via `from megatron.core.tensor_parallel.gtp import GTP_CONFIG, update_gtp_config`: +Set via `from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG, update_gtp_config`: ```python update_gtp_config( @@ -330,6 +359,9 @@ GTP_remat runs up to **three** independent prefetch chains, all following one ru | 1 | fwd | weight `i` | `next_w` = i+1 ‖ `GEMM_i` | rowwise (`fwd=True`) | `_prefetch_handle` | | 2 | bwd dgrad | weight `i` | `prev_w` = i−1 ‖ `Dgrad_i` | columnwise (`fwd=False`) | `_prefetch_handle` | | 3 | bwd recompute | weight `i` | `_recompute_next` = i+1 ‖ `recompute_GEMM_i` | rowwise (`fwd=True`) | `_recompute_prefetch_handle` (separate) | +| 1b | fwd (MoE, eager) | expert weight `i` | same role in MoE block i+1 ‖ *whole block i* | rowwise (`fwd=True`) | `_prefetch_handle` | + +Row 1b is chain 1 applied to a *homogeneous* chain: routed-expert `fc1`/`fc2` link across consecutive MoE blocks, so the runway is a full block rather than one GEMM (§3.4 *Grouped-expert chains*). Chain 3 exists only when activation recompute is on. It mirrors chain 1 (rowwise, prefetch `next`) but runs *during* backward, so it overlaps chain 2 in time on the same weight — hence its **own** slot. fwd (1) and bwd-dgrad (2) never overlap in time, so they safely share `_prefetch_handle`. See *Recompute-forward prefetch chain* below. @@ -382,7 +414,7 @@ Everything else — bucketing, the reduce-scatter/all-reduce schedule and its ov **Why this matters:** - **Free reuse of a mature stack.** GTP_remat inherits DDP's bucketing + comm/compute overlap, the distributed optimizer's fp32-master + Adam-moment sharding, grad-norm/clip, and the existing checkpoint format — no parallel re-implementation to write or maintain (contrast FSDP, which replaces all of these). -- **Orthogonal composability.** Because GTP_remat is a rank-grid sub-axis cut like TP (along `out_features`), it composes with TP/EP/CP/PP and the DistOpt the same way TP does — no special nesting logic. +- **Orthogonal composability.** Because GTP_remat is a rank-grid sub-axis cut along `out_features` (dim 0, whichever axis TP used), it composes with TP/EP/CP/PP and the DistOpt the same way TP does — no special nesting logic. - **Zero-cost when off.** With GTP_remat disabled the gtp_remat axis is size-1 and the hooks become no-ops, so non-GTP_remat runs hit byte-identical behavior — GTP_remat can be toggled without forking the DDP/optimizer code paths. - **Small, auditable surface.** These three hooks are the whole integration contract, which is what makes the correctness argument below tractable. @@ -434,7 +466,7 @@ Because the offsets reconstruct the global shape, the checkpoint is independent **Alignment padding & cross-topology reshard.** When `_gtp_slice_one_param` pads `out_features` to a multiple of `gtp_remat_size · pad_for_alignment`, the saved global describes the *padded* shape, so the helper sets `allow_shape_mismatch=True`. DCP then tolerates a load-side topology whose alignment yields a different padded size — the unpadded data overlaps and the tail pad rows are zeros GTP_remat recomputes. ->> Note: Mamba's `in_proj` is a special case: it **all-gathers its GTP_remat shards** back to the logical TP-local size and strips the pad *before* saving, so its global is topology-independent and needs no `allow_shape_mismatch`. +> Note: Mamba's `in_proj` is a special case: it **all-gathers its GTP_remat shards** back to the logical TP-local size and strips the pad *before* saving, so its global is topology-independent and needs no `allow_shape_mismatch`. **Optimizer state.** The distributed optimizer's master/moment `ShardedObject`s are keyed by `dp_group_idx`. Under GTP_remat/EGTP_remat each peer owns a *different* master shard (the optimizer shards over the gtp_remat/egtp_remat-**excluded** replicate group), so the index is taken from the gtp_remat/egtp_remat-**merged** model-parallel group (`mp_group` for dense, `expt_tp_pp_with_egtp_remat_group` for expert) — giving every peer a distinct key while replicate-group ranks remain true replicas under that key. @@ -446,7 +478,10 @@ The prefetch chains (§3.1) are **not configured — they are observed at runtim **Construction (two steps).** -1. **Classification (once, at build).** `classify_gtp_chains(model)` runs in `training.py`'s `get_model` after the model is built. It walks `named_parameters()` and, for each `GTPShardedParam`, sets `chain_id` to `GRAPHED` or `UNGRAPHED` (via `_classify_param_chain`, from the active `cuda_graph_modules`) and to the dense vs. expert chain. Membership is fixed from here on; re-classifying an already-linked param into a different chain is rejected. +1. **Classification (once, at build).** `classify_gtp_chains(model)` runs in `training.py`'s `get_model` after the model is built. It walks `named_parameters()` and, for each `GTPShardedParam`, sets `chain_id` (via `_classify_param_chain`, from the active `cuda_graph_modules`) and the dense vs. expert chain. Membership is fixed from here on; re-classifying an already-linked param into a different chain is rejected. + + Routed grouped experts are the exception: their `fc1`/`fc2` weights get their own homogeneous chains for a deeper prefetch — see [Grouped-expert chains](#grouped-expert-chains-one-block-ahead) below. + 2. **Linking (lazily, on the first forward).** The doubly-linked list (`prev_w` / `next_w`) is built the **first time each weight is materialized** inside `all_gather_and_prefetch`: a class-level per-chain cursor (`GTPShardedParam._chain_state[chain_id]["last_weight"]`) records the previously-seen weight, and the current weight links itself after it. The chain therefore **encodes the forward execution order of the first step** and replays it every step after to predict the next weight to prefetch. The recompute chain (`_recompute_next`) self-populates the same way, from the weights re-gathered while `in_fp8_activation_recompute_phase()` is true. Weights that must **not** join a chain (embedding, output_layer — they all-gather synchronously and run outside the CUDA-graph boundary) are excluded by setting `weight.prefetch_initialized = True` (and `_need_weight_prefetch = False`) at construction, which skips registration entirely. @@ -469,6 +504,36 @@ Weights that must **not** join a chain (embedding, output_layer — they all-gat **Rule of thumb:** any change that creates/replaces params at runtime, makes forward order data-dependent, runs GTP_remat concurrently, or builds multiple GTP models per process must be checked against the table above. When in doubt, exclude the affected weights so they fall back to synchronous, chain-free all-gather. +#### Grouped-expert chains (one-block-ahead) + +*Problem.* A chain gives every all-gather exactly **one consume-step of runway** — layer *i*'s AG hides behind layer *i−1*'s GEMM — which suffices only while the transfer stays inside the NVLink domain. Routed-expert weights fail that test twice over: by **volume**, a block gathers `2 × num_experts / EP` expert weights — NCCL-coalesced into just **two** all-gathers, one per role — so those two transfers carry most of the block's bytes; by **distance**, `EGTP_remat` is the group that leaves the NVLink domain. The expert transfer therefore stays partly exposed in **every** MoE block, and the exposure grows as expert count rises and per-GEMM time falls. + +*Design.* When MoE is *not* captured, `linear_fc1` and `linear_fc2` each get their own homogeneous chain (`GTP_remat_grouped_fc1_ungraphed` / `GTP_remat_grouped_fc2_ungraphed`) instead of sharing the general `UNGRAPHED` chain. A homogeneous chain links the **same weight role of consecutive MoE blocks**, so `next_w` points a whole block ahead rather than one GEMM ahead. The roles stay in *separate* chains deliberately: merging them would link `layer_N.fc1 → layer_N.fc2 → layer_{N+1}.fc1 → …`, so `fc1` would prefetch the **same block's** `fc2` — one GEMM of runway again — and only `fc2` would reach across the block boundary. + +*Result.* The win is **resource overlap**, not faster compute and not a faster network: + +- **Runway** — an expert gather now hides behind the entire preceding **MoE block** instead of a single GEMM. +- **Utilization** — the interconnect works under the dense window where it used to idle, and the GPU no longer stalls waiting on the gather: both are busy at once. +- **Cost** — one extra buffer per weight role (see *mandatory double buffering* below). No extra collectives, no change to the math. +- **Bound** — same transfers, same GEMMs, only a different schedule, so the recovered time is exactly the transfer that used to sit on the critical path. + +The figure below puts both schedules on one time axis, aligned at *block start* (**top:** shared chain, **bottom:** per-role chains). **Shaded bands** mark which resource is idle — red where one side waits, green where both are busy; **dashed arrows** trace each gather from the GEMM that launches it to the GEMM that consumes it; the **arrow at the right** is the recovered time, equal to the two hatched `STALL` bars above it. + +![GTP grouped-expert AG prefetch — one-step-ahead vs one-block-ahead](../../images/generalized_tensor_parallel/0725_gtp_grouped_oneblock_prefetch.png) + +Three consequences: +- **One shared stream.** `_stream_key` collapses the fc1/fc2 role, so both chains resolve to a single AG stream and their all-gathers serialize instead of splitting interconnect bandwidth. The capture-axis suffix is preserved, so eager and captured ops still never share a stream. +- **Mandatory double buffering** — this is what makes the deeper prefetch *safe*, and it is not optional: + - the weight cache keys **one buffer per `(shape, dtype, expert_idx)`**, which assumes at most one same-key weight is live; + - one-block-ahead makes block *N* and block *N+1* weights **live at the same time** — same key, two tensors in flight; + - fix: a chain-position **parity (0,1,0,1…)** is folded into the cache key, so consecutive blocks alternate between **exactly two** buffers (counter cleared by `reset_gtp_state()`); + - without it the prefetch would **overwrite the weight the running GEMM is still reading** — a silent-correctness bug, not a crash. +- **Eager only** — the optimization disables itself under CUDA-graph capture: + - `_classify_param_chain` evaluates `graphed = _FULL_ITERATION or ("moe" in cuda_graph_modules)` **before** the split, and returns the plain `GRAPHED` chain when it is true; + - so with `--cuda-graph-impl full_iteration` **every** param is `GRAPHED` — expert weights included — and they keep the ordinary one-step-ahead prefetch; + - why it must: `cuda_graphs.py` drains with `wait_async_comms(GTPChain.GRAPHED.value)`, matching the id **literally**, so a weight in `GTP_remat_grouped_fc1_ungraphed` would never be joined at the graph boundary — a **correctness** hazard, not just a lost overlap; + - lifting it would mean draining by chain-id *prefix* (`_chain_is_grouped`) or registering the grouped streams before capture — neither is done today. + ## 4. Testing **Whenever you add or change a GTP_remat/EGTP_remat feature, run the GTP_remat unit-test suite below as a sanity check before opening a PR.** These tests exercise the full TE↔Mcore path (weight gather/RS, DDP, distributed optimizer, finalize, grad-norm) and catch silent-correctness regressions that don't surface as crashes. diff --git a/docs/images/generalized_tensor_parallel/0725_gtp_grouped_oneblock_prefetch.png b/docs/images/generalized_tensor_parallel/0725_gtp_grouped_oneblock_prefetch.png new file mode 100644 index 0000000000000000000000000000000000000000..5cacd7f20f9c9c35831956c5fc69929166cbcb43 GIT binary patch literal 173139 zcmeEv2V7H0*FL>>kQO=uK|m5p072mcv zu!@RGSFnY)0wVlxAYrlWzWaXP?&|mcUw40yJ9qBf$vo$rIp;hxL%gZ6EGW ziYwjS8lg?M09`M*hpUl00*MA5Lj-I;1cgA9wi`kO5=vkS+a)eugx7DiDg(h4n4^O5 z?}Gxp#r43&7llB2;~(BG@^N=Z`=C5NEp&3nV31BepHzfyw9Y1?En0^p~ZUD1?tIzAjV_-#=V+JtWG-75{2^Mf?pnIH5AWz}pp$aQEMS9$y4U zz&(6Wen?Gsw7VC+qi|mzcii{cHbazAgcm)E(kAGa2AkTO`kd=pRzt;aZp^}kT+%9w7 zB~MT&panQZfaB%EWv6Xi>7RiUsDqb=e_LqOw~(@T!uR2?082wrUQQl|=O2QFamN5s z@sB{Gh#N)RSnil8xi6rB%6}fg>**xI03Uh&VK@Il9aa((nAXFii8967sSsSU+RcEn6aFI*%s*q6yPQz6yTGn<8Qd35D45P`H$E~ z*k|SuZ}oOfTuP9y=%5VVVty0r=FFzW+9J-pmK+<`H6$G~wu z6V3yJxFO-*zFvTrAOq3WHZlT!;U-()rWYLJ?F`@od_>{gV*mw+v4tTDgKOBy-OcT{ zcHP}jK0Zjqe&B`{661|~)s`m$fdj=D-1~4PGPo8HxcgEbIInT56%St@pt>e-VXlp< zg!9xPfDgIdBZ8RX?rWiNFWl~&rW;^(@RboLZ=g9qcnLRwzB!^WNT4_WmWX~;BzMa! zULyWp59%{9B>3O|QVj8;`Ax)7-5w2h#6?87@!n-}G_-Y0j%=Z-Eh7R13`&6Y#90^M z=MKV7u5c7!1aNjoT^CF+PtHzIaiC-iag?{W@0NJFW6(HP6OJ=m=Gs!uICl*v7@s^R zgL`gwO5IjX16rpq#if!yBz~U z**B5~jGsN)@k?>gLOR2J(ExgYyCWL_2Zj)cT#&dB9oPRq4GqqzCQKb(+Y#yS&m6n; z_bcHY!L9u3A|NF^96P?;myjuZ1u;25HE^TwjSxdQeibo&K*)E%0&4>Xex2>w5 zCK+H`|A0GNDz=@_f_|$!0~GQlW-+V?D#UMmThKVTN;i{z|}fbYwI&zXq5V zOalg_V?G58XH2)>|MyO0@`U*H5A32WJ`Dbj@WT0)Um&*Si2i>7nd0YZi7$0b_WPU4 zynWzkG+|!<^Sy4iD;gq_ns^+ZQUkm6^k- z@m~a)&iCoh0+j#1HniWNcLW6bC|pkcb4!bh^Z^bXcLyS}D_iH|iYxJVb>F%}NbdTh zFgT7MpP=)H18f1F=l&~D$CIL8(Ar8{{{Zd%gx3Cimfh`7;jPEt8`^wH5`AfZze*B) zWBUtt+3|^Xg`-=1(5^J>wxs+OumVZH@6>6MCuDpG_Lp$IE6)m5`u+?s4#6*{(9aP3 z|6&IH!tMPkg7~5Mj;2t6-*WdR*v?&As7)w`Y#HV){%qS6J9(kDI8wm);z=~Hqx%+_ zhL3O|j&LvUe>Xw9oBP7k8#p`qwK#OQef>u=>Yv+J8NzajUD_%4HEI@$*Vu2AQ}=fT z*eXEeb|?F!fJ+QOl-uu2p4+IR+}x1Bf<+t^3i0-Jazc80yL$nj0cuPJ;_U9_5BI_m zwC-LA93hJ%y}WVU?e@b2k`&?su&P^xtrHxB0T$2UoG|zn%kK-2uv?7p_H@3z@Ja@9 z!q*#D%Ml5@0{3bZ?rMv41J)UO`vQFeB51f9E=7!S1TtZ`Tp;T{kc5V$)N8h3FARyoQ*d@;U&*aHmhc548syQ{%3rkp=sT7aYK2z(G;c0XT8 z;N!mCcN`l3tTP}(2)uR)s{(NI!a3+$Ec#Xi4cT(-AYczKq#uqDN5g$FPOe+3_X%5$W7%=m zgzykf0{`VhIEo!tahFs0uQl}3Y2yox{RZM8O9;>Ym3R40^>8(}$u_I%%v;S8nypQRCi%+L1v6@sb!%o_f0Qj~upME)fG@!Rrp zKsP?&c9nq3f5NOn39FLuh<{BGyKhe<#F$B8mOIK}^`+^#>3W{HNVv%s0(xYS{mGQ54RW{Q+j*-=ZiD z`~NPA`UAkeyG2nN_WxZJ^~XB)J=(?pE|9_RQ2l&~)Bh1~^}mZH2wRL{gn<7KjOKUf zeE$EuSVDoo#QXtd-=Wd`FF{Vwxz9uVuO)T9KZ0Dt{=Wn{0YO;>Aimud8+;u>d@kfW z3g~cInlBPGNch%nPy8wsz!}@#MED8Kf%3tTBkuk<0(FZVA?!Td+QSSKZgZ2k6d9hq z+}V=2MY$8Urr~bl+VDntp#YXKXp2MHW>0X)0KUvG2V1o80!R{8^X@H{#0b-;aKE8|z`@gu$%e>iUMDj>4JNj(6i@8u5E z#qWj1Z@b>wjSUA%361RZfUx`y|L(7Jaf|tL1-8p?$*MmfvPggf2in@*Y5cN2C0qvp zcVv6#HN@T52MsLX!+!(ZBRdurkq z%WWK=QrCc3e91l|5x`ki_=6>Y;|*NhU2q2+X#ZBadx^p)iPqnk8r(vLFIF$?Y~}cL z(E|=|WB|&?3fMsj*)N0J9*MiO1}<^U;67{N!cCl&-@3s0y;~O;;IyReryyHT;jVFy z?cDtBIovHR!h>6r!}cCaC7@B<<*v0^JKg+Uy2sny*K2FTCs5-z>H|O6z#1VJB(RbP zIC%lMxy$Rtjq7&tR!!MIJc|pbpupGobc=!)5)H?l{I#oc2)HcY6763M3w!F9i~R<3CFv5lD|O%^h$c0s@#xKa;0jORPSzUq}o>9e4U4pkLe0 z0bp=22pwQUB2e-_HE9GJwquz7j6M6roNcwRWx#gK;Qz#&eZGtjSm^2l_)UL6Ykz9z zcA2tIMd~-A|36Nm@S3vAzWv=2wWI96Z<4;XMD29(uaKy(8mZs%v;RpF`?Sjr;dk%+p2hmY$J#kZ;#2X#iOlcL9{=DN{~4#}H`mA19R-{m`n$(r0=vex zzZ1d7-nLixr*MOeJKpLqeWe^>bq?W`+m`Tu@Jd<0{{c?e+}VKo$KAx&-a4K2U)B8L z5LZ8YxHE7hoIM~`+vD{&$=;6R{Hg5WJfZ*dvZp{GL2xky@NND9+57yJ|3_tSJFoY* z3*XMk4*!9|2Za#UE&OxR_r*K^kBZ;cl>61=xCpt&U6b;k&Qakq41|pH&VieswK z;@!Due2DV*=AvZ?d1k!05LTb<%31B$uTN6Zcvgcjx&5Q3MiXYKubp*AfNE<7gMha- zb9|Y&`r~aAe+IIh8SEd23`W4{46tg&?<<>Rw~nO+()7RG++_`SMI=rDzu}E?0u11m;UpwXek?qimv-B2vyAHnl6wTky(*FBF=>L^?=U+O2e|^03pE2vJ+xu>9(ETjV#`9|?#@aw= z=!L{*w}8yEyBCgb+dc{xM;UIP(2P4ocPl}SJIDifpTK?MNlsh>`#0i`P-$zA@iw)$ zwT1l4?BgF#Q@BW-;8}e4d&gn;?9DF6;;Y+nzp25~__{P`m>ksu5X7?&041|!u_3-+EyCmcm9$MA))fypA_1iDA~G8_#;N!H}DVO{_N3ql1LDefh8hhBGT7V zKN>*%`aEUc4;{;Q`cJv5SHoNQPNHl4S&isK*-cS2oh+L*5j7VoDieycT@4+ptV%ky z)Mt;#C3M;qcP4O4{JJ0J@c!xMyC3~sZmGO|a>||d$-R)7#vJ8SA>K2+y*Um3-ZP;o zVZviniX>nB8hFI~GV_ghsZRN83-`>g)lnN_#%A)86I-6}$;@8}I`EY?_?WQ=lt|p- z!X)G$GX+aOX7(WCA=YjF?bl6ql4r+&0#|D?o67sO0YR8AJ0e3Buz#ymE&*tVJGx8w z^X|UkC9W#i>Y^TGBFP)(@Y17|m$jd>NbNu2E?Pyi z%r-(P)X(%qgZX;P%lA1s6t9 zFG7OU%qTXy8p4#UOg33+2G0KC9AgAqrrQH1(-rDv;MkU%Hph+Z*Vc^8D_h zij+7B7sq-xrgdrk;HHgnxl`4A@0mYZv-N_N25E&4UQe)QL)IB|{H#}o)wx*-nk`yc zIz_!o&X+lqGT6URysWj!4u9~lzhB!8Wnr@Cp5jfVGDNF@Z?Dg}dX?(?AhEA(Pe2|zX+pKX+ZD7o= zr*kik)J0qFjUyFxkhOq!CPp-@QS4BQ)m8V?juXf|aNkbp@Hx^~8qY3Y ztuYOQQyw^)GT?tZE%(r>>92{!AGN);vYYSLC&u3#@LNlM!j`co+|O1L6q0c!`|9#x zl{t^b(MGRpSJ8%=*>vVnZQ3o(S(R?Dkh5nvSD2c@>K*$}vX4KiIDO}g*QQnGGgs-0E&m=|d}RH@Yy7*KVpbbB#`+ z2kI#KX!h6~W|*T7S>kZAx1>Imgd3#F3;_9j?o@8GKkQisK0uQp@2u$`Kp;0y(9| zaKAv%9kVyOjgtH!sOeOPM8Ohs?Hp(Hh1JL!vi>&y_HORTX)MsMoWCCeaBcXR?LC=&kl??ViZf(t{=g$tK2O-(ia`!RB`1b{hO7)P< z^*F~{rpKA&Nv@j(^XfS8&aVyT`@WS-q^Id%!%Ei028lgk(=zSF&Z#2^H%n&!~-ErhidZ`QxJ>H!`@9LDDmJ;&AQhOMwf^T;MOFkmBshyKpOn^PY>z-X#L0UP<}M^;EZ_x6oRLU2^C>%`ob!Fd|?XXTs_0hE`#{k!_qs zNwWuAx^BCya)j);SFYZ@AkCj=l^+}PhL$m5)6ZmgcFN}7f5Em$#4MAhV*1v${6!el z&C#&6Et7N;dZ>EOUQnRTuS1dI3r7OcU*QQwIKs`g#WQhJV_qj131P< z%q}NYaPh@UDjy8lh-ox?Z5RW_IM6!SbFQ0OwSCr%s;Yg)r@)9V5eXy8bB?88$R}-S9L77!G>w1m>>ZKwKDV#D21}bOn=etCkr}qu ze>NFj)YF;%tNP0PG5W;Aum}}znpBx^Bl*OW>-i5E-yn+ixs=2-3JRU=DPWbG)8Xl% zlx1H~F@M0Jc%=P~Lra2oDAu_sB{v!hTSl;Q9@4oJ0Yg_%#SX5N*c>D6KXAYphV1H* z!&*`axCPio1`Wk9b&3mO=GH|Rm<<>M*os52FZmXv?+hrQs~;t?6fBHDCK$AS2B+q- zN_Ss{8Y)IlPuZHWl0G*#cA6bQ6bb0D%u%Wji$j%x-RpMu4GW;f(|4pjE*ji zogD}bWGZSteFkOvGODTEewOmF31aP>yG5ZXZLUTp=S7J$dY)4vN7=!P?GvMM}t z9o_=fj=gNDFPQSLw}@e>diG=z!58(EBQ+rBDXcYbNBa$xkB&J=YPL(s=eN1>PMC)F zO#W~vU-R~$uDV6BgQH5(=w4&ZcGM{<-3OCp`6Emqi>du1i%IJd1*Ds1`!_nsbl!x= z4lYMXrw?_hjYhSYSSQ4Ky-H2ewAQf;en=iI+C!c{Zp)TaPs+^4#2&P^K(D}eqP%yrz;$1 z3|qs3{A@{xuUv_V31R1{wSIl|kpU~M68R5k#p&?_$`8+(?T?CIVaK{>Vs8(W==#Xf6bXZh57y zR8Jofp?$Bty2#+pIE_O2OrDo_q)`0P_iZte63@fy`_he%?+ZO`U&_z^s8&)k>5*Cl zafolG6UN5<+K6Sq-vPjc*BS@ zG=)F+kevuzwnLUoXT)@DXvs|wCV<*#H{xf$E$BFI0*V8{eA=I<6tER_724W#xMV!% z6y=wYs%q~tpeuihR^vW=C)7PqLf`B|TOaG=?vr~~OkugKc9jmY?anlg-X;M87$VQs zyKaJy<3bNGfb5zU(`Rfa$c`OPsaDr{)G3;j>~+kHwX>{PbjD;2*;_NKkzS}f`J>7R zy1mAXE}VYy5SB?Myze-B6>F9c(+vjjq~}3wfzOx`mgI>*RWpq>vxmje$z>+v!f}cG zqPmGVm$lo&0W+|}oS`EQ;VkrzNKDqvxtgU_?&fgLomHF*8Cmj_hzpsFu{eLj^XKBc zc|&`d7&C4tg&8&swCW*Dql}gQn0$n>Q~+e$$}QB%t)LRedT8))P`+9FDIwi+SzHg% z)#J38y5N2`q~MTU0N57VDP4sXa3_^+OMGsq`;&Lp@y&O`9d|Uk?@AmSz2sn{fd#|E z>&2r$IT14QqZYjrp?1vFA2%+s%+tV+>61uz{rt?MBvrXZcjF^|{-(JoEqOrt0yS;%Nvv(6);O|?eRaQ=3G z!`guJu!RIt&oNz$X5mpHO5+j~KfEKVRdmI3D0-pQnI$xnXC$1PyZ?#A5zf#S9pbw> z*~!8Qny^jy)$*6LTvH}kLkX&YTcXKM;(^5Zjc+xq>qK6i5r&OthNEf4Hk0nu1q-Pp zw$e*sQVT1n+~ARh!EE{HR!>>u2HyzBbMvilnu4cS`co$6Pl@6tzS3w%TE>_5Mk1jUI_44mODo-DdwNopwUD}$OQ~j-qv}; z!fcf{KS$m^D=HQ*Id$00xHTq;UaRZ0wn9%NHt+h70x6cU{2BV(Q0}|xW~cl8&M-8I zqLK)2J3Z}dNrQr*a*k{0{cVn?ee&YGL3@qz9Lr7K4T2=+^lz%=8F~wG#~J;ov>LmV zcY$01maFmFk>+_?vua}u8H_k|z2VGh4CUJi)_0EU`j!mg=5cq*;&YWvu}+#GW39GM zGCr#EQS?&7fxfbgp|!LtP-V%1c*;;y*xMVc`u9JGD?AsVN}YCKnSjaj&bAqm!8TPi zlSna#)jFyQs%J%w&BgniP}62XJY|8AUN1>sOVW6n3AHG6M4c8&eAu1%WN%&iLo$u$ zfGdX`qn%(g=RSF8#@dFDCc@`jOsDJ(+18ad9eol}Frt!DCOMZ?xl~rti?YKy-G3pw z^PH!tjpY>i^S4wALMs+jJw|Ne`V_vZ>Sa;$i3$mqmSjV$qXvamyjiIfCJS;!>bcB= zC3EOu3e6ERbb)*9pn`Vi2H%iWF+E^cVe2t=Nd(@Tz;4?+D6Hm?p+e4wvi%SICLX}L?((HS?RUHM^RtRHSt3tQ)-{}NWl0V zeNa`eM?zn>Ogp7vn;Yqbhhv``^OZf`C&_QCjn<7_%Tx=tsm@#1H?a#wuquGq(3lfZ z?nkgZaj*er<5K-tRr~%I7IYXYZ8kS}M#mi;6z%+B@e6txWp%CKHzoy9LA4EIrJ#TS?n&4wKt9)5|ECwJQZ*`;vFs4#t`d?72aQ!EBD3|(W#|rKtFM&c;H4<)@cs)qC00oGnMP4!GP;A81qCl zZ_0Zp0@|VrAvIwwXM?`2pVbO5JyBRq+rw$+q6Uv=;l5P=s97{`1uML;$N$`8TE@~> z?a9)xN${n}UO|6&?3)3Nc+Eb7W=lXf?VOcnjRf*zZOUnYrb2Ma{Am zv~M32?Wgp}W$|3VOECwr7aC=i*_pX1=JTdt1#eT8>K3{XEX&8{8yKwSy!raLw*XIfw>TT_?lgg{11o@I+^>jdTe8jGKjma=B!5vQ~!z_kvJam-Up((i;$ zCOpJqy?1*W#D&HwYSUC62|eN}pmv`V#uF_z`@}-oSc)#^VZ%WQ zR12ktY`X98#(AN<14Q%a<@+UXlCn!8G@J5F7f+E+3}670uW+)9ztq^1$9O%0Kli~H z11vFc^a3M@8Ags3{-|dq0GXNMY@!aKe}p9mF)fJl&5p=b$G=cA_55%yL0@ThML&VvHkfe)Qj{&sn|lHyl*UMtL+QQx>Q z>69IOG4h2Peew$?F}KA7EIlmiBjClytUdbqf(T+irv--I9FzHJDji(>YKbj0;gl(3O@S(Lw2?!|_if1Z``c@&!;O zY2IpvCNr+z2_2^yZ(%_7RH{}5hbh-aEhSk3zU<^>LFsjY{Mp4qxxFFcNXc62XM1_G zy*0sIp6jib4~#MeI=PrBIWYxSD9j$7=j!PKkdDmGs5wsv!a0E;d=GLq9hXuC0&*8H z%&M+d-GTY;j{`QRoJ*R|SWXO!rA0#41u+M@P7=4hm!)ztzpbVw%qY3C;zyJSHdd{q zq&1q;8pP&9Ed9F+wD~tsjv0DC2HeP@r+W88hRaADYgUIquFsV!)_XtvXxAcW633{` zBF*H65LUOj!ZCEn__l`P3ibfC<$3Xp+W3rThMsrB-w&@gQNk=I%+yTz-p~aifou`~ zT70AFNE#&&6biz`SI8$$ehkhc&TAfPWD>}whgdPCXzXoQr`F0Yw$fZNXGgUVX*0|Z zwJRL2VAZlOZS%MqCq_51CLhA2awjxoj5OZn;|qyRhhFi65AVjCG{(fG!df%h%lQLJ z!L}4P^t!}GEvN(UzwLy;Bh`*ooc3wgCTZx1(}8?!Y27*9zt8lxE#Nk1}VpJ}A{QKM-f zDoP1Ms2T(q{`Ka3m@Y^>{n2sqiCA?pNw)clkK53Y6#6%-D92{ zu?kBz#vAmd(hQTwk-V1~&Y4?9X%FvEOpT%7|G%+uQQ+yo^BIAh~-d$L=~p3)2B zWamP_9UP@BgICHNju;;>_HIe4FA9cbVQFrJ9pk=hs#DWvRoLG^J~S%x=t8*c4WfO0 zi4U*W>+5`2t#|i4*g}D1IF-Yo*+6WU^h@VjmSSnA_ksQ~3ecMB#Y2L|4Y0=1?8d^7 zb`;oz?Z9xQ{1ogX<=L*utRJZ7Brlqg8Uxvp3a|^Ah)@%T~yp-Zer=@%&tnfG>Mm05HNon7ivD^dd1ZHTHgD!4PJc@ zCmSeiyLGY0exe5-36K{!)pw(G!ESifBQgqzFzxowEj+1JX=+jXdFZ*i>)o}0M{bd7 zF5LZYk%d`vxP-KtHxsrFhRKjCh1ngYpTKjZlz9HkhD8~?zSz!SfbomO&G0l5C|u&t6fN{&6E z{UF{J^Mt3Qggt!z8#&4oekB~Ja#Kh3M+tn}27+T7yW3DC;bq4$o+DQl69DkTgsWdN zV_AOd$QHigzvABX*xWt97S}gf!7;Zj;&T`HJmD5P!0242t9E$@48^p-m?xoEyZG7) z9^hR^Q*z2Z$R4v3OF!M!)ty=b>OecxbQ?p~%ve7>Z}%x*>tjcDGvP>yfvwf(1HF)^ zT9^~e*yj(_1rpy6ib!3FUDj&P_@%V3*7oM|vz3&nuzp_N${XyLBu<8>h`KMH(9pej z^`}w9)yyMT1da_mn_Z?=7Gm6=&}4Ni8VxSyN1Snsdwy&(GI+MCR$?fE`AO~TOU|bJ z)%R$;Rdoydm|f@8KE|HlD|bCRs+@F=fPn&A`S+q(~TquYtYO#1$P zA-d&LWV+MCl}ExpSoliqi?+A*=y>30KwNN1QrQhQ*hXY#2?>9n@Z@HK3Wn{}{vbO! z`z2j7NN5`A>jdfz3f1*0)4aMO>3LP_{ONYHy69f@1tpVA*c2#W@0;gG-u{w#U$gjj z;yFjxlVj!PnJtfsAIMNjI6-vsyK=I9mlOFH$UpoXWDHu%xgH%LIXWoWbUK}o3m8`& zXd5byp<8)9Y)Zy>k}X{BUhKYK4TKC!*Poil&%}=eboa=8=;E-WT1v^^+~;TpzNnca zXMyrBP1c(neLPuLbn|?LT2Iz5SB#EbIe+0?zW9lE6kMDStc)sOl^MucFU7pHWj=X2 z8R^7*j+NS-`qT!PL#?1*s72~gTSn3tsi2BK0GLvTZ=q{ zj8ks9lzm2*=48FWlgC#2(@AG?dJraNPGxLYl1nej<@xr&xZ;m-=Ozh_-eq>>2`}3x zZ&mlM^!3k)XIT#la*MR0s|sSZua&Jof5Wy+Z01duAx;85PnpuF4PCj|1Y z<9^5N(QBbUFpJWvuICy^>R4qr+Ge(F1jWQ6d{1Nm(S&ABZ}z3;=wxPAmh3{N(F^-0 zZXo%^Gz2rt#2093V%&Rp>5%8m-(xFYdrg2Jla$5g*RuDc_{=id6)3@Xvb2AUJLlNE zA3NmRGu96P^W*_C_~`lMYu7^3Y$dzK-peoZ(0#10;tP>}d?bqGmH8voqW!_wQu~lE zIeN73olThBNET-DaPGCOd_6V0$oHT$Hpm0oj;td++> z+lr`qHrkew)X$zJAf(}O;v}EpqNVt2?hL0hDNYRJq7?vh;81_L=U8xz%80q7^oM=- zt&6?rmxHTEyY{q4inVPN4PJ*?(5VazSix=UOJr74E1((CC!J9H=wFDwSuw{0Us=mm`popLNKmm&^+7F+sM=$dqZm5hl`}k1=8Q0=Ai4Fcb1|x7s_i25 z<#Fw|tyI-zQx?LL_gZagZ-la##quKB+#O7KtJ^Bw+27qNUqP`NkEUe3a49~)T)vw3 ztoKarO=yZ~a9Zd&PH)2)@Vko0VzHS!YxGXc`TN9DPD?s)atu#i&XiW5BT4m;VjES; z30JLfVZS~+ePWt;KX#yMS_cL(PPoJVQBGrAlBYSPL-LeSF6jfB>yGR5k%Q}Gw`;&U zSIj6MlWewL945|DKb6Kme1564^CgPr0;ZN70yc5obXXOlL~_yYV@KAfN1{q8Ry-2s zemgHjmlPQVUG2e8dfsju58wWUZ(Zy!x1~~jB@~-#%q|n7Or~N_Eh*~92 zgtD0u9nZL#@qANUEP(gYT6eOz%cvPYq|9cDZdt_bT!rT0uEPs^s~j6FFh4ePmBQOsY32>eWp;Jj0SdpLGN3$Hl)S3tJGbBYjq4 z|FQ(Knc1pzv1!U){Jvkn885)k&1=}}(6K^`@emF<+bGr1hRVwUwJA!8Y;xVUmf3sJ zR`Pncv;^I4`K8Flh#TorU0iE5nU})fU7cx=TrIsM`+;Rs?tbW!tP^)|;)}V7c{9m} zewNNjsI}N@>c@&M7q$!dThV$dQz=Cl3JRQjqo$h9xX8P#4y$D+EjgZ9af2#{#28Dy zafzdKEy`zD_r7FrMi+L8`yyM@h3;b8>F#qxfvtIQgUpZgbH{GP%xH;R?n<+Nux_Gy z<`_q7n?p3uN{B5=^)$WTFmr?HPkp5UrrodTeS5gglSpUugd&u@A(UpNYh_1yIosg$ zk*`gv+Pahz(=59#ODe@QI*!7#xIwcj<6;4HAqF3WPh!qe`Z}E=g&Lz24B%4&%hB)C zf427Cvq-WC@yzCL4YGS=flgygqF-<{cG|>-d!Z^W*IhmuYIqLw z_1v3yVIzglq@GMmUs$h&4Aa-dgAO1(*5Qi7<7rw3BdGR4d|W8l|U|N;Q9@^R=s)hKF>)O971CcBU+;k!&_Xn~RH0fvQURi=!C8HY%+YpvR4xrQvr z_GEAb))McPhFw87=B!7s2uwvskgM3I-C-AcmWy22cP}wmsYs2vx$>Pf+l|{)y@mI^ zgguzRby@n!2Wk~H2UueUB<{WUDxE}dm6Fdlo7lw=O&w)Tnq|ed@)LInWzzG4I-ie; zJ6^3WMz4is=&k9d>NIsc_KxCL(byc~FYOUJCD4N$ocbkBVLBdCN*BOzI;)2GV-Zhd zzUNIe1YUFeCye0*5ct`Q5W9jy&kQ-8b#wQVW_oy1arnmMiG2!OW2v(VL?&0$A}L?G ztOzE)XS?>S+RPzRE3`Qj;J)<&BI%(4ZOVHUbyTsQ0yGHPy;>rEBNZ)m>hN=K<#1NAISO}KzUKW0ufGhX zi7c>I5tPV=v~}-AQJGBg!z@?IS7&JhgH5zPRFWqz3<}dzkv&v@KU~=e%adKaq;{xPbC}gm zXa4{Lf^#~YBk{f*#Z+{o7OZBrus`PR)l43TlHPz#E6+5JOlAaa6_HHS9z=4oazKo^ zf?ci#s@KY~Hx0(*tHBQe6?XFJgzT}ul-;fZAdDkLI>kEgiMi9fdCsK=THjKfxou}2TG-X4dK%Y4|j8~h z!Zdfztz!NMQcJYuuY+uwAtulF^DhM3!&ZfLYacKyu}(4~L8sy;B7KI_Jqw1%zVhRD89 zY|xekTFVOZRL=4hT{OnFshYB1={@7b7B0Vb0}<8;9p%t3Md(@LWX!GER-7s}_nq5P1ca5Z zm|Ydrt}i43!?+W{O5Gtcjx8^h*s3y0bzTp9v^hzlLsO0K7A2X&NzE=HMf#3TCwYYjKl1TRhz{*agjEX?$=2PWLSG3DCx0{UN4|kh z0CNa(8<#e8#ye6-LEnX!FN*93F=I$QNO-W5d&3%Yw5(SI(bo0Q_}Z7qmb)LW*px74 zLyH6hs7u}rpf6fez#PJqnYdBx`y~;Nx?IlenQ)Btnw5AQF%g}-ud!{S;AWu|g)riYcA@EG-5!%K$Omsn|aME7H(EnB3SP8r%W-J96MQ^}EQBWldXGkhaYG4s6W z!q`%>`E*_AIo<3_^`7nVjFWY2*Cq2zy9Vwwf({?#3U#nkxjrUd9m$^~ms8J`D#<=; z582;Yz}jefM^06cws}t}*wco8@n8F5}8!3Sep=ev7V4`w=TH6J8f?x>TH9 zB*~uCrJBi8Iz_audC*J;V$s6C+PX54Nq5_{DyEb7W0(!G3;QIvGTo%eZlp`T)97&5 z-LtNYwHbk=Io&gmzUS8KM6fZkrY?gio;Uj}_KkB;<)r2|P%1MesxCO70n==JEM!0$ z;JyYqUvkuaGNg+GC8JNslD0Sy%XYnhQKxW|TU8YSxQW ztWR}!@^z3!Y2+`|&deQ|6qb5u8ez&fgUIyiB3luvV+M67dW+|3E=-NZUE})LJ`(es zD;KLe|59u%&zC1q@Zreo)2g)zu&Z`z?0c$A(%h301xi{~sPEOW23LBsHw|B7Gjm69 z(O4j0Jx)_X%h4{)VU6i`!#Txf*L$Y@*J=YcD7_y?_e$7tcrmG~0?jCeO+-izo35;|+u3m>gPkxD14fgC=99Xa5KfGo0jj=xy4={T2geXPHUL_~CvC6KH* zWF_6@T-r%N2ER&qo@LjH%cX0i`DOhHFK#bcjSGflEP1@F}$MQKg<=_foCg0Zy^UR0LV z4>7wQA~7;M;X#tL5Z=uMzuS7Anxca&mj#xaXDSn4+gx^5a)#NkK8L7Hg4$5cunf(f zz1ONP&fbo4jF{32Qygp2 zqNI>)wZgWVY?&5bDv`ax{TJBRBaCdDvt3rCus>9W=<0_TlQ-(MSxr(v<+7zT7_y-l(ezcF*RA* zGB3TTOQdzEJzI@}GMc_N%p7~fizv_3qV>fvEf?LpbTMBw_9N1r;=vXtMxjk<;#f*A zW-d)WRBJ&E@oix->NZhc&cS!nJ$GlWi;X22mLw00VEro(go!79XjYXp*4Mr5;KCfN z5gKMfRsI|t8F)2wz0jVMR*?P_vBear9V)N;BF{$YMlUnU@mY+jhEfvwO0884Ykudx z`orDFr&N9KO^k=h1`Zl`icj*c#`0@z^vc86Stvt0(P@Gzn%KPtbTfucx^2fO6lznk zYF!q__tV8!Y97s`Mb_dxn5KYyVJWAZRv~BR4qx#XP858g-etTN7R*_Y^k|^KdXki{ zm~R;5&slZBC6dH`jG?_#sY`i%q+*~7Ycy85^f)|ZVj!}9Ds_S6kki$~b?0X~80X6- z5oooLv_riav+@?=!SMy!jr$^tvjg*tcor4b^=vB?)-^v2E(&61+9%eV8?qDTd(Lf+ z%R*e9>gHp`y+|%`_FwYkdVfh`p7A8lOz1?`32$+}y+(yGoopim(!H|7k#bwH(@WV- z^K8vo@5eNu>Gtf*u?u5~O!;S>#x(Pzkja1=K~t|{%IRAeK}ZIeH!rN+rng?*TAwTY z{o%eJmO#PWuyrhrH*Fw!ah_xT*y<~#i!kRM+fp*Tp2a6&?WE8}$AGTnYNXV`^7n@u zjMMmm`Qu_QO+lC%TKJ+G=XFJE%EquPeC~#P%_Y6O7m%qsxz?0hvC|;*O5(#8y&O~1 zQ&e@FB$8zNx|3GG@)6eyB+lgx)(kj7TbtEJgfj<%uBJ^N;1GM2M?xklN>@q-+AH_e zu(9!H9hJGZ%DsN)IOFmTd7Nne#e+i0a#-n+ZBn;_Jj8-l; zpgRGWo4CmmJ(a6!$W{l|%zLzw)Gis30^{w}f+qQO$xFku^s>1&5VbG}^AX1C3yfAO zWs4#v`3=ulXtG!F-e8XWl7i*oZ3Ns zs@?km=k!vXIjVI?>$t0INF5_rb8&S1K}m*jQuYa(chQs)s>>HJUAG3x!P*s!Zjp>> zaANxj;i{MZ2;Nie8hJzDPI_Oec8UZe%osd?MSG7gos*IjWwW?LXRAC~qfO}i(y&C5 zf516U;@f7TD(XRY;TmV#l)>5J1z5FV)<^e`EYvoo3P$f^P`!pXRvBxJJ$=n70v?npR}3nDvu; zPzwC5gJjVqLsqe}Oy-JVD`TfKezCQQ(^4M`I@%oec$D=H7_SIp#TK=*Pqwa<-i~L? zSJ%PPg$pmo1k41>Fl3`rY}NeTnDhSFQ2oPJ0uL;}FK0mySbE*+&SNL8ky2_cu?CwczN!#- z*qq;})S`nq;xdsY<}LIp!rto@rf)`&%bLxiU)jQeRvyTVz z^w!lzFX$~Ez-0a+$n*2?I%5HOj;>0;+4-)E#g~3oRC6-a=oPEuxYWvBel&MBR+8^R zlG+mJ?F6)=Z_f-ELsqo+#aZyY!Lm{1fo2m2{bO-2n$5u^5~rPd!saRMBTO`%G|lMq zr+;8zTU03*A9bGGqq?5M%GH)+gt=|1Q{htCkgyRYrf;I0Eu0e?$Pm@aY6mV$+uzA?v7t8j=8S5~|3*(16Yf;LJ_8hp(_3T;s~ zxGOk0PgKgA>w`=u!f7FFzWW%Nb>|53U~7Ja=0q61FIkCW1O3_I8`(D=wLz|od$ykE z33nD|%Dz0B6ye8AFP(IEmZ3ZjA{xOvqch^o-O+l!CBZpczd%3Vh$43n`$nE?tts50 za9-16{tXF?#%5XSiupj-RlP#V)*J*A4HZ%@1szs@^KLR@-F3qR$$>|gJXv~3qUrZc z*UXEiR;Rhhsz2p=Sw~A8!Ok|WrV>{>@V0o5vv<$lwir_Q;b)oJDvy%|1Q~&yCkY&< zsSbs!!p>JuaRLNkWwSv8H=!`k4y?S3Dx1 zth$x8aU5XV{j|@k!x53h>`PqSr~sBrEpgYq=FES>U}=^~6-0lC8&Y|!_7tTorac@U z5@XNwKtFy`4)z$xm>erZ^29=xW%NHrbpX+?9qlcbzjFgJ|%qt@+6 z=%i56wcB3)q4cj2kY z9>0Y_P^cK>sVyn}KeoO)F0N(SI=EYKx8UxB>p+0u9^4_gLvVL@2`+))5G=U6I|O$L z?gMQR{ATFsUA=d8?e40zRuft*L+de~#Mfv!`f0#@hkF+Ny1L)!vfvR;C@(+br14iuYNHI#24@iTkFDo^RB9;@sp>a(f?=2zCY}6Wz5g19>Cb-^p(I@qz+oUjQXIWQEkpFIvAb&G*OSVi zY-C3|I`s`k<;eA{kX;bCo01cZ4;_%t{LsAoLC8*?gP}j)SLxNXqxp21>9Fe8=oQJI zb1ibD+ONQCiN5p>eB;unIWs7Tb|mGM(;D?HMCdidZB_h4TO>wwQqD!xDzkJ>?LueE z;3)J%*n)7o*1e+>Pd*lV3oBOlk;+O?gcyn|h+$Z2-Z@I6y{~^$w$xjT_tKA#!*;Qw zMrw18$+ook!SBNF1pWp|AhmC$d;)*r9LW(-=&*uefdiRL&!s3)U4t031-ThxU@+a8 zBb^C@8z~G8fdoZOfTO9J@+056bCMz?mX%zf=a55;|2QehN?~Wh&&WSEQ=4FCs8a-f zV{cmw-kY0aXXHb)Fcg8IG(ja-0_h7Q-%#Wj71aCa4}=8J5Qzv5(-NMRxGpu777&x zu;r5~%J^|0iEwvb!`P7f+V_lo5N$>WpodzyoD=VE=ttba%VI|I)Nh|-;1xt|3CH@m zSm2tLgbU@xtO{*DOI$+9K9W+A0?>0X8yvUdNQ}A=SvZQ_nTtCqt;;Q(;7i7|o>5^L zmBj?61FcGHVAfd=F>`v&Q zPvG4|&<(WAuu(HkKSM41IdG6Pvu1#f=d@4&H6e`!oQ4Gl2OXqL4|hZ$j42shb_vBk6gNVNV>)OEHZ1c+1AdI@(;0X1vaLD-bQi zROKRzx7XGb#9HuY+V3cRjaGRp{jln;m$UVuVBZJG@i{1Ib{=0ll(y5U!9CKog&=_X zGDue2Kd=0q3!t*O$P)8NLV@+4c4QQck&Q;V^i#}d58W82xc6Uyih`O4mg{faDZ<)L zCujWlP9vc3$_iUMe_F^A8YdV9cMtV24uHQAF*E&{3@Camn7x;eO)jq)$;fgI=7Za{ zbnmx?*RVaKxC6T*&3teU7I|BZH}+z9H{WHr<@q3L3-ztdUKg1P2dQ-)rgHO@>8UNTKFaH>BnM;b`DX&oaS`wI(0I0&OLp;oMgk+A5!OqDa9sp%Zbs^&ep&dh}p zx`<^5qua(=Cmgvv-5Sznmt=0E>M!x^3d0^KEdepDqG~F}$b-=Sew;&qDV-YiW}k#$ z#PhH>Gf9C?qnZB%oq4lIeW?2&1`d9o3!M}yR!Qj&{*szZf3Q~=W

^YJzkSSBi#g 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

GLi|7sq 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 literal 0 HcmV?d00001 diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index 754633240e5..08f17e54996 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -108,6 +108,36 @@ class GTPChain(str, Enum): UNGRAPHED = "GTP_ungraphed" +# One-block-ahead prefetch for routed grouped experts (see docs §3.4 "Grouped-expert chains"): +# - own chain per weight role -> next_w links the SAME role of CONSECUTIVE MoE blocks, +# so each all-gather gets a whole block of runway instead of one GEMM; +# - fc1/fc2 stay SEPARATE (merging leaves fc2 a GEMM behind) but share ONE stream via +# _stream_key, so their gathers serialize instead of splitting bandwidth; +# - the "_graphed"/"_ungraphed" suffix keeps captured and eager ops off the same stream. +_GTP_REMAT_GROUPED_PREFIX = "GTP_remat_grouped_" +_GTP_REMAT_GROUPED_FC1 = f"{_GTP_REMAT_GROUPED_PREFIX}fc1" +_GTP_REMAT_GROUPED_FC2 = f"{_GTP_REMAT_GROUPED_PREFIX}fc2" + + +def _graphness_suffix(graphed: bool) -> str: + """Chain-id suffix encoding the CUDA-graph capture axis.""" + return "graphed" if graphed else "ungraphed" + + +def _chain_is_grouped(chain_id: str) -> bool: + """True for the per-role grouped-expert chains (``_GTP_REMAT_GROUPED_FC1`` / ``_FC2``).""" + return chain_id.startswith(_GTP_REMAT_GROUPED_PREFIX) + + +def _chain_is_graphed(chain_id: str) -> bool: + """True for any CUDA-graph-captured chain, including grouped fc1/fc2 chains. + + Every chain id ends in "graphed" or "ungraphed" (see ``_classify_param_chain``), so testing + the eager suffix is exact; a new chain id must keep that convention. + """ + return not chain_id.endswith("ungraphed") + + # Active cuda_graph config, set by the integrator via set_cuda_graph_modules() before # classify_gtp_chains(); consumed by _classify_param_chain. _CUDA_GRAPH_MODULES: Optional[set] = None # scope tags, e.g. {"mamba","attn","moe_router"} @@ -139,41 +169,60 @@ def set_cuda_graph_modules( _CUDA_GRAPH_MODULES = set(scope) if scope else None -def _classify_param_chain(param_name: str) -> "GTPChain": - """Map a GTPShardedParam name + active cuda_graph config to its chain. +def _classify_param_chain(param_name: str) -> str: + """Map a GTPShardedParam name + active cuda_graph config to its chain id (a string). Full-iteration -> GRAPHED. Otherwise embedding/output_layer are UNGRAPHED, and - each layer kind (mixer, attention, shared/routed experts) is GRAPHED iff its - scope tag is in cuda_graph_modules. + each layer kind (mixer, attention, shared experts) is GRAPHED iff its scope tag is in + cuda_graph_modules. Routed grouped experts (``.mlp.experts.``) are special-cased FIRST: + fc1/fc2 each go to their own homogeneous grouped chain (see ``_GTP_REMAT_GROUPED_FC1``), with + graphness following the same "moe" scope rule. """ n = param_name + G = GTPChain.GRAPHED.value + U = GTPChain.UNGRAPHED.value + + # Routed grouped experts: own homogeneous chain per weight-role (fc1/fc2) for one-block-ahead + # prefetch. Checked BEFORE the generic rules (".mlp.shared_experts." is a distinct substring, so + # shared experts never fall in here). + if ".mlp.experts." in n: + graphed = _FULL_ITERATION or bool(_CUDA_GRAPH_MODULES and "moe" in _CUDA_GRAPH_MODULES) + # The grouped split is an EAGER-only optimization: when MoE is captured, keep grouped + # weights in the plain GRAPHED chain so the cross-graph drain — wait_async_comms( + # GTPChain.GRAPHED.value) in cuda_graphs.py — still targets them by exact chain id. + if graphed: + return G + eager = _graphness_suffix(False) + if ".linear_fc1." in n: + return f"{_GTP_REMAT_GROUPED_FC1}_{eager}" + if ".linear_fc2." in n: + return f"{_GTP_REMAT_GROUPED_FC2}_{eager}" + # Unknown grouped role (e.g. single fused weight): keep it in the general chain. + return U if _FULL_ITERATION: - return GTPChain.GRAPHED + return G # embedding/output_layer live outside any per-layer CG runner. if "embedding" in n or "output_layer" in n: - return GTPChain.UNGRAPHED + return U scope = _CUDA_GRAPH_MODULES if not scope: # CG disabled - return GTPChain.UNGRAPHED + return U if ".mlp.shared_experts." in n: if _MOE_SHARED_EXPERT_OVERLAP: - return GTPChain.UNGRAPHED - return GTPChain.GRAPHED if ("moe" in scope or "moe_router" in scope) else GTPChain.UNGRAPHED - - if ".mlp.experts." in n: - return GTPChain.GRAPHED if "moe" in scope else GTPChain.UNGRAPHED + return U + return G if ("moe" in scope or "moe_router" in scope) else U if ".self_attention." in n or ".cross_attention." in n: - return GTPChain.GRAPHED if "attn" in scope else GTPChain.UNGRAPHED + return G if "attn" in scope else U if ".mixer." in n: - return GTPChain.GRAPHED if "mamba" in scope else GTPChain.UNGRAPHED + return G if "mamba" in scope else U - return GTPChain.UNGRAPHED + return U def classify_gtp_chains(model) -> None: @@ -187,7 +236,7 @@ def classify_gtp_chains(model) -> None: for name, param in model.named_parameters(): if not is_gtp_param(param): continue - target = _classify_param_chain(name).value + target = _classify_param_chain(name) if param.prefetch_initialized and param.chain_id != target: conflicts.append((name, param.chain_id, target)) continue @@ -230,6 +279,18 @@ class GTPWeightState(Enum): # wgrad bufs need address stability for CG replay and are not pool-recycled. _wgrad_buf_pool: Dict[tuple, list] = {} +# Double-buffering for the grouped one-block-ahead chains (docs §3.4): +# - the weight cache shares ONE buffer per (shape, dtype, expert_idx) -> safe only while at +# most one same-key weight is live; +# - one-block-ahead keeps blocks N and N+1 live at once, so without a tiebreak the prefetch +# would clobber the weight the running GEMM is still reading; +# - fold a chain-position parity (0,1,0,1...) into the cache key -> consecutive blocks +# alternate between exactly TWO buffers. +# Parity is assigned on first cache-key use, which happens in forward (= chain) order, so this +# per-(shape, expert_idx, chain_id) counter yields the alternating sequence. Cleared by +# reset_gtp_state so a rebuilt model restarts numbering. +_GTP_GROUPED_BUF_PARITY_COUNTER: Dict[tuple, int] = {} + def _wgrad_pool_get(shape: tuple, dtype: torch.dtype, device) -> torch.Tensor: """Get a pool buffer or allocate fresh, tagged so _wgrad_pool_put accepts only @@ -260,7 +321,12 @@ def _stream_key(chain_id: str, group) -> tuple: Partitioned on two axes: chain_id (captured GRAPHED vs eager UNGRAPHED ops must not share a stream) and group (independent NCCL, e.g. GTP_remat vs EGTP_remat, no serialization). + + Grouped fc1/fc2 are separate chains but must share ONE stream, so their gathers serialize + instead of splitting bandwidth: drop the role from the key, keep the capture suffix. """ + if _chain_is_grouped(chain_id): + chain_id = _GTP_REMAT_GROUPED_PREFIX + _graphness_suffix(_chain_is_graphed(chain_id)) return (chain_id, id(group) if group is not None else 0) @@ -917,15 +983,35 @@ def _set_rs_state(self, new_state: GTPWeightState): return self.rs_state = new_state + def _double_buffer_parity(self) -> int: + """Chain-position parity (0/1) that keeps neighbouring blocks on different buffers. + + First use draws from a per-(shape, expert_idx, chain_id) counter; since first use follows + chain order, consecutive weights get 0,1,0,1... The value is cached on the param, so the + weight's fwd-AG, bwd-AG and RS buffers all share it. See ``_GTP_GROUPED_BUF_PARITY_COUNTER`` + """ + p = getattr(self, "_buf_parity", None) + if p is None: + counter_key = (self._unsharded_shape_padded, self.expert_idx, self.chain_id) + n = _GTP_GROUPED_BUF_PARITY_COUNTER.get(counter_key, 0) + p = n & 1 + _GTP_GROUPED_BUF_PARITY_COUNTER[counter_key] = n + 1 + self._buf_parity = p + return p + def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: """Build cache key from output shape + dtype. Weights with matching gathered shape and dtype share a buffer. For experts gathered in parallel, self.expert_idx keeps each distinct; same-indexed experts across layers share. + + Grouped one-block-ahead chains additionally fold in a double-buffer parity so a prefetched + layer N+1 weight never lands in the buffer that layer N is still consuming (see + ``_GTP_GROUPED_BUF_PARITY_COUNTER``). """ if not isinstance(dtype, torch.dtype): - return ( + key = ( self._unsharded_shape_padded, dtype, fwd, @@ -933,7 +1019,13 @@ def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: self.expert_idx, reduce_scatter, ) - return (self._unsharded_shape_padded, dtype, self.expert_idx, reduce_scatter) + else: + key = (self._unsharded_shape_padded, dtype, self.expert_idx, reduce_scatter) + if _chain_is_grouped(self.chain_id): + # chain_id keeps fc1/fc2 apart (both can be in flight at once, even if same-shaped); + # parity alternates consecutive blocks between two buffers. + key = key + (self.chain_id, self._double_buffer_parity()) + return key def _strip_padding(self, tensor): if self.pad_length == 0: @@ -1415,7 +1507,7 @@ def _wait_reduce_scatter(self, finalize_grad=False): # Release stashed wgrad inputs: UNGRAPHED buffers go back to the pool; # GRAPHED just drops Python refs (addresses must stay stable for CG). if getattr(self, "_wgrad_input_bufs", None) is not None: - if self.chain_id == GTPChain.UNGRAPHED.value: + if not _chain_is_graphed(self.chain_id): for buf in self._wgrad_input_bufs: _wgrad_pool_put(buf) self._wgrad_input_bufs = None @@ -1513,7 +1605,7 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None): # UNGRAPHED wgrads recycle via the standalone pool (_wgrad_pool_put); GRAPHED wgrads # cannot, since CUDA graphs require stable buffer addresses across replay. - poolable = self.chain_id == GTPChain.UNGRAPHED.value + poolable = not _chain_is_graphed(self.chain_id) if GTP_CONFIG.async_reduction and self.prev_w is not None: # Async RS (not last weight — deferred finish). Pre-RS work on caller; NCCL wrap @@ -1651,7 +1743,7 @@ def set_cuda_graph_mempool(device, mempool): def _graphed_alloc(chain_id): """Route allocations in this block into the registered CG mempool when ``chain_id`` is GRAPHED and a pool is registered; otherwise a no-op (regular allocator).""" - if _CG_MEMPOOL is not None and chain_id == GTPChain.GRAPHED.value: + if _CG_MEMPOOL is not None and _chain_is_graphed(chain_id): torch._C._cuda_beginAllocateCurrentThreadToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) try: yield @@ -1980,6 +2072,7 @@ def reset_gtp_state(): """ GTPShardedParam._chain_state.clear() GTPShardedParam._recompute_chain_state.clear() + _GTP_GROUPED_BUF_PARITY_COUNTER.clear() # ------------------------------------------------------------------------ diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py index dd044f63adc..f488d64ae6a 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py @@ -415,6 +415,109 @@ def test_async_prefetch_second_pass(self): _run_distributed(_worker_chain_async_prefetch, 4) +class TestGroupedExpertChainClassification: + """Routed grouped experts get their own per-role homogeneous prefetch chains + (one-block-ahead), while sharing a single IB stream. Pure classification logic, + no GPU/distributed needed.""" + + FC1 = "decoder.layers.3.mlp.experts.linear_fc1.weight0" + FC2 = "decoder.layers.3.mlp.experts.linear_fc2.weight0" + SHARED = "decoder.layers.3.mlp.shared_experts.linear_fc1.weight" + MIXER = "decoder.layers.3.mixer.in_proj.weight" + + def teardown_method(self, method): + # Restore the module default so other tests see a clean CG config. + gtp_module.set_cuda_graph_modules(None, cuda_graph_impl="none") + + def test_ungraphed_moe_splits_fc1_fc2_into_own_chains(self): + gtp_module.set_cuda_graph_modules(None, cuda_graph_impl="none") + c1 = gtp_module._classify_param_chain(self.FC1) + c2 = gtp_module._classify_param_chain(self.FC2) + assert c1 == "GTP_remat_grouped_fc1_ungraphed", c1 + assert c2 == "GTP_remat_grouped_fc2_ungraphed", c2 + # Separate linked-list chains so next_w links consecutive MoE layers (one-block-ahead). + assert c1 != c2 + # Removed from the general chain; other layer kinds are unaffected. + assert gtp_module._classify_param_chain(self.SHARED) == "GTP_ungraphed" + assert gtp_module._classify_param_chain(self.MIXER) == "GTP_ungraphed" + + def test_fc1_fc2_share_one_ib_stream(self): + gtp_module.set_cuda_graph_modules(None, cuda_graph_impl="none") + group = object() # same EGTP group for both roles + c1 = gtp_module._classify_param_chain(self.FC1) + c2 = gtp_module._classify_param_chain(self.FC2) + # Distinct chains but ONE shared IB stream (serialize fc1 then fc2). + assert gtp_module._stream_key(c1, group) == gtp_module._stream_key(c2, group) + # Distinct from the general ungraphed chain's stream. + assert gtp_module._stream_key(c1, group) != gtp_module._stream_key("GTP_ungraphed", group) + + def test_graphed_moe_keeps_grouped_in_plain_graphed_chain(self): + # When MoE is captured, grouped weights stay in the plain GRAPHED chain so the + # cross-graph drain wait_async_comms(GRAPHED) still targets them by exact chain id. + gtp_module.set_cuda_graph_modules({"moe"}, cuda_graph_impl="local") + assert gtp_module._classify_param_chain(self.FC1) == "GTP_graphed" + assert gtp_module._classify_param_chain(self.FC2) == "GTP_graphed" + + def test_graphness_helpers(self): + # "ungraphed" is the eager suffix; everything else is captured. + assert not gtp_module._chain_is_graphed("GTP_remat_grouped_fc1_ungraphed") + assert not gtp_module._chain_is_graphed("GTP_ungraphed") + assert gtp_module._chain_is_graphed("GTP_graphed") + + +class TestGroupedDoubleBuffer: + """One-block-ahead grouped chains must double-buffer: consecutive MoE layers get distinct + gather buffers (else prefetching layer N+1 clobbers layer N's in-use weight). Pure cache-key + logic, no GPU/distributed needed.""" + + class _Fake: + _unsharded_shape_padded = (128, 256) + expert_idx = 0 + + def __init__(self, chain_id): + self.chain_id = chain_id + + _double_buffer_parity = gtp_module.GTPShardedParam._double_buffer_parity + _get_cache_key = gtp_module.GTPShardedParam._get_cache_key + + def setup_method(self, method): + gtp_module.reset_gtp_state() + + def teardown_method(self, method): + gtp_module.reset_gtp_state() + + def _key(self, chain_id): + return self._Fake(chain_id)._get_cache_key(torch.bfloat16, fwd=True, reduce_scatter=False) + + def test_consecutive_layers_use_two_alternating_buffers(self): + keys = [self._key("GTP_remat_grouped_fc1_ungraphed") for _ in range(4)] + # Consecutive layers differ (no clobber); alternating layers share; exactly two buffers. + assert keys[0] != keys[1] + assert keys[1] != keys[2] + assert keys[0] == keys[2] + assert keys[1] == keys[3] + assert len(set(keys)) == 2 + + def test_fc1_fc2_never_share_a_buffer(self): + # Both can be in-flight at once on the shared IB stream; role folded into key keeps + # them distinct even when gathered shapes match (as in this fake). + assert self._key("GTP_remat_grouped_fc1_ungraphed") != self._key( + "GTP_remat_grouped_fc2_ungraphed" + ) + + def test_non_grouped_key_unchanged(self): + assert self._key("GTP_ungraphed") == ((128, 256), torch.bfloat16, 0, False) + + def test_parity_cached_and_stable(self): + f = self._Fake("GTP_remat_grouped_fc1_ungraphed") + fwd = f._get_cache_key(torch.bfloat16, fwd=True, reduce_scatter=False) + bwd = f._get_cache_key(torch.bfloat16, fwd=False, reduce_scatter=False) + rs = f._get_cache_key(torch.bfloat16, fwd=False, reduce_scatter=True) + # Same parity for all of this weight's buffers (distinct from neighbours, consistent here). + assert f._buf_parity == 0 + assert fwd[-1] == 0 and bwd[-1] == 0 and rs[-1] == 0 + + # --------------------------------------------------------------------------- # Wgrad reduce-scatter: shape and deferred async path # --------------------------------------------------------------------------- From 076f61fc7be6dd0d8a7a95f99cc66dd775ac5c42 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Mon, 27 Jul 2026 11:16:54 +0200 Subject: [PATCH 115/290] chore(codeowners): AUT-1094 add GTP owners (#6062) Signed-off-by: svcnemo-autobot --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 64ecd80d3d6..3ca7754175b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,5 @@ megatron/core/ @NVIDIA/core-adlr @NVIDIA/core-nemo +megatron/core/tensor_parallel/generalized_tensor_parallelism.py @NVIDIA/core-adlr @NVIDIA/core-nemo @NVIDIA/gtp megatron/core/models/bert/ @NVIDIA/core-adlr @NVIDIA/core-nemo @NVIDIA/gpt From 876016a717582a566c34566cd62ec1770ddc0ebf Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 28 Jul 2026 00:25:05 +0000 Subject: [PATCH 116/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index 5043e800188..1d358a70b42 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From fc57daaca8708e63ecf84f02c52f2a3d1ff28b41 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Tue, 28 Jul 2026 00:39:16 +0200 Subject: [PATCH 117/290] fix(dist-ckpt): AUT-1100 restore default strategy factories (#6065) Signed-off-by: svcnemo-autobot --- megatron/core/dist_checkpointing/serialization.py | 13 ++++++++++--- .../dist_checkpointing/test_serialization.py | 13 ++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index a76f21fed8c..177f27c418d 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -49,9 +49,16 @@ logger = logging.getLogger(__name__) -# monkeypatch needed for ModelOpt -# will be removed once MLM updated to newer ModelOpt -get_default_load_sharded_strategy = TorchDistLoadShardedStrategy + +def get_default_load_sharded_strategy(checkpoint_dir: str | Path | None = None): + """Create the default torch distributed load strategy.""" + return TorchDistLoadShardedStrategy(checkpoint_name=checkpoint_dir) + + +def get_default_save_sharded_strategy(backend: str = "torch_dist"): + """Create the default torch distributed save strategy.""" + return TorchDistSaveShardedStrategy(backend=backend) + # flat state dict with sharded objects without any data CkptShardedMetadata = Dict[str, Union[ShardedTensor, ShardedObject]] diff --git a/tests/unit_tests/dist_checkpointing/test_serialization.py b/tests/unit_tests/dist_checkpointing/test_serialization.py index 6fea505f66b..aadda4d5a06 100644 --- a/tests/unit_tests/dist_checkpointing/test_serialization.py +++ b/tests/unit_tests/dist_checkpointing/test_serialization.py @@ -30,10 +30,15 @@ from megatron.core.dist_checkpointing.dict_utils import diff from megatron.core.dist_checkpointing.mapping import ShardedObject, ShardedTensorFactory from megatron.core.dist_checkpointing.serialization import ( + get_default_load_sharded_strategy, + get_default_save_sharded_strategy, load_sharded_metadata, load_tensors_metadata, ) -from megatron.core.dist_checkpointing.strategies.torch import TorchDistSaveShardedStrategy +from megatron.core.dist_checkpointing.strategies.torch import ( + TorchDistLoadShardedStrategy, + TorchDistSaveShardedStrategy, +) from megatron.core.dist_checkpointing.validation import StrictHandling from megatron.core.utils import is_torch_min_version from tests.unit_tests.dist_checkpointing import TempNamedDir @@ -47,6 +52,12 @@ def setup_method(self, method): def teardown_method(self, method): Utils.destroy_model_parallel() + def test_default_torch_dist_strategies(self): + assert isinstance(get_default_load_sharded_strategy(), TorchDistLoadShardedStrategy) + assert isinstance( + get_default_save_sharded_strategy("torch_dist"), TorchDistSaveShardedStrategy + ) + def test_single_process_save_load(self, tmp_path_dist_ckpt): Utils.initialize_model_parallel(1, 1) From 10cd30c0b2face6e460362adf01cffa45c71693b Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Mon, 27 Jul 2026 19:21:48 -0400 Subject: [PATCH 118/290] Deprecate GPTModel in favor of HybridModel (#5911) Signed-off-by: Philip Petrakian --- megatron/core/models/gpt/gpt_model.py | 11 +++++++++++ tests/unit_tests/models/test_gpt_model.py | 24 +++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index dac1df51a18..ff3514b7433 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -1,5 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import logging from collections import OrderedDict from typing import Any, Callable, Dict, Literal, Optional @@ -41,8 +42,11 @@ WrappedTensor, deprecate_inference_params, is_using_quantization_scales, + log_single_rank, ) +logger = logging.getLogger(__name__) + class GPTModel(LanguageModule): """GPT Transformer language model. @@ -111,6 +115,13 @@ def __init__( pg_collection: Optional[ProcessGroupCollection] = None, vp_stage: Optional[int] = None, ) -> None: + log_single_rank( + logger, + logging.WARNING, + "GPTModel IS DEPRECATED. GPTModel is only accepting critical bug fixes, no new " + "features. Please reference the migration guide " + "`docs/user-guide/hybrid-model-migration.md` for details on how to use `HybridModel`", + ) super().__init__(config=config, pg_collection=pg_collection) if has_config_logger_enabled(config): diff --git a/tests/unit_tests/models/test_gpt_model.py b/tests/unit_tests/models/test_gpt_model.py index 6368dc4975c..d2cb12841c4 100644 --- a/tests/unit_tests/models/test_gpt_model.py +++ b/tests/unit_tests/models/test_gpt_model.py @@ -1,6 +1,7 @@ # Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved. import inspect +import logging import os from datetime import timedelta from unittest.mock import MagicMock, patch @@ -47,18 +48,29 @@ def setup_method(self, method): use_cpu_initialization=True, embedding_init_method_std=1.0, # Test that we can initialize the embedding weights to something else. ) - self.gpt_model = GPTModel( - config=transformer_config, - transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec(), - vocab_size=100, - max_sequence_length=4, - ) + with patch('megatron.core.models.gpt.gpt_model.log_single_rank') as mock_log_single_rank: + self.gpt_model = GPTModel( + config=transformer_config, + transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec(), + vocab_size=100, + max_sequence_length=4, + ) + self.mock_log_single_rank = mock_log_single_rank def teardown_method(self, method): Utils.destroy_model_parallel() @pytest.mark.internal def test_constructor(self): + self.mock_log_single_rank.assert_called_once() + _, level, message = self.mock_log_single_rank.call_args.args + assert level == logging.WARNING + assert message == ( + "GPTModel IS DEPRECATED. GPTModel is only accepting critical bug fixes, no new " + "features. Please reference the migration guide " + "`docs/user-guide/hybrid-model-migration.md` for details on how to use `HybridModel`" + ) + assert isinstance(self.gpt_model, GPTModel) assert self.gpt_model.max_sequence_length == 4 From 4b18b260f012c8de51f729fb09771f99266bc675 Mon Sep 17 00:00:00 2001 From: Jimmy Zhang <133159885+jiemingz@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:45:50 -0400 Subject: [PATCH 119/290] Fix CUDA graph correctness issues due to memory bugs (#5975) Signed-off-by: Jimmy Zhang --- megatron/core/transformer/cuda_graphs.py | 308 +++++++++++++----- .../core/transformer/transformer_layer.py | 88 +++-- .../transformer/test_cuda_graphs.py | 102 +++++- 3 files changed, 377 insertions(+), 121 deletions(-) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index a64347e00f8..782fd6bf14f 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -94,6 +94,49 @@ logger = logging.getLogger(__name__) +def _get_tensor_alias_chain(tensor): + """Return a tensor followed by each underlying base tensor.""" + aliases = [] + while torch.is_tensor(tensor): + aliases.append(tensor) + base = getattr(tensor, "_base", None) + if base is None or base is tensor: + break + tensor = base + return aliases + + +def _apply_cudagraph_buffer_metadata(tensor, *, is_output=False): + """Attach one shared CUDA graph metadata object to a tensor and its base chain.""" + aliases = _get_tensor_alias_chain(tensor) + metadata = next( + (alias.cg_buffer_metadata for alias in aliases if hasattr(alias, "cg_buffer_metadata")), + None, + ) + if is_output: + metadata = CudagraphBufferMetadata( + is_cudagraph_output=True, + is_saved_for_backward=bool(metadata and metadata.is_saved_for_backward), + ) + elif metadata is None: + metadata = CudagraphBufferMetadata() + for alias in aliases: + alias.cg_buffer_metadata = metadata + return metadata + + +def _tag_cudagraph_buffer_saved_for_backward(tensor): + """Tag a CUDA graph input or output observed in a Python 'save_for_backward' call.""" + if not torch.is_tensor(tensor): + return + + # Views of the same graph buffer share one metadata object. If this tensor has not reached a + # graph boundary yet, initialize its metadata now so record-time input/output classification + # can preserve the saved-for-backward lifetime. + metadata = _apply_cudagraph_buffer_metadata(tensor) + metadata.is_saved_for_backward = True + + _GTP_RUNNER_STREAMS: List[torch.cuda.Stream] = [] @@ -181,10 +224,25 @@ class CudagraphBufferMetadata: Metadata saved to tensors during cudagraph capture. This data will be used to determine during graph captue when a cudagraph can reuse a buffer or directly write its output into a subsequent's graph's input. + + Set during recording: + is_cudagraph_input / is_cudagraph_output — which graph boundary this buffer sits on. + is_saved_for_backward — set by the save_for_backward observer; means the forward + buffer must outlive the forward graph and stay allocator-owned until backward + capture. + + Reuse accounting (used during graph creation): + input_use_count — times this buffer appears as a graph input. + cudagraph_reuse_ref_count / capture_reuse_count — remaining reuses; drives + can_skip_replay_copy and when args_to_clear_buffers fires. + fwd_cudagraph_buffer / bwd_cudagraph_buffer — the shared strong-ref buffer other + graphs alias for this input/grad. + """ is_cudagraph_input: bool = False is_cudagraph_output: bool = False + is_saved_for_backward: bool = False input_use_count: int = 0 cudagraph_reuse_ref_count: int = 0 capture_reuse_count: int = 0 @@ -243,7 +301,19 @@ def wrapper(arg): changes = { f.name: tree_map_pyt(func, getattr(arg, f.name)) for f in dataclasses.fields(arg) } - return dataclasses.replace(arg, **changes) + mapped_arg = dataclasses.replace(arg, **changes) + + # 'dataclasses.replace' reruns '__post_init__', which may overwrite a tensor + # field that was explicitly mapped above. In particular, PackedSeqParams rebuilds + # 'seq_idx' from 'cu_seqlens'. CUDA graph input buffers are zero-initialized, so + # that rebuild assigns every token the padded sequence count and can make Mamba + # kernels access out of bounds during graph capture. Preserve the tensor selected by + # the mapping operation; replay will populate that buffer with the real input value. + for name, value in changes.items(): + if torch.is_tensor(value) and getattr(mapped_arg, name) is not value: + object.__setattr__(mapped_arg, name, value) + + return mapped_arg # Otherwise, apply the user function return func(arg) @@ -377,7 +447,7 @@ def create_strong_ref(ten: torch.Tensor): def _backup_grads_before_capture(runner): """Snapshot main_grad so create_fwd_graph's eager warmup can't corrupt the finalized grads; - restore with ``_restore_grads_after_capture``. + restore with '_restore_grads_after_capture'. """ backup = {} for p in runner.base_module.parameters(): @@ -400,7 +470,7 @@ def _backup_grads_before_capture(runner): def _restore_grads_after_capture(backup): - """Restore the main_grad snapshots taken by ``_backup_grads_before_capture``.""" + """Restore the main_grad snapshots taken by '_backup_grads_before_capture'.""" for p, saved in backup.values(): p.main_grad.copy_(saved) @@ -418,6 +488,36 @@ class _CudagraphGlobalRecord: 'record_bwd_graph.""" cudagraph_record: list[tuple] = [] cudagraph_inference_record: list[tuple] = [] + _saved_tensors_observer = None + + @classmethod + def _enable_saved_tensors_observer(cls): + """Observe Python 'save_for_backward' calls while recording and capturing graphs.""" + if cls.cudagraph_created or cls._saved_tensors_observer is not None: + return + + function_ctx = torch.autograd.function.FunctionCtx + original_save_for_backward = function_ctx.save_for_backward + + def observing_save_for_backward(ctx, *tensors): + for tensor in tensors: + _tag_cudagraph_buffer_saved_for_backward(tensor) + return original_save_for_backward(ctx, *tensors) + + cls._saved_tensors_observer = (original_save_for_backward, observing_save_for_backward) + function_ctx.save_for_backward = observing_save_for_backward + + @classmethod + def _disable_saved_tensors_observer(cls): + """Restore Python's original 'save_for_backward' implementation.""" + if cls._saved_tensors_observer is None: + return + + original_save_for_backward, observing_save_for_backward = cls._saved_tensors_observer + function_ctx = torch.autograd.function.FunctionCtx + if function_ctx.save_for_backward is observing_save_for_backward: + function_ctx.save_for_backward = original_save_for_backward + cls._saved_tensors_observer = None @classmethod def record_fwd_graph(cls, runner, args, kwargs, out): @@ -431,6 +531,14 @@ def record_bwd_graph(cls, runner): @classmethod def create_cudagraphs(cls): + """Create recorded CUDA graphs, then remove the saved-tensor observer.""" + try: + return cls._create_cudagraphs() + finally: + cls._disable_saved_tensors_observer() + + @classmethod + def _create_cudagraphs(cls): """Iterate through 'cudagraph_record' creating graphs in the order in which they were recorded.""" # Cudagraphs have already been created, check that no cudagraphed modules ran in eager mode @@ -573,6 +681,8 @@ def create_cudagraphs(): def delete_cuda_graphs(): """Delete all CUDA graphs.""" + _CudagraphGlobalRecord._disable_saved_tensors_observer() + # Reset runners. for record in [ *_CudagraphGlobalRecord.cudagraph_record, @@ -663,7 +773,12 @@ def forward(ctx, runner, is_first_microbatch, *inputs): can_skip_replay_copy = getattr( cudagraph_input, "can_skip_replay_copy", False ) and getattr(user_input, "can_skip_replay_copy", True) - if can_skip_replay_copy: + + # When the same input (like cu_seqlens) is passed to multiple cudagraphs, the first + # cudagraph copies it into the corresponding 'cudagraph_input'. Subsequent cudagraphs + # will then read the same cudagraph_input, leading to a case where the passed tensor + # doesn't need a copy despite being a different data_ptr as its 'cudagraph_input'. + if can_skip_replay_copy and cudagraph_input.cg_buffer_metadata.input_use_count == 1: assert user_input.data_ptr() == cudagraph_input.data_ptr() elif user_input.data_ptr() != cudagraph_input.data_ptr(): cudagraph_input.copy_(user_input) @@ -696,12 +811,6 @@ def forward(ctx, runner, is_first_microbatch, *inputs): torch.cuda.current_stream().wait_event(runner.fwd_completion_event) else: runner.fwd_graph.replay() - - if runner.is_last_layer: - outputs = tuple(torch.clone(t) for t in runner.fwd_graph_output_surface) - for output in outputs: - output.can_skip_replay_copy = False - return outputs return runner.fwd_graph_output_surface @staticmethod @@ -967,6 +1076,64 @@ def get_connected_params(self, outputs): # Return module params that were found in the graph, preserving original order return tuple(p for p in self.base_module.parameters() if id(p) in p_ids) + def _weakref_forward_buffers(self, preserve_forward_to_backward_lifetimes: bool) -> None: + """Release ownership only when CUDA graph topology proves the buffer reclaimable. + + `make_weakref` preserves a captured address but releases allocator ownership. + Although CUDA graph memory is pinned to a stable address, the graph-pool allocator may + reuse an unowned allocation before backward capture and overwrite its contents. + these conditions only within that interval avoids retaining every boundary tensor. + """ + + def is_saved_for_backward(tensor) -> bool: + """Return whether a tensor is needed for the backward pass graph. + + Preserving allocator ownership guards against the graph pool reusing and overwriting + that storage before backward capture records the read. + """ + + metadata = getattr(tensor, "cg_buffer_metadata", None) + return bool( + torch.is_tensor(tensor) and metadata is not None and metadata.is_saved_for_backward + ) + + def is_differentiable_cudagraph_output_escape(tensor) -> bool: + """Return whether a differentiable graph output escapes to eager code. + + Outputs that are also inputs to another CUDA graph are protected by graph-to-graph + reuse accounting. However, an output that is not another graph's input has no + such owner. Preserving it's ownership guards against premature graph-pool storage + reuse across that graph boundary. + """ + metadata = getattr(tensor, "cg_buffer_metadata", None) + return bool( + torch.is_tensor(tensor) + and tensor.requires_grad + and metadata is not None + and metadata.is_cudagraph_output + and not metadata.is_cudagraph_input + ) + + def weakref_input(tensor): + if preserve_forward_to_backward_lifetimes: + if is_saved_for_backward(tensor): + return tensor + return make_weakref(tensor) + + def weakref_output(tensor): + if preserve_forward_to_backward_lifetimes: + if is_saved_for_backward(tensor): + return tensor + if is_differentiable_cudagraph_output_escape(tensor): + return tensor + return make_weakref(tensor) + + self.fwd_graph_input_surface = tree_map(weakref_input, self.fwd_graph_input_surface) + self.fwd_graph_input_args = tree_map(weakref_input, self.fwd_graph_input_args) + self.fwd_graph_input_kwargs = tree_map(weakref_input, self.fwd_graph_input_kwargs) + self.fwd_graph_outputs = tree_map(weakref_output, self.fwd_graph_outputs) + self.fwd_graph_output_surface = tree_map(weakref_output, self.fwd_graph_output_surface) + def create_fwd_graph(self, args, kwargs, outputs=None, clone_inputs=True): """Create a fwd cudagraph for this runner. Should be called inside 'create_cudagraphs()'.""" @@ -1032,52 +1199,49 @@ def create_fwd_graph(self, args, kwargs, outputs=None, clone_inputs=True): def _resolve_input_buffer(ten): if not isinstance(ten, ArgMetadata): return ten + metadata = getattr(ten, "cg_buffer_metadata", None) + # the input tensor is resued from another cudagraph's input or output - if ( - hasattr(ten, "cg_buffer_metadata") - and ten.cg_buffer_metadata.fwd_cudagraph_buffer is not None - ): - buf = ten.cg_buffer_metadata.fwd_cudagraph_buffer + if metadata is not None and metadata.fwd_cudagraph_buffer is not None: + shared_buf = metadata.fwd_cudagraph_buffer + buf_metadata = shared_buf.cg_buffer_metadata - assert ( - ten.cg_buffer_metadata.is_cudagraph_input - and buf.cg_buffer_metadata.capture_reuse_count > 0 - ) + assert metadata.is_cudagraph_input and buf_metadata.capture_reuse_count > 0 - if ( - ten.cg_buffer_metadata.input_use_count > 1 - and ten.cg_buffer_metadata.input_use_count - == buf.cg_buffer_metadata.capture_reuse_count - ): - can_skip_replay_copy = False - else: - can_skip_replay_copy = True + can_skip_replay_copy = not ( + metadata.input_use_count > 1 + and metadata.input_use_count == buf_metadata.capture_reuse_count + ) - buf.cg_buffer_metadata.capture_reuse_count -= 1 - if buf.cg_buffer_metadata.capture_reuse_count == 0: + buf_metadata.capture_reuse_count -= 1 + if buf_metadata.capture_reuse_count == 0: args_to_clear_buffers.append(ten) + + buf = create_strong_ref(shared_buf) else: # need to provide a fresh buffer from the pool buf = alloc_tensor_from_graph_mempool(ten) + if metadata is not None: + buf.cg_buffer_metadata = deepcopy(metadata) can_skip_replay_copy = False buf.can_skip_replay_copy = can_skip_replay_copy return buf if clone_inputs: - # if a buffer is used for multiple inputs, create it now - for ten in self.get_tensors(args, kwargs): + # Recorded graph arguments are ArgMetadata, not tensors. Preallocate a shared + # buffer before resolving each occurrence so later graph inputs can alias it. + for ten in self.get_arg_metas(args, kwargs): + metadata = getattr(ten, "cg_buffer_metadata", None) if ( - hasattr(ten, 'cg_buffer_metadata') - and ten.cg_buffer_metadata.input_use_count > 1 - and ten.cg_buffer_metadata.fwd_cudagraph_buffer is None + metadata is not None + and metadata.input_use_count > 1 + and metadata.fwd_cudagraph_buffer is None ): buf = alloc_tensor_from_graph_mempool(ten) - buf.cg_buffer_metadata = deepcopy(ten.cg_buffer_metadata) - buf.cg_buffer_metadata.capture_reuse_count = ( - ten.cg_buffer_metadata.input_use_count - ) - ten.cg_buffer_metadata.fwd_cudagraph_buffer = buf + buf.cg_buffer_metadata = deepcopy(metadata) + buf.cg_buffer_metadata.capture_reuse_count = metadata.input_use_count + metadata.fwd_cudagraph_buffer = buf fwd_buffer_reuse_ref_count += 1 self.fwd_graph_input_args = tree_map(_resolve_input_buffer, args) @@ -1181,19 +1345,15 @@ def clone_ten(ten): for fwd_graph_out, o in zip( self.get_tensors(fwd_graph_outputs), self.get_arg_metas(self.outputs) ): - assert hasattr(o, "cg_buffer_metadata") and o.cg_buffer_metadata.is_cudagraph_output + metadata = getattr(o, "cg_buffer_metadata", None) + assert metadata is not None and metadata.is_cudagraph_output fwd_graph_out.is_from_global_mempool = True - fwd_graph_out.cg_buffer_metadata = deepcopy(o.cg_buffer_metadata) + fwd_graph_out.cg_buffer_metadata = deepcopy(metadata) - if ( - o.cg_buffer_metadata.is_cudagraph_input - and o.cg_buffer_metadata.fwd_cudagraph_buffer is None - ): + if metadata.is_cudagraph_input and metadata.fwd_cudagraph_buffer is None: buf = create_strong_ref(fwd_graph_out) - buf.cg_buffer_metadata.capture_reuse_count = ( - o.cg_buffer_metadata.cudagraph_reuse_ref_count - ) - o.cg_buffer_metadata.fwd_cudagraph_buffer = buf + buf.cg_buffer_metadata.capture_reuse_count = metadata.cudagraph_reuse_ref_count + metadata.fwd_cudagraph_buffer = buf fwd_buffer_reuse_ref_count += 1 if self.training and torch.is_grad_enabled(): @@ -1203,11 +1363,8 @@ def clone_ten(ten): however the graphed module must output at least one tensor, so that a corresponding backward node may be registered in the autograd graph.""" - self.fwd_graph_input_surface = tree_map(make_weakref, self.fwd_graph_input_surface) - self.fwd_graph_input_args = tree_map(make_weakref, self.fwd_graph_input_args) - self.fwd_graph_input_kwargs = tree_map(make_weakref, self.fwd_graph_input_kwargs) - self.fwd_graph_outputs = tree_map(make_weakref, self.fwd_graph_outputs) - self.fwd_graph_output_surface = tree_map(make_weakref, self.fwd_graph_output_surface) + # Preserve only forward buffers whose lifetime crosses into backward capture. + self._weakref_forward_buffers(preserve_forward_to_backward_lifetimes=True) self.params_to_backprop = self.get_connected_params(fwd_graph_outputs) self.num_dgrads = len(self.fwd_graph_input_surface) @@ -1249,17 +1406,15 @@ def create_bwd_graph(self): for o in self.get_arg_metas(self.outputs): out_grad = None if o.requires_grad: + metadata = o.cg_buffer_metadata # TODO: (jiemingz) [interaction with recompute] # for activation recompute, the fwd pass is rerun in the backward pass and # the metadata we attach in record_graph_capture is lost. As a result the next # cudagraph expects the buffer to be provided 'fwd_cudagraph_buffer' but is missing. # So, we cannot always assume this metadata exists. Consequently, there are extra # copies between the outputs of the fwd-bwd pass and the bwd pass. - if ( - o.cg_buffer_metadata.is_cudagraph_input - and o.cg_buffer_metadata.bwd_cudagraph_buffer is not None - ): - out_grad = o.cg_buffer_metadata.bwd_cudagraph_buffer + if metadata.is_cudagraph_input and metadata.bwd_cudagraph_buffer is not None: + out_grad = metadata.bwd_cudagraph_buffer args_to_clear_buffers.append(o) out_grad.cg_buffer_metadata.capture_reuse_count -= 1 else: @@ -1366,16 +1521,14 @@ def create_bwd_graph(self): self.static_grad_inputs = [] for input_tensor in self.get_arg_metas(self.args, self.kwargs): if input_tensor.requires_grad: + metadata = input_tensor.cg_buffer_metadata input_grad = grad_inputs.pop(0) input_grad.is_from_global_mempool = True - input_grad.cg_buffer_metadata = deepcopy(input_tensor.cg_buffer_metadata) + input_grad.cg_buffer_metadata = deepcopy(metadata) - if ( - input_tensor.cg_buffer_metadata.is_cudagraph_output - and input_tensor.cg_buffer_metadata.bwd_cudagraph_buffer is None - ): + if metadata.is_cudagraph_output and metadata.bwd_cudagraph_buffer is None: buf = create_strong_ref(input_grad) - input_tensor.cg_buffer_metadata.bwd_cudagraph_buffer = buf + metadata.bwd_cudagraph_buffer = buf buf.cg_buffer_metadata.capture_reuse_count += 1 bwd_buffer_reuse_ref_count += 1 self.static_grad_inputs.append(input_grad) @@ -1390,6 +1543,8 @@ def create_bwd_graph(self): # stored in 'bwd_cudagraph_buffer' self.static_grad_inputs = tree_map(make_weakref, self.static_grad_inputs) self.static_grad_outputs = tree_map(make_weakref, self.static_grad_outputs) + # Backward capture is the final recorded use of forward buffers retained for autograd. + self._weakref_forward_buffers(preserve_forward_to_backward_lifetimes=False) delattr(self, "args") delattr(self, "kwargs") @@ -1399,19 +1554,15 @@ def apply_cudagraph_record_metadata(self, args, kwargs, outputs): """Attaches graph capture metadata to all passed in tensors.""" for t in self.get_tensors(args, kwargs): - if not hasattr(t, "cg_buffer_metadata"): - t.cg_buffer_metadata = CudagraphBufferMetadata() - - t.cg_buffer_metadata.is_cudagraph_input = True - t.cg_buffer_metadata.input_use_count += 1 + cg_buffer_metadata = _apply_cudagraph_buffer_metadata(t) + cg_buffer_metadata.is_cudagraph_input = True + cg_buffer_metadata.input_use_count += 1 - if t.cg_buffer_metadata.is_cudagraph_output: - t.cg_buffer_metadata.cudagraph_reuse_ref_count += 1 + if cg_buffer_metadata.is_cudagraph_output: + cg_buffer_metadata.cudagraph_reuse_ref_count += 1 - # mark all outputs, so that the fwd graph we may reuse cudagraph output buffers as inputs - for o in self.get_tensors(outputs): - o.cg_buffer_metadata = CudagraphBufferMetadata() - o.cg_buffer_metadata.is_cudagraph_output = True + for t in self.get_tensors(outputs): + _apply_cudagraph_buffer_metadata(t, is_output=True) def record_graph_capture(self, args, kwargs): """Records the data needed to create this runner's forward cudagraph. @@ -1693,6 +1844,11 @@ def wrapped_func(*args, eager=False, cache_key=None, **kwargs): # capture, so change to a side stream. torch.cuda.set_stream(torch.cuda.Stream()) + # Enable one hook for the eager recording phase. Repeated manager construction is + # idempotent, and graph creation removes the hook before capture begins. + if need_backward: + _CudagraphGlobalRecord._enable_saved_tensors_observer() + def call_ddp_preforward_hook(self, module): """Call any DDP pre-forward hooks which are used to launch async data parallel param gather. Any other pre-forward hooks are not allowed.""" diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index f6ea382077e..3fa91068769 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -18,7 +18,7 @@ from megatron.core.inference.utils import InferenceMode from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.process_groups_config import ProcessGroupCollection -from megatron.core.transformer.cuda_graphs import is_graph_capturing, is_graph_warmup, make_weakref +from megatron.core.transformer.cuda_graphs import is_graph_capturing from megatron.core.transformer.enums import CudaGraphModule, InferenceCudaGraphScope, LayerType from megatron.core.transformer.identity_op import IdentityFuncOp, IdentityOp from megatron.core.transformer.mlp import MLP @@ -1522,7 +1522,7 @@ def __init__(self, *args, **kwargs): self.is_moe_layer = True self.use_partial_cudagraphs = False self.moe_layer_recompute = False - self.token_dispatcher_attrs = {} + self._local_cudagraph_attr_names = None super().__init__(*args, **kwargs) @@ -1614,11 +1614,24 @@ def _resolve_token_dispatcher_attr(self, attr_name: str) -> tuple[Any, str]: obj = getattr(obj, parent_name) return obj, leaf_attr_name or attr_name - def _restore_token_dispatcher_attrs(self): - for attr_name, attr in self.token_dispatcher_attrs.items(): + def _restore_token_dispatcher_attrs(self, attr_outputs): + assert len(attr_outputs) == len(self._local_cudagraph_attr_names) + for attr_name, attr in zip(self._local_cudagraph_attr_names, attr_outputs): obj, name = self._resolve_token_dispatcher_attr(attr_name) setattr(obj, name, attr) + def _get_token_dispatcher_attrs(self): + attr_names = [] + token_dispatcher_attr_outputs = [] + for attr_name in self.mlp.token_dispatcher.cudagraph_attrs: + obj, name = self._resolve_token_dispatcher_attr(attr_name) + attr = getattr(obj, name) + if torch.is_tensor(attr): + attr_names.append(attr_name) + token_dispatcher_attr_outputs.append(attr) + + return tuple(attr_names), token_dispatcher_attr_outputs + def _forward_mlp_router(self, hidden_states, padding_mask=None): """ Executes the router phase of the MoE block. @@ -1643,21 +1656,29 @@ def _forward_mlp_router(self, hidden_states, padding_mask=None): if self.config.fp32_residual_connection: residual = residual.float() - router_outputs = apply_module(self.mlp)( + hidden_states, probs, shared_expert_output = apply_module(self.mlp)( pre_mlp_layernorm_output, intermediate_tensors=(), padding_mask=padding_mask ) - if is_graph_capturing() and not is_graph_warmup(): - for attr_name in self.mlp.token_dispatcher.cudagraph_attrs: - obj, name = self._resolve_token_dispatcher_attr(attr_name) - attr = getattr(obj, name) - if torch.is_tensor(attr): - attr.is_from_global_mempool = True - self.token_dispatcher_attrs[attr_name] = attr + if self.use_partial_cudagraphs: + attr_names, token_dispatcher_attr_outputs = self._get_token_dispatcher_attrs() + if self._local_cudagraph_attr_names is None: + self._local_cudagraph_attr_names = attr_names + else: + assert attr_names == self._local_cudagraph_attr_names + else: + # For eager mode, no need to pass the token_dispatcher attributes + token_dispatcher_attr_outputs = [] - return residual, *router_outputs + return ( + residual, + hidden_states, + probs, + shared_expert_output, + *token_dispatcher_attr_outputs, + ) - def _forward_mlp_expert_compute(self, hidden_states, probs): + def _forward_mlp_expert_compute(self, hidden_states, probs, token_dispatcher_attr_outputs): """ Executes the actual computation of the experts. @@ -1666,12 +1687,9 @@ def _forward_mlp_expert_compute(self, hidden_states, probs): step runs eagerly between the router and postprocess graph replays. """ - # During partial CUDA graph replay, use the probs returned from the graph in order - # to retain the router autograd edge. Rebinding it to the live router output ensures - # the backward DDP hook of router.weight is properly triggered. - if '_comm_manager.token_probs' in self.token_dispatcher_attrs: - self.token_dispatcher_attrs['_comm_manager.token_probs'] = probs - self._restore_token_dispatcher_attrs() + if self.use_partial_cudagraphs: + # Restore the token dispatcher attrs returned on the router graph's output surface. + self._restore_token_dispatcher_attrs(token_dispatcher_attr_outputs) self.mlp.fwd_execution_map = "expert_compute" return apply_module(self.mlp)(None, intermediate_tensors=(hidden_states, probs)) @@ -1688,16 +1706,7 @@ def _forward_mlp_postprocess(self, residual, output, shared_expert_output, mlp_b self.mlp.fwd_execution_map = "postprocess" output = apply_module(self.mlp)(None, intermediate_tensors=(output, shared_expert_output)) - out = self._forward_post_mlp((output, mlp_bias), residual) - - if is_graph_capturing() and not is_graph_warmup(): - for attr_name, attr in self.token_dispatcher_attrs.items(): - weak_ref = make_weakref(attr, inplace=False) - self.token_dispatcher_attrs[attr_name] = weak_ref - obj, name = self._resolve_token_dispatcher_attr(attr_name) - setattr(obj, name, weak_ref) - - return out + return self._forward_post_mlp((output, mlp_bias), residual) def _forward_mlp( self, hidden_states, inference_context=None, padding_mask=None, packed_seq_params=None @@ -1719,18 +1728,25 @@ def _forward_mlp( def _forward_mlp_partial_cudagraphs( hidden_states, inference_context=None, padding_mask=None ): - residual, hidden_states, probs, shared_expert_output = self._forward_mlp_router( - hidden_states, padding_mask=padding_mask - ) + router_outputs = self._forward_mlp_router(hidden_states, padding_mask=padding_mask) + ( + residual, + hidden_states, + probs, + shared_expert_output, + *token_dispatcher_attr_outputs, + ) = router_outputs # After the router graph replays, the captured .copy_() operations that update - # self.token_dispatcher_attrs via `_maybe_dtoh_and_synchronize` are queued on the - # current stream but may not have completed. Record an event after the router + # the returned dispatcher tensors via `_maybe_dtoh_and_synchronize` are queued on + # the current stream but may not have completed. Record an event after the router # graph and wait on it, so we block only until the router's D2H copies complete. self._router_dtoh_event.record() self._router_dtoh_event.synchronize() - expert_output, mlp_bias = self._forward_mlp_expert_compute(hidden_states, probs) + expert_output, mlp_bias = self._forward_mlp_expert_compute( + hidden_states, probs, token_dispatcher_attr_outputs + ) return self._forward_mlp_postprocess( residual, expert_output, shared_expert_output, mlp_bias ) diff --git a/tests/unit_tests/transformer/test_cuda_graphs.py b/tests/unit_tests/transformer/test_cuda_graphs.py index ef556b29f42..f387e58953e 100644 --- a/tests/unit_tests/transformer/test_cuda_graphs.py +++ b/tests/unit_tests/transformer/test_cuda_graphs.py @@ -37,7 +37,12 @@ _CudagraphGlobalRecord, create_cudagraphs, ) -from megatron.core.transformer.enums import CudaGraphModule, CudaGraphScope, InferenceCudaGraphScope +from megatron.core.transformer.enums import ( + AttnBackend, + CudaGraphModule, + CudaGraphScope, + InferenceCudaGraphScope, +) from megatron.core.transformer.mlp import MLPSubmodules from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.moe.fused_a2a import reset_hybrid_ep_buffer @@ -461,12 +466,29 @@ class TestPackedSeqCudagraphs: SEQ_LENGTHS = [7, 5] SLOT_STARTS = [0, 8, 16] # slot layout aligned to 2 * cp_size for every cp_size tested BIN_SIZE = 32 + NVTE_ENV_VARS = ( + "NVTE_FLASH_ATTN", + "NVTE_FUSED_ATTN", + "NVTE_UNFUSED_ATTN", + "NVTE_ALLOW_NONDETERMINISTIC_ALGO", + ) + + def setup_method(self, method): + self.original_nvte_env = {name: os.environ.get(name) for name in self.NVTE_ENV_VARS} + os.environ["NVTE_ALLOW_NONDETERMINISTIC_ALGO"] = "0" def teardown_method(self, method): - Utils.destroy_model_parallel() - _CudagraphGlobalRecord.cudagraph_created = False - _CudagraphGlobalRecord.cudagraph_record = [] - CudaGraphManager.global_mempool = None + try: + Utils.destroy_model_parallel() + _CudagraphGlobalRecord.cudagraph_created = False + _CudagraphGlobalRecord.cudagraph_record = [] + CudaGraphManager.global_mempool = None + finally: + for name, value in self.original_nvte_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value def _build_packed_seq_params(self, device): # Actual boundaries: each sequence's real tokens inside its slot; the trailing bin @@ -495,6 +517,9 @@ def test_thd_capture_with_pad_between_seqs(self, cp_size): initialize_rng_tracker(use_te_rng_tracker=True, force_reset=True) Utils.initialize_model_parallel(context_parallel_size=cp_size) model_parallel_cuda_manual_seed(123) + os.environ["NVTE_FLASH_ATTN"] = "0" + os.environ["NVTE_FUSED_ATTN"] = "1" + os.environ["NVTE_UNFUSED_ATTN"] = "0" config = TransformerConfig( num_layers=2, @@ -505,7 +530,10 @@ def test_thd_capture_with_pad_between_seqs(self, cp_size): params_dtype=torch.bfloat16, attention_dropout=0.0, hidden_dropout=0.0, + attention_backend=AttnBackend.fused, + deterministic_mode=True, cuda_graph_impl="local", + cuda_graph_warmup_steps=1, use_cpu_initialization=True, ) block = TransformerBlock(config, get_gpt_layer_with_transformer_engine_spec()).cuda() @@ -526,20 +554,76 @@ def test_thd_capture_with_pad_between_seqs(self, cp_size): eager_out = block( hidden_states=hidden_states, attention_mask=None, packed_seq_params=packed_seq_params ) + hidden_states_metadata = hidden_states.cg_buffer_metadata + assert hidden_states_metadata.is_cudagraph_input + assert hidden_states_metadata.is_saved_for_backward + + # The second layer's TE input layernorm saves the first layer's output for backward. + # This naturally exercises a CUDA graph output whose pool buffer must stay alive until + # backward capture. + first_runner = block.layers[0].cudagraph_manager.cudagraph_runners[0] + first_runner_record = next( + record + for record in _CudagraphGlobalRecord.cudagraph_record + if record[0] is first_runner and record[1] == "fwd" + ) + recorded_outputs = first_runner_record[4] + output_metadata = first_runner.get_arg_metas(recorded_outputs)[0].cg_buffer_metadata + output_metadata_state = ( + f"input={output_metadata.is_cudagraph_input}, " + f"output={output_metadata.is_cudagraph_output}, " + f"saved={output_metadata.is_saved_for_backward}" + ) + assert output_metadata.is_cudagraph_input, output_metadata_state + assert output_metadata.is_cudagraph_output, output_metadata_state + assert output_metadata.is_saved_for_backward, output_metadata_state + + # The q/kv aliases for each offsets tensor must share one metadata object while recording + # every graph-input use for replay-buffer sharing. + actual_cu_seqlens_metadata = packed_seq_params.cu_seqlens_q.cg_buffer_metadata + padded_cu_seqlens_metadata = packed_seq_params.cu_seqlens_q_padded.cg_buffer_metadata + assert packed_seq_params.cu_seqlens_kv.cg_buffer_metadata is actual_cu_seqlens_metadata + assert ( + packed_seq_params.cu_seqlens_kv_padded.cg_buffer_metadata is padded_cu_seqlens_metadata + ) + assert actual_cu_seqlens_metadata.is_cudagraph_input + assert padded_cu_seqlens_metadata.is_cudagraph_input eager_out.sum().backward() # This is the primary function under test. create_cudagraphs() + runners = [] for layer in block.layers: - runners = layer.cudagraph_manager.cudagraph_runners - assert len(runners) == 1 - assert runners[0].fwd_graph is not None + layer_runners = layer.cudagraph_manager.cudagraph_runners + assert len(layer_runners) == 1 + assert layer_runners[0].fwd_graph is not None + runners.extend(layer_runners) + + # There are four cu_seqlens arguments per layer: q/kv pairs for the real and padded + # offsets. Each pair and every later layer should alias one of two shared buffers. Within + # each buffer group, only its first graph-input occurrence performs the replay copy. + cu_seqlens_buffers = [ + tensor + for runner in runners + for tensor in runner.fwd_graph_input_surface[: runner.num_dgrads] + if tensor.dtype == torch.int32 and tensor.shape == packed_seq_params.cu_seqlens_q.shape + ] + assert len(cu_seqlens_buffers) == 4 * len(runners) + buffers_by_ptr = {} + for tensor in cu_seqlens_buffers: + buffers_by_ptr.setdefault(tensor.data_ptr(), []).append(tensor) + assert len(buffers_by_ptr) == 2 + for shared_buffers in buffers_by_ptr.values(): + assert sum(not tensor.can_skip_replay_copy for tensor in shared_buffers) == 1 graphed_out = block( hidden_states=hidden_states, attention_mask=None, packed_seq_params=packed_seq_params ) - assert torch.allclose(graphed_out.float(), eager_out.float(), rtol=1e-2, atol=1e-2) + assert torch.equal(graphed_out, eager_out), ( + "CUDA graph replay output is not bitwise equal to eager output: " + f"max_abs_diff={(graphed_out.float() - eager_out.float()).abs().max().item()}" + ) graphed_out.sum().backward() # Destroy captured graphs deterministically before parallel-state teardown. From 07e958fc74a1678f77fdf18c59b139176b324f3f Mon Sep 17 00:00:00 2001 From: Xuanteng Huang <44627253+xuantengh@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:49:21 +0800 Subject: [PATCH 120/290] refactor: extract and split common logic between GDN & GDN2 (#5843) Signed-off-by: Xuanteng Huang --- megatron/core/ssm/gated_delta_net/__init__.py | 33 ++ .../common.py} | 449 ++++++------------ megatron/core/ssm/gated_delta_net/gdn.py | 278 +++++++++++ tests/unit_tests/ssm/test_gated_delta_net.py | 145 ++++-- .../ssm/test_split_tensor_factory.py | 2 +- 5 files changed, 554 insertions(+), 353 deletions(-) create mode 100644 megatron/core/ssm/gated_delta_net/__init__.py rename megatron/core/ssm/{gated_delta_net.py => gated_delta_net/common.py} (69%) create mode 100644 megatron/core/ssm/gated_delta_net/gdn.py diff --git a/megatron/core/ssm/gated_delta_net/__init__.py b/megatron/core/ssm/gated_delta_net/__init__.py new file mode 100644 index 00000000000..6514f7b3a87 --- /dev/null +++ b/megatron/core/ssm/gated_delta_net/__init__.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Gated Delta Net (GDN) family of layers. + +This package replaces the former ``megatron/core/ssm/gated_delta_net.py`` module +at the same import path; the names below preserve that module's public surface. +""" + +from megatron.core.ssm.gated_delta_net.common import ( + HAVE_FLA, + GatedDeltaNetSubmodules, + causal_conv1d, + chunk_gated_delta_rule, + get_parameter_local_cp, + l2norm, + tensor_a2a_cp2hp, + tensor_a2a_hp2cp, + torch_chunk_gated_delta_rule, +) +from megatron.core.ssm.gated_delta_net.gdn import GatedDeltaNet + +__all__ = [ + "HAVE_FLA", + "GatedDeltaNet", + "GatedDeltaNetSubmodules", + "causal_conv1d", + "chunk_gated_delta_rule", + "get_parameter_local_cp", + "l2norm", + "tensor_a2a_cp2hp", + "tensor_a2a_hp2cp", + "torch_chunk_gated_delta_rule", +] diff --git a/megatron/core/ssm/gated_delta_net.py b/megatron/core/ssm/gated_delta_net/common.py similarity index 69% rename from megatron/core/ssm/gated_delta_net.py rename to megatron/core/ssm/gated_delta_net/common.py index 06eb0763e57..7ddaf6c3a1b 100644 --- a/megatron/core/ssm/gated_delta_net.py +++ b/megatron/core/ssm/gated_delta_net/common.py @@ -1,21 +1,21 @@ -# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. # Copyright (c) 2025, Songlin Yang, Jan Kautz, Ali Hatamizadeh. # Some of this code was adopted from https://github.com/huggingface/transformers # This source code is licensed under the Apache license found in the # LICENSE file in the root directory of this source tree. +# pylint: disable=unused-import + import logging from dataclasses import dataclass from functools import lru_cache -from typing import Optional, Union +from typing import Callable, Optional, Protocol, Union import torch import torch.nn as nn import torch.nn.functional as F -from torch import Tensor -from megatron.core import tensor_parallel from megatron.core.fp8_utils import get_fp8_align_size from megatron.core.inference.contexts import BaseInferenceContext from megatron.core.jit import jit_fuser @@ -38,7 +38,7 @@ make_sharded_tensors_for_checkpoint, sharded_state_dict_default, ) -from megatron.core.utils import deprecate_inference_params, nvtx_range_pop, nvtx_range_push +from megatron.core.utils import nvtx_range_pop, nvtx_range_push try: from fla.modules.convolution import causal_conv1d @@ -67,13 +67,45 @@ class GatedDeltaNetSubmodules: out_proj: Union[ModuleSpec, type] = IdentityOp -class GatedDeltaNet(MegatronModule): - """Gated Delta Net (GDN) layer class +class GatedDeltaRuleInterface(Protocol): + """ + Unified typing protocol for GDN core computation interfaces. + """ + + def __call__( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + g: torch.Tensor, + scale: float | None = None, + initial_state: torch.Tensor | None = None, + output_final_state: bool = False, + use_qk_l2norm_in_kernel: bool = False, + cu_seqlens: torch.LongTensor | None = None, + **kwargs, + ) -> tuple[torch.Tensor, torch.Tensor | None]: ... + + +class _GDNBase(MegatronModule): + """Common base class for the Gated Delta Net (GDN) family of layers. - GDN layer takes input with size [s, b, h] - and returns output of the same size. + Hosts everything the GDN variants share: the fused input projection, causal + convolution on q/k/v, the CP all-to-all plumbing, the kernel-input preparation + skeleton, the gated output norm + projection, and sharded checkpointing. """ + dt_bias_dim: int + a_log_dim: int + in_proj_qkvg_dim: int + in_proj_extra_dim: int + in_proj_dim: int + + dt_bias: nn.Parameter + A_log: nn.Parameter + + gated_delta_rule: GatedDeltaRuleInterface + def __init__( self, config: TransformerConfig, @@ -81,11 +113,13 @@ def __init__( layer_number: int = None, bias: bool = False, conv_bias: bool = False, - conv_init: Optional[float] = None, + conv_init: float | None = None, use_qk_l2norm: bool = True, A_init_range: tuple[float, float] = (1, 16), pg_collection: ProcessGroupCollection = None, + *, name: str | None = None, + cp_comm_type: str | None = None, ): """ Args: @@ -100,11 +134,14 @@ def __init__( pg_collection: The required process groups to use for tensor model parallel and context parallel. name (str | None): module instance name passed top-down from its paranet module + cp_comm_type (Optional[str]): Accepted for TransformerLayer compatibility and + ignored; GDN implements context parallelism with its own all-to-alls rather + than the attention CP communication schemes. """ - if not HAVE_FLA: raise ImportError( - "FLA is not installed. Please install it with `pip install flash-linear-attention`." + "FLA is not installed. Please install it with " + "`pip install flash-linear-attention[cuda]`." ) super().__init__(config) @@ -139,10 +176,25 @@ def __init__( self.qk_dim_local_tp = self.qk_dim // self.tp_size self.v_dim_local_tp = self.v_dim // self.tp_size - # Input projection (hidden_states -> q, k, v, gate, beta, alpha) - # TODO: for now, output gate is forced for GDN. - # We may remove this restriction in the future. - self.in_proj_dim = self.qk_dim * 2 + self.v_dim * 2 + self.num_value_heads * 2 + self.num_v_heads_local_tp = self.num_value_heads // self.tp_size + self.num_k_heads_local_tp = self.num_key_heads // self.tp_size + + attrs_to_check = ( + "dt_bias_dim", + "a_log_dim", + "in_proj_extra_dim", + "in_proj_split_names", + "in_proj_split_sections", + "feat_dim_split", + "gated_delta_rule", + ) + self._setup_variant_attrs() + for attr in attrs_to_check: + assert getattr(self, attr, None) is not None, f"Attribute {attr} for GDN is not set" + # QK, V, gate, shared across all variants + self.in_proj_qkvg_dim = self.qk_dim * 2 + self.v_dim * 2 + self.in_proj_dim = self.in_proj_qkvg_dim + self.in_proj_extra_dim + if self.config.fp8: fp8_align_size = get_fp8_align_size(self.config.fp8_recipe) assert self.in_proj_dim % fp8_align_size == 0, ( @@ -168,8 +220,6 @@ def __init__( self.conv_dim = self.qk_dim * 2 + self.v_dim self.conv_dim_local_tp = self.conv_dim // self.tp_size - # weight shape: [conv_dim, 1, d_conv] - # bias shape: [conv_dim] self.conv1d = nn.Conv1d( in_channels=self.conv_dim_local_tp, out_channels=self.conv_dim_local_tp, @@ -186,34 +236,22 @@ def __init__( setattr(self.conv1d.bias, "tensor_model_parallel", True) setattr(self.conv1d.bias, "partition_dim", 0) - # Time step projection (discretization) - self.num_v_heads_local_tp = self.num_value_heads // self.tp_size - # dt_bias parameter self.dt_bias = nn.Parameter( torch.empty( - self.num_v_heads_local_tp, - dtype=config.params_dtype, - device=torch.cuda.current_device(), + self.dt_bias_dim, dtype=self.config.params_dtype, device=torch.cuda.current_device() ) ) setattr(self.dt_bias, "tensor_model_parallel", True) setattr(self.dt_bias, "partition_dim", 0) - # A_log parameter + self.A_log = nn.Parameter( torch.empty( - self.num_v_heads_local_tp, - dtype=config.params_dtype, - device=torch.cuda.current_device(), + self.a_log_dim, dtype=self.config.params_dtype, device=torch.cuda.current_device() ) ) setattr(self.A_log, "tensor_model_parallel", True) setattr(self.A_log, "partition_dim", 0) - if self.config.deterministic_mode: - self.gated_delta_rule = torch_chunk_gated_delta_rule - else: - self.gated_delta_rule = chunk_gated_delta_rule - # Output layernorm before projection self.out_norm = build_module( submodules.out_norm, @@ -243,23 +281,35 @@ def __init__( self.reset_parameters() + def _setup_variant_attrs(self): + """Set variant specifics on the module. Called once from ``__init__``. + + Must set: + - ``in_proj_dim`` + - ``in_proj_split_names`` + - ``in_proj_split_sections`` + - ``feat_dim_split`` + - ``dt_bias_dim`` / ``a_log_dim`` (sizes of the gate parameters, which the + base class creates after the conv1d module to preserve the original + parameter registration order) + - ``gated_delta_rule`` (the kernel callable). + """ + raise NotImplementedError + def reset_parameters(self): """Reset the parameters.""" if self.config.perform_initialization: with get_cuda_rng_tracker().fork(): - # conv1d.weight if self.conv_init is not None: nn.init.uniform_(self.conv1d.weight, -self.conv_init, self.conv_init) - # dt_bias torch.ones( - self.num_v_heads_local_tp, - out=self.dt_bias.data, + self.dt_bias_dim, dtype=self.config.params_dtype, device=torch.cuda.current_device(), + out=self.dt_bias.data, ) - # A_log A = torch.empty( - self.num_v_heads_local_tp, + self.A_log.shape[0], dtype=self.config.params_dtype, device=torch.cuda.current_device(), ).uniform_(*self.A_init_range) @@ -267,265 +317,54 @@ def reset_parameters(self): def forward( self, - hidden_states: Tensor, - attention_mask: Tensor, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor, inference_context: Optional[BaseInferenceContext] = None, packed_seq_params: Optional[PackedSeqParams] = None, sequence_len_offset: Optional[int] = None, *, inference_params: Optional[BaseInferenceContext] = None, **kwargs, - ): - """ - Perform a forward pass through the GDN module. - - Args: - hidden_states (Tensor): Hidden states. - attention_mask (Tensor): Attention mask. - inference_context (Optional[BaseInferenceContext]): Inference context that manages - KV cache. - packed_seq_params (Optional[PackedSeqparams]): Parameters used for THD format. - sequence_len_offset (Optional[int]): Sequence length offset used for - inference CUDA graphs. - - Return: - (tuple[Tensor, Tensor]) GDN output and bias. + ) -> tuple[torch.Tensor, torch.Tensor]: + # pylint: disable=missing-function-docstring + raise NotImplementedError - """ - # TODO: Deal with attention_mask - - inference_context = deprecate_inference_params(inference_context, inference_params) + def _gated_norm_and_a2a( + self, + core_attn_out: torch.Tensor, + gate: torch.Tensor, + thd_cp_a2a_inv: torch.Tensor | None, + batch: int, + seq_len: int, + packed_seq_params: PackedSeqParams | None = None, + ) -> torch.Tensor: + # RMSNorm + nvtx_range_push(suffix="gated_norm") + norm_out_hp = self._apply_gated_norm(core_attn_out, gate) + nvtx_range_pop(suffix="gated_norm") - seq_len, batch, _ = hidden_states.shape - seq_len = seq_len * self.sp_size * self.cp_size - - if inference_context is not None: - assert ( - inference_context.is_static_batching() - ), "GDN does not currently support dynamic inference batching." - assert not self.config.sequence_parallel - # TODO: support inference - raise NotImplementedError("GDN does not support inference for now.") + # Transpose: b s x --> s b x + # From bshd back to sbhd format + norm_out_hp = norm_out_hp.reshape(batch, seq_len, -1) + norm_out_hp = norm_out_hp.transpose(0, 1).contiguous() + # CP all to all: HP to CP if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': - assert batch == 1, "Packed sequence expects batch dimension to be 1" - assert ( - not self.config.deterministic_mode - ), "Packed sequence does not support deterministic mode." - - # Resolve cu_seqlens with alignment padding handling. - cu_seqlens_q = self._resolve_cu_seqlens( - packed_seq_params.cu_seqlens_q_padded, - packed_seq_params.cu_seqlens_q, - seq_len, - "cu_seqlens_q", - cp_size=self.cp_size, - ) - cu_seqlens_kv = self._resolve_cu_seqlens( - packed_seq_params.cu_seqlens_kv_padded, - packed_seq_params.cu_seqlens_kv, - seq_len, - "cu_seqlens_kv", - cp_size=self.cp_size, - ) - assert torch.equal(cu_seqlens_q, cu_seqlens_kv), ( - "Currently only support cu_seqlens_q equals to cu_seqlens_kv, " - f"but got {cu_seqlens_q=} and {cu_seqlens_kv=}" - ) - num_packed_seqs = cu_seqlens_q.shape[0] - 1 - assert num_packed_seqs > 0, ( - "Number of packed sequences must be greater than 0, " - f"but got {cu_seqlens_q=} and {cu_seqlens_kv=}" - ) - else: - cu_seqlens_q = None - cu_seqlens_kv = None - - # Input projection - nvtx_range_push(suffix="in_proj") - qkvzba, _ = self.in_proj(hidden_states) - nvtx_range_pop(suffix="in_proj") - - # CP All to All: CP to HP - if self.cp_size > 1: - # # Pre-permute head dim so a single unsectioned a2a is equivalent to per-section a2a. - head_perm = _build_head_perm_for_split_sections( - ( - self.qk_dim_local_tp, - self.qk_dim_local_tp, - self.v_dim_local_tp, - self.v_dim_local_tp, - self.num_value_heads // self.tp_size, - self.num_value_heads // self.tp_size, - ), - self.pg_collection.cp.size(), - torch.cuda.current_device(), - ) - qkvzba = qkvzba.index_select(-1, head_perm) - if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': - qkvzba = tensor_a2a_cp2hp( - qkvzba, + if self.cp_size > 1: + norm_out_hp = norm_out_hp.index_select(0, thd_cp_a2a_inv) + norm_out = tensor_a2a_hp2cp( + norm_out_hp, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp, - undo_attention_load_balancing=False, - ) - if self.cp_size > 1: - # Permute at the seq dim so that a single unsectioned a2a - # is equivalent to per-sequence a2a. - # This also folds the ``_undo_attention_load_balancing`` step. - thd_cp_a2a_idx, thd_cp_a2a_inv = _build_thd_cp_a2a_perm( - cu_seqlens_q, self.cp_size, seq_len - ) - qkvzba = qkvzba.index_select(0, thd_cp_a2a_idx) - else: - qkvzba = tensor_a2a_cp2hp( - qkvzba, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp - ) - - # Transpose: s b x --> b s x - # From sbhd to bshd format - qkvzba = qkvzba.transpose(0, 1) - - # Split, reorder, and reshape the tensor into q, k, v, gate, beta, alpha - qkv, gate, beta, alpha = torch.split( - qkvzba, - [ - (self.qk_dim_local_tp * 2 + self.v_dim_local_tp) // self.cp_size, - self.v_dim_local_tp // self.cp_size, - self.num_value_heads // self.tp_size // self.cp_size, - self.num_value_heads // self.tp_size // self.cp_size, - ], - dim=-1, - ) - gate = gate.reshape(batch, seq_len, -1, self.value_head_dim) - beta = beta.reshape(batch, seq_len, -1) - alpha = alpha.reshape(batch, seq_len, -1) - - # Convolution on qkv - nvtx_range_push(suffix="conv1d") - seq_len = qkv.shape[1] - qkv_channels_split_sections = [ - self.qk_dim_local_tp, - self.qk_dim_local_tp, - self.v_dim_local_tp, - ] - conv1d_weight = get_parameter_local_cp( - self.conv1d.weight, - dim=0, - cp_group=self.pg_collection.cp, - split_sections=qkv_channels_split_sections, - ) - conv1d_bias = ( - get_parameter_local_cp( - self.conv1d.bias, - dim=0, - cp_group=self.pg_collection.cp, - split_sections=qkv_channels_split_sections, - ) - if self.conv_bias - else None - ) - if self.config.deterministic_mode: - qkv = qkv.transpose(1, 2).contiguous() # b, s, d -> b, d, s - conv_out = F.conv1d( - input=qkv, # Torch-native only accept [b, d, s] format input - weight=conv1d_weight, - bias=conv1d_bias, - stride=self.conv1d.stride, - padding=self.conv1d.padding, - dilation=self.conv1d.dilation, - groups=self.conv_dim_local_tp // self.cp_size, + redo_attention_load_balancing=False, ) - qkv = self.act_fn(conv_out[..., :seq_len]) - qkv = qkv.transpose(1, 2) # b, d, s -> b, s, d else: - assert self.activation in ["silu", "swish"] - qkv, _ = causal_conv1d( - x=qkv, # FLA conv1d accepts [b, s, d] format input - weight=conv1d_weight.squeeze(1), # d, 1, w -> d, w - bias=conv1d_bias, - activation=self.activation, - initial_state=None, - output_final_state=False, - cu_seqlens=cu_seqlens_q, + norm_out = tensor_a2a_hp2cp( + norm_out_hp, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp ) - nvtx_range_pop(suffix="conv1d") - - # Prepare QKV tensors (split, reshape, L2 norm, repeat_interleave, contiguous) - nvtx_range_push(suffix="prepare_qkv_for_gated_delta_rule") - query, key, value, gate, beta, alpha = self._prepare_qkv_for_gated_delta_rule( - qkv, gate, beta, alpha, batch, seq_len - ) - nvtx_range_pop(suffix="prepare_qkv_for_gated_delta_rule") - - # Calculate g and beta - nvtx_range_push(suffix="g_and_beta") - A_log_local_cp = get_parameter_local_cp(self.A_log, dim=0, cp_group=self.pg_collection.cp) - dt_bias_local_cp = get_parameter_local_cp( - self.dt_bias, dim=0, cp_group=self.pg_collection.cp - ) - g, beta = self._compute_g_and_beta(A_log_local_cp, dt_bias_local_cp, alpha, beta) - nvtx_range_pop(suffix="g_and_beta") - - nvtx_range_push(suffix="gated_delta_rule") - core_attn_out, last_recurrent_state = self.gated_delta_rule( - query, - key, - value, - g=g, - beta=beta, - initial_state=None, - output_final_state=False, - use_qk_l2norm_in_kernel=False, - cu_seqlens=cu_seqlens_q, - ) - nvtx_range_pop(suffix="gated_delta_rule") - - def _gated_norm_and_a2a(core_attn_out: torch.Tensor, gate: torch.Tensor): - # RMSNorm - nvtx_range_push(suffix="gated_norm") - norm_out_hp = self._apply_gated_norm(core_attn_out, gate) - nvtx_range_pop(suffix="gated_norm") - - # Transpose: b s x --> s b x - # From bshd back to sbhd format - norm_out_hp = norm_out_hp.reshape(batch, seq_len, -1) - norm_out_hp = norm_out_hp.transpose(0, 1).contiguous() - - # CP all to all: HP to CP - if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': - if self.cp_size > 1: - norm_out_hp = norm_out_hp.index_select(0, thd_cp_a2a_inv) - norm_out = tensor_a2a_hp2cp( - norm_out_hp, - seq_dim=0, - head_dim=-1, - cp_group=self.pg_collection.cp, - redo_attention_load_balancing=False, - ) - else: - norm_out = tensor_a2a_hp2cp( - norm_out_hp, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp - ) - return norm_out - - if self.recompute_norm_out: - self.norm_out_checkpoint = tensor_parallel.CheckpointWithoutOutput() - norm_out = self.norm_out_checkpoint.checkpoint(_gated_norm_and_a2a, core_attn_out, gate) - else: - norm_out = _gated_norm_and_a2a(core_attn_out, gate) - - # Output projection - nvtx_range_push(suffix="out_proj") - out, out_bias = self.out_proj(norm_out) - nvtx_range_pop(suffix="out_proj") - - if self.recompute_norm_out: - self.norm_out_checkpoint.discard_output_and_register_recompute(out) - - return out, out_bias + return norm_out @jit_fuser def _apply_gated_norm(self, x, gate): @@ -540,10 +379,21 @@ def _apply_gated_norm(self, x, gate): return y @jit_fuser - def _prepare_qkv_for_gated_delta_rule(self, qkv, gate, beta, alpha, batch, seq_len): + def _prepare_input_for_gated_delta_rule( + self, + qkv: torch.Tensor, + gate: torch.Tensor, + batch: int, + seq_len: int, + *gate_feats: tuple[torch.Tensor], + ) -> tuple[torch.Tensor, ...]: """ - Prepare query, key, value, gate, beta, alpha tensors for gated delta rule. + Prepare the query, key, value, gate, and variant gate-feature tensors for the + gated delta rule kernels. + Fuses split, reshape, L2 norm, repeat_interleave, and contiguous operations. + ``gate_feats`` holds the variant-specific in_proj sections, which are returned + contiguous for the decay/gating computation in ``forward``. """ # Split qkv into query_key and value query_key, value = torch.split( @@ -575,13 +425,18 @@ def _prepare_qkv_for_gated_delta_rule(self, qkv, gate, beta, alpha, batch, seq_l key = key.contiguous() value = value.contiguous() gate = gate.contiguous() - beta = beta.contiguous() - alpha = alpha.contiguous() + gate_feats = tuple(t.contiguous() for t in gate_feats) - return query, key, value, gate, beta, alpha + return query, key, value, gate, *gate_feats @jit_fuser - def _compute_g_and_beta(self, A_log_local_cp, dt_bias_local_cp, alpha, beta): + def _compute_g_and_beta( + self, + A_log_local_cp: torch.Tensor, + dt_bias_local_cp: torch.Tensor, + alpha: torch.Tensor, + beta: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: """ Compute g (decay) and beta (sigmoid) for gated delta rule. Fuses exp, softplus, mul, neg, and sigmoid operations. @@ -669,15 +524,8 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None, tp_gr sharded_state_dict[f"{prefix}in_proj.weight"] = _split_tensor_factory( sharded_state_dict[f"{prefix}in_proj.weight"], - [ - self.qk_dim_local_tp, - self.qk_dim_local_tp, - self.v_dim_local_tp, - self.v_dim_local_tp, - self.num_value_heads // self.tp_size, - self.num_value_heads // self.tp_size, - ], - ["query", "key", "value", "z", "beta", "alpha"], + list(self.in_proj_split_sections), + self.in_proj_split_names, 0, ) @@ -951,9 +799,9 @@ def tensor_a2a_hp2cp( # Torch native gated delta rule #################### def torch_chunk_gated_delta_rule( - query, - key, - value, + q, + k, + v, g, beta, chunk_size=64, @@ -961,7 +809,7 @@ def torch_chunk_gated_delta_rule( output_final_state=False, use_qk_l2norm_in_kernel=False, cu_seqlens=None, -): +) -> tuple[torch.Tensor, torch.Tensor | None]: # pylint: disable=line-too-long ''' Torch-native implementation of chunked gated delta rule for deterministic mode. @@ -974,6 +822,7 @@ def torch_chunk_gated_delta_rule( cu_seqlens is None ), "cu_seqlens is not supported for torch_chunk_gated_delta_rule for now." + query, key, value = q, k, v initial_dtype = query.dtype if use_qk_l2norm_in_kernel: query = l2norm(query, dim=-1, eps=1e-6) diff --git a/megatron/core/ssm/gated_delta_net/gdn.py b/megatron/core/ssm/gated_delta_net/gdn.py new file mode 100644 index 00000000000..65d9dc7df0a --- /dev/null +++ b/megatron/core/ssm/gated_delta_net/gdn.py @@ -0,0 +1,278 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2025, Songlin Yang, Jan Kautz, Ali Hatamizadeh. + +# Some of this code was adopted from https://github.com/huggingface/transformers +# This source code is licensed under the Apache license found in the +# LICENSE file in the root directory of this source tree. + +from functools import partial +from typing import Optional + +import torch +import torch.nn.functional as F + +from megatron.core import tensor_parallel +from megatron.core.inference.contexts import BaseInferenceContext +from megatron.core.packed_seq_params import PackedSeqParams +from megatron.core.ssm.gated_delta_net.common import ( + _build_head_perm_for_split_sections, + _build_thd_cp_a2a_perm, + _GDNBase, + causal_conv1d, + chunk_gated_delta_rule, + get_parameter_local_cp, + tensor_a2a_cp2hp, + torch_chunk_gated_delta_rule, +) +from megatron.core.utils import deprecate_inference_params, nvtx_range_pop, nvtx_range_push + + +class GatedDeltaNet(_GDNBase): + # pylint: disable=missing-class-docstring + def _setup_variant_attrs(self): + """Set the GDN in_proj sizing, split tables, gate parameter dims, and kernel.""" + # alpha, beta + self.in_proj_extra_dim = self.num_value_heads * 2 + + # Per-section sizes (and names) of the in_proj output, local to this TP rank. + # Used for the CP head permutation (pre-a2a), for splitting the projection + # output (post-a2a), and for the sharded checkpoint split of in_proj.weight. + self.in_proj_split_names = ["query", "key", "value", "z", "beta", "alpha"] + self.in_proj_split_sections = ( + self.qk_dim_local_tp, # q + self.qk_dim_local_tp, # k + self.v_dim_local_tp, # v + self.v_dim_local_tp, # gate (z) + self.num_value_heads // self.tp_size, # beta + self.num_value_heads // self.tp_size, # alpha + ) + self.feat_dim_split = ( + (self.qk_dim_local_tp * 2 + self.v_dim_local_tp) // self.cp_size, # qkv + self.v_dim_local_tp // self.cp_size, # gate (z) + self.num_value_heads // self.tp_size // self.cp_size, # beta + self.num_value_heads // self.tp_size // self.cp_size, # alpha + ) + + self.dt_bias_dim = self.num_v_heads_local_tp + self.a_log_dim = self.num_v_heads_local_tp + + if self.config.deterministic_mode: + self.gated_delta_rule = torch_chunk_gated_delta_rule + else: + self.gated_delta_rule = chunk_gated_delta_rule + + def forward( + self, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor, + inference_context: Optional[BaseInferenceContext] = None, + packed_seq_params: Optional[PackedSeqParams] = None, + sequence_len_offset: Optional[int] = None, + *, + inference_params: Optional[BaseInferenceContext] = None, + **kwargs, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + """ + Perform a forward pass through the GDN module. + + Return: + (tuple[torch.Tensor, torch.Tensor]) GDN output and bias. + """ + + inference_context = deprecate_inference_params(inference_context, inference_params) + + seq_len, batch, _ = hidden_states.shape + seq_len = seq_len * self.sp_size * self.cp_size + + if inference_context is not None: + assert ( + inference_context.is_static_batching() + ), "GDN does not currently support dynamic inference batching." + assert not self.config.sequence_parallel + # TODO: support inference + raise NotImplementedError("GDN does not support inference for now.") + + if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': + assert batch == 1, "Packed sequence expects batch dimension to be 1" + assert ( + not self.config.deterministic_mode + ), "Packed sequence does not support deterministic mode." + + # Resolve cu_seqlens with alignment padding handling. + cu_seqlens_q = self._resolve_cu_seqlens( + packed_seq_params.cu_seqlens_q_padded, + packed_seq_params.cu_seqlens_q, + seq_len, + "cu_seqlens_q", + cp_size=self.cp_size, + ) + cu_seqlens_kv = self._resolve_cu_seqlens( + packed_seq_params.cu_seqlens_kv_padded, + packed_seq_params.cu_seqlens_kv, + seq_len, + "cu_seqlens_kv", + cp_size=self.cp_size, + ) + assert torch.equal(cu_seqlens_q, cu_seqlens_kv), ( + "Currently only support cu_seqlens_q equals to cu_seqlens_kv, " + f"but got {cu_seqlens_q=} and {cu_seqlens_kv=}" + ) + num_packed_seqs = cu_seqlens_q.shape[0] - 1 + assert num_packed_seqs > 0, ( + "Number of packed sequences must be greater than 0, " + f"but got {cu_seqlens_q=} and {cu_seqlens_kv=}" + ) + else: + cu_seqlens_q = None + cu_seqlens_kv = None + + # Input projection + nvtx_range_push(suffix="in_proj") + qkvzba, _ = self.in_proj(hidden_states) + nvtx_range_pop(suffix="in_proj") + + # CP All to All: CP to HP + if self.cp_size > 1: + # # Pre-permute head dim so a single unsectioned a2a is equivalent to per-section a2a. + head_perm = _build_head_perm_for_split_sections( + self.in_proj_split_sections, + self.pg_collection.cp.size(), + torch.cuda.current_device(), + ) + qkvzba = qkvzba.index_select(-1, head_perm) + + thd_cp_a2a_idx, thd_cp_a2a_inv = None, None + if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': + qkvzba = tensor_a2a_cp2hp( + qkvzba, + seq_dim=0, + head_dim=-1, + cp_group=self.pg_collection.cp, + undo_attention_load_balancing=False, + ) + if self.cp_size > 1: + # Permute at the seq dim so that a single unsectioned a2a + # is equivalent to per-sequence a2a. + # This also folds the ``_undo_attention_load_balancing`` step. + thd_cp_a2a_idx, thd_cp_a2a_inv = _build_thd_cp_a2a_perm( + cu_seqlens_q, self.cp_size, seq_len + ) + qkvzba = qkvzba.index_select(0, thd_cp_a2a_idx) + else: + qkvzba = tensor_a2a_cp2hp( + qkvzba, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp + ) + + # Transpose: s b x --> b s x + # From sbhd to bshd format + qkvzba = qkvzba.transpose(0, 1) + + # Split the tensor into q, k, v, gate (z), and the variant-specific gate features + # (beta, alpha for GDN; f, b, w for GDN2) + qkv, gate, beta, alpha = torch.split(qkvzba, self.feat_dim_split, dim=-1) + gate = gate.reshape(batch, seq_len, -1, self.value_head_dim) + + # Convolution on qkv + nvtx_range_push(suffix="conv1d") + seq_len = qkv.shape[1] + qkv_channels_split_sections = [ + self.qk_dim_local_tp, + self.qk_dim_local_tp, + self.v_dim_local_tp, + ] + conv1d_weight = get_parameter_local_cp( + self.conv1d.weight, + dim=0, + cp_group=self.pg_collection.cp, + split_sections=qkv_channels_split_sections, + ) + conv1d_bias = ( + get_parameter_local_cp( + self.conv1d.bias, + dim=0, + cp_group=self.pg_collection.cp, + split_sections=qkv_channels_split_sections, + ) + if self.conv_bias + else None + ) + if self.config.deterministic_mode: + qkv = qkv.transpose(1, 2).contiguous() # b, s, d -> b, d, s + conv_out = F.conv1d( + input=qkv, # Torch-native only accept [b, d, s] format input + weight=conv1d_weight, + bias=conv1d_bias, + stride=self.conv1d.stride, + padding=self.conv1d.padding, + dilation=self.conv1d.dilation, + groups=self.conv_dim_local_tp // self.cp_size, + ) + qkv = self.act_fn(conv_out[..., :seq_len]) + qkv = qkv.transpose(1, 2) # b, d, s -> b, s, d + else: + assert self.activation in ["silu", "swish"] + qkv, _ = causal_conv1d( + x=qkv, # FLA conv1d accepts [b, s, d] format input + weight=conv1d_weight.squeeze(1), # d, 1, w -> d, w + bias=conv1d_bias, + activation=self.activation, + initial_state=None, + output_final_state=False, + cu_seqlens=cu_seqlens_q, + ) + nvtx_range_pop(suffix="conv1d") + + A_log_local_cp = get_parameter_local_cp(self.A_log, dim=0, cp_group=self.pg_collection.cp) + dt_bias_local_cp = get_parameter_local_cp( + self.dt_bias, dim=0, cp_group=self.pg_collection.cp + ) + + # Prepare QKV tensors (split, reshape, L2 norm, repeat_interleave, contiguous) + nvtx_range_push(suffix="prepare_input_for_gated_delta_rule") + query, key, value, gate, beta, alpha = self._prepare_input_for_gated_delta_rule( + qkv, gate, batch, seq_len, beta, alpha + ) + nvtx_range_pop(suffix="prepare_input_for_gated_delta_rule") + + nvtx_range_push(suffix="g_and_beta") + g, beta = self._compute_g_and_beta(A_log_local_cp, dt_bias_local_cp, alpha, beta) + nvtx_range_pop(suffix="g_and_beta") + + nvtx_range_push(suffix="gated_delta_rule") + core_attn_out, _ = self.gated_delta_rule( + query, + key, + value, + g=g, + beta=beta, + initial_state=None, + output_final_state=False, + use_qk_l2norm_in_kernel=False, + cu_seqlens=cu_seqlens_q, + ) + nvtx_range_pop(suffix="gated_delta_rule") + + if self.recompute_norm_out: + self.norm_out_checkpoint = tensor_parallel.CheckpointWithoutOutput() + norm_func = partial( + self._gated_norm_and_a2a, + thd_cp_a2a_inv=thd_cp_a2a_inv, + batch=batch, + seq_len=seq_len, + packed_seq_params=packed_seq_params, + ) + norm_out = self.norm_out_checkpoint.checkpoint(norm_func, core_attn_out, gate) + else: + norm_out = self._gated_norm_and_a2a( + core_attn_out, gate, thd_cp_a2a_inv, batch, seq_len, packed_seq_params + ) + + # Output projection + nvtx_range_push(suffix="out_proj") + out, out_bias = self.out_proj(norm_out) + nvtx_range_pop(suffix="out_proj") + + if self.recompute_norm_out: + self.norm_out_checkpoint.discard_output_and_register_recompute(out) + + return out, out_bias diff --git a/tests/unit_tests/ssm/test_gated_delta_net.py b/tests/unit_tests/ssm/test_gated_delta_net.py index 074e7740db2..7cd2eb5e104 100644 --- a/tests/unit_tests/ssm/test_gated_delta_net.py +++ b/tests/unit_tests/ssm/test_gated_delta_net.py @@ -2,41 +2,27 @@ import copy import os -from unittest import mock import pytest import torch import torch.nn.functional as F from megatron.core import parallel_state -from megatron.core.models.common.embeddings.rope_utils import ( - get_pos_emb_on_this_cp_rank as get_tensor_on_this_cp_rank, -) from megatron.core.models.gpt.experimental_attention_variant_module_specs import ( get_experimental_attention_variant_module_spec, get_transformer_block_with_experimental_attention_variant_spec, ) -from megatron.core.models.gpt.gpt_model import GPTModel from megatron.core.process_groups_config import ProcessGroupCollection -from megatron.core.ssm.gated_delta_net import ( - GatedDeltaNet, +from megatron.core.ssm.gated_delta_net import GatedDeltaNet +from megatron.core.ssm.gated_delta_net.common import ( _build_head_perm_for_split_sections, _build_thd_cp_a2a_perm, tensor_a2a_cp2hp, tensor_a2a_hp2cp, + torch_chunk_gated_delta_rule, ) from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer import TransformerConfig -from megatron.core.utils import unwrap_model -from megatron.training.arguments import parse_args -from megatron.training.checkpointing import load_checkpoint, save_checkpoint -from megatron.training.global_vars import set_args -from megatron.training.training import get_model -from tests.unit_tests.dist_checkpointing import ( - TempNamedDir, - init_basic_mock_args, - init_checkpointing_mock_args, -) from tests.unit_tests.test_utilities import Utils from tests.unit_tests.transformer.test_attention import _test_parallel_attention_correctness from tests.unit_tests.transformer.test_multi_latent_attention import ( @@ -247,6 +233,78 @@ def run(gdn, hidden_states): rec_grads[name], base_grads[name] ), f"Grad not identical for {name} ({rank=})" + def test_deterministic_mode(self): + tp_group = parallel_state.get_tensor_model_parallel_group() + cp_group = parallel_state.get_context_parallel_group() + pg_collection = ProcessGroupCollection(tp=tp_group, cp=cp_group) + + det_config = copy.deepcopy(self.transformer_config) + det_config.deterministic_mode = True + + gdn_submodules = get_experimental_attention_variant_module_spec( + config=det_config + ).submodules + + model_parallel_cuda_manual_seed(42) + torch.manual_seed(42) + gdn = ( + GatedDeltaNet( + det_config, + submodules=gdn_submodules, + layer_number=1, + bias=False, + conv_bias=False, + conv_init=1.0, + use_qk_l2norm=True, + A_init_range=(1, 16), + pg_collection=pg_collection, + ) + .cuda() + .bfloat16() + ) + + # deterministic_mode must select the torch-native kernel, not FLA. + assert gdn.gated_delta_rule is torch_chunk_gated_delta_rule + + micro_batch_size = 2 + seq_length = 64 + torch.manual_seed(0) + base_input = torch.randn( + (seq_length // self.sp_size // self.cp_size, micro_batch_size, gdn.config.hidden_size), + device=torch.cuda.current_device(), + dtype=torch.bfloat16, + ) + + def run(): + hidden_states = base_input.clone().requires_grad_(True) + output, _ = gdn(hidden_states, None) + output.float().sum().backward() + grads = { + name: param.grad.detach().clone() + for name, param in gdn.named_parameters() + if param.grad is not None + } + gdn.zero_grad(set_to_none=True) + return output.detach().clone(), grads, hidden_states.grad.detach().clone() + + out1, grads1, input_grad1 = run() + out2, grads2, input_grad2 = run() + + rank = torch.distributed.get_rank() + assert torch.equal(out1, out2), f"Output not reproducible ({rank=})" + assert torch.equal(input_grad1, input_grad2), f"Input grad not reproducible ({rank=})" + assert set(grads1.keys()) == set(grads2.keys()) + for name in grads1: + assert torch.equal( + grads1[name], grads2[name] + ), f"Grad not reproducible for {name} ({rank=})" + + def test_module_construction(self): + gdn = self.gdn + assert gdn.in_proj_dim == 2 * gdn.qk_dim + 2 * gdn.v_dim + 2 * gdn.num_value_heads + assert gdn.A_log.shape == (gdn.num_value_heads // self.tp_size,) + assert gdn.dt_bias.shape == (gdn.num_value_heads // self.tp_size,) + def test_jit_compiled_helpers(self): import torch._dynamo @@ -254,62 +312,45 @@ def test_jit_compiled_helpers(self): batch = 2 seq_len = 16 + device = torch.cuda.current_device() num_v_heads_local = gdn.num_value_heads // gdn.tp_size // gdn.cp_size + num_k_heads_local = gdn.num_key_heads // gdn.tp_size // gdn.cp_size + qk_dim_local = gdn.qk_dim_local_tp // gdn.cp_size + v_dim_local = gdn.v_dim_local_tp // gdn.cp_size - qkv_last_dim = (2 * gdn.qk_dim_local_tp + gdn.v_dim_local_tp) // gdn.cp_size qkv = torch.randn( - batch, seq_len, qkv_last_dim, device=torch.cuda.current_device(), dtype=torch.bfloat16 + batch, seq_len, 2 * qk_dim_local + v_dim_local, device=device, dtype=torch.bfloat16 ) gate = torch.randn( batch, seq_len, num_v_heads_local, gdn.value_head_dim, - device=torch.cuda.current_device(), - dtype=torch.bfloat16, - ) - beta = torch.randn( - batch, - seq_len, - num_v_heads_local, - device=torch.cuda.current_device(), - dtype=torch.bfloat16, - ) - alpha = torch.randn( - batch, - seq_len, - num_v_heads_local, - device=torch.cuda.current_device(), + device=device, dtype=torch.bfloat16, ) + gate_feats = ( + torch.randn(batch, seq_len, num_v_heads_local, device=device, dtype=torch.bfloat16), + torch.randn(batch, seq_len, num_v_heads_local, device=device, dtype=torch.bfloat16), + ) # beta, alpha # Disable dynamo so coverage.py can trace through the method bodies, # which are normally wrapped by @jit_fuser (torch.compile). with torch._dynamo.config.patch(disable=True): - query, key, value, gate_out, beta_out, alpha_out = ( - gdn._prepare_qkv_for_gated_delta_rule(qkv, gate, beta, alpha, batch, seq_len) + query, key, value, gate_out, *gate_feats_out = gdn._prepare_input_for_gated_delta_rule( + qkv, gate, batch, seq_len, *gate_feats ) assert query.shape == (batch, seq_len, num_v_heads_local, gdn.key_head_dim) assert key.shape == (batch, seq_len, num_v_heads_local, gdn.key_head_dim) assert value.shape == (batch, seq_len, num_v_heads_local, gdn.value_head_dim) - assert query.is_contiguous() - assert key.is_contiguous() - assert value.is_contiguous() - - A_log_mock = torch.randn( - num_v_heads_local, device=torch.cuda.current_device(), dtype=torch.bfloat16 - ) - dt_bias_mock = torch.randn( - num_v_heads_local, device=torch.cuda.current_device(), dtype=torch.bfloat16 - ) - - with torch._dynamo.config.patch(disable=True): - g, beta_sig = gdn._compute_g_and_beta(A_log_mock, dt_bias_mock, alpha, beta) + for t in (query, key, value, gate_out, *gate_feats_out): + assert t.is_contiguous() - assert g.dtype == torch.float32 - assert g.shape == alpha.shape - assert beta_sig.shape == beta.shape + # The variant gate features (beta, alpha) pass through with shapes intact + beta_out, alpha_out = gate_feats_out + assert beta_out.shape == (batch, seq_len, num_v_heads_local) + assert alpha_out.shape == (batch, seq_len, num_v_heads_local) def test_gpu_forward_thd_correctness(self): if self.sp_size > 1: diff --git a/tests/unit_tests/ssm/test_split_tensor_factory.py b/tests/unit_tests/ssm/test_split_tensor_factory.py index abb668e16a8..ab9fd434e08 100644 --- a/tests/unit_tests/ssm/test_split_tensor_factory.py +++ b/tests/unit_tests/ssm/test_split_tensor_factory.py @@ -7,7 +7,7 @@ import torch from megatron.core.dist_checkpointing import ShardedTensor -from megatron.core.ssm.gated_delta_net import ( +from megatron.core.ssm.gated_delta_net.common import ( _split_tensor_factory as gated_delta_split_tensor_factory, ) from megatron.core.ssm.mamba_mixer import _split_tensor_factory as mamba_split_tensor_factory From c4b4d5930e9dcd58ab86a91e1c2ceffc237180d9 Mon Sep 17 00:00:00 2001 From: Guihong Li Date: Mon, 27 Jul 2026 22:48:30 -0700 Subject: [PATCH 121/290] Add load-time GPT-to-Hybrid checkpoint translation (#5675) (#5792) Signed-off-by: guihong-nv --- docs/user-guide/hybrid-model-migration.md | 160 +- .../gpt_checkpoint_interop.py | 362 ++++ megatron/training/checkpointing.py | 1453 +++++++++++------ .../models/test_gpt_hybrid_interop.py | 827 ++++++++++ tests/unit_tests/dist_checkpointing/utils.py | 30 +- 5 files changed, 2335 insertions(+), 497 deletions(-) create mode 100644 megatron/core/dist_checkpointing/gpt_checkpoint_interop.py create mode 100644 tests/unit_tests/dist_checkpointing/models/test_gpt_hybrid_interop.py diff --git a/docs/user-guide/hybrid-model-migration.md b/docs/user-guide/hybrid-model-migration.md index 445a93e81e2..8f9d02bf18f 100644 --- a/docs/user-guide/hybrid-model-migration.md +++ b/docs/user-guide/hybrid-model-migration.md @@ -70,11 +70,123 @@ treated as a new architecture and benchmarked independently. ## 2. How to Convert a Checkpoint -Use -[`tools/checkpoint/gpt_hybrid_conversion.py`](https://github.com/NVIDIA/Megatron-LM/blob/main/tools/checkpoint/gpt_hybrid_conversion.py) -to convert a `GPTModel` checkpoint directly to `HybridModel` state-dict keys. +There are two ways to bring `GPTModel` weights into a `HybridModel` run. Both +stay in Megatron's distributed-checkpoint format and can reshard across a +different tensor, pipeline, expert, or FSDP layout on the following load. + +- **Option A — translate at load time (no separate step).** Start the hybrid + run directly against the GPT checkpoint. The hybrid model retargets its own + checkpoint state dict at the GPT checkpoint's keys during loading, so no + second copy is written to disk. This supports both `torch_dist` checkpoints + and Megatron-FSDP `fsdp_dtensor` checkpoints, including their optimizer + state. This path also supports patterns that contain layer families with no + GPT counterpart, such as Mamba (`M`) positions, which keep their fresh + initialization. +- **Option B — convert offline to a new checkpoint.** Use + [`tools/checkpoint/gpt_hybrid_conversion.py`](https://github.com/NVIDIA/Megatron-LM/blob/main/tools/checkpoint/gpt_hybrid_conversion.py) + to write a standalone `HybridModel` checkpoint whose keys already match the + hybrid layout. Use this when you need a persisted hybrid checkpoint, an + architecture-preserving `*-` or `*E` copy, or a target you can inspect before + training. This path only supports `*-` and `*E` layouts. + +### Option A: Translate at load time + +Load-time translation is handled by +[`megatron/core/dist_checkpointing/gpt_checkpoint_interop.py`](https://github.com/NVIDIA/Megatron-LM/blob/main/megatron/core/dist_checkpointing/gpt_checkpoint_interop.py). +It triggers automatically when a non-hybrid (GPT) checkpoint is loaded into a +`HybridModel` run: for `torch_dist`, the run's model and optimizer sharded +state dicts are rewritten into the GPT checkpoint's homogeneous-layer format; +for `fsdp_dtensor`, their explicit parameter-name mappings are rewritten onto +the GPT keys before Torch DCP planning. The checkpoint is read directly, and +the weights and optimizer state are resharded to the current +TP/PP/EP/ETP/FSDP layout. No conversion tool is run, and the GPT checkpoint on +disk is never modified. + +The reverse mismatch is an error: loading a checkpoint that was saved by a +hybrid run into a non-hybrid run raises a `RuntimeError` that directs you to the +hybrid training entrypoint. + +#### Select the checkpoint semantics + +When the hybrid run loads a GPT checkpoint, it must set +`--hybrid-layer-pattern` so checkpoint layers can be paired with hybrid layer +positions. Point either `--load` or `--pretrained-checkpoint` at the GPT +checkpoint root (see [Section 3](#3-how-to-train-a-model)). + +`--finetune` is optional and retains its normal checkpoint-loading meaning; the +GPT-to-Hybrid translation does not select it on the user's behalf: + +- Without `--finetune`, a direct `--load` resumes iteration, optimizer, + scheduler, RNG, and rerun state according to the normal checkpoint and + parallel-layout compatibility rules. +- With `--finetune`, iteration, scheduler, RNG, and rerun state restart fresh. + Model weights still load, and translated optimizer state loads unless + `--no-load-optim` is set. +- The existing `--pretrained-checkpoint` fallback uses finetuning semantics + when the `--load` directory contains no checkpoint. Use `--load` directly + when full resume semantics are desired. + +By default the GPT run's **optimizer state is also translated and loaded** — +Adam moments and fp32 master params for the attention and MLP layers carry over, +enabling architecture-preserving continued training. Pass `--no-load-optim` to +skip this and start every layer's optimizer state fresh. + +For `torch_dist`, loading optimizer state requires the GPT checkpoint to use a +model-space distributed-optimizer format (`fully_reshardable` or +`fully_sharded_model_space`, i.e. saved with +`--dist-ckpt-optim-fully-reshardable`). The bucket-space formats key optimizer +state by a flat buffer layout that the extra hybrid layers reshuffle, so the +run raises an error directing you to re-save the checkpoint or pass +`--no-load-optim`. + +For Megatron FSDP, save and load with `--ckpt-format fsdp_dtensor`. The loader +retargets the explicit DTensor model keys and the model-parameter names used by +the distributed optimizer, so model weights and optimizer state can be +resharded across a different FSDP, TP, EP, or ETP layout during the automatic +GPT-to-Hybrid load. This path is for Megatron FSDP; Torch FSDP2's `torch_dcp` +format is not supported by this automatic translation. + +Layers without a GPT counterpart (for example Mamba `M` positions) have no +optimizer state in the checkpoint; their moments start fresh, and the run prints +a warning naming how many layers are affected. + +#### Supported patterns and key mapping + +The main pattern (the part before any `/` MTP suffix, with `|` pipeline +separators ignored) may contain: + +| Symbol | Source of weights | +|--------|-------------------| +| `*` | GPT `self_attention` sub-module of the paired layer | +| `-` or `E` | GPT `mlp` sub-module of the paired layer (MoE tensors also live under `mlp.*`) | +| `M` | No GPT source; the Mamba layer keeps its fresh initialization | + +Parameters are paired by occurrence: the *i*-th `*` position takes GPT layer +*i*'s attention, and the *i*-th `-`/`E` position takes GPT layer *i*'s MLP. +`decoder.final_norm` is loaded from GPT's `decoder.final_layernorm`, and +embedding and output weights are copied unchanged. + +Because each GPT layer supplies exactly one attention and one MLP sub-module, +the loader rejects a pattern that: + +- contains MTP layers (a `/...` suffix), which have no GPT source weights; +- uses a layer type it cannot translate, such as GDN (`G`) or DeepSeek Sparse + Attention (`D`), whose weight layouts differ from GPT attention; +- mixes dense (`-`) and MoE (`E`) MLP positions in one pattern; or +- has an unequal or zero number of `*` and MLP positions. + +The checkpoint's `num_layers` must equal the number of `*` positions in the +pattern; a mismatch is rejected. -### Choose an architecture-preserving pattern +```{warning} +Optimizer translation covers the Adam moments and fp32 master params. Pass +`--no-load-optim` for a weights-only load. Use `--finetune` when iteration, +scheduler, RNG, and rerun state should restart instead of resume. +``` + +### Option B: Convert offline with `gpt_hybrid_conversion.py` + +#### Choose an architecture-preserving pattern For a source checkpoint with *N* GPT layers: @@ -90,7 +202,7 @@ The converter maps parameters by occurrence, not merely by numeric layer index: | Embedding and output weights | Copied without changing their model role | | `decoder.final_layernorm` | Renamed to `decoder.final_norm` | -### Check the prerequisites +#### Check the prerequisites The source must use one of these distributed-checkpoint formats: @@ -117,7 +229,7 @@ not constitute a converted optimizer or RNG state. Start the converted model with a fresh optimizer and RNG state. ``` -### Run the conversion +#### Run the conversion The following example converts a four-layer dense GPT model. Its equivalent HybridModel has the eight-layer pattern `*-*-*-*-`: @@ -172,11 +284,20 @@ Start with the command that trained the GPT model and make these changes: parser derives `num_layers` from the pattern. 3. Select the HybridModel stack specification with `--spec megatron.core.models.hybrid.hybrid_layer_specs hybrid_stack_spec`. -4. Load the converted weights with a fresh optimizer and write new training - checkpoints to a separate directory. Set `--ckpt-format` to the converter's - `torch_dist` or `fsdp_dtensor` output format. - -A minimal migration of the model and checkpoint arguments looks like this: +4. Point a checkpoint input at the pretrained weights and write new training + checkpoints to a separate directory: + - With **Option A (load-time translation)**, point + `--load` directly at the *GPT* checkpoint for resume semantics, or use + `--pretrained-checkpoint` for finetuning semantics. The optimizer state is + loaded by default; add `--no-load-optim` only if you want a fresh + optimizer. No offline conversion is needed, and `--finetune` is not + required by the translation. + - With **Option B (offline conversion)**, point `--pretrained-checkpoint` at + the converted *hybrid* checkpoint. Set `--ckpt-format` to the converter's + `torch_dist` or `fsdp_dtensor` output format. + +A minimal Option A migration — loading the GPT checkpoint and its optimizer +state directly for architecture-preserving continued training — looks like this: ```diff - torchrun --nproc_per_node=8 pretrain_gpt.py \ @@ -185,11 +306,14 @@ A minimal migration of the model and checkpoint arguments looks like this: - --save /path/to/gpt-checkpoints + torchrun --nproc_per_node=8 pretrain_hybrid.py \ + --hybrid-layer-pattern '*-*-*-*-' \ -+ --pretrained-checkpoint /path/to/hybrid-checkpoints \ # first-time only -+ --load /path/to/new-training-checkpoints \ ++ --spec megatron.core.models.hybrid.hybrid_layer_specs hybrid_stack_spec \ ++ --load /path/to/gpt-checkpoints \ # first launch; switch to the save directory later + --save /path/to/new-training-checkpoints ``` +For Option B, point `--pretrained-checkpoint` at the converted hybrid +checkpoint instead. + Keep the existing architecture, optimizer, precision, data, and basic TP/DP/EP/CP arguments unless this guide identifies a required change. Review pattern-driven pipeline layout and GPT-specific dataset features separately. @@ -200,10 +324,12 @@ A GPT training workflow that uses fill-in-the-middle data needs a custom dataset path or equivalent Hybrid entry-point support before migration. ``` -With an empty `--load` directory, `--pretrained-checkpoint` loads the converted -weights with finetuning semantics: iteration starts at zero, and optimizer and -RNG state are not restored. After the job writes a checkpoint to `--load`, later -launches resume the new HybridModel training state normally. +With an empty `--load` directory, `--pretrained-checkpoint` loads the pretrained +weights with finetuning semantics: iteration starts at zero and RNG state is not +restored. For Option A the optimizer state is still warm-started unless +`--no-load-optim` is set (Option B always starts with a fresh optimizer). After +the job writes a checkpoint to `--load`, later launches resume the new +HybridModel training state normally. ### Train from scratch diff --git a/megatron/core/dist_checkpointing/gpt_checkpoint_interop.py b/megatron/core/dist_checkpointing/gpt_checkpoint_interop.py new file mode 100644 index 00000000000..47baffda9df --- /dev/null +++ b/megatron/core/dist_checkpointing/gpt_checkpoint_interop.py @@ -0,0 +1,362 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Load GPT (pure transformer) distributed checkpoints into HybridModel runs. + +A GPTModel decoder layer packs self-attention and an MLP into a single +``TransformerLayer``, so a GPT checkpoint with ``L`` layers stores both +sub-modules under ``decoder.layers..``. HybridModel gives every sub-block +its own layer: in a pattern such as ``M*-M*-`` each GPT layer corresponds to +one attention ('*') position and one MLP ('-' dense or 'E' MoE) position, +while SSM ('M') positions have no GPT counterpart. + +Rather than rewriting the checkpoint on disk, the hybrid run's own sharded +state dict is retargeted at load time: + +* attention and MLP entries are rewritten to the GPT checkpoint's canonical + homogeneous-layer format: the layer index is dropped from the storage + ``key`` (``decoder.layers..mlp...`` -> ``decoder.layers.mlp...``) and + the matching GPT layer index becomes a prepended sharding axis, exactly + mirroring ``TransformerBlock.sharded_state_dict`` with + ``non_homogeneous_layers=False`` (the format GPTModel training saves); +* ``decoder.final_norm`` is pointed at GPT's ``decoder.final_layernorm``; +* HybridModel's empty ``output_layer._extra_state`` entry stays local because + GPT checkpoints intentionally omit that backward-compatibility key; +* entries of layers without a GPT counterpart are wrapped in + ``LocalNonpersistentObject`` so no storage read is attempted and the + freshly initialized module values are kept (and remain visible to the + subsequent strict ``load_state_dict``). + +The retargeted sharded state dict is then handed to the regular +``dist_checkpointing.load`` machinery, which reads the GPT checkpoint +directly and reshards across any TP/PP/EP/ETP layout change on the way. + +The same retargeting also applies to the distributed optimizer's sharded +state dict. In the model-space checkpoint formats (``fully_reshardable`` / +``fully_sharded_model_space``) every optimizer-state ``ShardedTensor`` is built +by copying the corresponding model param's metadata and prefixing its ``key`` +with ``optimizer.state..`` (see +``DistributedOptimizer.sharded_param_state_*``). Those entries therefore carry +the same ``decoder.layers..`` keys and sharding as the model tensors, so +:func:`retarget_sharded_state_dict_to_gpt_checkpoint` rewrites them onto the GPT +checkpoint identically -- optimizer moments and fp32 master params for +attention/MLP layers load from the GPT run, while fresh layers (e.g. Mamba) +keep their freshly initialized optimizer state via ``LocalNonpersistentObject``. +""" + +import re +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +from megatron.core.dist_checkpointing.dict_utils import dict_list_map_inplace +from megatron.core.dist_checkpointing.mapping import ( + LocalNonpersistentObject, + ShardedBase, + ShardedObject, + ShardedStateDict, + ShardedTensor, + ShardedTensorFactory, +) +from megatron.core.models.hybrid.hybrid_layer_allocation import ( + Symbols, + get_layer_maps_from_layer_type_list, + parse_hybrid_pattern, +) + +# Hybrid layer symbols that have a GPT-side source of weights ('*', '-', 'E') +# or that are explicitly initialized from scratch ('M'). Attention layers map +# onto GPT ``self_attention`` sub-modules; dense and MoE MLP layers map onto +# GPT ``mlp`` sub-modules (both models keep MoE tensors under ``mlp.*``). +# GDN ('G') and DS-attention ('D') use different weight layouts than GPT +# attention and are rejected rather than silently mistranslated. +_GPT_SOURCED_SYMBOLS = (Symbols.ATTENTION, Symbols.MLP, Symbols.MOE) +_FRESH_INIT_SYMBOLS = (Symbols.MAMBA,) + +_DECODER_LAYER_KEY_RE = re.compile(r'decoder\.layers\.(\d+)\.') + +_GPT_FINAL_NORM_KEY_MAP = {'decoder.final_norm.': 'decoder.final_layernorm.'} +_GPT_OMITTED_LOCAL_KEYS = ('output_layer._extra_state',) + + +@dataclass(frozen=True) +class GPTCompatLayerMaps: + """Correspondence between hybrid layer indices and GPT layer indices. + + Attributes: + attention_to_gpt: hybrid global layer index of the i-th attention + position -> GPT layer index i. + mlp_to_gpt: hybrid global layer index of the i-th MLP-bearing + position ('-' or 'E') -> GPT layer index i. + fresh_init: hybrid global layer indices with no GPT counterpart; + their modules keep the run's fresh initialization. + num_gpt_layers: number of layers the source GPT checkpoint must have. + """ + + attention_to_gpt: Mapping[int, int] + mlp_to_gpt: Mapping[int, int] + fresh_init: frozenset + num_gpt_layers: int + + +def gpt_compatible_layer_maps(hybrid_layer_pattern: str) -> GPTCompatLayerMaps: + """Derive hybrid->GPT layer index maps from a hybrid layer pattern. + + Args: + hybrid_layer_pattern: the run's unified hybrid layer pattern + (pipeline '|' separators allowed). + + Returns: + GPTCompatLayerMaps for retargeting a sharded state dict. + + Raises: + ValueError: if the pattern cannot be paired one-to-one with a GPT + checkpoint layout (MTP present, non-translatable symbols, + mixed dense/MoE positions, or unbalanced '*' vs MLP counts). + """ + parsed = parse_hybrid_pattern(hybrid_layer_pattern) + if parsed.mtp_num_depths > 0: + raise ValueError( + f"Hybrid layer pattern {hybrid_layer_pattern!r} contains MTP layers " + f"('/{parsed.mtp_pattern}'), which have no source weights in a GPT " + f"checkpoint. Remove the MTP part of the pattern to load a GPT checkpoint." + ) + main_pattern = (parsed.main_pattern or '').replace(Symbols.PIPE, '') + if not main_pattern: + raise ValueError("Hybrid layer pattern is empty; set --hybrid-layer-pattern.") + + layer_type_list = list(main_pattern) + translatable = set(_GPT_SOURCED_SYMBOLS) | set(_FRESH_INIT_SYMBOLS) + unknown = sorted(set(layer_type_list) - translatable) + if unknown: + raise ValueError( + f"Hybrid layer pattern {hybrid_layer_pattern!r} contains layer types " + f"{unknown} that cannot be translated from a GPT checkpoint. " + f"Supported: {sorted(translatable)} ('M' layers keep their fresh " + f"initialization)." + ) + + layer_maps = get_layer_maps_from_layer_type_list(layer_type_list) + dense_map = layer_maps[Symbols.MLP] + moe_map = layer_maps[Symbols.MOE] + if dense_map and moe_map: + raise ValueError( + f"Hybrid layer pattern {hybrid_layer_pattern!r} mixes dense ('-') and " + f"MoE ('E') MLP positions. GPT checkpoints have one MLP kind on every " + f"layer, so the pattern must use only one of '-' or 'E'." + ) + mlp_map = moe_map if moe_map else dense_map + attention_map = layer_maps[Symbols.ATTENTION] + + if len(attention_map) != len(mlp_map) or not attention_map: + raise ValueError( + f"Hybrid layer pattern {hybrid_layer_pattern!r} has " + f"{len(attention_map)} attention ('*') and {len(mlp_map)} MLP ('-'/'E') " + f"positions. Each GPT layer provides exactly one attention and one MLP " + f"sub-module, so the pattern needs an equal, nonzero number of each." + ) + + return GPTCompatLayerMaps( + attention_to_gpt=dict(attention_map), + mlp_to_gpt=dict(mlp_map), + fresh_init=frozenset(layer_maps[Symbols.MAMBA]), + num_gpt_layers=len(attention_map), + ) + + +def _prepend_gpt_layer_axis(entry, gpt_layer_idx: int, num_gpt_layers: int): + """Add the GPT layer index as the leading sharding axis of an entry. + + Mirrors what ``TransformerBlock.sharded_state_dict`` does for homogeneous + layers by passing ``sharded_offsets=[(0, layer_idx, num_layers)]`` down to + ``make_sharded_tensors_for_checkpoint``: + + * ShardedTensor: one more prepended axis of size ``num_gpt_layers`` + at position 0, this shard sitting at ``gpt_layer_idx``; + * ShardedObject: ``(1,)/(0,)`` placeholder offsets (from + ``_get_extra_state_offsets`` with no offsets) are replaced by the layer + axis, otherwise the layer axis is prepended (e.g. before an expert axis); + * ShardedTensorFactory: the built sub-entries get the same treatment. + """ + if isinstance(entry, ShardedTensor): + entry.global_shape = (num_gpt_layers, *entry.global_shape) + entry.global_offset = (gpt_layer_idx, *entry.global_offset) + entry.axis_fragmentations = (num_gpt_layers, *entry.axis_fragmentations) + entry.prepend_axis_num += 1 + elif isinstance(entry, ShardedObject): + if entry.global_shape == (1,) and entry.global_offset == (0,): + entry.global_shape = (num_gpt_layers,) + entry.global_offset = (gpt_layer_idx,) + else: + entry.global_shape = (num_gpt_layers, *entry.global_shape) + entry.global_offset = (gpt_layer_idx, *entry.global_offset) + elif isinstance(entry, ShardedTensorFactory): + inner_build_fn = entry.build_fn + + def _build_with_gpt_layer_axis(key, data, replica_id, flattened_range): + built = inner_build_fn(key, data, replica_id, flattened_range) + dict_list_map_inplace( + lambda sub: _prepend_gpt_layer_axis(sub, gpt_layer_idx, num_gpt_layers), built + ) + return built + + entry.build_fn = _build_with_gpt_layer_axis + return entry + + +def retarget_sharded_state_dict_to_gpt_checkpoint( + sharded_state_dict: ShardedStateDict, layer_maps: GPTCompatLayerMaps +) -> None: + """Point a hybrid model's sharded state dict at a GPT checkpoint, in place. + + Only the storage lookup metadata (``key`` and sharding axes) of each + ``ShardedBase`` entry is rewritten into the GPT checkpoint's homogeneous + layer format; the nested state dict structure (used by the subsequent + ``load_state_dict``) keeps the hybrid model's own names. Entries of layers + with no GPT counterpart are replaced by ``LocalNonpersistentObject`` so the + loaded state dict returns their current (freshly initialized) values. + + The same routine handles the distributed optimizer's sharded state dict: its + per-parameter entries embed the model key (``optimizer.state..decoder. + layers....``) and mirror the model param's sharding, so they retarget the + same way, and fresh-layer optimizer state is likewise kept local. + + Args: + sharded_state_dict: one model chunk's sharded state dict (as produced by + ``model.sharded_state_dict()``) or the matching optimizer sharded + state dict. + layer_maps: maps from :func:`gpt_compatible_layer_maps` derived from + the same pattern the model was built with. + """ + + def _retarget(entry): + if not isinstance(entry, ShardedBase): + return entry + + if entry.key.endswith(_GPT_OMITTED_LOCAL_KEYS): + return LocalNonpersistentObject(entry.data) + + layer_match = _DECODER_LAYER_KEY_RE.search(entry.key) + if layer_match is not None: + hybrid_idx = int(layer_match.group(1)) + if hybrid_idx in layer_maps.fresh_init: + return LocalNonpersistentObject(entry.data) + gpt_idx = layer_maps.attention_to_gpt.get(hybrid_idx) + if gpt_idx is None: + gpt_idx = layer_maps.mlp_to_gpt.get(hybrid_idx) + if gpt_idx is None: + raise ValueError( + f"Sharded state dict entry {entry.key!r} refers to hybrid layer " + f"{hybrid_idx}, which is not part of the hybrid layer pattern " + f"used to derive the GPT layer maps. The pattern and the " + f"instantiated model do not match." + ) + # GPT checkpoints use the homogeneous layer format: no layer index + # in the key, the layer is a sharding axis instead. + entry.key = ( + f'{entry.key[:layer_match.start()]}decoder.layers.' + f'{entry.key[layer_match.end():]}' + ) + return _prepend_gpt_layer_axis(entry, gpt_idx, layer_maps.num_gpt_layers) + + for hybrid_prefix, gpt_prefix in _GPT_FINAL_NORM_KEY_MAP.items(): + pos = entry.key.find(hybrid_prefix) + if pos != -1: + entry.key = f'{entry.key[:pos]}{gpt_prefix}{entry.key[pos + len(hybrid_prefix):]}' + break + return entry + + dict_list_map_inplace(_retarget, sharded_state_dict) + + +def _retarget_explicit_key_to_gpt_checkpoint( + key: Any, layer_maps: GPTCompatLayerMaps, checkpoint_keys: Iterable[str] | None = None +) -> Any | None: + """Translate one explicit HybridModel state-dict key to its GPT key. + + ``fsdp_dtensor`` checkpoints store explicit parameter names rather than + homogeneous-layer ``ShardedTensor`` metadata. Returning ``None`` omits a + fresh-only or GPT-omitted entry from the DCP load plan while leaving its + existing HybridModel value untouched. + """ + if not isinstance(key, str): + return key + if key.endswith(_GPT_OMITTED_LOCAL_KEYS): + return None + + layer_match = _DECODER_LAYER_KEY_RE.search(key) + if layer_match is not None: + hybrid_idx = int(layer_match.group(1)) + if hybrid_idx in layer_maps.fresh_init: + return None + gpt_idx = layer_maps.attention_to_gpt.get(hybrid_idx) + if gpt_idx is None: + gpt_idx = layer_maps.mlp_to_gpt.get(hybrid_idx) + if gpt_idx is None: + raise ValueError( + f"FSDP state dict entry {key!r} refers to hybrid layer {hybrid_idx}, " + "which is not part of the hybrid layer pattern used to derive the " + "GPT layer maps." + ) + key = f'{key[:layer_match.start()]}decoder.layers.{gpt_idx}.' f'{key[layer_match.end():]}' + + for hybrid_prefix, gpt_prefix in _GPT_FINAL_NORM_KEY_MAP.items(): + pos = key.find(hybrid_prefix) + if pos != -1: + key = f'{key[:pos]}{gpt_prefix}{key[pos + len(hybrid_prefix):]}' + break + + # FSDP optimizer parameter names include the wrapper hierarchy. GPTModel + # and HybridModel can have different Float16/FSDP wrapper depths, so use + # checkpoint metadata to recover the exact source-side ``module.`` prefix. + if checkpoint_keys is not None: + bare_key = re.sub(r'^(?:module\.)+', '', key) + key_pattern = re.compile(rf'(? dict[Any, Any]: + """Return an ``fsdp_dtensor`` model or optimizer state dict under GPT keys. + + FSDP model state is a flat parameter-name mapping. Distributed-optimizer + state can contain nested ``state`` and ``param_to_group_meta`` mappings + (and chained-optimizer integer keys), so the translation recursively + rewrites every parameter-name key while preserving the DTensor leaves. + """ + + checkpoint_key_set = set(checkpoint_keys) if checkpoint_keys is not None else None + + def _retarget(value, path): + if isinstance(value, Mapping): + translated = {} + for key, child in value.items(): + translated_key = _retarget_explicit_key_to_gpt_checkpoint( + key, layer_maps, checkpoint_key_set + ) + if translated_key is not None: + child_path = f'{path}.{translated_key}' if path else str(translated_key) + translated_child = _retarget(child, child_path) + if ( + checkpoint_key_set is None + or isinstance(child, (Mapping, list, tuple)) + or child_path in checkpoint_key_set + ): + translated[translated_key] = translated_child + return translated + if isinstance(value, list): + return [_retarget(child, f'{path}.{idx}') for idx, child in enumerate(value)] + if isinstance(value, tuple): + return tuple(_retarget(child, f'{path}.{idx}') for idx, child in enumerate(value)) + return value + + return _retarget(state_dict, checkpoint_prefix) diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 8a96ad7a1e2..d727233cb2d 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -7,6 +7,7 @@ import multiprocessing import os import random +import re import shutil import sys import threading @@ -58,6 +59,7 @@ handle_swiglu_in_state_dict, print_diff_in_state_dicts, ) + HAVE_MEGATRON_FSDP = True except ImportError: HAVE_MEGATRON_FSDP = False @@ -68,6 +70,7 @@ from modelopt.torch.opt.plugins import save_modelopt_state, save_sharded_modelopt_state from megatron.post_training.utils import print_distributed_quant_summary + has_nvidia_modelopt = True except Exception: has_nvidia_modelopt = False @@ -82,6 +85,7 @@ # Track deletion processes to prevent zombies _deletion_processes = [] + def finalize_deletion_processes(blocking=False): """Clean up deletion processes to prevent zombie processes. @@ -99,17 +103,19 @@ def finalize_deletion_processes(blocking=False): finished = [] for proc in _deletion_processes: if not proc.is_alive() or blocking: - logger.debug(f"Joining deletion process {proc.pid} (blocking={blocking}, is_alive={proc.is_alive()})") + logger.debug( + f'Joining deletion process {proc.pid} (blocking={blocking}, is_alive={proc.is_alive()})' + ) proc.join() finished.append(proc) for proc in finished: _deletion_processes.remove(proc) + def set_checkpoint_version(value): global _CHECKPOINT_VERSION if _CHECKPOINT_VERSION is not None: - assert _CHECKPOINT_VERSION == value, \ - "checkpoint versions do not match" + assert _CHECKPOINT_VERSION == value, 'checkpoint versions do not match' _CHECKPOINT_VERSION = value @@ -134,12 +140,15 @@ def get_loaded_iteration(): return _LOADED_ITERATION -def check_checkpoint_args(checkpoint_args): +def check_checkpoint_args(checkpoint_args, skip_args: set[str] | None = None): """Ensure fixed arguments for a model are the same for the input arguments and the one retrieved from checkpoint.""" args = get_args() + skip_args = skip_args or set() def _compare(arg_name, old_arg_name=None, default=None): + if arg_name in skip_args: + return if old_arg_name is not None: ckpt_arg_name = old_arg_name else: @@ -149,9 +158,11 @@ def _compare(arg_name, old_arg_name=None, default=None): else: checkpoint_value = getattr(checkpoint_args, ckpt_arg_name) args_value = getattr(args, arg_name) - error_message = '{} value from checkpoint ({}) is not equal to the ' \ - 'input argument value ({}).'.format( - arg_name, checkpoint_value, args_value) + error_message = ( + '{} value from checkpoint ({}) is not equal to the input argument value ({}).'.format( + arg_name, checkpoint_value, args_value + ) + ) assert checkpoint_value == args_value, error_message _compare('num_layers') @@ -169,23 +180,30 @@ def _compare(arg_name, old_arg_name=None, default=None): if args.phase_transition_iterations: _compare('global_batch_size') if get_checkpoint_version() < 3.0: - _compare('tensor_model_parallel_size', - old_arg_name='model_parallel_size') + _compare('tensor_model_parallel_size', old_arg_name='model_parallel_size') if get_checkpoint_version() >= 3.0 and not args.use_dist_ckpt: _compare('tensor_model_parallel_size') _compare('pipeline_model_parallel_size') + def ensure_directory_exists(filename, check_parent=True): """Build filename's path if it does not already exists.""" dirname = os.path.dirname(filename) if check_parent else filename maybe_msc.os.makedirs(dirname, exist_ok=True) -def get_checkpoint_name(checkpoints_path, iteration, release=False, - pipeline_parallel=None, - tensor_rank=None, pipeline_rank=None, - expert_parallel=None, expert_rank=None, - return_base_dir=False, basename="model_optim_rng.pt"): +def get_checkpoint_name( + checkpoints_path, + iteration, + release=False, + pipeline_parallel=None, + tensor_rank=None, + pipeline_rank=None, + expert_parallel=None, + expert_rank=None, + return_base_dir=False, + basename='model_optim_rng.pt', +): """Determine the directory name for this rank's checkpoint.""" if release: directory = 'release' @@ -197,13 +215,13 @@ def get_checkpoint_name(checkpoints_path, iteration, release=False, # Use both the tensor and pipeline MP rank. if pipeline_parallel is None: - pipeline_parallel = (mpu.get_pipeline_model_parallel_world_size() > 1) + pipeline_parallel = mpu.get_pipeline_model_parallel_world_size() > 1 if tensor_rank is None: tensor_rank = mpu.get_tensor_model_parallel_rank() if pipeline_rank is None: pipeline_rank = mpu.get_pipeline_model_parallel_rank() if expert_parallel is None: - expert_parallel = (mpu.get_expert_model_parallel_world_size() > 1) + expert_parallel = mpu.get_expert_model_parallel_world_size() > 1 if expert_rank is None: expert_rank = mpu.get_expert_model_parallel_rank() @@ -211,11 +229,11 @@ def get_checkpoint_name(checkpoints_path, iteration, release=False, # optimizer, then the optimizer's path must additionally include the # data parallel rank. if not pipeline_parallel: - common_path = os.path.join(checkpoints_path, directory, - f'mp_rank_{tensor_rank:02d}') + common_path = os.path.join(checkpoints_path, directory, f'mp_rank_{tensor_rank:02d}') else: - common_path = os.path.join(checkpoints_path, directory, - f'mp_rank_{tensor_rank:02d}_{pipeline_rank:03d}') + common_path = os.path.join( + checkpoints_path, directory, f'mp_rank_{tensor_rank:02d}_{pipeline_rank:03d}' + ) if expert_parallel: common_path = common_path + f'_{expert_rank:03d}' @@ -223,7 +241,7 @@ def get_checkpoint_name(checkpoints_path, iteration, release=False, return os.path.join(common_path, basename) -def get_load_checkpoint_path_by_args(args, load_arg="load"): +def get_load_checkpoint_path_by_args(args, load_arg='load'): """Get the checkpoint path based on the arguments.""" load_dir = getattr(args, load_arg) iteration, release = -1, False @@ -234,15 +252,14 @@ def get_load_checkpoint_path_by_args(args, load_arg="load"): iteration, release = read_metadata(tracker_filename) # Allow user to specify the loaded iteration. - if getattr(args, "ckpt_step", None): + if getattr(args, 'ckpt_step', None): iteration = args.ckpt_step return get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) def get_distributed_optimizer_checkpoint_name(model_checkpoint_name): - return os.path.join(os.path.dirname(model_checkpoint_name), - "distrib_optim.pt") + return os.path.join(os.path.dirname(model_checkpoint_name), 'distrib_optim.pt') def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): @@ -255,41 +272,65 @@ def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): """ # Look for checkpoint with no pipelining and no expert parallelism - filename = get_checkpoint_name(checkpoints_path, iteration, release, - pipeline_parallel=False, - tensor_rank=0, pipeline_rank=0, - expert_parallel=False, expert_rank=0) + filename = get_checkpoint_name( + checkpoints_path, + iteration, + release, + pipeline_parallel=False, + tensor_rank=0, + pipeline_rank=0, + expert_parallel=False, + expert_rank=0, + ) if maybe_msc.os.path.isfile(filename): return filename # Look for checkpoint with no pipelining and expert parallelism - filename = get_checkpoint_name(checkpoints_path, iteration, release, - pipeline_parallel=False, - tensor_rank=0, pipeline_rank=0, - expert_parallel=True, expert_rank=0) + filename = get_checkpoint_name( + checkpoints_path, + iteration, + release, + pipeline_parallel=False, + tensor_rank=0, + pipeline_rank=0, + expert_parallel=True, + expert_rank=0, + ) if maybe_msc.os.path.isfile(filename): return filename # Look for checkpoint with pipelining and no expert parallelism - filename = get_checkpoint_name(checkpoints_path, iteration, release, - pipeline_parallel=True, - tensor_rank=0, pipeline_rank=0, - expert_parallel=False, expert_rank=0) + filename = get_checkpoint_name( + checkpoints_path, + iteration, + release, + pipeline_parallel=True, + tensor_rank=0, + pipeline_rank=0, + expert_parallel=False, + expert_rank=0, + ) if maybe_msc.os.path.isfile(filename): return filename # Look for checkpoint with pipelining and expert parallelism - filename = get_checkpoint_name(checkpoints_path, iteration, release, - pipeline_parallel=True, - tensor_rank=0, pipeline_rank=0, - expert_parallel=True, expert_rank=0) + filename = get_checkpoint_name( + checkpoints_path, + iteration, + release, + pipeline_parallel=True, + tensor_rank=0, + pipeline_rank=0, + expert_parallel=True, + expert_rank=0, + ) if maybe_msc.os.path.isfile(filename): return filename # Look for a distributed checkpoint - filename = get_checkpoint_name(checkpoints_path, iteration, release, - pipeline_parallel=True, - return_base_dir=True) + filename = get_checkpoint_name( + checkpoints_path, iteration, release, pipeline_parallel=True, return_base_dir=True + ) if dist_checkpointing.check_is_distributed_checkpoint(filename): return filename @@ -297,7 +338,6 @@ def find_checkpoint_rank_0(checkpoints_path, iteration, release=False): def get_checkpoint_tracker_filename(checkpoints_path): - """Tracker file rescords the latest chckpoint during training to restart from.""" return os.path.join(checkpoints_path, 'latest_checkpointed_iteration.txt') @@ -323,14 +363,12 @@ def read_metadata(tracker_filename): except ValueError: release = metastring == 'release' if not release: - print_rank_0('ERROR: Invalid metadata file {}. Exiting'.format( - tracker_filename)) + print_rank_0('ERROR: Invalid metadata file {}. Exiting'.format(tracker_filename)) sys.exit() else: # Set iteration to 0 for release checkpoints iteration = 0 - assert iteration > -1 or release, 'error parsing metadata file {}'.format( - tracker_filename) + assert iteration > -1 or release, 'error parsing metadata file {}'.format(tracker_filename) # Get the max iteration retrieved across the ranks. if torch.distributed.is_initialized(): @@ -343,10 +381,12 @@ def read_metadata(tracker_filename): # iteration across all ranks. if iteration != max_iter: rank = torch.distributed.get_rank() - print('WARNING: on rank {} found iteration {} in the ' - 'metadata while max iteration across the ranks ' - 'is {}, replacing it with max iteration.'.format( - rank, iteration, max_iter), flush=True) + print( + 'WARNING: on rank {} found iteration {} in the ' + 'metadata while max iteration across the ranks ' + 'is {}, replacing it with max iteration.'.format(rank, iteration, max_iter), + flush=True, + ) else: # When loading a checkpoint outside of training (for example, # when editing it), we might not have torch distributed @@ -375,14 +415,15 @@ def get_rng_state( 'np_rng_state': np.random.get_state(), 'torch_rng_state': torch.get_rng_state(), 'cuda_rng_state': torch.cuda.get_rng_state(), - 'rng_tracker_states': tensor_parallel.get_cuda_rng_tracker().get_states()} + 'rng_tracker_states': tensor_parallel.get_cuda_rng_tracker().get_states(), + } - dp_world_size = get_pg_size(dp_group) if dp_group is not None else mpu.get_data_parallel_world_size() + dp_world_size = ( + get_pg_size(dp_group) if dp_group is not None else mpu.get_data_parallel_world_size() + ) rng_state_list = None - if args.data_parallel_random_init and torch.distributed.is_initialized() and \ - dp_world_size > 1: - rng_state_list = \ - [None for i in range(dp_world_size)] + if args.data_parallel_random_init and torch.distributed.is_initialized() and dp_world_size > 1: + rng_state_list = [None for i in range(dp_world_size)] torch.distributed.all_gather_object( rng_state_list, rng_state, @@ -391,23 +432,31 @@ def get_rng_state( else: rng_state_list = [rng_state] - dp_cp_rank = get_pg_rank(dp_cp_group) if dp_cp_group is not None else mpu.get_data_parallel_rank(with_context_parallel=True) - if ckpt_format == "torch_dist": + dp_cp_rank = ( + get_pg_rank(dp_cp_group) + if dp_cp_group is not None + else mpu.get_data_parallel_rank(with_context_parallel=True) + ) + if ckpt_format == 'torch_dist': pp_rank = get_pg_rank(pp_group) pp_size = get_pg_size(pp_group) tp_rank = get_pg_rank(tp_group) tp_size = get_pg_size(tp_group) - rng_state_list = ShardedObject(f'{key_prefix}rng_state', rng_state_list, (pp_size, tp_size), (pp_rank, tp_rank), - replica_id=dp_cp_rank) - elif ckpt_format == "fsdp_dtensor": + rng_state_list = ShardedObject( + f'{key_prefix}rng_state', + rng_state_list, + (pp_size, tp_size), + (pp_rank, tp_rank), + replica_id=dp_cp_rank, + ) + elif ckpt_format == 'fsdp_dtensor': pp_rank = get_pg_rank(pp_group) tp_rank = get_pg_rank(tp_group) - rng_state_list = { - f"({pp_rank}, {tp_rank})": rng_state_list - } + rng_state_list = {f'({pp_rank}, {tp_rank})': rng_state_list} return rng_state_list + class CheckpointType(Enum): LEGACY = auto() LOCAL = auto() @@ -416,7 +465,9 @@ class CheckpointType(Enum): FSDP_DTENSOR = auto() -def _build_sharded_state_dict_metadata(args: Namespace, dp_cp_group: Optional[torch.distributed.ProcessGroup] = None) -> dict: +def _build_sharded_state_dict_metadata( + args: Namespace, dp_cp_group: Optional[torch.distributed.ProcessGroup] = None +) -> dict: """Builds metadata used for sharded_state_dict versioning. The whole content metadata is passed to ``shared_state_dict`` model and optimizer methods @@ -441,13 +492,15 @@ def _build_sharded_state_dict_metadata(args: Namespace, dp_cp_group: Optional[to args, 'use_layer_wise_distributed_optimizer', False ) - if has_distributed_optimizer and args.ckpt_format == "fsdp_dtensor": + if has_distributed_optimizer and args.ckpt_format == 'fsdp_dtensor': metadata['distrib_optim_sharding_type'] = 'fsdp_dtensor' - if has_distributed_optimizer and args.ckpt_format != "fsdp_dtensor": + if has_distributed_optimizer and args.ckpt_format != 'fsdp_dtensor': if args.dist_ckpt_optim_fully_reshardable: metadata['distrib_optim_sharding_type'] = 'fully_reshardable' - metadata['distrib_optim_fully_reshardable_mem_efficient'] = args.distrib_optim_fully_reshardable_mem_efficient + metadata['distrib_optim_fully_reshardable_mem_efficient'] = ( + args.distrib_optim_fully_reshardable_mem_efficient + ) else: metadata['distrib_optim_sharding_type'] = 'dp_reshardable' @@ -467,8 +520,9 @@ def save_grads(save_dir, state_dict, iteration, grad_label): NOTE: wgrads for non-expert layers will be duplicated if using expert parallelism, but this can be handled in postprocessing.""" - print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] saving {grad_label} " - f"from iteration {iteration:7d}") + print_rank_0( + f' [{datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}] saving {grad_label} from iteration {iteration:7d}' + ) if mpu.get_expert_data_parallel_rank() == 0: # Create saving directory. @@ -477,26 +531,47 @@ def save_grads(save_dir, state_dict, iteration, grad_label): tp_rank = mpu.get_tensor_model_parallel_rank() assert save_dir is not None assert iteration is not None - save_dir = os.path.join(save_dir, grad_label, f"iter_{iteration:07d}") + save_dir = os.path.join(save_dir, grad_label, f'iter_{iteration:07d}') os.makedirs(save_dir, exist_ok=True) # Save state_dict. - checkpoint_name = f"mp_rank_{tp_rank:02d}" + checkpoint_name = f'mp_rank_{tp_rank:02d}' if mpu.get_pipeline_model_parallel_world_size() > 1: - checkpoint_name += f"_{pp_rank:03d}" + checkpoint_name += f'_{pp_rank:03d}' if mpu.get_expert_model_parallel_world_size() > 1: - checkpoint_name += f"_{ep_rank:03d}" - full_save_path = os.path.join(save_dir, f"{checkpoint_name}.pth") + checkpoint_name += f'_{ep_rank:03d}' + full_save_path = os.path.join(save_dir, f'{checkpoint_name}.pth') # Convert back to dict (e.g., from collections.defaultdict) for easy loading later. torch.save(dict(state_dict), full_save_path) - print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] saved {grad_label} " - f"from iteration {iteration:7d}") + print_rank_0( + f' [{datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}] saved {grad_label} from iteration {iteration:7d}' + ) -def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floating_point_operations_so_far, - checkpointing_context=None, pipeline_rank=None, expert_rank=None, tensor_rank=None, pipeline_parallel=None, expert_parallel=None, non_persistent_ckpt=False, - train_data_iterator=None, preprocess_common_state_dict_fn = None, release=False, tp_group: Optional[torch.distributed.ProcessGroup] = None, pp_group: Optional[torch.distributed.ProcessGroup] = None, dp_cp_group: Optional[torch.distributed.ProcessGroup] = None, dp_group: Optional[torch.distributed.ProcessGroup] = None, expt_dp_group: Optional[torch.distributed.ProcessGroup] = None, rng_state_key_prefix: str = ''): +def save_checkpoint( + iteration, + model, + optimizer, + opt_param_scheduler, + num_floating_point_operations_so_far, + checkpointing_context=None, + pipeline_rank=None, + expert_rank=None, + tensor_rank=None, + pipeline_parallel=None, + expert_parallel=None, + non_persistent_ckpt=False, + train_data_iterator=None, + preprocess_common_state_dict_fn=None, + release=False, + tp_group: Optional[torch.distributed.ProcessGroup] = None, + pp_group: Optional[torch.distributed.ProcessGroup] = None, + dp_cp_group: Optional[torch.distributed.ProcessGroup] = None, + dp_group: Optional[torch.distributed.ProcessGroup] = None, + expt_dp_group: Optional[torch.distributed.ProcessGroup] = None, + rng_state_key_prefix: str = '', +): """Save a model, optimizer and optionally dataloader checkpoint. Checkpointing context is used to persist some checkpointing state @@ -520,7 +595,9 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati args = get_args() if args.async_save and not is_empty_async_queue(): - print_rank_0('WARNING: Starting a checkpoint save before previous has finished. Consider increasing the checkpoint interval.') + print_rank_0( + 'WARNING: Starting a checkpoint save before previous has finished. Consider increasing the checkpoint interval.' + ) # Prepare E2E metrics at start of save checkpoint productive_metrics = on_save_checkpoint_start(args.async_save) @@ -551,37 +628,54 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati ckpt_type = CheckpointType.LOCAL save_dir = checkpointing_context['local_checkpoint_manager'].local_ckpt_dir else: - raise NotImplementedError(f"Please use local or global non-persistent checkpoints (got: {args.non_persistent_ckpt_type})") + raise NotImplementedError( + f'Please use local or global non-persistent checkpoints (got: {args.non_persistent_ckpt_type})' + ) ckpt_format = args.ckpt_format if ckpt_type == CheckpointType.GLOBAL else 'torch' - print_rank_0(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] saving checkpoint " - f"at iteration {iteration:7d} to {save_dir} in {ckpt_format} format") + print_rank_0( + f'[{datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}] saving checkpoint ' + f'at iteration {iteration:7d} to {save_dir} in {ckpt_format} format' + ) # Collect rng state across data parallel ranks. if tp_group is None and pp_group is None: tp_group = mpu.get_tensor_model_parallel_group() pp_group = mpu.get_pipeline_model_parallel_group() - rng_state = get_rng_state(args.ckpt_format, tp_group, pp_group, - dp_cp_group=dp_cp_group, - dp_group=dp_group, - key_prefix=rng_state_key_prefix) + rng_state = get_rng_state( + args.ckpt_format, + tp_group, + pp_group, + dp_cp_group=dp_cp_group, + dp_group=dp_group, + key_prefix=rng_state_key_prefix, + ) # Collect rerun state across all ranks rerun_state_machine = get_rerun_state_machine() rerun_state = rerun_state_machine.state_dict( - data_iterator=train_data_iterator, ckpt_format=args.ckpt_format, + data_iterator=train_data_iterator, ckpt_format=args.ckpt_format ) # Checkpoint name. - return_base_dir = (ckpt_type != CheckpointType.LEGACY) - checkpoint_name = get_checkpoint_name(save_dir, iteration, release=release, pipeline_parallel=pipeline_parallel, - tensor_rank=tensor_rank, pipeline_rank=pipeline_rank, expert_parallel=expert_parallel, expert_rank=expert_rank, return_base_dir=return_base_dir) + return_base_dir = ckpt_type != CheckpointType.LEGACY + checkpoint_name = get_checkpoint_name( + save_dir, + iteration, + release=release, + pipeline_parallel=pipeline_parallel, + tensor_rank=tensor_rank, + pipeline_rank=pipeline_rank, + expert_parallel=expert_parallel, + expert_rank=expert_rank, + return_base_dir=return_base_dir, + ) # Save dataloader state if the external dataloader supports it. maybe_save_dataloader_state( train_data_iterator, iteration, - getattr(args, "dataloader_save", None), + getattr(args, 'dataloader_save', None), tp_group=tp_group, pp_group=pp_group, dp_group=dp_group, @@ -594,16 +688,17 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati and optimizer is not None and ckpt_type == CheckpointType.LEGACY ): - optim_checkpoint_name = \ - get_distributed_optimizer_checkpoint_name(checkpoint_name) + optim_checkpoint_name = get_distributed_optimizer_checkpoint_name(checkpoint_name) ensure_directory_exists(optim_checkpoint_name) if not optimizer.is_stub_optimizer: optimizer.save_parameter_state(optim_checkpoint_name) # LayerWiseDistributedOptimizer save optimizer state to file on different ranks - if getattr(args, "use_layer_wise_distributed_optimizer", False) and args.ckpt_format == 'torch': + if getattr(args, 'use_layer_wise_distributed_optimizer', False) and args.ckpt_format == 'torch': dp_rank = mpu.get_data_parallel_rank() - optim_checkpoint_name = os.path.join(os.path.dirname(checkpoint_name), f"layer_wise_optimizer_{dp_rank}.pt") + optim_checkpoint_name = os.path.join( + os.path.dirname(checkpoint_name), f'layer_wise_optimizer_{dp_rank}.pt' + ) ensure_directory_exists(optim_checkpoint_name) if not optimizer.is_stub_optimizer: optimizer.save_state_dict_to_file(optim_checkpoint_name) @@ -611,9 +706,17 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati async_save_request = None if args.async_save: if ckpt_type == CheckpointType.LEGACY: - raise NotImplementedError('Async checkpoint save not implemented for legacy checkpoints') - elif ckpt_type == CheckpointType.GLOBAL and args.ckpt_format not in ['torch_dist', 'torch_dcp', 'fsdp_dtensor']: - raise NotImplementedError(f'Async checkpoint save not implemented for {args.ckpt_format} distributed checkpoint format') + raise NotImplementedError( + 'Async checkpoint save not implemented for legacy checkpoints' + ) + elif ckpt_type == CheckpointType.GLOBAL and args.ckpt_format not in [ + 'torch_dist', + 'torch_dcp', + 'fsdp_dtensor', + ]: + raise NotImplementedError( + f'Async checkpoint save not implemented for {args.ckpt_format} distributed checkpoint format' + ) rank = torch.distributed.get_rank() if torch.distributed.is_initialized() else 0 dp_rank = 0 @@ -631,15 +734,19 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati # exactly one rank. Neither dp_rank==0 nor edp_rank==0 alone covers all shards when # the dense and expert parallelism layouts disagree (e.g. TP > EP*ETP); the union # does, with at most one rank per (tp_rank, ep_rank) inside any DP group. - if not torch.distributed.is_initialized() \ - or ckpt_type != CheckpointType.LEGACY \ - or dp_rank == 0 \ - or expt_dp_rank == 0: + if ( + not torch.distributed.is_initialized() + or ckpt_type != CheckpointType.LEGACY + or dp_rank == 0 + or expt_dp_rank == 0 + ): if ckpt_type != CheckpointType.LEGACY: sharded_sd_metadata = _build_sharded_state_dict_metadata(args, dp_cp_group=dp_cp_group) if args.use_distributed_optimizer: - print_rank_0(f'Storing distributed optimizer sharded state of type' - f' {sharded_sd_metadata["distrib_optim_sharding_type"]}') + print_rank_0( + f'Storing distributed optimizer sharded state of type' + f' {sharded_sd_metadata["distrib_optim_sharding_type"]}' + ) else: sharded_sd_metadata = None state_dict = generate_state_dict( @@ -655,7 +762,7 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati ) state_dict['num_floating_point_operations_so_far'] = num_floating_point_operations_so_far - if ckpt_type == CheckpointType.GLOBAL and ckpt_format == "torch_dist": + if ckpt_type == CheckpointType.GLOBAL and ckpt_format == 'torch_dist': if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: # TODO Handle non-empty directories (e.g., after a crash during saving). ensure_directory_exists(checkpoint_name, check_parent=False) @@ -674,66 +781,90 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati save_strategy.thread_count = args.dist_ckpt_workers else: # We don't allow per-rank parallel save for sync save - logger.warning('Per-rank parallel save is not supported for sync save. ' - 'Setting args.dist_ckpt_workers to 1') + logger.warning( + 'Per-rank parallel save is not supported for sync save. ' + 'Setting args.dist_ckpt_workers to 1' + ) save_strategy.thread_count = 1 - if checkpointing_context is not None and 'load_strategy' in checkpointing_context: - cached_global_metadata = getattr(checkpointing_context['load_strategy'], 'cached_global_metadata', None) + if ( + checkpointing_context is not None + and 'load_strategy' in checkpointing_context + ): + cached_global_metadata = getattr( + checkpointing_context['load_strategy'], 'cached_global_metadata', None + ) if cached_global_metadata is not None: - logger.debug("Plugging in the read metadata from the load strategy...") + logger.debug('Plugging in the read metadata from the load strategy...') save_strategy.cached_global_metadata = cached_global_metadata else: - logger.debug("Failed to plug in the read metadata from the load strategy...") + logger.debug( + 'Failed to plug in the read metadata from the load strategy...' + ) if args.ckpt_fully_parallel_save: if args.ckpt_fully_parallel_save_process_group == 'dp': - process_group = dp_cp_group if dp_cp_group is not None else mpu.get_data_parallel_group(with_context_parallel=True) + process_group = ( + dp_cp_group + if dp_cp_group is not None + else mpu.get_data_parallel_group(with_context_parallel=True) + ) elif args.ckpt_fully_parallel_save_process_group == 'ep_dp': - process_group = expt_dp_group if expt_dp_group is not None else mpu.get_expert_data_parallel_group() - save_strategy = FullyParallelSaveStrategyWrapper(save_strategy, process_group, - args.ckpt_assume_constant_structure) + process_group = ( + expt_dp_group + if expt_dp_group is not None + else mpu.get_expert_data_parallel_group() + ) + save_strategy = FullyParallelSaveStrategyWrapper( + save_strategy, process_group, args.ckpt_assume_constant_structure + ) # Store save strategy for future checkpoint saves if checkpointing_context is not None: checkpointing_context['save_strategy'] = save_strategy end_ckpt = time() - logger.debug(f"rank: {rank}, takes {end_ckpt - start_ckpt} to prepare state dict for ckpt ") - async_save_request = dist_checkpointing.save(state_dict, checkpoint_name, save_strategy, - async_sharded_save=args.async_save, - validate_access_integrity=validate_sharding_integrity, - preprocess_common_before_consistancy_check=preprocess_common_state_dict_fn, - content_metadata=_clean_metadata_for_serialization(sharded_sd_metadata), - async_strategy=args.async_strategy, - verify_integrity=args.verify_integrity) + logger.debug( + f'rank: {rank}, takes {end_ckpt - start_ckpt} to prepare state dict for ckpt ' + ) + async_save_request = dist_checkpointing.save( + state_dict, + checkpoint_name, + save_strategy, + async_sharded_save=args.async_save, + validate_access_integrity=validate_sharding_integrity, + preprocess_common_before_consistancy_check=preprocess_common_state_dict_fn, + content_metadata=_clean_metadata_for_serialization(sharded_sd_metadata), + async_strategy=args.async_strategy, + verify_integrity=args.verify_integrity, + ) # [ModelOpt]: save sharded modelopt_state if has_nvidia_modelopt: save_sharded_modelopt_state(model, checkpoint_name, (args.ckpt_format, 1)) - elif ckpt_type == CheckpointType.GLOBAL and ckpt_format in ["torch_dcp", "fsdp_dtensor"]: + elif ckpt_type == CheckpointType.GLOBAL and ckpt_format in ['torch_dcp', 'fsdp_dtensor']: if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: # TODO Handle non-empty directories (e.g., after a crash during saving). ensure_directory_exists(checkpoint_name, check_parent=False) - if ckpt_format == "fsdp_dtensor": + if ckpt_format == 'fsdp_dtensor': state_dict = preprocess_fsdp_dtensor_state_dict(args, state_dict, model[0]) if args.async_save: planner = torch.distributed.checkpoint.DefaultSavePlanner() coordinator_rank = 0 _, async_modules = get_async_strategy(args.async_strategy) - FileSystemWriterAsync = async_modules["FileSystemWriterAsync"] - save_state_dict_async_plan = async_modules["save_state_dict_async_plan"] + FileSystemWriterAsync = async_modules['FileSystemWriterAsync'] + save_state_dict_async_plan = async_modules['save_state_dict_async_plan'] _cpu_shm = getattr(args, 'async_ckpt_use_cpu_shm', False) _writer_kwargs = {} if _cpu_shm: if ( - "use_cpu_shm_for_gpu_tensors" + 'use_cpu_shm_for_gpu_tensors' in inspect.signature(FileSystemWriterAsync.__init__).parameters ): - _writer_kwargs["use_cpu_shm_for_gpu_tensors"] = True + _writer_kwargs['use_cpu_shm_for_gpu_tensors'] = True else: raise AssertionError( - "Installed nvidia-resiliency-ext does not support " - "use_cpu_shm_for_gpu_tensors. Update nvidia-resiliency-ext " - "to use --async-ckpt-use-cpu-shm." + 'Installed nvidia-resiliency-ext does not support ' + 'use_cpu_shm_for_gpu_tensors. Update nvidia-resiliency-ext ' + 'to use --async-ckpt-use-cpu-shm.' ) fs_storage_writer = FileSystemWriterAsync( checkpoint_name, @@ -743,7 +874,12 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati ) save_state_dict_ret = save_state_dict_async_plan( - state_dict, fs_storage_writer, None, coordinator_rank, planner=planner, enable_cache=args.ckpt_assume_constant_structure + state_dict, + fs_storage_writer, + None, + coordinator_rank, + planner=planner, + enable_cache=args.ckpt_assume_constant_structure, ) async_save_request = get_save_and_finalize_callbacks( fs_storage_writer, save_state_dict_ret, args.async_strategy @@ -751,8 +887,7 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati else: fs_storage_writer = torch.distributed.checkpoint.FileSystemWriter(checkpoint_name) torch.distributed.checkpoint.save( - state_dict=state_dict, - storage_writer=fs_storage_writer, + state_dict=state_dict, storage_writer=fs_storage_writer ) else: # [ModelOpt]: Inject modelopt_state into state_dict @@ -763,16 +898,23 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati save_modelopt_state(model, state_dict) end_ckpt = time() - logger.debug(f"rank: {rank}, takes {end_ckpt - start_ckpt} to prepare state dict for ckpt ") + logger.debug( + f'rank: {rank}, takes {end_ckpt - start_ckpt} to prepare state dict for ckpt ' + ) if ckpt_type == CheckpointType.LOCAL: try: from megatron.core.dist_checkpointing.tensor_aware_state_dict import ( MCoreTensorAwareStateDict, ) except ModuleNotFoundError: - raise RuntimeError("The 'nvidia_resiliency_ext' module is required for local " - "checkpointing but was not found. Please ensure it is installed.") - if (sharded_sd_metadata or {}).get('distrib_optim_sharding_type') in ['fully_reshardable', 'dp_zero_gather_scatter']: + raise RuntimeError( + "The 'nvidia_resiliency_ext' module is required for local " + 'checkpointing but was not found. Please ensure it is installed.' + ) + if (sharded_sd_metadata or {}).get('distrib_optim_sharding_type') in [ + 'fully_reshardable', + 'dp_zero_gather_scatter', + ]: # Note: Currently full reshardabilty is not supported when local checkpoints are used. raise RuntimeError( f"Local checkpointing does not support optimizer sharding type '{sharded_sd_metadata['distrib_optim_sharding_type']}'. " @@ -780,10 +922,15 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati ) algo = args.non_persistent_local_ckpt_algo cached_metadata = None - if args.ckpt_assume_constant_structure and 'local_checkpoint_cache' in checkpointing_context: + if ( + args.ckpt_assume_constant_structure + and 'local_checkpoint_cache' in checkpointing_context + ): cached_metadata = checkpointing_context['local_checkpoint_cache'] state_dict_for_save, cacheable_metadata = MCoreTensorAwareStateDict.from_state_dict( - state_dict, algo=algo, cached_metadata=cached_metadata, + state_dict, + algo=algo, + cached_metadata=cached_metadata, parallelization_group=( dp_cp_group if dp_cp_group is not None @@ -809,64 +956,89 @@ def save_checkpoint(iteration, model, optimizer, opt_param_scheduler, num_floati torch.distributed.barrier() # And update the latest iteration - if not torch.distributed.is_initialized() \ - or torch.distributed.get_rank() == 0: + if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: tracker_filename = get_checkpoint_tracker_filename(save_dir) if ckpt_type == CheckpointType.LOCAL: + def iter_finalize_fn(): - print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] successfully " - f"saved local checkpoint from iteration {iteration:7d}") + print_rank_0( + f' [{datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}] successfully ' + f'saved local checkpoint from iteration {iteration:7d}' + ) if args.log_progress and args.async_save: - append_to_progress_log(args.save, f'Saved async local checkpoint\tIteration: {iteration}', - barrier=False) + append_to_progress_log( + args.save, + f'Saved async local checkpoint\tIteration: {iteration}', + barrier=False, + ) + else: + def _rank_and_size(explicit_rank, group, mpu_rank_fn, mpu_size_fn): rank = ( - explicit_rank if explicit_rank is not None - else get_pg_rank(group) if group is not None - else mpu_rank_fn() + explicit_rank + if explicit_rank is not None + else get_pg_rank(group) if group is not None else mpu_rank_fn() ) size = get_pg_size(group) if group is not None else mpu_size_fn() return rank + 1, size tensor_mp_rank, tp_size_to_print = _rank_and_size( - tensor_rank, tp_group, - mpu.get_tensor_model_parallel_rank, mpu.get_tensor_model_parallel_world_size, + tensor_rank, + tp_group, + mpu.get_tensor_model_parallel_rank, + mpu.get_tensor_model_parallel_world_size, ) pipeline_mp_rank, pp_size_to_print = _rank_and_size( - pipeline_rank, pp_group, - mpu.get_pipeline_model_parallel_rank, mpu.get_pipeline_model_parallel_world_size, + pipeline_rank, + pp_group, + mpu.get_pipeline_model_parallel_rank, + mpu.get_pipeline_model_parallel_world_size, ) gtp_remat_rank = mpu.get_gtp_weight_remat_rank() + 1 gtp_remat_size_to_print = mpu.get_gtp_weight_remat_world_size() def iter_finalize_fn(): prev_iteration = 0 - save_retain_interval = getattr(args, 'save_retain_interval', None) # For backwards compatibility of tests. + save_retain_interval = getattr( + args, 'save_retain_interval', None + ) # For backwards compatibility of tests. if save_retain_interval is not None: if maybe_msc.os.path.exists(tracker_filename): with maybe_msc.open(tracker_filename, 'r') as f: prev_iteration = int(f.read().strip()) with maybe_msc.open(tracker_filename, 'w') as f: - f.write("release" if release else str(iteration)) - print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] successfully saved " - f"checkpoint from iteration {int(iteration):7d} to {args.save} " - f"[ t {tensor_mp_rank}/{tp_size_to_print}, " - f"gtp_remat {gtp_remat_rank}/{gtp_remat_size_to_print}, " - f"p {pipeline_mp_rank}/{pp_size_to_print} ]") + f.write('release' if release else str(iteration)) + print_rank_0( + f' [{datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}] successfully saved ' + f'checkpoint from iteration {int(iteration):7d} to {args.save} ' + f'[ t {tensor_mp_rank}/{tp_size_to_print}, ' + f'gtp_remat {gtp_remat_rank}/{gtp_remat_size_to_print}, ' + f'p {pipeline_mp_rank}/{pp_size_to_print} ]' + ) if args.log_progress and args.async_save: - append_to_progress_log(args.save, f'Saved async checkpoint\tIteration: {iteration}', - barrier=False) + append_to_progress_log( + args.save, f'Saved async checkpoint\tIteration: {iteration}', barrier=False + ) if save_retain_interval is not None: - if prev_iteration > 0 and prev_iteration != iteration and prev_iteration % save_retain_interval != 0: - checkpoint_name = get_checkpoint_name(args.save, iteration=prev_iteration, - return_base_dir=True) + if ( + prev_iteration > 0 + and prev_iteration != iteration + and prev_iteration % save_retain_interval != 0 + ): + checkpoint_name = get_checkpoint_name( + args.save, iteration=prev_iteration, return_base_dir=True + ) # Don't delete if `checkpoint_name` is a symbolic link. - if os.path.islink(checkpoint_name): # TODO: Make this work with MSC remote paths? - print_rank_0(f' skipping deleting checkpoint from iteration {prev_iteration:7d} ' - f'at {args.save} since it is a symbolic link') + if os.path.islink( + checkpoint_name + ): # TODO: Make this work with MSC remote paths? + print_rank_0( + f' skipping deleting checkpoint from iteration {prev_iteration:7d} ' + f'at {args.save} since it is a symbolic link' + ) else: # Asynchronous version of delete_checkpoint(args, iteration_to_delete=prev_iteration). # Use multiprocessing to delete checkpoint in background @@ -876,15 +1048,24 @@ def iter_finalize_fn(): ctx = multiprocessing.get_context('fork') delete_process = ctx.Process( target=_async_delete_checkpoint_impl, - args=(args.save, prev_iteration, args.log_progress, True, - args.async_ckpt_cpu_priority, args.async_ckpt_io_priority), - daemon=True + args=( + args.save, + prev_iteration, + args.log_progress, + True, + args.async_ckpt_cpu_priority, + args.async_ckpt_io_priority, + ), + daemon=True, ) delete_process.start() # Track the process so we can join it later to prevent zombies _deletion_processes.append(delete_process) else: - th = threading.Thread(target=_async_delete_checkpoint_impl, args=(args.save, prev_iteration, args.log_progress)) + th = threading.Thread( + target=_async_delete_checkpoint_impl, + args=(args.save, prev_iteration, args.log_progress), + ) th.start() if args.async_save: @@ -894,10 +1075,11 @@ def iter_finalize_fn(): iter_finalize_fn() # Additional callback for one_logger (last rank) - if not torch.distributed.is_initialized() \ - or is_last_rank(): + if not torch.distributed.is_initialized() or is_last_rank(): + def onelogger_finalize_fn(): on_save_checkpoint_success(productive_metrics, args.async_save) + if args.async_save: assert async_save_request is not None async_save_request.add_finalize_fn(onelogger_finalize_fn) @@ -905,10 +1087,13 @@ def onelogger_finalize_fn(): onelogger_finalize_fn() # Additional callback for wandb (last rank) - if not torch.distributed.is_initialized() \ - or is_last_rank(): + if not torch.distributed.is_initialized() or is_last_rank(): + def wandb_finalize_fn(): - wandb_utils.on_save_checkpoint_success(checkpoint_name, get_checkpoint_tracker_filename(save_dir), save_dir, iteration) + wandb_utils.on_save_checkpoint_success( + checkpoint_name, get_checkpoint_tracker_filename(save_dir), save_dir, iteration + ) + if args.async_save: assert async_save_request is not None async_save_request.add_finalize_fn(wandb_finalize_fn) @@ -925,7 +1110,7 @@ def wandb_finalize_fn(): logits_saver = get_logits_saver() if logits_saver is not None: - async_request_cls = get_async_strategy(args.async_strategy)[1]["AsyncRequest"] + async_request_cls = get_async_strategy(args.async_strategy)[1]['AsyncRequest'] async_logits_request = async_request_cls( async_fn=logits_saver._write_batched_tar, async_fn_args=logits_saver.take_pending_data(), @@ -936,11 +1121,13 @@ def wandb_finalize_fn(): schedule_async_save(async_save_request) if logits_saver is not None: schedule_async_save(async_logits_request) - print_rank_0(f" [{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}] scheduled " - f"an async checkpoint save at iteration {iteration:7d} to {save_dir}") + print_rank_0( + f' [{datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}] scheduled ' + f'an async checkpoint save at iteration {iteration:7d} to {save_dir}' + ) end_misc = time() - logger.debug(f"rank: {rank}, takes {end_misc - start_misc} to finalize ckpt save ") + logger.debug(f'rank: {rank}, takes {end_misc - start_misc} to finalize ckpt save ') if not args.async_save: # Add a barrier so that all ranks wait for finalization to complete @@ -950,9 +1137,16 @@ def wandb_finalize_fn(): ft_integration.on_checkpointing_end(is_async_finalization=False) + @_disable_gc() -def _async_delete_checkpoint_impl(save_path, iteration_to_delete, log_progress=False, lower_priority=False, - cpu_priority=None, io_priority=None): +def _async_delete_checkpoint_impl( + save_path, + iteration_to_delete, + log_progress=False, + lower_priority=False, + cpu_priority=None, + io_priority=None, +): """Module-level function for async checkpoint deletion. This function can be pickled and executed by the async worker process. @@ -969,19 +1163,28 @@ def _async_delete_checkpoint_impl(save_path, iteration_to_delete, log_progress=F """ if lower_priority: from megatron.core.dist_checkpointing.strategies.async_utils import _set_process_qos + _set_process_qos(cpu_priority=cpu_priority, io_priority=io_priority) - checkpoint_name = get_checkpoint_name(save_path, iteration=iteration_to_delete, - return_base_dir=True) + checkpoint_name = get_checkpoint_name( + save_path, iteration=iteration_to_delete, return_base_dir=True + ) try: shutil.rmtree(checkpoint_name) # TODO: Make this work with MSC remote paths? - print(f' successfully deleted checkpoint from iteration {iteration_to_delete:7d} ' - f'at {save_path}', flush=True) + print( + f' successfully deleted checkpoint from iteration {iteration_to_delete:7d} at {save_path}', + flush=True, + ) if log_progress: - append_to_progress_log(save_path, f'Deleted checkpoint\tIteration: {iteration_to_delete}', barrier=False) + append_to_progress_log( + save_path, f'Deleted checkpoint\tIteration: {iteration_to_delete}', barrier=False + ) except Exception as e: - print(f' encountered exception "{e}" when trying to delete checkpoint from ' - f'iteration {iteration_to_delete:7d} at {save_path}', flush=True) + print( + f' encountered exception "{e}" when trying to delete checkpoint from ' + f'iteration {iteration_to_delete:7d} at {save_path}', + flush=True, + ) # Any exception encountered in checkpoint deletion can be ignored and is not fatal. pass @@ -991,9 +1194,11 @@ def cleanup_old_non_persistent_checkpoint(save_dir, leave_ckpt_num=1, do_async=F return save_dir = Path(save_dir) - iter_prefix = "iter_" + iter_prefix = 'iter_' iter_ckpts = save_dir.rglob(f'{iter_prefix}*') - sorted_iter_ckpts = sorted(iter_ckpts, key=lambda ckpt_name: int(ckpt_name.name[len(iter_prefix):])) + sorted_iter_ckpts = sorted( + iter_ckpts, key=lambda ckpt_name: int(ckpt_name.name[len(iter_prefix) :]) + ) if not sorted_iter_ckpts: return rm_iter_ckpts = sorted_iter_ckpts[:-leave_ckpt_num] @@ -1003,6 +1208,7 @@ def cleanup_old_non_persistent_checkpoint(save_dir, leave_ckpt_num=1, do_async=F def remove_iter_ckpts(_iter_ckpts): for ckpt in _iter_ckpts: shutil.rmtree(ckpt) + if do_async: threading.Thread(target=remove_iter_ckpts, args=(rm_iter_ckpts,)).start() else: @@ -1010,13 +1216,7 @@ def remove_iter_ckpts(_iter_ckpts): def maybe_save_dataloader_state( - train_iterator, - iteration, - dataloader_save_path, - *, - tp_group=None, - pp_group=None, - dp_group=None, + train_iterator, iteration, dataloader_save_path, *, tp_group=None, pp_group=None, dp_group=None ): """Saves dataloader state if the dataloader supports it. @@ -1035,12 +1235,14 @@ def maybe_save_dataloader_state( dp_group (ProcessGroup): Data-parallel group, or MPU fallback when unset. """ # If no dataloader or saving path is provided, exit early, otherwise, raise an error. - if train_iterator is None or dataloader_save_path is None or dataloader_save_path == "": + if train_iterator is None or dataloader_save_path is None or dataloader_save_path == '': return # If dataloader doesn't support saving state, raise an error. - if not hasattr(train_iterator.iterable, "save_state"): - raise RuntimeError(f"Could not find a save_state for the train_iterator of type {type(train_iterator)}") + if not hasattr(train_iterator.iterable, 'save_state'): + raise RuntimeError( + f'Could not find a save_state for the train_iterator of type {type(train_iterator)}' + ) # Save dataloader state for each data parallel rank only once. first_rank = ( @@ -1058,7 +1260,7 @@ def maybe_save_dataloader_state( dp_rank = get_pg_rank(dp_group) if dp_group is not None else mpu.get_data_parallel_rank() train_dataloader_state_dict = train_iterator.iterable.save_state() if dp_rank == 0: - print(f"saving dataloader checkpoint at iteration {iteration} to {dataloader_save_path}") + print(f'saving dataloader checkpoint at iteration {iteration} to {dataloader_save_path}') data_state_save_path = get_checkpoint_name( dataloader_save_path, iteration, @@ -1102,7 +1304,7 @@ def generate_state_dict( model_sd_kwargs=None, rerun_state=None, ): - """Generate a state dict from given model, optimizer, scheduler, rng state and others. """ + """Generate a state dict from given model, optimizer, scheduler, rng state and others.""" # Arguments, iteration, and model. state_dict = {} @@ -1112,19 +1314,22 @@ def generate_state_dict( state_dict['iteration'] = iteration for i in range(len(model)): - key = "model" + key = 'model' if len(model) > 1: - key = f"model{i}" + key = f'model{i}' - if args.ckpt_format == "torch_dist": + if args.ckpt_format == 'torch_dist': model_sd = model[i].sharded_state_dict( - **(model_sd_kwargs or { - "metadata": { - "dp_cp_group": mpu.get_data_parallel_group(with_context_parallel=True) + **( + model_sd_kwargs + or { + 'metadata': { + 'dp_cp_group': mpu.get_data_parallel_group(with_context_parallel=True) + } } - }) + ) ) - else: # torch, torch_dcp, fsdp_dtensor + else: # torch, torch_dcp, fsdp_dtensor model_sd = model[i].state_dict_for_save_checkpoint() state_dict[key] = model_sd @@ -1132,21 +1337,25 @@ def generate_state_dict( # Optimizer stuff. if not args.no_save_optim: if optimizer is not None and not optimizer.is_stub_optimizer: - - if args.ckpt_format == "torch_dist": + if args.ckpt_format == 'torch_dist': optimizer_sd = optimizer.sharded_state_dict( state_dict, - **(optim_sd_kwargs or { - "metadata": { - "dp_cp_group": mpu.get_data_parallel_group(with_context_parallel=True) + **( + optim_sd_kwargs + or { + 'metadata': { + 'dp_cp_group': mpu.get_data_parallel_group( + with_context_parallel=True + ) + } } - }) + ), ) - elif args.ckpt_format == "fsdp_dtensor": + elif args.ckpt_format == 'fsdp_dtensor': if optim_sd_kwargs is None: optim_sd_kwargs = {} - if "metadata" not in optim_sd_kwargs: - optim_sd_kwargs["metadata"] = {} + if 'metadata' not in optim_sd_kwargs: + optim_sd_kwargs['metadata'] = {} optim_sd_kwargs['metadata'].update(_build_sharded_state_dict_metadata(args)) optimizer_sd = optimizer.sharded_state_dict(state_dict, **optim_sd_kwargs) else: @@ -1155,8 +1364,7 @@ def generate_state_dict( state_dict['optimizer'] = optimizer_sd if opt_param_scheduler is not None: - state_dict['opt_param_scheduler'] = \ - opt_param_scheduler.state_dict() + state_dict['opt_param_scheduler'] = opt_param_scheduler.state_dict() # Rerun state if rerun_state: @@ -1164,28 +1372,26 @@ def generate_state_dict( # RNG states. if not args.no_save_rng and rng_state: - state_dict["rng_state"] = rng_state + state_dict['rng_state'] = rng_state return state_dict def preprocess_fsdp_dtensor_state_dict(args, raw_state_dict, model): state_dict = raw_state_dict.copy() - handle_fp8_extra_state_case(state_dict["model"]) + handle_fp8_extra_state_case(state_dict['model']) if args.swiglu: - if "optimizer" in state_dict: + if 'optimizer' in state_dict: model_state_dict, optimizer_state_dict = handle_swiglu_in_state_dict( - model, state_dict["model"], state_dict["optimizer"] + model, state_dict['model'], state_dict['optimizer'] ) - state_dict["model"] = model_state_dict - state_dict["optimizer"] = optimizer_state_dict + state_dict['model'] = model_state_dict + state_dict['optimizer'] = optimizer_state_dict else: - model_state_dict, _ = handle_swiglu_in_state_dict( - model, state_dict["model"], None - ) - state_dict["model"] = model_state_dict + model_state_dict, _ = handle_swiglu_in_state_dict(model, state_dict['model'], None) + state_dict['model'] = model_state_dict if args.num_experts: - state_dict["model"] = handle_experts_in_state_dict(state_dict["model"], args.num_experts) + state_dict['model'] = handle_experts_in_state_dict(state_dict['model'], args.num_experts) preprocess_state_dict_for_uneven_dtensor(state_dict) return state_dict @@ -1204,11 +1410,13 @@ def _transpose_first_dim(t, num_splits, num_splits_first, model): """[num_splits * np * hn, h] -->(view) [num_splits, np, hn, h] -->(tranpose) [np, num_splits, hn, h] - -->(view) [np * num_splits * hn, h] """ + -->(view) [np * num_splits * hn, h]""" - intermediate_shape = \ - (num_splits, num_attention_heads_per_partition, - hidden_size_per_attention_head) + input_shape[1:] + intermediate_shape = ( + num_splits, + num_attention_heads_per_partition, + hidden_size_per_attention_head, + ) + input_shape[1:] t = t.view(*intermediate_shape) t = t.transpose(0, 1).contiguous() @@ -1216,12 +1424,13 @@ def _transpose_first_dim(t, num_splits, num_splits_first, model): """[np * hn * num_splits, h] -->(view) [np, hn, num_splits, h] -->(tranpose) [np, num_splits, hn, h] - -->(view) [np * num_splits * hn, h] """ + -->(view) [np * num_splits * hn, h]""" - intermediate_shape = \ - (num_attention_heads_per_partition, - hidden_size_per_attention_head, num_splits) +\ - input_shape[1:] + intermediate_shape = ( + num_attention_heads_per_partition, + hidden_size_per_attention_head, + num_splits, + ) + input_shape[1:] t = t.view(*intermediate_shape) t = t.transpose(1, 2).contiguous() @@ -1236,7 +1445,7 @@ def fix_query_key_value_ordering(model, checkpoint_version): """ if checkpoint_version < 2.0: if isinstance(model, list): - assert len(model)==1 + assert len(model) == 1 model = model[0] for name, param in model.named_parameters(): if name.endswith(('.query_key_value.weight', '.query_key_value.bias')): @@ -1245,7 +1454,7 @@ def fix_query_key_value_ordering(model, checkpoint_version): elif checkpoint_version == 1.0: fixed_param = _transpose_first_dim(param.data, 3, False, model) else: - print_rank_0(f"Invalid checkpoint version {checkpoint_version}.") + print_rank_0(f'Invalid checkpoint version {checkpoint_version}.') sys.exit() param.data.copy_(fixed_param) if name.endswith(('.key_value.weight', '.key_value.bias')): @@ -1254,32 +1463,36 @@ def fix_query_key_value_ordering(model, checkpoint_version): elif checkpoint_version == 1.0: fixed_param = _transpose_first_dim(param.data, 2, False, model) else: - print_rank_0(f"Invalid checkpoint version {checkpoint_version}.") + print_rank_0(f'Invalid checkpoint version {checkpoint_version}.') sys.exit() param.data.copy_(fixed_param) - print_rank_0(" successfully fixed query-key-values ordering for" - " checkpoint version {}".format(checkpoint_version)) + print_rank_0( + ' successfully fixed query-key-values ordering for checkpoint version {}'.format( + checkpoint_version + ) + ) def _get_non_persistent_iteration(non_persistent_global_dir, args, checkpointing_context=None): if args.non_persistent_ckpt_type is None: return -1 - elif args.non_persistent_ckpt_type == "global": + elif args.non_persistent_ckpt_type == 'global': tracker_filename = get_checkpoint_tracker_filename(non_persistent_global_dir) if maybe_msc.os.path.isfile(tracker_filename): iteration, release = read_metadata(tracker_filename) if release: - raise RuntimeError('Non-persistent checkpoint can\'t be a release checkpoint') + raise RuntimeError("Non-persistent checkpoint can't be a release checkpoint") else: iteration = -1 print_rank_0('WARNING: could not find the metadata file {}'.format(tracker_filename)) print_rank_0(' will not load any non-persistent checkpoint') return iteration - elif args.non_persistent_ckpt_type == "local": + elif args.non_persistent_ckpt_type == 'local': return checkpointing_context['local_checkpoint_manager'].find_latest() else: - assert False, 'Please use local or global non-persistent checkpoints' \ - f'(got: {args.non_persistent_ckpt_type})' + assert ( + False + ), f'Please use local or global non-persistent checkpoints(got: {args.non_persistent_ckpt_type})' def _load_non_persistent_base_checkpoint( @@ -1292,22 +1505,27 @@ def _load_non_persistent_base_checkpoint( dp_cp_group=None, expt_dp_group=None, ): - """ Load the base state_dict from a non-persistent distributed checkpoint. + """Load the base state_dict from a non-persistent distributed checkpoint. Depending on the non_persistent_ckpt_type, different logic may be required. """ assert args.non_persistent_ckpt_type is not None - if args.non_persistent_ckpt_type == "global": + if args.non_persistent_ckpt_type == 'global': if not rank0: print_rank_0( f'Loading from a non-persistent checkpoint (non-persistent iter {non_persistent_iteration})' ) return _load_global_dist_base_checkpoint( - non_persistent_global_dir, args, rank0, sharded_state_dict, non_persistent_iteration, False, + non_persistent_global_dir, + args, + rank0, + sharded_state_dict, + non_persistent_iteration, + False, checkpointing_context=checkpointing_context, dp_cp_group=dp_cp_group, expt_dp_group=expt_dp_group, ) - elif args.non_persistent_ckpt_type == "local": + elif args.non_persistent_ckpt_type == 'local': intermediate_state_dict, checkpoint_name = checkpointing_context[ 'local_checkpoint_manager' ].load() @@ -1322,7 +1540,9 @@ def _load_non_persistent_base_checkpoint( ) return state_dict, checkpoint_name, False, CheckpointType.LOCAL else: - raise NotImplementedError(f"Please use local or global non-persistent checkpoints (got: {args.non_persistent_ckpt_type})") + raise NotImplementedError( + f'Please use local or global non-persistent checkpoints (got: {args.non_persistent_ckpt_type})' + ) def _load_global_dist_base_checkpoint( @@ -1336,7 +1556,7 @@ def _load_global_dist_base_checkpoint( dp_cp_group=None, expt_dp_group=None, ): - """ Load the base state_dict from the given directory containing the global distributed checkpoint """ + """Load the base state_dict from the given directory containing the global distributed checkpoint""" if rank0: checkpoint_name = find_checkpoint_rank_0(load_dir, iteration, release) state_dict = dist_checkpointing.load_common_state_dict(checkpoint_name) @@ -1363,18 +1583,18 @@ def _load_global_dist_base_checkpoint( ) elif args.ckpt_fully_parallel_load_process_group == 'ep_dp': process_group = ( - expt_dp_group - if expt_dp_group is not None - else mpu.get_expert_data_parallel_group() + expt_dp_group if expt_dp_group is not None else mpu.get_expert_data_parallel_group() ) else: - raise ValueError(f"Invalid load process group: {args.ckpt_fully_parallel_load_process_group}") + raise ValueError( + f'Invalid load process group: {args.ckpt_fully_parallel_load_process_group}' + ) load_strategy = FullyParallelLoadStrategyWrapper( load_strategy, process_group, exchange_algo=args.ckpt_fully_parallel_load_exchange_algo ) if checkpointing_context is not None: - checkpointing_context["load_strategy"] = load_strategy + checkpointing_context['load_strategy'] = load_strategy state_dict = dist_checkpointing.load( sharded_state_dict, checkpoint_name, @@ -1389,20 +1609,20 @@ def _load_global_dist_base_checkpoint( def _get_checkpoint_format(checkpoint_name, args): """Get the format of an existing checkpoint.""" checkpoint_dir = maybe_msc.Path(checkpoint_name) - is_torch_ckpt = any([f.name.startswith("mp_rank_0") for f in checkpoint_dir.iterdir()]) - is_torch_dcp = checkpoint_dir.joinpath(".metadata").exists() + is_torch_ckpt = any([f.name.startswith('mp_rank_0') for f in checkpoint_dir.iterdir()]) + is_torch_dcp = checkpoint_dir.joinpath('.metadata').exists() ckpt_format = None if dist_checkpointing.check_is_distributed_checkpoint(checkpoint_name): - ckpt_format = "torch_dist" + ckpt_format = 'torch_dist' elif is_torch_ckpt: - ckpt_format = "torch" + ckpt_format = 'torch' elif is_torch_dcp: - ckpt_format = "torch_dcp" - if getattr(args, "use_megatron_fsdp", False): - ckpt_format = "fsdp_dtensor" + ckpt_format = 'torch_dcp' + if getattr(args, 'use_megatron_fsdp', False): + ckpt_format = 'fsdp_dtensor' else: - raise NotImplementedError(f"unknown checkpoint format in {checkpoint_name}") + raise NotImplementedError(f'unknown checkpoint format in {checkpoint_name}') return ckpt_format @@ -1415,8 +1635,9 @@ def _load_base_checkpoint( checkpointing_context=None, dp_cp_group=None, expt_dp_group=None, + gpt_compat_layer_maps=None, ): - """ Load the base state_dict from the given directory + """Load the base state_dict from the given directory If rank0 is true, just loads rank 0 checkpoint, ignoring arguments. """ @@ -1437,7 +1658,7 @@ def _load_base_checkpoint( iteration, release = read_metadata(tracker_filename) # Allow user to specify the loaded iteration. - if getattr(args, "ckpt_step", None): + if getattr(args, 'ckpt_step', None): iteration = args.ckpt_step # Record the iteration loaded (stored separately from args to avoid @@ -1472,14 +1693,14 @@ def _load_base_checkpoint( torch.distributed.barrier() sys.exit() - return None, "", False, None + return None, '', False, None # Determine the type of the checkpoint on disk. checkpoint_name = get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) ckpt_format = _get_checkpoint_format(checkpoint_name, args) if not rank0: - dist_infix = "distributed " if ckpt_format == "torch_dist" else "" + dist_infix = 'distributed ' if ckpt_format == 'torch_dist' else '' if release: print_rank_0(f' loading release {dist_infix}checkpoint from {load_dir}') else: @@ -1490,7 +1711,7 @@ def _load_base_checkpoint( ckpt_type = None # Handle global distributed checkpoint - if ckpt_format == "torch_dist": + if ckpt_format == 'torch_dist': return _load_global_dist_base_checkpoint( load_dir, args, @@ -1502,30 +1723,29 @@ def _load_base_checkpoint( dp_cp_group=dp_cp_group, expt_dp_group=expt_dp_group, ) - elif ckpt_format == "torch": + elif ckpt_format == 'torch': ckpt_type = CheckpointType.LEGACY # Handle global legacy checkpoint if rank0: checkpoint_name = find_checkpoint_rank_0(load_dir, iteration, release) else: - checkpoint_name = get_checkpoint_name(load_dir, iteration, release, return_base_dir=False) + checkpoint_name = get_checkpoint_name( + load_dir, iteration, release, return_base_dir=False + ) try: state_dict = torch.load(checkpoint_name, map_location='cpu') except Exception as e: print('could not load the checkpoint') print(e) sys.exit() - elif ckpt_format == "torch_dcp": + elif ckpt_format == 'torch_dcp': ckpt_type = CheckpointType.TORCH_DCP if rank0: # _load_base_checkpoint is called from load_args_from_checkpoint. torch.distributed is not initialized. # Load only metadata. - state_dict = {"args": None, "iteration": None} - torch.distributed.checkpoint.load( - state_dict=state_dict, - checkpoint_id=checkpoint_name, - ) + state_dict = {'args': None, 'iteration': None} + torch.distributed.checkpoint.load(state_dict=state_dict, checkpoint_id=checkpoint_name) else: # _load_base_checkpoint is called from load_checkpoint with a proper state dict. state_dict = sharded_state_dict @@ -1533,51 +1753,69 @@ def _load_base_checkpoint( fs_storage_reader = torch.distributed.checkpoint.FileSystemReader(checkpoint_name) torch.distributed.checkpoint.load_state_dict( - state_dict=state_dict, - storage_reader=fs_storage_reader, + state_dict=state_dict, storage_reader=fs_storage_reader ) - elif ckpt_format == "fsdp_dtensor": - assert HAVE_MEGATRON_FSDP, "Should not be called if Megatron-FSDP is not available." + elif ckpt_format == 'fsdp_dtensor': + assert HAVE_MEGATRON_FSDP, 'Should not be called if Megatron-FSDP is not available.' if rank0: - return {}, checkpoint_name, release, CheckpointType.FSDP_DTENSOR + state_dict = {'args': None, 'iteration': None, 'checkpoint_version': None} + torch.distributed.checkpoint.load(state_dict=state_dict, checkpoint_id=checkpoint_name) + return state_dict, checkpoint_name, release, CheckpointType.FSDP_DTENSOR state_dict = sharded_state_dict - raw_optimizer_state_dict = state_dict["optimizer"].copy() if "optimizer" in state_dict else None - raw_model_state_dict = state_dict["model"].copy() if "model" in state_dict else None - model = state_dict.pop("_model") + raw_optimizer_state_dict = ( + state_dict['optimizer'].copy() if 'optimizer' in state_dict else None + ) + raw_model_state_dict = state_dict['model'].copy() if 'model' in state_dict else None + model = state_dict.pop('_model') state_dict = preprocess_fsdp_dtensor_state_dict(args, state_dict, model[0]) + fs_storage_reader = torch.distributed.checkpoint.FileSystemReader(checkpoint_name) + state_dict_metadata = fs_storage_reader.read_metadata().state_dict_metadata + if gpt_compat_layer_maps is not None: + from megatron.core.dist_checkpointing.gpt_checkpoint_interop import ( + retarget_fsdp_state_dict_to_gpt_checkpoint, + ) + + state_dict['model'] = retarget_fsdp_state_dict_to_gpt_checkpoint( + state_dict['model'], + gpt_compat_layer_maps, + tuple(key for key in state_dict_metadata if key.startswith('model.')), + checkpoint_prefix='model', + ) + if 'optimizer' in state_dict: + state_dict['optimizer'] = retarget_fsdp_state_dict_to_gpt_checkpoint( + state_dict['optimizer'], + gpt_compat_layer_maps, + tuple(key for key in state_dict_metadata if key.startswith('optimizer.')), + checkpoint_prefix='optimizer', + ) ckpt_type = CheckpointType.FSDP_DTENSOR - fs_storage_reader = torch.distributed.checkpoint.FileSystemReader(checkpoint_name) allow_partial_load = not getattr(args, 'strict_fsdp_dtensor_load', False) if allow_partial_load: - state_dict_metadata = fs_storage_reader.read_metadata().state_dict_metadata rank = torch.distributed.get_rank() import time as _time + _time.sleep(rank * 0.001) # Make that logs of different ranks do not overlap print_diff_in_state_dicts(state_dict_metadata, state_dict) planner = default_planner.DefaultLoadPlanner(allow_partial_load=allow_partial_load) torch.distributed.checkpoint.load_state_dict( - state_dict=state_dict, - storage_reader=fs_storage_reader, - planner=planner, + state_dict=state_dict, storage_reader=fs_storage_reader, planner=planner ) if raw_optimizer_state_dict is not None: - state_dict["optimizer"] = raw_optimizer_state_dict + state_dict['optimizer'] = raw_optimizer_state_dict if raw_model_state_dict is not None: - state_dict["model"] = raw_model_state_dict + state_dict['model'] = raw_model_state_dict else: - raise NotImplementedError(f"checkpoint format {ckpt_format} not supported") + raise NotImplementedError(f'checkpoint format {ckpt_format} not supported') return state_dict, checkpoint_name, release, ckpt_type -def load_args_from_checkpoint( - args, load_arg='load', checkpointing_context=None -): +def load_args_from_checkpoint(args, load_arg='load', checkpointing_context=None): """Set required arguments from the checkpoint specified in the arguments. @@ -1597,10 +1835,7 @@ def load_args_from_checkpoint( return args state_dict, checkpoint_name, release, ckpt_type = _load_base_checkpoint( - load_dir, - args, - rank0=True, - checkpointing_context=checkpointing_context, + load_dir, args, rank0=True, checkpointing_context=checkpointing_context ) # Args. @@ -1623,10 +1858,13 @@ def load_args_from_checkpoint( ) # Backward compat: old checkpoints have hybrid_override_pattern but not hybrid_layer_pattern - if (getattr(checkpoint_args, 'hybrid_override_pattern', None) is not None - and getattr(checkpoint_args, 'hybrid_layer_pattern', None) is None): + if ( + getattr(checkpoint_args, 'hybrid_override_pattern', None) is not None + and getattr(checkpoint_args, 'hybrid_layer_pattern', None) is None + ): setattr( - checkpoint_args, 'hybrid_layer_pattern', + checkpoint_args, + 'hybrid_layer_pattern', getattr(checkpoint_args, 'hybrid_override_pattern'), ) # num_layers is now derived from hybrid_layer_pattern in validate_args, and should not be @@ -1644,10 +1882,10 @@ def _set_arg(arg_name, old_arg_name=None, force=False): checkpoint_value = getattr(checkpoint_args, arg_name, None) if checkpoint_value is not None: - print_rank_0(f"Setting {arg_name} to {checkpoint_value} from checkpoint") + print_rank_0(f'Setting {arg_name} to {checkpoint_value} from checkpoint') setattr(args, arg_name, checkpoint_value) else: - print_rank_0(f"Checkpoint did not provide arguments {arg_name}") + print_rank_0(f'Checkpoint did not provide arguments {arg_name}') # Model args. _set_arg('num_layers') @@ -1742,8 +1980,101 @@ def _set_arg(arg_name, old_arg_name=None, force=False): return args, checkpoint_args -def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', strict=True, - checkpointing_context=None, skip_load_to_model_and_opt=False, tp_group: Optional[torch.distributed.ProcessGroup] = None, pp_group: Optional[torch.distributed.ProcessGroup] = None, dp_cp_group: Optional[torch.distributed.ProcessGroup] = None, dp_group: Optional[torch.distributed.ProcessGroup] = None, expt_dp_group: Optional[torch.distributed.ProcessGroup] = None, rng_state_key_prefix: str = ''): +def _maybe_setup_gpt_to_hybrid_load(args, ckpt_args, model): + """Detect a GPT (pure transformer) checkpoint being loaded into a HybridModel run. + + Returns ``(layer_maps, load_optim)`` where ``layer_maps`` is used to retarget + the run's sharded state dict at the GPT checkpoint's keys (see + ``megatron.core.dist_checkpointing.gpt_checkpoint_interop``) and ``load_optim`` is + True when the GPT optimizer state should be translated and loaded as well. + Returns ``(None, False)`` when checkpoint and runtime already agree. Raises + RuntimeError for combinations that cannot be loaded. + """ + from megatron.core.dist_checkpointing.gpt_checkpoint_interop import gpt_compatible_layer_maps + from megatron.core.models.hybrid.hybrid_model import HybridModel + + def _contains_hybrid_model(module): + # Megatron-FSDP and Float16Module both retain the wrapped module under + # ``module`` but are intentionally not handled by the regular + # ``unwrap_model`` helper. + while module is not None: + if isinstance(module, HybridModel): + return True + module = getattr(module, 'module', None) + return False + + runtime_is_hybrid = any(_contains_hybrid_model(m) for m in model) + ckpt_pattern = getattr(ckpt_args, 'hybrid_layer_pattern', None) or getattr( + ckpt_args, 'hybrid_override_pattern', None + ) + if runtime_is_hybrid == bool(ckpt_pattern): + return None, False + if not runtime_is_hybrid: + raise RuntimeError( + f'The checkpoint was saved by a hybrid model run (hybrid layer pattern ' + f'{ckpt_pattern!r}) but the current run builds a non-hybrid model. Load it ' + f'with the hybrid training entrypoint instead.' + ) + + # GPT checkpoint feeding a HybridModel run: translate the sharded state + # dict at load time instead of converting the checkpoint on disk. + if not args.hybrid_layer_pattern: + raise RuntimeError( + 'Loading a GPT checkpoint into a hybrid model requires ' + '--hybrid-layer-pattern so checkpoint layers can be paired with ' + 'hybrid layer positions.' + ) + try: + layer_maps = gpt_compatible_layer_maps(args.hybrid_layer_pattern) + except ValueError as exc: + raise RuntimeError(f'Cannot load a GPT checkpoint into this hybrid model: {exc}') from exc + + ckpt_num_layers = getattr(ckpt_args, 'num_layers', None) + if ckpt_num_layers is not None and ckpt_num_layers != layer_maps.num_gpt_layers: + raise RuntimeError( + f'Hybrid layer pattern {args.hybrid_layer_pattern!r} pairs with a GPT ' + f'checkpoint of {layer_maps.num_gpt_layers} layers, but the checkpoint ' + f'has num_layers={ckpt_num_layers}.' + ) + + # The optimizer state is loaded unless the user opts out or the GPT run saved + # no optimizer state. Fresh layers (e.g. Mamba) have no counterpart in the GPT + # checkpoint, so their optimizer state stays freshly initialized; warn about it. + load_optim = not args.no_load_optim and not getattr(ckpt_args, 'no_save_optim', False) + if load_optim and layer_maps.fresh_init: + print_rank_0( + f'> WARNING: {len(layer_maps.fresh_init)} hybrid layer(s) have no GPT ' + f'counterpart (e.g. Mamba positions); their weights and optimizer state ' + f'start from a fresh initialization while the GPT-sourced attention and ' + f'MLP layers load their optimizer state. Pass --no-load-optim to start ' + f"every layer's optimizer state fresh." + ) + + print_rank_0( + f'> loading a GPT checkpoint into the hybrid model: {layer_maps.num_gpt_layers} ' + f'GPT layers feed {len(layer_maps.attention_to_gpt)} attention and ' + f'{len(layer_maps.mlp_to_gpt)} MLP positions; {len(layer_maps.fresh_init)} ' + f'hybrid layers keep their fresh initialization' + + ('; optimizer state will be loaded.' if load_optim else '; optimizer state starts fresh.') + ) + return layer_maps, load_optim + + +def load_checkpoint( + ddp_model, + optimizer, + opt_param_scheduler, + load_arg='load', + strict=True, + checkpointing_context=None, + skip_load_to_model_and_opt=False, + tp_group: Optional[torch.distributed.ProcessGroup] = None, + pp_group: Optional[torch.distributed.ProcessGroup] = None, + dp_cp_group: Optional[torch.distributed.ProcessGroup] = None, + dp_group: Optional[torch.distributed.ProcessGroup] = None, + expt_dp_group: Optional[torch.distributed.ProcessGroup] = None, + rng_state_key_prefix: str = '', +): """Load a model checkpoint and return the iteration. strict (bool): whether to strictly enforce that the keys in :attr:`state_dict` of the checkpoint match the names of @@ -1766,89 +2097,117 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', ) load_dir = pretrained_dir if not checkpoint_exists(load_dir): - raise FileNotFoundError("No checkpoint found in load directory or pretrained directory") + raise FileNotFoundError('No checkpoint found in load directory or pretrained directory') args.finetune = True model = unwrap_model(ddp_model) ckpt_format = args.ckpt_format - if args.auto_detect_ckpt_format or ckpt_format == "torch_dist": + state_dict = None + release = False + if args.auto_detect_ckpt_format or ckpt_format in ('torch_dist', 'fsdp_dtensor'): state_dict, checkpoint_name, release, ckpt_type = _load_base_checkpoint( - load_dir, - args, - rank0=True, - checkpointing_context=checkpointing_context, + load_dir, args, rank0=True, checkpointing_context=checkpointing_context ) ckpt_format = None if ckpt_type == CheckpointType.TORCH_DCP: - ckpt_format = "torch_dcp" + ckpt_format = 'torch_dcp' elif ckpt_type == CheckpointType.FSDP_DTENSOR: - ckpt_format = "fsdp_dtensor" + ckpt_format = 'fsdp_dtensor' elif ckpt_type == CheckpointType.LEGACY: - ckpt_format = "torch" + ckpt_format = 'torch' elif ckpt_type in [CheckpointType.LOCAL, CheckpointType.GLOBAL]: - ckpt_format = "torch_dist" + ckpt_format = 'torch_dist' elif ckpt_type == None: - pass # Not loaded. + pass # Not loaded. else: - raise NotImplementedError(f"checkpoint format {ckpt_format} not supported") + raise NotImplementedError(f'checkpoint format {ckpt_format} not supported') load_kwargs = {} ignore_rng_state = False ignore_rerun_state = True - if ckpt_format == "torch_dist": - ckpt_args = types.SimpleNamespace() - if state_dict is not None and "args" in state_dict: - ckpt_args = state_dict.get("args") + ckpt_args = types.SimpleNamespace() + if ( + ckpt_format in ('torch_dist', 'fsdp_dtensor') + and state_dict is not None + and 'args' in state_dict + ): + ckpt_args = state_dict.get('args') or types.SimpleNamespace() + + # Both model-space torch_dist and fsdp_dtensor checkpoints carry model-keyed + # optimizer state that can be retargeted from GPTModel to HybridModel. + gpt_compat_layer_maps, gpt_compat_load_optim = ( + _maybe_setup_gpt_to_hybrid_load(args, ckpt_args, model) + if ckpt_format in ('torch_dist', 'fsdp_dtensor') and state_dict is not None + else (None, False) + ) + gpt_compat_load_optim = gpt_compat_load_optim and not release - if not hasattr(ckpt_args, "tensor_model_parallel_size"): - print_rank_0("WARNING: TP size not found in checkpoint args, using 1 as default.") - if not hasattr(ckpt_args, "pipeline_model_parallel_size"): - print_rank_0("WARNING: PP size not found in checkpoint args, using 1 as default.") + if ckpt_format == 'torch_dist': + if not hasattr(ckpt_args, 'tensor_model_parallel_size'): + print_rank_0('WARNING: TP size not found in checkpoint args, using 1 as default.') + if not hasattr(ckpt_args, 'pipeline_model_parallel_size'): + print_rank_0('WARNING: PP size not found in checkpoint args, using 1 as default.') ckpt_tp_pp = ( - getattr(ckpt_args, "tensor_model_parallel_size", 1), - getattr(ckpt_args, "pipeline_model_parallel_size", 1), - ) - run_tp_pp = ( - args.tensor_model_parallel_size, - args.pipeline_model_parallel_size, + getattr(ckpt_args, 'tensor_model_parallel_size', 1), + getattr(ckpt_args, 'pipeline_model_parallel_size', 1), ) + run_tp_pp = (args.tensor_model_parallel_size, args.pipeline_model_parallel_size) ckpt_world_size = getattr(ckpt_args, 'world_size', 0) run_world_size = getattr(args, 'world_size', 0) ckpt_dp = getattr(ckpt_args, 'data_parallel_size', 0) run_dp = getattr(args, 'data_parallel_size', 0) - mismatch_msg = "(TP, PP) mismatch after resume ({} vs {} from checkpoint)".format( + mismatch_msg = '(TP, PP) mismatch after resume ({} vs {} from checkpoint)'.format( run_tp_pp, ckpt_tp_pp ) # Determine if RNG state will be loaded - if (ckpt_tp_pp == run_tp_pp and not release and not args.finetune and not args.no_load_rng - and not getattr(ckpt_args, 'no_save_rng', False)): + if ( + ckpt_tp_pp == run_tp_pp + and not release + and not args.finetune + and not args.no_load_rng + and not getattr(ckpt_args, 'no_save_rng', False) + ): if tp_group is None and pp_group is None: tp_group = mpu.get_tensor_model_parallel_group() pp_group = mpu.get_pipeline_model_parallel_group() - gen_sd_rng_state = get_rng_state(args.ckpt_format, tp_group, pp_group, - dp_cp_group=dp_cp_group, - dp_group=dp_group, - key_prefix=rng_state_key_prefix) # we can load the rng state + gen_sd_rng_state = get_rng_state( + args.ckpt_format, + tp_group, + pp_group, + dp_cp_group=dp_cp_group, + dp_group=dp_group, + key_prefix=rng_state_key_prefix, + ) # we can load the rng state else: ignore_rng_state = True gen_sd_rng_state = None if ckpt_tp_pp != run_tp_pp: - print_rank_0("{}: RNG state will be ignored".format(mismatch_msg)) + print_rank_0('{}: RNG state will be ignored'.format(mismatch_msg)) if ckpt_type == CheckpointType.LOCAL: sharded_sd_metadata = _build_sharded_state_dict_metadata(args, dp_cp_group=dp_cp_group) else: - sharded_sd_metadata = dist_checkpointing.load_content_metadata(preloaded_state_dict=state_dict) - print_rank_0(f'sharded_state_dict metadata loaded from the checkpoint: {sharded_sd_metadata}') + sharded_sd_metadata = dist_checkpointing.load_content_metadata( + preloaded_state_dict=state_dict + ) + print_rank_0( + f'sharded_state_dict metadata loaded from the checkpoint: {sharded_sd_metadata}' + ) - # Determine if optimizer state will be loaded - if (not release and not args.finetune and not args.no_load_optim - and not getattr(ckpt_args, 'no_save_optim', False)): + # Determine if optimizer state will be loaded. For a GPT->hybrid load the + # optimizer state is retargeted at the GPT checkpoint even under --finetune, + # which independently controls iteration and LR-schedule reset semantics. + if ( + not release + and (not args.finetune or gpt_compat_load_optim) + and not args.no_load_optim + and not getattr(ckpt_args, 'no_save_optim', False) + ): gen_sd_optim = optimizer gen_sd_opt_param_scheduler = opt_param_scheduler @@ -1858,23 +2217,50 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', # Can be removed after ending support for MLM optimizer checkpoints with MCore < v0.13 # (for MCore v0.13+ checkpoints `sharded_sd_metadata is not None`) sharded_sd_metadata = { - 'distrib_optim_sharding_type': ('fully_sharded_model_space' - if getattr(ckpt_args, 'ckpt_fully_parallel_save', False) - else 'dp_zero_gather_scatter'), + 'distrib_optim_sharding_type': ( + 'fully_sharded_model_space' + if getattr(ckpt_args, 'ckpt_fully_parallel_save', False) + else 'dp_zero_gather_scatter' + ) } + # Retargeting optimizer state onto a GPT checkpoint only works for the + # model-space formats, where each optimizer ShardedTensor carries the + # model param's key and sharding. Bucket-space formats key state by a + # flat buffer layout that the extra hybrid layers reshuffle. + if gpt_compat_load_optim and sharded_sd_metadata[ + 'distrib_optim_sharding_type' + ] not in ('fully_reshardable', 'fully_sharded_model_space'): + raise RuntimeError( + 'Loading optimizer state from a GPT checkpoint into a hybrid model ' + 'is only supported for model-space distributed-optimizer checkpoints ' + "(sharding type 'fully_reshardable' or 'fully_sharded_model_space'), " + f'but the checkpoint uses ' + f'{sharded_sd_metadata["distrib_optim_sharding_type"]!r}. Re-save the ' + 'GPT checkpoint with --dist-ckpt-optim-fully-reshardable, or pass ' + '--no-load-optim ' + 'to start from a fresh optimizer.' + ) if ( ckpt_tp_pp != run_tp_pp and sharded_sd_metadata['distrib_optim_sharding_type'] not in DistributedOptimizer.checkpoint_fully_reshardable_formats ): - raise RuntimeError(f"{mismatch_msg}: not supported for DistributedOptimizer with sharding type" - f" {sharded_sd_metadata['distrib_optim_sharding_type']}." - f" Please use `--ckpt-fully-parallel-save` flag during checkpoint saving.") + raise RuntimeError( + f'{mismatch_msg}: not supported for DistributedOptimizer with sharding type' + f' {sharded_sd_metadata["distrib_optim_sharding_type"]}.' + f' Please use `--ckpt-fully-parallel-save` flag during checkpoint saving.' + ) # Check if fully parallel load is compatible with sharding type - if args.ckpt_fully_parallel_load and sharded_sd_metadata['distrib_optim_sharding_type'] == 'dp_zero_gather_scatter': - raise RuntimeError("Fully parallel load is not supported for dp_zero_gather_scatter checkpoints. " - "Please remove --ckpt-fully-parallel-load flag") + if ( + args.ckpt_fully_parallel_load + and sharded_sd_metadata['distrib_optim_sharding_type'] + == 'dp_zero_gather_scatter' + ): + raise RuntimeError( + 'Fully parallel load is not supported for dp_zero_gather_scatter checkpoints. ' + 'Please remove --ckpt-fully-parallel-load flag' + ) else: gen_sd_optim = None gen_sd_opt_param_scheduler = None @@ -1886,7 +2272,7 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', # Ensure we have a dict before updating to avoid NoneType AttributeError. if sharded_sd_metadata is None: sharded_sd_metadata = {} - sharded_sd_metadata["dp_cp_group"] = dp_cp_group + sharded_sd_metadata['dp_cp_group'] = dp_cp_group optim_sd_kwargs = dict(metadata=sharded_sd_metadata, is_loading=True) model_sd_kwargs = dict(metadata=sharded_sd_metadata) @@ -1904,48 +2290,67 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', rerun_state_machine = get_rerun_state_machine() if rerun_state_machine.validate_state_dict(state_dict['rerun_state_machine']): gen_sd_rerun_state = rerun_state_machine.state_dict( - data_iterator=None, ckpt_format=ckpt_format, force=True, + data_iterator=None, ckpt_format=ckpt_format, force=True ) ignore_rerun_state = False - if ( - ckpt_world_size != run_world_size - or ckpt_tp_pp != run_tp_pp - or ckpt_dp != run_dp - ): - print_rank_0("Job sharding has changed: Rerun state will be ignored") + if ckpt_world_size != run_world_size or ckpt_tp_pp != run_tp_pp or ckpt_dp != run_dp: + print_rank_0('Job sharding has changed: Rerun state will be ignored') # [ModelOpt]: Initial loading from non-resume sharded checkpoint to a Distillation Model # will result in key mismatch with loss modules potentially containing parameters, since # it requires generating a state_dict before loading. Here we hide those modules if present. - with contextlib.ExitStack() as stack: # Allows multiple context managers for each model shard - if args.finetune and hasattr(model[0], "hide_loss_modules"): + with ( + contextlib.ExitStack() as stack + ): # Allows multiple context managers for each model shard + if args.finetune and hasattr(model[0], 'hide_loss_modules'): for m in model: stack.enter_context(m.hide_loss_modules()) load_kwargs['sharded_state_dict'] = generate_state_dict( - args, model, gen_sd_optim, gen_sd_opt_param_scheduler, gen_sd_rng_state, - optim_sd_kwargs=optim_sd_kwargs, model_sd_kwargs=model_sd_kwargs, - rerun_state=gen_sd_rerun_state + args, + model, + gen_sd_optim, + gen_sd_opt_param_scheduler, + gen_sd_rng_state, + optim_sd_kwargs=optim_sd_kwargs, + model_sd_kwargs=model_sd_kwargs, + rerun_state=gen_sd_rerun_state, ) - elif args.ckpt_format == "torch_dcp": + + if gpt_compat_layer_maps is not None: + from megatron.core.dist_checkpointing.gpt_checkpoint_interop import ( + retarget_sharded_state_dict_to_gpt_checkpoint, + ) + + # The optimizer sharded state dict is built from the (hybrid) model sharded + # state dict, so its entries carry the same ``decoder.layers..`` keys and + # sharding; the same retargeting points them at the GPT checkpoint too. + for sd_key, sub_sd in load_kwargs['sharded_state_dict'].items(): + is_model = sd_key == 'model' or re.fullmatch(r'model\d+', sd_key) + is_optim = gpt_compat_load_optim and ( + sd_key == 'optimizer' or re.fullmatch(r'optimizer\d+', sd_key) + ) + if is_model or is_optim: + retarget_sharded_state_dict_to_gpt_checkpoint(sub_sd, gpt_compat_layer_maps) + elif args.ckpt_format == 'torch_dcp': model_sd = model[0].state_dict() optimizer_sd = optimizer.state_dict(is_loading=True) if tp_group is None and pp_group is None: tp_group = mpu.get_tensor_model_parallel_group() pp_group = mpu.get_pipeline_model_parallel_group() sharded_state_dict = { - "model": model_sd, - "optimizer": optimizer_sd, - "args": None, - "iteration": 1, - "rng_state": get_rng_state( + 'model': model_sd, + 'optimizer': optimizer_sd, + 'args': None, + 'iteration': 1, + 'rng_state': get_rng_state( args.ckpt_format, tp_group, pp_group, dp_cp_group=dp_cp_group, dp_group=dp_group ), - "checkpoint_version": None, - "opt_param_scheduler": opt_param_scheduler.state_dict(), - "num_floating_point_operations_so_far": 0, + 'checkpoint_version': None, + 'opt_param_scheduler': opt_param_scheduler.state_dict(), + 'num_floating_point_operations_so_far': 0, } - load_kwargs["sharded_state_dict"] = sharded_state_dict - elif args.ckpt_format == "fsdp_dtensor": + load_kwargs['sharded_state_dict'] = sharded_state_dict + elif args.ckpt_format == 'fsdp_dtensor': reader = FileSystemReader(get_load_checkpoint_path_by_args(args)) try: state_dict_metadata = reader.read_metadata().state_dict_metadata @@ -1957,16 +2362,17 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', gen_sd_rng_state = None gen_sd_optim = None if not args.finetune: - if "rerun_state_machine" in state_dict_metadata: + if 'rerun_state_machine' in state_dict_metadata: gen_sd_rerun_state = get_rerun_state_machine().state_dict( - data_iterator=None, ckpt_format=ckpt_format, force=True, + data_iterator=None, ckpt_format=ckpt_format, force=True ) if not args.no_load_rng: gen_sd_rng_state = get_rng_state( args.ckpt_format, tp_group, pp_group, dp_cp_group=dp_cp_group, dp_group=dp_group ) - if not args.no_load_optim: - gen_sd_optim = optimizer + if (not args.finetune or gpt_compat_load_optim) and not args.no_load_optim: + gen_sd_optim = optimizer + if not args.finetune: gen_sd_opt_param_scheduler = opt_param_scheduler optim_sd_kwargs = dict( @@ -1974,18 +2380,43 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', is_loading=True, ) - state_dict = generate_state_dict( - args, - model=model, - optimizer=gen_sd_optim, - opt_param_scheduler=gen_sd_opt_param_scheduler, - rng_state=gen_sd_rng_state, - optim_sd_kwargs=optim_sd_kwargs, - rerun_state=gen_sd_rerun_state, - iteration=1, - ) - state_dict["_model"] = model - load_kwargs["sharded_state_dict"] = state_dict + # Megatron-FSDP materializes optimizer slots with a dummy zero-gradient + # step while building a loading state dict. A normal full resume + # overwrites every model parameter afterward, but GPT->Hybrid leaves + # fresh-only layers untouched. Temporarily zero the optimizer LR so the + # shape-materialization step cannot weight-decay or momentum-update + # those fresh model parameters. + optimizer_lrs = [] + if gpt_compat_layer_maps is not None and gen_sd_optim is not None: + pending_optimizers = [gen_sd_optim] + while pending_optimizers: + pending_optimizer = pending_optimizers.pop() + if hasattr(pending_optimizer, 'chained_optimizers'): + pending_optimizers.extend(pending_optimizer.chained_optimizers) + continue + inner_optimizer = getattr(pending_optimizer, 'optimizer', None) + if inner_optimizer is None: + continue + for param_group in inner_optimizer.param_groups: + optimizer_lrs.append((param_group, param_group.get('lr'))) + param_group['lr'] = 0.0 + + try: + state_dict = generate_state_dict( + args, + model=model, + optimizer=gen_sd_optim, + opt_param_scheduler=gen_sd_opt_param_scheduler, + rng_state=gen_sd_rng_state, + optim_sd_kwargs=optim_sd_kwargs, + rerun_state=gen_sd_rerun_state, + iteration=1, + ) + finally: + for param_group, lr in optimizer_lrs: + param_group['lr'] = lr + state_dict['_model'] = model + load_kwargs['sharded_state_dict'] = state_dict state_dict, checkpoint_name, release, ckpt_type = _load_base_checkpoint( load_dir, @@ -1994,7 +2425,8 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', checkpointing_context=checkpointing_context, dp_cp_group=dp_cp_group, expt_dp_group=expt_dp_group, - **load_kwargs + gpt_compat_layer_maps=gpt_compat_layer_maps, + **load_kwargs, ) # Checkpoint not loaded. @@ -2021,16 +2453,18 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', ) state_dict['args'].consumed_train_samples = target_iter * args.global_batch_size state_dict['args'].skipped_train_samples = 0 - print_rank_0(f'Overriding checkpoint iteration to {target_iter} ' - f'(consumed_train_samples = {target_iter * args.global_batch_size})') + print_rank_0( + f'Overriding checkpoint iteration to {target_iter} ' + f'(consumed_train_samples = {target_iter * args.global_batch_size})' + ) # Set checkpoint version. set_checkpoint_version(state_dict.get('checkpoint_version', 0)) # Convert to regular torch tensor to DTensor. - if ckpt_type == CheckpointType.LEGACY and args.ckpt_format == "torch_dcp": - dtensor_state_dict = _to_dtensor(ddp_model, state_dict["model"]) - state_dict["model"] = dtensor_state_dict + if ckpt_type == CheckpointType.LEGACY and args.ckpt_format == 'torch_dcp': + dtensor_state_dict = _to_dtensor(ddp_model, state_dict['model']) + state_dict['model'] = dtensor_state_dict # Set iteration. if args.finetune or release: @@ -2042,22 +2476,27 @@ def load_checkpoint(ddp_model, optimizer, opt_param_scheduler, load_arg='load', try: # Backward compatible with older checkpoints iteration = state_dict['total_iters'] except KeyError: - print_rank_0('A metadata file exists but unable to load ' - 'iteration from checkpoint {}, exiting'.format(checkpoint_name)) + print_rank_0( + 'A metadata file exists but unable to load iteration from checkpoint {}, exiting'.format( + checkpoint_name + ) + ) sys.exit() num_floating_point_operations_so_far = state_dict.get('num_floating_point_operations_so_far', 0) # Check arguments. if 'args' in state_dict and not args.finetune: checkpoint_args = state_dict['args'] - check_checkpoint_args(checkpoint_args) - args.consumed_train_samples = getattr(checkpoint_args, - 'consumed_train_samples', 0) - args.skipped_train_samples = getattr(checkpoint_args, - 'skipped_train_samples', 0) + # A GPT block is split into separate attention and MLP positions in + # HybridModel, so num_layers intentionally differs even for an + # architecture-preserving load. Keep every other resume-time argument + # compatibility check. + skip_args = {'num_layers'} if gpt_compat_layer_maps is not None else None + check_checkpoint_args(checkpoint_args, skip_args=skip_args) + args.consumed_train_samples = getattr(checkpoint_args, 'consumed_train_samples', 0) + args.skipped_train_samples = getattr(checkpoint_args, 'skipped_train_samples', 0) update_num_microbatches(consumed_samples=args.consumed_train_samples, verbose=True) - args.consumed_valid_samples = getattr(checkpoint_args, - 'consumed_valid_samples', 0) + args.consumed_valid_samples = getattr(checkpoint_args, 'consumed_valid_samples', 0) else: print_rank_0('could not find arguments in the checkpoint ...') @@ -2083,9 +2522,18 @@ def load_model_state_dict(module, state_dict, strict: bool): # Fallback support for backward compatibility breaking changes in TransformerEngine with load_ctx(): load_return = module.load_state_dict(state_dict, strict=False) - print(f"load_return: {load_return}") + print(f'load_return: {load_return}') + + # Megatron-FSDP DTensors are loaded into the model buffers in-place above. + # Replaying the translated raw state dict through ``load_state_dict`` would + # reapply HybridModel-local entries that were intentionally omitted (for + # example fresh Mamba layers). + gpt_fsdp_model_loaded_in_place = ( + ckpt_format == 'fsdp_dtensor' and gpt_compat_layer_maps is not None + ) + # Model. - if not skip_load_to_model_and_opt: + if not skip_load_to_model_and_opt and not gpt_fsdp_model_loaded_in_place: if len(ddp_model) == 1: load_model_state_dict(ddp_model[0], state_dict['model'], strict) else: @@ -2101,45 +2549,61 @@ def load_model_state_dict(module, state_dict, strict: bool): fix_query_key_value_ordering(model, checkpoint_version) # Optimizer. - if not release and not args.finetune and not args.no_load_optim: + if not release and (not args.finetune or gpt_compat_load_optim) and not args.no_load_optim: try: # Load state dict. - if getattr(args, "use_layer_wise_distributed_optimizer", False) and args.ckpt_format == 'torch': + if ( + getattr(args, 'use_layer_wise_distributed_optimizer', False) + and args.ckpt_format == 'torch' + ): # LayerWiseDistributedOptimizer load optimizer state from file on different ranks dp_rank = mpu.get_data_parallel_rank() - optim_checkpoint_name = os.path.join(os.path.dirname(checkpoint_name), f"layer_wise_optimizer_{dp_rank}.pt") + optim_checkpoint_name = os.path.join( + os.path.dirname(checkpoint_name), f'layer_wise_optimizer_{dp_rank}.pt' + ) optimizer.load_state_dict_from_file(optim_checkpoint_name) - elif not skip_load_to_model_and_opt and optimizer is not None and not optimizer.is_stub_optimizer: + elif ( + not skip_load_to_model_and_opt + and optimizer is not None + and not optimizer.is_stub_optimizer + ): optimizer.load_state_dict(state_dict['optimizer']) # Load distributed optimizer's custom parameter state. # For distributed checkpoint it's already loaded in load_state_dict above - is_torch_dist = ckpt_format == "torch_dist" - if args.use_distributed_optimizer and not is_torch_dist and ckpt_format not in ["torch_dcp", "fsdp_dtensor"]: + is_torch_dist = ckpt_format == 'torch_dist' + if ( + args.use_distributed_optimizer + and not is_torch_dist + and ckpt_format not in ['torch_dcp', 'fsdp_dtensor'] + ): # NOTE: this is a manual read of the tracker file. # This code should not be reached when reading from a non_persistent checkpoint assert not is_torch_dist tracker_filename = get_checkpoint_tracker_filename(load_dir) iteration, release = read_metadata(tracker_filename) - model_checkpoint_name = \ - get_checkpoint_name(load_dir, iteration, release) - optim_checkpoint_name = \ - get_distributed_optimizer_checkpoint_name( - model_checkpoint_name) - optimizer.load_parameter_state(optim_checkpoint_name, - update_legacy_format=args.ckpt_convert_update_legacy_dist_opt_format) - - # Load scheduler. - if opt_param_scheduler is not None: - if 'lr_scheduler' in state_dict: # backward compatbility + model_checkpoint_name = get_checkpoint_name(load_dir, iteration, release) + optim_checkpoint_name = get_distributed_optimizer_checkpoint_name( + model_checkpoint_name + ) + optimizer.load_parameter_state( + optim_checkpoint_name, + update_legacy_format=args.ckpt_convert_update_legacy_dist_opt_format, + ) + + # Load scheduler unless --finetune requests a fresh iteration and LR schedule. + if opt_param_scheduler is not None and not args.finetune: + if 'lr_scheduler' in state_dict: # backward compatbility opt_param_scheduler.load_state_dict(state_dict['lr_scheduler']) else: opt_param_scheduler.load_state_dict(state_dict['opt_param_scheduler']) except KeyError as e: - print_rank_0('Unable to load optimizer from checkpoint {}. ' - 'Specify --no-load-optim or --finetune to prevent ' - 'attempting to load the optimizer state, ' - 'exiting ...'.format(checkpoint_name)) + print_rank_0( + 'Unable to load optimizer from checkpoint {}. ' + 'Specify --no-load-optim or --finetune to prevent ' + 'attempting to load the optimizer state, ' + 'exiting ...'.format(checkpoint_name) + ) raise e else: if (args.fp16 or args.bf16) and optimizer is not None: @@ -2154,7 +2618,7 @@ def load_model_state_dict(module, state_dict, strict: bool): if 'rerun_state_machine' in state_dict: get_rerun_state_machine().load_state_dict(state_dict['rerun_state_machine']) except Exception as e: - print_rank_0(f"Unable to restore RerunMachine from checkpoint: {e}. Skipping.") + print_rank_0(f'Unable to restore RerunMachine from checkpoint: {e}. Skipping.') # rng states. if not release and not args.finetune and not args.no_load_rng and not ignore_rng_state: @@ -2162,21 +2626,33 @@ def load_model_state_dict(module, state_dict, strict: bool): cuda_rng_tracker = tensor_parallel.get_cuda_rng_tracker() graph_safe_rng = tensor_parallel.is_graph_safe_cuda_rng_tracker(cuda_rng_tracker) if 'rng_state' in state_dict: - if args.ckpt_format == "fsdp_dtensor": + if args.ckpt_format == 'fsdp_dtensor': # FSDP DTensor checkpoints store rng_state in a different format. - tp_rank = get_pg_rank(tp_group) if tp_group is not None else mpu.get_tensor_model_parallel_rank() - pp_rank = get_pg_rank(pp_group) if pp_group is not None else mpu.get_pipeline_model_parallel_rank() - if f"({pp_rank}, {tp_rank})" in state_dict['rng_state']: - rng_state = state_dict['rng_state'][f"({pp_rank}, {tp_rank})"] + tp_rank = ( + get_pg_rank(tp_group) + if tp_group is not None + else mpu.get_tensor_model_parallel_rank() + ) + pp_rank = ( + get_pg_rank(pp_group) + if pp_group is not None + else mpu.get_pipeline_model_parallel_rank() + ) + if f'({pp_rank}, {tp_rank})' in state_dict['rng_state']: + rng_state = state_dict['rng_state'][f'({pp_rank}, {tp_rank})'] else: - print_rank_0("WARNING: RNG state not found for current TP/PP rank") + print_rank_0('WARNING: RNG state not found for current TP/PP rank') rng_state = next(iter(state_dict['rng_state'].values())) else: rng_state = state_dict['rng_state'] # access rng_state for data parallel rank if args.data_parallel_random_init: - dp_rank = get_pg_rank(dp_group) if dp_group is not None else mpu.get_data_parallel_rank() + dp_rank = ( + get_pg_rank(dp_group) + if dp_group is not None + else mpu.get_data_parallel_rank() + ) rng_state = rng_state[dp_rank] else: rng_state = rng_state[0] @@ -2205,10 +2681,12 @@ def load_model_state_dict(module, state_dict, strict: bool): } cuda_rng_tracker.set_states(rng_tracker_states) except KeyError: - print_rank_0('Unable to load rng state from checkpoint {}. ' - 'Specify --no-load-rng or --finetune to prevent ' - 'attempting to load the rng state, ' - 'exiting ...'.format(checkpoint_name)) + print_rank_0( + 'Unable to load rng state from checkpoint {}. ' + 'Specify --no-load-rng or --finetune to prevent ' + 'attempting to load the rng state, ' + 'exiting ...'.format(checkpoint_name) + ) sys.exit() # Some utilities want to load a checkpoint without distributed being initialized @@ -2216,27 +2694,38 @@ def load_model_state_dict(module, state_dict, strict: bool): torch.distributed.barrier() _tp_r = get_pg_rank(tp_group) if tp_group is not None else mpu.get_tensor_model_parallel_rank() - _tp_w = get_pg_size(tp_group) if tp_group is not None else mpu.get_tensor_model_parallel_world_size() - _pp_r = get_pg_rank(pp_group) if pp_group is not None else mpu.get_pipeline_model_parallel_rank() - _pp_w = get_pg_size(pp_group) if pp_group is not None else mpu.get_pipeline_model_parallel_world_size() + _tp_w = ( + get_pg_size(tp_group) + if tp_group is not None + else mpu.get_tensor_model_parallel_world_size() + ) + _pp_r = ( + get_pg_rank(pp_group) if pp_group is not None else mpu.get_pipeline_model_parallel_rank() + ) + _pp_w = ( + get_pg_size(pp_group) + if pp_group is not None + else mpu.get_pipeline_model_parallel_world_size() + ) _gtp_remat_r = mpu.get_gtp_weight_remat_rank() _gtp_remat_w = mpu.get_gtp_weight_remat_world_size() - print_rank_0(f' successfully loaded checkpoint from {load_dir} ' - f'[ t {_tp_r + 1}/{_tp_w}, ' - f'gtp_remat {_gtp_remat_r + 1}/{_gtp_remat_w}, ' - f'p {_pp_r + 1}/{_pp_w} ] ' - f'at iteration {iteration}') + print_rank_0( + f' successfully loaded checkpoint from {load_dir} ' + f'[ t {_tp_r + 1}/{_tp_w}, ' + f'gtp_remat {_gtp_remat_r + 1}/{_gtp_remat_w}, ' + f'p {_pp_r + 1}/{_pp_w} ] ' + f'at iteration {iteration}' + ) # Additional callback for wandb (last rank) - if not torch.distributed.is_initialized() \ - or is_last_rank(): + if not torch.distributed.is_initialized() or is_last_rank(): wandb_utils.on_load_checkpoint_success(checkpoint_name, load_dir) torch.cuda.empty_cache() if iteration > 0: # Notify FT that a checkpoint was loaded. - is_local_chkpt = (ckpt_type == CheckpointType.LOCAL) + is_local_chkpt = ckpt_type == CheckpointType.LOCAL ft_integration.on_checkpoint_loaded(is_local_chkpt=is_local_chkpt) # Patch checkpoint as needed if required field is not found. @@ -2246,11 +2735,13 @@ def load_model_state_dict(module, state_dict, strict: bool): if 'default_config' not in param_group: param_group['default_config'] = True if not log_printed: - print_rank_0(">>> Inserting 'default_config' field into optimizer.param_groups...") + print_rank_0( + ">>> Inserting 'default_config' field into optimizer.param_groups..." + ) log_printed = True if has_nvidia_modelopt: - print_distributed_quant_summary(model, msg="After loading checkpoint") + print_distributed_quant_summary(model, msg='After loading checkpoint') return iteration, num_floating_point_operations_so_far @@ -2261,7 +2752,7 @@ def _to_dtensor(wrapped_model, model_state_dict): new_model_sd = dict() for k, v in model_state_dict.items(): # FP8 extra state cannot be converted to dtensor yet. - if "_extra_state" in k: + if '_extra_state' in k: new_model_sd[k] = v else: new_model_sd[k] = torch.distributed.tensor.distribute_tensor(v, device_mesh) @@ -2269,8 +2760,9 @@ def _to_dtensor(wrapped_model, model_state_dict): return new_model_sd -def load_biencoder_checkpoint(model, only_query_model=False, - only_context_model=False, custom_load_path=None): +def load_biencoder_checkpoint( + model, only_query_model=False, only_context_model=False, custom_load_path=None +): """ selectively load retrieval models for indexing/retrieving from saved checkpoints @@ -2287,13 +2779,16 @@ def load_biencoder_checkpoint(model, only_query_model=False, with maybe_msc.open(tracker_filename, 'r') as f: iteration = int(f.read().strip()) - checkpoint_name = get_checkpoint_name(load_path, iteration, - args.use_distributed_optimizer, - release=False) + checkpoint_name = get_checkpoint_name( + load_path, iteration, args.use_distributed_optimizer, release=False + ) if mpu.get_data_parallel_rank() == 0: - print('global rank {} is loading checkpoint {}'.format( - torch.distributed.get_rank(), checkpoint_name)) + print( + 'global rank {} is loading checkpoint {}'.format( + torch.distributed.get_rank(), checkpoint_name + ) + ) state_dict = torch.load(checkpoint_name, map_location='cpu') ret_state_dict = state_dict['model'] diff --git a/tests/unit_tests/dist_checkpointing/models/test_gpt_hybrid_interop.py b/tests/unit_tests/dist_checkpointing/models/test_gpt_hybrid_interop.py new file mode 100644 index 00000000000..dae973f4a7b --- /dev/null +++ b/tests/unit_tests/dist_checkpointing/models/test_gpt_hybrid_interop.py @@ -0,0 +1,827 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Tests for loading GPT checkpoints into HybridModel runs. + +Covers the load-time sharded state dict retargeting in +``megatron.core.dist_checkpointing.gpt_checkpoint_interop``: + +* pure layer-map derivation and validation (no GPU state), +* pure key retargeting on synthetic sharded state dicts, +* end-to-end: save a GPTModel dist checkpoint under one (TP, PP, EP, ETP) + layout, load it into a HybridModel under another layout, and verify + attention/MLP weights round-trip bit-for-bit while layers without a GPT + counterpart keep their fresh initialization. +""" + +from functools import partial +from unittest import mock + +import pytest +import torch + +from megatron.core import parallel_state as ps +from megatron.core.dist_checkpointing import load, load_plain_tensors, save +from megatron.core.dist_checkpointing.dict_utils import diff +from megatron.core.dist_checkpointing.gpt_checkpoint_interop import ( + gpt_compatible_layer_maps, + retarget_fsdp_state_dict_to_gpt_checkpoint, + retarget_sharded_state_dict_to_gpt_checkpoint, +) +from megatron.core.dist_checkpointing.mapping import ( + LocalNonpersistentObject, + ShardedObject, + ShardedTensor, + ShardedTensorFactory, +) +from megatron.core.dist_checkpointing.validation import StrictHandling +from megatron.core.models.gpt.gpt_layer_specs import ( + get_gpt_decoder_block_spec, + get_gpt_layer_with_transformer_engine_spec, +) +from megatron.core.models.gpt.gpt_model import GPTModel +from megatron.core.models.hybrid.hybrid_layer_specs import hybrid_stack_spec +from megatron.core.models.hybrid.hybrid_model import HybridModel +from megatron.core.num_microbatches_calculator import ( + destroy_num_microbatches_calculator, + init_num_microbatches_calculator, +) +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.training.arguments import parse_args +from megatron.training.checkpointing import load_checkpoint, save_checkpoint +from tests.unit_tests.dist_checkpointing import TempNamedDir +from tests.unit_tests.dist_checkpointing.utils import ( + init_checkpointing_mock_args, + setup_model_and_optimizer, +) +from tests.unit_tests.test_utilities import Utils + + +class TestGPTCompatLayerMaps: + def test_pairs_positions_in_pattern_order(self): + maps = gpt_compatible_layer_maps('M*-M*-') + assert maps.attention_to_gpt == {1: 0, 4: 1} + assert maps.mlp_to_gpt == {2: 0, 5: 1} + assert maps.fresh_init == frozenset({0, 3}) + assert maps.num_gpt_layers == 2 + + def test_pipeline_separators_are_ignored(self): + assert gpt_compatible_layer_maps('M*-|M*-') == gpt_compatible_layer_maps('M*-M*-') + + def test_moe_positions_pair_like_dense_ones(self): + maps = gpt_compatible_layer_maps('M*EM*E') + assert maps.attention_to_gpt == {1: 0, 4: 1} + assert maps.mlp_to_gpt == {2: 0, 5: 1} + assert maps.num_gpt_layers == 2 + + def test_attention_only_positions_can_precede_all_mlps(self): + # Pairing is positional per type, not adjacency-based. + maps = gpt_compatible_layer_maps('**--') + assert maps.attention_to_gpt == {0: 0, 1: 1} + assert maps.mlp_to_gpt == {2: 0, 3: 1} + assert maps.fresh_init == frozenset() + + def test_rejects_empty_pattern(self): + with pytest.raises(ValueError, match='empty'): + gpt_compatible_layer_maps(None) + + def test_rejects_mtp_pattern(self): + with pytest.raises(ValueError, match='MTP'): + gpt_compatible_layer_maps('M*-M*-/MM/MM') + + def test_rejects_untranslatable_layer_types(self): + with pytest.raises(ValueError, match='cannot be translated'): + gpt_compatible_layer_maps('M*-G*-') + # 'D' cannot be combined with '*' at all, so use a pattern the + # production parser accepts and let the interop validation reject it. + with pytest.raises(ValueError, match='cannot be translated'): + gpt_compatible_layer_maps('MD-') + + def test_rejects_mixed_dense_and_moe(self): + with pytest.raises(ValueError, match="one of '-' or 'E'"): + gpt_compatible_layer_maps('M*-M*E') + + def test_rejects_unbalanced_attention_and_mlp(self): + with pytest.raises(ValueError, match='equal, nonzero'): + gpt_compatible_layer_maps('M**-') + with pytest.raises(ValueError, match='equal, nonzero'): + gpt_compatible_layer_maps('MMMM') + + +def _sharded_tensor(key): + return ShardedTensor.from_rank_offsets(key, torch.ones(4)) + + +class TestRetargetShardedStateDict: + def test_keys_point_at_gpt_layout_and_fresh_layers_stay_local(self): + # GPT checkpoints use the homogeneous layer format: numberless keys + # with the layer index as the leading sharding axis. + maps = gpt_compatible_layer_maps('M*-') + mixer_weight = torch.full((4,), 7.0) + sharded_sd = { + 'attn': _sharded_tensor('decoder.layers.1.self_attention.linear_qkv.weight'), + 'mlp': _sharded_tensor('decoder.layers.2.mlp.linear_fc1.layer_norm_weight'), + 'mixer': ShardedTensor.from_rank_offsets( + 'decoder.layers.0.mixer.in_proj.weight', mixer_weight + ), + 'final_norm': _sharded_tensor('decoder.final_norm.weight'), + 'embedding': _sharded_tensor('embedding.word_embeddings.weight'), + 'output_extra_state': ShardedObject('output_layer._extra_state', None, (1,), (0,)), + 'nested': { + 'proj': _sharded_tensor('decoder.layers.1.self_attention.linear_proj.weight') + }, + } + + retarget_sharded_state_dict_to_gpt_checkpoint(sharded_sd, maps) + + attn = sharded_sd['attn'] + assert attn.key == 'decoder.layers.self_attention.linear_qkv.weight' + assert attn.prepend_axis_num == 1 + assert attn.global_shape == (1, 4) + assert attn.global_offset == (0, 0) + assert attn.axis_fragmentations == (1, 1) + assert sharded_sd['mlp'].key == 'decoder.layers.mlp.linear_fc1.layer_norm_weight' + assert sharded_sd['mlp'].global_offset == (0, 0) + assert ( + sharded_sd['nested']['proj'].key == 'decoder.layers.self_attention.linear_proj.weight' + ) + assert sharded_sd['final_norm'].key == 'decoder.final_layernorm.weight' + assert sharded_sd['final_norm'].prepend_axis_num == 0 + assert sharded_sd['embedding'].key == 'embedding.word_embeddings.weight' + assert isinstance(sharded_sd['output_extra_state'], LocalNonpersistentObject) + assert sharded_sd['output_extra_state'].unwrap() is None + assert isinstance(sharded_sd['mixer'], LocalNonpersistentObject) + assert sharded_sd['mixer'].unwrap() is mixer_weight + + def test_extra_state_and_factory_entries_follow_the_layer_axis(self): + maps = gpt_compatible_layer_maps('M*-M*-') # 2 GPT layers + extra_state = ShardedObject( + 'decoder.layers.5.mlp.linear_fc2._extra_state', None, (1,), (0,) + ) + + def build_fn(key, data, replica_id, flattened_range): + return { + 'chunk': ShardedTensor.from_rank_offsets( + f'{key}_chunk', data, replica_id=replica_id + ) + } + + factory = ShardedTensorFactory( + 'decoder.layers.2.mlp.linear_fc1.weight', + torch.ones(4), + build_fn, + lambda sd: sd['chunk'], + ) + sharded_sd = {'extra_state': extra_state, 'factory': factory} + + retarget_sharded_state_dict_to_gpt_checkpoint(sharded_sd, maps) + + # Hybrid layer 5 is the 2nd MLP position -> GPT layer 1. + assert extra_state.key == 'decoder.layers.mlp.linear_fc2._extra_state' + assert extra_state.global_shape == (2,) + assert extra_state.global_offset == (1,) + # Hybrid layer 2 is the 1st MLP position -> GPT layer 0; sub-tensors + # built by the factory inherit the layer axis. + assert factory.key == 'decoder.layers.mlp.linear_fc1.weight' + built = factory.build() + assert built['chunk'].key == 'decoder.layers.mlp.linear_fc1.weight_chunk' + assert built['chunk'].prepend_axis_num == 1 + assert built['chunk'].global_shape == (2, 4) + assert built['chunk'].global_offset == (0, 0) + + def test_layer_outside_pattern_raises(self): + maps = gpt_compatible_layer_maps('M*-') + sharded_sd = {'bad': _sharded_tensor('decoder.layers.7.self_attention.linear_qkv.weight')} + with pytest.raises(ValueError, match='not part of the hybrid layer pattern'): + retarget_sharded_state_dict_to_gpt_checkpoint(sharded_sd, maps) + + def test_optimizer_state_entries_retarget_like_the_model(self): + # The distributed optimizer's model-space sharded state dict embeds the + # model key under ``optimizer.state..`` and mirrors the + # model param's sharding, so the same retargeting must point the moments + # and fp32 master params at the GPT checkpoint and keep fresh-layer + # optimizer state local. + maps = gpt_compatible_layer_maps('M*-') + fresh_exp_avg = torch.full((4,), 3.0) + optim_sd = { + 'param_state': { + # attention position (hybrid layer 1 -> GPT layer 0) + 0: { + 'exp_avg': _sharded_tensor( + 'optimizer.state.exp_avg.decoder.layers.1.self_attention.linear_qkv.weight' + ), + 'fp32_param': _sharded_tensor( + 'optimizer.state.fp32_param.decoder.layers.1.self_attention.linear_qkv.weight' + ), + }, + # MLP position (hybrid layer 2 -> GPT layer 0) + 1: { + 'exp_avg_sq': _sharded_tensor( + 'optimizer.state.exp_avg_sq.decoder.layers.2.mlp.linear_fc1.weight' + ) + }, + # fresh Mamba position (hybrid layer 0) -> stays local + 2: { + 'exp_avg': ShardedTensor.from_rank_offsets( + 'optimizer.state.exp_avg.decoder.layers.0.mixer.in_proj.weight', + fresh_exp_avg, + ) + }, + }, + 'param_state_sharding_type': 'fully_sharded_model_space', + } + + retarget_sharded_state_dict_to_gpt_checkpoint(optim_sd, maps) + + attn = optim_sd['param_state'][0]['exp_avg'] + assert attn.key == 'optimizer.state.exp_avg.decoder.layers.self_attention.linear_qkv.weight' + assert attn.prepend_axis_num == 1 + assert attn.global_shape == (1, 4) + assert attn.global_offset == (0, 0) + master = optim_sd['param_state'][0]['fp32_param'] + assert ( + master.key + == 'optimizer.state.fp32_param.decoder.layers.self_attention.linear_qkv.weight' + ) + assert optim_sd['param_state'][1]['exp_avg_sq'].key == ( + 'optimizer.state.exp_avg_sq.decoder.layers.mlp.linear_fc1.weight' + ) + fresh = optim_sd['param_state'][2]['exp_avg'] + assert isinstance(fresh, LocalNonpersistentObject) + assert fresh.unwrap() is fresh_exp_avg + # Non-sharded bookkeeping is passed through untouched. + assert optim_sd['param_state_sharding_type'] == 'fully_sharded_model_space' + + def test_fsdp_model_and_optimizer_keys_retarget_recursively(self): + maps = gpt_compatible_layer_maps('M*-') + attn = torch.ones(2) + mlp_moment = torch.ones(2) + fresh = torch.ones(2) + state_dict = { + 'model': { + 'decoder.layers.1.self_attention.linear_qkv.weight': attn, + 'decoder.layers.0.mixer.in_proj.weight': fresh, + 'decoder.final_norm.weight': torch.ones(2), + 'output_layer._extra_state': None, + }, + 'optimizer': { + 'state': { + 'decoder.layers.2.mlp.linear_fc1.weight': {'exp_avg': mlp_moment}, + 'decoder.layers.0.mixer.in_proj.weight': {'exp_avg': fresh}, + }, + 'param_to_group_meta': { + 'decoder.layers.2.mlp.linear_fc1.weight': {'lr_mult': 1.0}, + 'decoder.layers.0.mixer.in_proj.weight': {'lr_mult': 1.0}, + }, + }, + } + + translated = retarget_fsdp_state_dict_to_gpt_checkpoint(state_dict, maps) + + assert set(translated['model']) == { + 'decoder.layers.0.self_attention.linear_qkv.weight', + 'decoder.final_layernorm.weight', + } + assert translated['model']['decoder.layers.0.self_attention.linear_qkv.weight'] is attn + assert set(translated['optimizer']['state']) == {'decoder.layers.0.mlp.linear_fc1.weight'} + assert ( + translated['optimizer']['state']['decoder.layers.0.mlp.linear_fc1.weight']['exp_avg'] + is mlp_moment + ) + assert set(translated['optimizer']['param_to_group_meta']) == { + 'decoder.layers.0.mlp.linear_fc1.weight' + } + + wrapped = {'module.module.module.decoder.layers.1.self_attention.linear_qkv.weight': attn} + translated = retarget_fsdp_state_dict_to_gpt_checkpoint( + wrapped, + maps, + ('optimizer.state.module.module.decoder.layers.0.' 'self_attention.linear_qkv.weight',), + checkpoint_prefix='optimizer.state', + ) + assert set(translated) == { + 'module.module.decoder.layers.0.self_attention.linear_qkv.weight' + } + + +def _base_config_kwargs(parallel, moe, glu): + tp, pp, ep, etp = parallel + config_kwargs = dict( + num_attention_heads=8, + # for Mamba: expand=2, headdim=64 -> nheads=8 (divisible by ngroups=8) + hidden_size=256, + use_cpu_initialization=True, + pipeline_dtype=torch.bfloat16, + tensor_model_parallel_size=tp, + pipeline_model_parallel_size=pp, + sequence_parallel=(tp > 1 and ep > 1), + # gated MLPs exercise the swiglu ShardedTensorFactory path + gated_linear_unit=glu, + add_bias_linear=not glu, + ) + if moe: + config_kwargs.update( + num_moe_experts=8, + moe_grouped_gemm=True, # the hybrid moe spec is built with grouped GEMM experts + add_bias_linear=False, + moe_router_topk=2, + expert_model_parallel_size=ep, + expert_tensor_parallel_size=etp, + ) + return config_kwargs + + +def initialize_gpt_model(seed, num_gpt_layers, parallel, moe, glu=False): + torch.manual_seed(seed) + model_parallel_cuda_manual_seed(seed) + + config = TransformerConfig(num_layers=num_gpt_layers, **_base_config_kwargs(parallel, moe, glu)) + if moe: + layer_spec = get_gpt_decoder_block_spec(config, use_transformer_engine=True) + else: + layer_spec = get_gpt_layer_with_transformer_engine_spec() + model = GPTModel( + config=config, + transformer_layer_spec=layer_spec, + vocab_size=128, + max_sequence_length=4, + pre_process=ps.is_pipeline_first_stage(), + post_process=ps.is_pipeline_last_stage(), + position_embedding_type='rope', + share_embeddings_and_output_weights=True, + ) + with torch.no_grad(): + for param in model.parameters(): + param.random_() + return model + + +def initialize_hybrid_model(seed, pattern, parallel, moe, glu=False): + torch.manual_seed(seed) + model_parallel_cuda_manual_seed(seed) + + num_layers = len(pattern.replace('|', '')) + config = TransformerConfig(num_layers=num_layers, **_base_config_kwargs(parallel, moe, glu)) + return HybridModel( + config=config, + hybrid_stack_spec=hybrid_stack_spec, + vocab_size=128, + max_sequence_length=4, + hybrid_layer_pattern=pattern, + pre_process=ps.is_pipeline_first_stage(), + post_process=ps.is_pipeline_last_stage(), + position_embedding_type='rope', + share_embeddings_and_output_weights=True, + ) + + +def _snapshot_fresh_layers(hybrid_model, layer_maps): + """Clone all tensors of layers that must keep their fresh initialization.""" + snapshot = {} + for layer in hybrid_model.decoder.layers: + global_idx = layer.layer_number - 1 + if global_idx in layer_maps.fresh_init: + snapshot[global_idx] = { + name: tensor.detach().clone() + for name, tensor in layer.state_dict().items() + if isinstance(tensor, torch.Tensor) + } + return snapshot + + +def _assert_fresh_layers_untouched(hybrid_model, layer_maps, snapshot): + for layer in hybrid_model.decoder.layers: + global_idx = layer.layer_number - 1 + if global_idx not in layer_maps.fresh_init: + continue + for name, tensor in layer.state_dict().items(): + if not isinstance(tensor, torch.Tensor): + continue + assert torch.equal( + tensor, snapshot[global_idx][name] + ), f'fresh layer {global_idx} tensor {name} was overwritten by the GPT load' + + +def _drop_extra_state(plain_state_dict): + return {k: v for k, v in plain_state_dict.items() if '_extra_state' not in k} + + +class TestGPTToHybridLoad: + def teardown_method(self, method): + Utils.destroy_model_parallel() + + @pytest.mark.internal + @pytest.mark.parametrize( + ('src_parallel', 'dest_parallel', 'pattern', 'moe', 'glu'), + [ + # (tp, pp, ep, etp) of the GPT save -> of the hybrid load. + # Dense: TP/PP resharding. + ((1, 1, 1, 1), (1, 1, 1, 1), 'M*-M*-M*-M*-', False, False), + ((2, 1, 1, 1), (1, 2, 1, 1), 'M*-M*-M*-M*-', False, False), + ((1, 2, 1, 1), (2, 1, 1, 1), 'M*-M*-M*-M*-', False, False), + ((2, 2, 1, 1), (4, 1, 1, 1), 'M*-M*-M*-M*-', False, False), + ((4, 1, 1, 1), (2, 4, 1, 1), 'M*-M*-M*-M*-', False, False), + # Gated MLP exercises the swiglu factory path. + ((2, 1, 1, 1), (1, 2, 1, 1), 'M*-M*-M*-M*-', False, True), + # Pipeline stage boundaries given explicitly with '|'. + ((1, 2, 1, 1), (1, 2, 1, 1), 'M*-M*-|M*-M*-', False, False), + # MoE: EP/ETP resharding (ETP defaults to TP when 1). + ((1, 1, 1, 1), (1, 1, 4, 1), 'M*EM*E', True, False), + ((1, 1, 4, 1), (2, 1, 1, 2), 'M*EM*E', True, False), + ((2, 1, 2, 2), (1, 1, 8, 1), 'M*EM*E', True, False), + ((1, 1, 2, 1), (4, 1, 2, 4), 'M*EM*E', True, False), + # MoE with PP as well. + ((2, 1, 2, 2), (1, 2, 2, 1), 'M*EM*EM*EM*E', True, False), + ], + ) + def test_gpt_checkpoint_loads_into_hybrid_across_parallel_layouts( + self, tmp_path_dist_ckpt, src_parallel, dest_parallel, pattern, moe, glu + ): + layer_maps = gpt_compatible_layer_maps(pattern) + src_tp, src_pp, src_ep, src_etp = src_parallel + dest_tp, dest_pp, dest_ep, dest_etp = dest_parallel + + Utils.initialize_model_parallel( + src_tp, src_pp, expert_model_parallel_size=src_ep, expert_tensor_parallel_size=src_etp + ) + with ( + TempNamedDir(tmp_path_dist_ckpt / 'gpt_hybrid_interop_gpt_src') as ckpt_dir_gpt, + TempNamedDir(tmp_path_dist_ckpt / 'gpt_hybrid_interop_roundtrip') as ckpt_dir_back, + ): + # Save a GPT checkpoint under the source parallel layout. + gpt_model = initialize_gpt_model(1, layer_maps.num_gpt_layers, src_parallel, moe, glu) + save(gpt_model.sharded_state_dict(), ckpt_dir_gpt) + Utils.destroy_model_parallel() + + # Load it into a hybrid model under the destination layout by + # retargeting the hybrid sharded state dict, exactly as + # load_checkpoint does for GPT checkpoints. + Utils.initialize_model_parallel( + dest_tp, + dest_pp, + expert_model_parallel_size=dest_ep, + expert_tensor_parallel_size=dest_etp, + ) + hybrid_model = initialize_hybrid_model(2, pattern, dest_parallel, moe, glu) + fresh_snapshot = _snapshot_fresh_layers(hybrid_model, layer_maps) + + sharded_sd = hybrid_model.sharded_state_dict() + retarget_sharded_state_dict_to_gpt_checkpoint(sharded_sd, layer_maps) + state_dict, missing_keys, unexpected_keys = load( + sharded_sd, ckpt_dir_gpt, strict=StrictHandling.RETURN_ALL + ) + # Any mismatch beyond TE extra states means the retargeting missed keys. + assert all('_extra_state' in k for k in missing_keys), missing_keys + assert all('_extra_state' in k for k in unexpected_keys), unexpected_keys + hybrid_model.load_state_dict(state_dict) + + _assert_fresh_layers_untouched(hybrid_model, layer_maps, fresh_snapshot) + + # Save the hybrid model back under GPT keys (fresh layers stay + # local and are skipped) and compare both checkpoints tensorwise. + sharded_sd_back = hybrid_model.sharded_state_dict() + retarget_sharded_state_dict_to_gpt_checkpoint(sharded_sd_back, layer_maps) + save(sharded_sd_back, ckpt_dir_back) + Utils.destroy_model_parallel() + + Utils.initialize_model_parallel(1, 1) + plain_gpt = _drop_extra_state(load_plain_tensors(ckpt_dir_gpt)) + plain_back = _drop_extra_state(load_plain_tensors(ckpt_dir_back)) + only_gpt, only_back, mismatch = diff(plain_gpt, plain_back) + assert not only_back, f'roundtrip produced keys missing from the GPT ckpt: {only_back}' + assert not only_gpt, f'GPT ckpt keys not covered by the hybrid load: {only_gpt}' + assert not mismatch, f'weights changed by the GPT->hybrid->GPT roundtrip: {mismatch}' + + +# --------------------------------------------------------------------------- +# End-to-end optimizer loading through save_checkpoint / load_checkpoint. +# --------------------------------------------------------------------------- + +_OPT_HIDDEN = 256 +_OPT_HEADS = 8 + + +def _opt_provider_config(num_layers, moe=False, **config_kwargs): + # get_model passes these through; they are not TransformerConfig fields. + for extra in ('pg_collection', 'config', 'vp_stage'): + config_kwargs.pop(extra, None) + config_kwargs.update( + num_layers=num_layers, + hidden_size=_OPT_HIDDEN, + num_attention_heads=_OPT_HEADS, + use_cpu_initialization=True, + add_bias_linear=not moe, + gated_linear_unit=False, + ) + if moe: + config_kwargs.update( + num_moe_experts=8, + moe_grouped_gemm=True, + moe_router_topk=2, + sequence_parallel=( + config_kwargs['tensor_model_parallel_size'] > 1 + and config_kwargs['expert_model_parallel_size'] > 1 + ), + ) + return TransformerConfig(**config_kwargs) + + +def gpt_provider_for_opt( + pre_process=True, post_process=True, *, seed=0, num_gpt_layers, moe=False, **kw +): + torch.manual_seed(seed) + model_parallel_cuda_manual_seed(seed) + config = _opt_provider_config(num_gpt_layers, moe=moe, **kw) + layer_spec = ( + get_gpt_decoder_block_spec(config, use_transformer_engine=True) + if moe + else get_gpt_layer_with_transformer_engine_spec() + ) + return GPTModel( + config=config, + transformer_layer_spec=layer_spec, + vocab_size=128, + max_sequence_length=4, + pre_process=pre_process, + post_process=post_process, + position_embedding_type='rope', + share_embeddings_and_output_weights=True, + ) + + +def hybrid_provider_for_opt( + pre_process=True, post_process=True, *, seed=0, pattern, moe=False, **kw +): + torch.manual_seed(seed) + model_parallel_cuda_manual_seed(seed) + num_layers = len(pattern.replace('|', '')) + return HybridModel( + config=_opt_provider_config(num_layers, moe=moe, **kw), + hybrid_stack_spec=hybrid_stack_spec, + vocab_size=128, + max_sequence_length=4, + hybrid_layer_pattern=pattern, + pre_process=pre_process, + post_process=post_process, + position_embedding_type='rope', + share_embeddings_and_output_weights=True, + ) + + +def _inner_optimizers(optimizer): + if hasattr(optimizer, 'chained_optimizers'): + return [o for opt in optimizer.chained_optimizers for o in _inner_optimizers(opt)] + inner = getattr(optimizer, 'optimizer', None) + return [inner] if inner is not None else [] + + +def _optimizer_moment_fingerprint(optimizer): + """Sum of norms of every floating-point optimizer-state tensor (per rank).""" + total = 0.0 + for inner in _inner_optimizers(optimizer): + for state in inner.state.values(): + for value in state.values(): + if torch.is_tensor(value) and value.is_floating_point(): + total += value.detach().double().norm().item() + return total + + +def _seed_optimizer_moments(optimizer, seed): + """Populate Adam moments even when DistOpt is nested in a chained optimizer.""" + torch.manual_seed(seed) + for inner in _inner_optimizers(optimizer): + for group in inner.param_groups: + for param in group['params']: + state = inner.state[param] + state['exp_avg'] = torch.rand_like(param) + state['exp_avg_sq'] = torch.rand_like(param) + + +def _set_checkpoint_parallel_args(args, parallel, moe): + tp, pp, cp, ep, etp = parallel + args.world_size = torch.distributed.get_world_size() + args.data_parallel_size = ps.get_data_parallel_world_size() + args.tensor_model_parallel_size = tp + args.pipeline_model_parallel_size = pp + args.context_parallel_size = cp + args.expert_model_parallel_size = ep + args.expert_tensor_parallel_size = etp + args.num_experts = 8 if moe else None + + +def _model_parameter_snapshot(model): + return {name: param.detach().clone() for name, param in model.named_parameters()} + + +def _unwrap_interop_model(model): + """Reach GPTModel/HybridModel through Float16 and Megatron-FSDP wrappers.""" + while not hasattr(model, 'decoder') and hasattr(model, 'module'): + model = model.module + return model + + +def _configure_checkpoint_args(args, ckpt_dir, parallel, moe, use_megatron_fsdp): + init_checkpointing_mock_args(args, ckpt_dir, fully_parallel=not use_megatron_fsdp) + _set_checkpoint_parallel_args(args, parallel, moe) + args.use_distributed_optimizer = True + args.use_megatron_fsdp = use_megatron_fsdp + args.data_parallel_sharding_strategy = 'optim_grads_params' if use_megatron_fsdp else 'no_shard' + args.ckpt_format = 'fsdp_dtensor' if use_megatron_fsdp else 'torch_dist' + args.dist_ckpt_optim_fully_reshardable = not use_megatron_fsdp + args.hidden_size = _OPT_HIDDEN + args.num_attention_heads = _OPT_HEADS + + +def _run_gpt_to_hybrid_optimizer_load( + tmp_path_dist_ckpt, + src_parallel, + dest_parallel, + pattern, + moe, + *, + finetune=True, + use_megatron_fsdp=False, +): + layer_maps = gpt_compatible_layer_maps(pattern) + num_gpt_layers = layer_maps.num_gpt_layers + + src_tp, src_pp, src_cp, src_ep, src_etp = src_parallel + dest_tp, dest_pp, dest_cp, dest_ep, dest_etp = dest_parallel + + Utils.initialize_model_parallel( + src_tp, + src_pp, + context_parallel_size=src_cp, + expert_model_parallel_size=src_ep, + expert_tensor_parallel_size=src_etp, + ) + with TempNamedDir(tmp_path_dist_ckpt / 'gpt_hybrid_opt_interop') as ckpt_dir: + mock_args = parse_args(ignore_unknown_args=True) + with mock.patch('megatron.training.checkpointing.get_args', new=lambda: mock_args): + # Build a GPT model + distributed optimizer whose Adam moments are + # seeded to random values, then save a full checkpoint. + gpt_model, gpt_optimizer = setup_model_and_optimizer( + seed=2, + tp=src_tp, + pp=src_pp, + cp=src_cp, + ep=src_ep, + etp=src_etp, + use_megatron_fsdp=use_megatron_fsdp, + initialize_fn=partial(gpt_provider_for_opt, num_gpt_layers=num_gpt_layers, moe=moe), + ) + _seed_optimizer_moments(gpt_optimizer, seed=3) + _configure_checkpoint_args(mock_args, ckpt_dir, src_parallel, moe, use_megatron_fsdp) + mock_args.num_layers = num_gpt_layers + save_checkpoint(10, gpt_model, gpt_optimizer, None, 0) + Utils.destroy_model_parallel() + + # Build a hybrid model + optimizer (independently seeded moments) and + # load the GPT checkpoint, translating model and optimizer state. + Utils.initialize_model_parallel( + dest_tp, + dest_pp, + context_parallel_size=dest_cp, + expert_model_parallel_size=dest_ep, + expert_tensor_parallel_size=dest_etp, + ) + hybrid_model, hybrid_optimizer = setup_model_and_optimizer( + seed=4, + tp=dest_tp, + pp=dest_pp, + cp=dest_cp, + ep=dest_ep, + etp=dest_etp, + use_megatron_fsdp=use_megatron_fsdp, + initialize_fn=partial(hybrid_provider_for_opt, pattern=pattern, moe=moe), + ) + _seed_optimizer_moments(hybrid_optimizer, seed=5) + hybrid_module = _unwrap_interop_model(hybrid_model[0]) + fresh_snapshot = _snapshot_fresh_layers(hybrid_module, layer_maps) + model_before = _model_parameter_snapshot(hybrid_module) + moments_before = _optimizer_moment_fingerprint(hybrid_optimizer) + + _configure_checkpoint_args(mock_args, ckpt_dir, dest_parallel, moe, use_megatron_fsdp) + mock_args.finetune = finetune + mock_args.hybrid_layer_pattern = pattern + mock_args.num_layers = len(pattern.replace('|', '')) + + if not finetune: + data_parallel_size = ps.get_data_parallel_world_size() + init_num_microbatches_calculator( + rank=torch.distributed.get_rank(), + global_batch_size=data_parallel_size, + micro_batch_size=1, + data_parallel_size=data_parallel_size, + ) + try: + iteration, _ = load_checkpoint(hybrid_model, hybrid_optimizer, None) + finally: + if not finetune: + destroy_num_microbatches_calculator() + + # GPT-to-Hybrid translation does not select checkpoint semantics: + # --finetune restarts iteration, while a regular load resumes it. + assert iteration == (0 if finetune else 10) + assert any( + not torch.equal(param, model_before[name]) + for name, param in hybrid_module.named_parameters() + ), 'model parameters do not appear to have been loaded' + # The optimizer state was actually loaded (GPT-sourced moments + # overwrite the freshly seeded ones). + moments_after = _optimizer_moment_fingerprint(hybrid_optimizer) + assert ( + abs(moments_after - moments_before) > 1e-6 + ), 'optimizer state does not appear to have been loaded' + # Layers without a GPT counterpart keep their fresh weights. + _assert_fresh_layers_untouched(hybrid_module, layer_maps, fresh_snapshot) + + +class TestGPTToHybridOptimizerLoad: + def teardown_method(self, method): + Utils.destroy_model_parallel() + + @pytest.mark.internal + @pytest.mark.parametrize( + ('src_parallel', 'dest_parallel', 'pattern', 'moe', 'finetune'), + [ + pytest.param( + (1, 1, 1, 1, 1), + (1, 1, 1, 1, 1), + 'M*-M*-', + False, + False, + id='resume-without-finetune', + ), + pytest.param((1, 1, 1, 1, 1), (1, 1, 1, 1, 1), '*-*-', False, True, id='dp8-dense'), + pytest.param((1, 1, 1, 1, 1), (1, 1, 1, 1, 1), 'M*-M*-', False, True, id='dp8-hybrid'), + pytest.param( + (1, 1, 1, 1, 1), (2, 1, 1, 1, 1), 'M*-M*-', False, True, id='dp8-to-tp2-dp4' + ), + pytest.param( + (2, 1, 1, 1, 1), (1, 2, 1, 1, 1), 'M*-M*-', False, True, id='tp2-dp4-to-pp2-dp4' + ), + pytest.param( + (1, 1, 2, 1, 1), (2, 1, 1, 1, 1), 'M*-M*-', False, True, id='cp2-dp4-to-tp2-dp4' + ), + pytest.param( + (1, 1, 2, 4, 1), (2, 1, 1, 2, 2), 'M*EM*E', True, True, id='cp2-ep4-to-tp2-ep2-etp2' + ), + pytest.param( + (2, 1, 1, 2, 2), (1, 2, 1, 4, 1), 'M*EM*E', True, True, id='tp2-ep2-etp2-to-pp2-ep4' + ), + ], + ) + def test_gpt_optimizer_state_loads_into_hybrid( + self, tmp_path_dist_ckpt, src_parallel, dest_parallel, pattern, moe, finetune + ): + _run_gpt_to_hybrid_optimizer_load( + tmp_path_dist_ckpt, src_parallel, dest_parallel, pattern, moe, finetune=finetune + ) + + +class TestGPTToHybridFSDPLoad: + def teardown_method(self, method): + Utils.destroy_model_parallel() + + @pytest.mark.internal + @pytest.mark.parametrize( + ('src_parallel', 'dest_parallel', 'pattern', 'moe', 'finetune'), + [ + pytest.param( + (1, 1, 1, 1, 1), + (1, 1, 1, 1, 1), + 'M*-M*-', + False, + False, + id='resume-without-finetune', + ), + pytest.param((1, 1, 1, 1, 1), (1, 1, 1, 1, 1), '*-*-', False, True, id='fsdp4-dense'), + pytest.param( + (2, 1, 1, 1, 1), (1, 1, 1, 1, 1), 'M*-M*-', False, True, id='tp2-fsdp2-to-fsdp4' + ), + pytest.param( + (1, 1, 2, 1, 1), (2, 1, 1, 1, 1), 'M*-M*-', False, True, id='cp2-fsdp2-to-tp2-fsdp2' + ), + pytest.param( + (1, 1, 1, 2, 1), + (2, 1, 1, 2, 2), + 'M*EM*E', + True, + True, + id='fsdp4-ep2-to-tp2-fsdp2-ep2-etp2', + ), + ], + ) + def test_gpt_fsdp_model_and_optimizer_load_into_hybrid( + self, tmp_path_dist_ckpt, src_parallel, dest_parallel, pattern, moe, finetune + ): + _run_gpt_to_hybrid_optimizer_load( + tmp_path_dist_ckpt, + src_parallel, + dest_parallel, + pattern, + moe, + finetune=finetune, + use_megatron_fsdp=True, + ) diff --git a/tests/unit_tests/dist_checkpointing/utils.py b/tests/unit_tests/dist_checkpointing/utils.py index d89abf5d7b4..ba774b34fd2 100644 --- a/tests/unit_tests/dist_checkpointing/utils.py +++ b/tests/unit_tests/dist_checkpointing/utils.py @@ -186,6 +186,10 @@ def setup_model_and_optimizer( optimizer='adam', use_param_layout=False, muon_scalar_optimizer='adam', + cp=1, + ep=1, + etp=1, + use_megatron_fsdp=False, ): optimizer_type = optimizer use_layer_wise = False @@ -206,6 +210,20 @@ def setup_model_and_optimizer( mock_args = parse_args(ignore_unknown_args=True) with mock.patch('megatron.training.training.get_args', new=lambda: mock_args): init_basic_mock_args(mock_args, tp, pp, bf16=bf16) + mock_args.context_parallel_size = cp + mock_args.expert_model_parallel_size = ep + mock_args.expert_tensor_parallel_size = etp + mock_args.use_megatron_fsdp = use_megatron_fsdp + mock_args.data_parallel_sharding_strategy = ( + 'optim_grads_params' if use_megatron_fsdp else 'no_shard' + ) + if use_megatron_fsdp: + # parse_args() leaves these as CLI strings until validate_args() + # maps them to the torch.dtype values expected by Megatron-FSDP. + mock_args.megatron_fsdp_main_params_dtype = torch.float32 + mock_args.megatron_fsdp_main_grads_dtype = None + mock_args.megatron_fsdp_grad_comm_dtype = None + mock_args.gradient_accumulation_fusion = False mock_args.use_distributed_optimizer = ddp_use_dist_opt mock_args.use_layer_wise_distributed_optimizer = ddp_use_layer_wise if ddp_use_layer_wise: @@ -217,6 +235,9 @@ def setup_model_and_optimizer( tensor_model_parallel_size=tp, pipeline_model_parallel_size=pp, pipeline_dtype=torch.bfloat16, + context_parallel_size=cp, + expert_model_parallel_size=ep, + expert_tensor_parallel_size=etp, bf16=bf16, ) ) @@ -229,6 +250,10 @@ def setup_model_and_optimizer( optimizer=optimizer, muon_scalar_optimizer=muon_scalar_optimizer, ) + if use_megatron_fsdp: + # The FSDP DTensor sharded-state path may materialize missing optimizer + # slots with a dummy step, which requires a concrete learning rate. + config.lr = 1.0e-3 if optimizer_type in ('muon', 'dist_muon'): config.lr = 0.0 @@ -269,7 +294,10 @@ def _init_states(optimizer): for key in state_keys: optimizer.optimizer.state[p][key] = torch.rand_like(p.data) - optimizer.reload_model_params() + # Megatron-FSDP owns the model/main-parameter synchronization and its + # DistributedOptimizer intentionally does not implement this legacy copy. + if not use_megatron_fsdp: + optimizer.reload_model_params() CachedMetadataFileSystemReader.clear_metadata_cache() return unwrap_model(model), optimizer From 866efa499372733af43dce4181dbabf62ccd6236 Mon Sep 17 00:00:00 2001 From: Antoni-Joan Solergibert Date: Tue, 28 Jul 2026 08:36:26 +0200 Subject: [PATCH 122/290] dist_ckpt: add --stream-ckpt-dequant to fix OOM on large FP8/MXFP8 loads with --fp8-param-gather (#4451) Signed-off-by: asolergi-nv Signed-off-by: Antoni-Joan Solergibert Co-authored-by: Claude Opus 4.7 (1M context) --- .../core/dist_checkpointing/serialization.py | 10 +- .../strategies/fully_parallel.py | 7 + .../dist_checkpointing/strategies/torch.py | 127 +++++- megatron/training/arguments.py | 17 + megatron/training/checkpointing.py | 5 +- megatron/training/config/training_config.py | 7 + .../test_pipeline_parallel_layout.py | 1 + .../test_stream_ckpt_dequant.py | 388 ++++++++++++++++++ tests/unit_tests/dist_checkpointing/utils.py | 1 + .../pipeline_parallel/test_pipeline_layout.py | 1 + tests/unit_tests/test_checkpointing.py | 1 + 11 files changed, 542 insertions(+), 23 deletions(-) create mode 100644 tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index 177f27c418d..0453f7ada8a 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -129,7 +129,15 @@ def load( # params with a high-precision state dict; # 2. When using delayed scaling, this loading process writes an extra value into the global # amax_history buffer of Transformer Engine, which is undesirable. - force_all_tensors_to_non_fp8(sharded_state_dict) + # + # When the sharded strategy supports per-tensor streaming dequantize + # (``stream_ckpt_dequant``), both concerns are handled inside the + # LoadPlanner on a per-tensor basis, which avoids peaking GPU memory + # with N simultaneous high-precision scratch tensors before the load + # begins. Covers FP8/MXFP8/blockwise-FP8/NVFP4 via the common + # ``QuantizedTensor`` base class. + if not getattr(sharded_strategy, "stream_ckpt_dequant", False): + force_all_tensors_to_non_fp8(sharded_state_dict) sharded_state_dict, nonpersistent_state_dict, sh_ten_factories = load_preprocess( sharded_state_dict diff --git a/megatron/core/dist_checkpointing/strategies/fully_parallel.py b/megatron/core/dist_checkpointing/strategies/fully_parallel.py index db3c8ee6cae..c201224efe9 100644 --- a/megatron/core/dist_checkpointing/strategies/fully_parallel.py +++ b/megatron/core/dist_checkpointing/strategies/fully_parallel.py @@ -184,6 +184,13 @@ def __init__( self.cached_distribution: Optional[ShardDistribution] = None self.cached_global_metadata: Optional[Metadata] = None + @property + def stream_ckpt_dequant(self) -> bool: + """Forward the streaming dequantize flag from the wrapped strategy so that + ``serialization.load`` can skip the upfront ``force_all_tensors_to_non_fp8`` pass + when streaming is enabled.""" + return getattr(self.base_strategy, "stream_ckpt_dequant", False) + @debug_time("FullyParallelLoadStrategyWrapper.load", logger) def load( self, diff --git a/megatron/core/dist_checkpointing/strategies/torch.py b/megatron/core/dist_checkpointing/strategies/torch.py index 617992986ff..d8487c4bb43 100644 --- a/megatron/core/dist_checkpointing/strategies/torch.py +++ b/megatron/core/dist_checkpointing/strategies/torch.py @@ -349,9 +349,20 @@ def _unwrap_pyt_sharded_tensor( ret_tensors = [] for sh in sh_ten.local_shards(): ten = sh.tensor - for _ in range(mcore_sh_ten.prepend_axis_num): - assert ten.size(0) == 1 - ten = ten[0] # NOTE: ten.squeeze(0) uses more memory for FP8 tensors + if mcore_sh_ten.prepend_axis_num > 0: + # NOTE: use ``view`` to strip the prepended singleton axes. Indexing + # (``ten[0]``) and ``squeeze`` both dispatch through + # ``aten.select.int`` / ``aten.squeeze`` which are not implemented + # by ``MXFP8Tensor`` (nor by the blockwise tensor class) — they + # fall back to ``QuantizedTensor.__torch_dispatch__`` which + # dequantizes the entire tensor to BF16 before applying the op. + # For large FP8/MXFP8 checkpoints this materializes a full-size + # BF16 copy per shard and OOMs right after a successful streaming + # load. ``view`` is handled natively by all TE quantized tensor + # classes without any dequantize. + for i in range(mcore_sh_ten.prepend_axis_num): + assert ten.size(i) == 1 + ten = ten.view(ten.shape[mcore_sh_ten.prepend_axis_num :]) ret_tensors.append(ten) return ret_tensors @@ -491,12 +502,19 @@ def __init__( *args, shapes_validation_sharded_tensors: Iterable[ShardedTensor] = (), allow_shape_mismatch_sharded_tensors: Optional[Dict[str, ShardedTensor]] = None, + stream_ckpt_dequant: bool = True, **kwargs, ) -> None: super().__init__(*args, **kwargs) self.shapes_validation_sharded_tensors = shapes_validation_sharded_tensors self.allow_shape_mismatch_sharded_tensors = allow_shape_mismatch_sharded_tensors - self._intermediate_read_item_and_target: Optional[Tuple[ReadItem, torch.Tensor]] = None + self.stream_ckpt_dequant = stream_ckpt_dequant + # Maps id(read_item) -> (read_item, target_tensor, amax_snapshot_or_None, kind) + # kind is "stream" for the streaming per-tensor dequant path and "noncontig" + # for the existing contiguity-fix path. + self._intermediate_read_items: Dict[ + int, Tuple[ReadItem, torch.Tensor, Optional[torch.Tensor], str] + ] = {} def _validate_global_shapes(self, metadata, sharded_tensors): for sh_ten in sharded_tensors: @@ -550,37 +568,92 @@ def create_local_plan(self) -> LoadPlan: return local_plan def resolve_tensor(self, read_item: ReadItem): - """Override to add FP8 support. - - Narrowing the Float8Tensor can create incontiguous tensors and there are - no `copy` kernels for such cases. This method creates a contiguous FP8 - tensors so that the subsequent `copy_` in FileSystemReader succeeds. - Note that this requires tracking the original tensor - (as `self._intermediate_read_item_and_target` attribute) - and restoring it in `commit_tensor` method. + """Override to add quantized-tensor support. + + Two paths are handled here: + + 1. Streaming per-tensor dequantize (when ``stream_ckpt_dequant`` is True + and the destination is a TE ``QuantizedTensor`` — covers Float8, + MXFP8, blockwise FP8, and NVFP4 via the common base class). We + allocate a per-tensor high-precision scratch buffer, return it as + the load destination, and quantize-copy it back into the original + tensor in ``commit_tensor``. This replaces the upfront bulk + dequantize done by ``force_all_tensors_to_non_fp8`` and keeps at + most one scratch tensor live at a time. + + 2. Non-contiguous Float8 fix: narrowing a Float8Tensor can produce a + non-contiguous view for which no ``copy_`` kernel exists. We fall + back to a contiguous Float8 clone and copy it back in + ``commit_tensor``. + + Both cases stash state in ``self._intermediate_read_items``, keyed by + ``id(read_item)``, so ``commit_tensor`` can undo them. """ target_tensor = super().resolve_tensor(read_item) + + # Lazy import to avoid circular imports (fp8_utils pulls in core.tensor_parallel). + from ...fp8_utils import is_float8tensor as _is_quantized_tensor + + if ( + self.stream_ckpt_dequant + and HAVE_TE + and _is_quantized_tensor(target_tensor) + and target_tensor.is_cuda + ): + # Snapshot amax for delayed-scaling quantizers so the subsequent + # BF16->FP8 quantize-copy does not pollute amax_history. For + # current-scaling / MXFP8 / blockwise / NVFP4 quantizers, amax is + # None or absent on the quantizer and the snapshot is a no-op. + amax_snapshot: Optional[torch.Tensor] = None + quantizer = getattr(target_tensor, "_quantizer", None) + amax = getattr(quantizer, "amax", None) if quantizer is not None else None + if isinstance(amax, torch.Tensor): + amax_snapshot = amax.detach().clone() + + scratch = torch.empty( + target_tensor.shape, dtype=target_tensor.dtype, device=target_tensor.device + ) + self._intermediate_read_items[id(read_item)] = ( + read_item, + target_tensor, + amax_snapshot, + "stream", + ) + return scratch + if ( not target_tensor.is_contiguous() and HAVE_TE and isinstance(target_tensor, Float8Tensor) ): - self._intermediate_read_item_and_target = (read_item, target_tensor) + self._intermediate_read_items[id(read_item)] = ( + read_item, + target_tensor, + None, + "noncontig", + ) target_tensor = Float8Tensor.make_like( target_tensor, data=target_tensor._data.contiguous() ) return target_tensor def commit_tensor(self, read_item: ReadItem, tensor: torch.Tensor) -> None: - """Restores the original FP8 tensor saved in `resolve_tensor`.""" - if self._intermediate_read_item_and_target is not None: - interm_read_item, target_tensor = self._intermediate_read_item_and_target - assert ( - interm_read_item is read_item - ), '`commit_tensor` method should be called right after `resolve_tensor`' + """Undo the detours stashed in ``resolve_tensor``. + + - Streaming case: copy the high-precision scratch back into the + original quantized tensor (quantize-on-copy), then restore the + pre-load ``amax`` for delayed-scaling quantizers. + - Non-contiguous case: copy the contiguous clone back into the + original narrowed Float8Tensor view. + """ + entry = self._intermediate_read_items.pop(id(read_item), None) + if entry is not None: + _, target_tensor, amax_snapshot, kind = entry target_tensor.copy_(tensor) + if kind == "stream" and amax_snapshot is not None: + # quantizer was non-None when we took the snapshot + target_tensor._quantizer.amax.copy_(amax_snapshot) tensor = target_tensor - self._intermediate_read_item_and_target = None return super().commit_tensor(read_item, tensor) @@ -851,9 +924,20 @@ def _get_filesystem_reader( class TorchDistLoadShardedStrategy: """Basic load strategy for the PyT Distributed format.""" - def __init__(self, cache_metadata: bool = False, checkpoint_name: str = None): + def __init__( + self, + cache_metadata: bool = False, + stream_ckpt_dequant: bool = True, + checkpoint_name: str = None, + ): self.cached_global_metadata: Optional[Metadata] = None self.cache_metadata = cache_metadata + # When True, quantized destinations (FP8/MXFP8/blockwise FP8/NVFP4) are + # dequantized per-tensor inside the LoadPlanner rather than all at once + # before the load starts. This trades a small planner overhead for a + # large reduction in peak GPU memory during load. See + # serialization.load() and MCoreLoadPlanner.resolve_tensor for details. + self.stream_ckpt_dequant = stream_ckpt_dequant self.checkpoint_name = checkpoint_name def load( @@ -898,6 +982,7 @@ def load( planner=MCoreLoadPlanner( shapes_validation_sharded_tensors=flexible_shape_sharded_tensors, allow_shape_mismatch_sharded_tensors=allow_shape_mismatch_sharded_tensors, + stream_ckpt_dequant=self.stream_ckpt_dequant, flatten_state_dict=False, flatten_sharded_tensors=False, ), diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 235fc95ec35..8b9571f736b 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1080,6 +1080,23 @@ def validate_args(args, defaults={}): ): raise ValueError("MXFP8 with inference optimized layers requires FlashInfer >= 0.6.4") + # Streaming dequantize is unsafe with tensorwise (current) FP8 scaling. + # The streaming planner does a BF16->FP8 ``copy_`` per slice; tensorwise + # recomputes the per-tensor scale from each slice's amax, so multi-shard + # destinations (e.g. resharded loads) end up with inconsistent scales + # across slices and the loaded weights are corrupted. Block-scaled + # recipes (mxfp8, blockwise, nvfp4) carry per-block scales and are + # unaffected. Force the upfront ``force_all_tensors_to_non_fp8`` path + # for tensorwise. + if args.fp8 and args.fp8_recipe == "tensorwise" and args.stream_ckpt_dequant: + warn_rank_0( + "--fp8-recipe=tensorwise is incompatible with the streaming " + "checkpoint dequantize path; falling back to the upfront " + "dequantize pass. Pass --no-stream-ckpt-dequant to silence " + "this warning." + ) + args.stream_ckpt_dequant = False + if args.use_megatron_fsdp: # NOTE: The flag `use_custom_fsdp` is deprecated and will be removed in future versions. # Please use `use_megatron_fsdp` instead, as all functionality will be migrated there. diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index d727233cb2d..eee57040dda 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -1572,7 +1572,10 @@ def _load_global_dist_base_checkpoint( ) checkpoint_name = get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) - load_strategy = TorchDistLoadShardedStrategy(cache_metadata=args.ckpt_assume_constant_structure) + load_strategy = TorchDistLoadShardedStrategy( + cache_metadata=args.ckpt_assume_constant_structure, + stream_ckpt_dequant=args.stream_ckpt_dequant, + ) # NOTE: `args.ckpt_fully_parallel_load` applies to both persistent and non-persistent checkpoints. if args.ckpt_fully_parallel_load: if args.ckpt_fully_parallel_load_process_group == 'dp': diff --git a/megatron/training/config/training_config.py b/megatron/training/config/training_config.py index fb5598b8d42..1cb71f21a08 100644 --- a/megatron/training/config/training_config.py +++ b/megatron/training/config/training_config.py @@ -616,6 +616,13 @@ class CheckpointConfig: verify_integrity: bool = False """Whether to hash checkpointing files during save and validate their integrity during load.""" + stream_ckpt_dequant: bool = True + """Per-tensor streaming dequantize when loading checkpoints with quantized model params + (FP8, MXFP8, blockwise FP8, NVFP4). The LoadPlanner dequantizes one destination at a time, + instead of dequantizing the entire state dict to high precision before the load starts + (which allocates N simultaneous scratch tensors and can OOM on large models). On by + default; pass --no-stream-ckpt-dequant to fall back to the legacy upfront pass.""" + def __post_init__(self): from megatron.training.utils import has_nvrx_checkpointing_async_support diff --git a/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py b/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py index 4ed91aa2cb6..aa1e682b39b 100644 --- a/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py +++ b/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py @@ -143,6 +143,7 @@ def create_args(): args.vocab_file = None args.add_position_embedding = False args.ckpt_assume_constant_structure = True + args.stream_ckpt_dequant = True args.ckpt_load_validate_sharding_integrity = True args.dist_ckpt_strictness = "assume_ok_unexpected" args.fp16 = False diff --git a/tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py b/tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py new file mode 100644 index 00000000000..bc940668469 --- /dev/null +++ b/tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py @@ -0,0 +1,388 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Tests for the streaming per-tensor dequantize path used when loading +distributed checkpoints with quantized (FP8 / MXFP8 / blockwise / NVFP4) +model parameters. + +The feature under test is ``stream_ckpt_dequant`` on ``MCoreLoadPlanner`` and +``TorchDistLoadShardedStrategy``. When on, the LoadPlanner dequantizes each +quantized destination one at a time inside ``resolve_tensor``/``commit_tensor`` +instead of up-front in ``force_all_tensors_to_non_fp8``. Tests cover: + +- Loaded-content equivalence vs. the legacy upfront path (FP8). +- Delayed-scaling ``amax_history`` is not polluted across a streaming load. +- ``_unwrap_pyt_sharded_tensor`` uses view-based axis stripping (no + dequantize fallback) — exercised implicitly by the MXFP8 save/load test. +- MXFP8 save/load round-trip. +- NVFP4 save/load round-trip (Blackwell+ only, skipped otherwise). +- No-op fall-through for plain (non-quantized) tensors. +""" + +import pytest +import torch + +try: + from transformer_engine.pytorch.float8_tensor import Float8Tensor + from transformer_engine.pytorch.tensor import QuantizedTensor + + HAVE_TE = True +except ImportError: + HAVE_TE = False + Float8Tensor = None # type: ignore + QuantizedTensor = None # type: ignore + +try: + import transformer_engine.pytorch.tensor.mxfp8_tensor # noqa: F401 + + HAVE_MXFP8 = True +except ImportError: + HAVE_MXFP8 = False + +try: + import transformer_engine.pytorch.tensor.nvfp4_tensor # noqa: F401 + + HAVE_NVFP4 = True +except ImportError: + HAVE_NVFP4 = False + +try: + from megatron.training.utils import get_device_arch_version + + _DEVICE_ARCH = get_device_arch_version() +except Exception: + _DEVICE_ARCH = 0 + +# MXFP8 and NVFP4 require Blackwell (arch 10+). +HAVE_MXFP8_HW = HAVE_MXFP8 and _DEVICE_ARCH >= 10 +HAVE_NVFP4_HW = HAVE_NVFP4 and _DEVICE_ARCH >= 10 + +from megatron.core.dist_checkpointing import ShardedTensor, load, save +from megatron.core.dist_checkpointing.strategies.torch import ( + MCoreLoadPlanner, + TorchDistLoadShardedStrategy, + TorchDistSaveShardedStrategy, +) +from tests.unit_tests.dist_checkpointing import TempNamedDir +from tests.unit_tests.test_utilities import Utils + + +def _to_float8(tensor: torch.Tensor): + """Convert a BF16 tensor to delayed-scaling Float8Tensor (TE 2.x API).""" + try: + return Float8Tensor.to_float8(tensor) + except Exception: + import transformer_engine_torch as tex + from transformer_engine.pytorch.tensor.float8_tensor import Float8Quantizer + + quantizer = Float8Quantizer( + scale=torch.full([1], 1.0, dtype=torch.float32, device="cuda"), + amax=torch.empty([1], dtype=torch.float32, device="cuda"), + fp8_dtype=tex.DType.kFloat8E4M3, + ) + return quantizer(tensor.cuda()) + + +def _to_mxfp8(tensor: torch.Tensor): + """Convert a BF16 tensor to MXFP8Tensor.""" + import transformer_engine_torch as tex + from transformer_engine.pytorch.tensor.mxfp8_tensor import MXFP8Quantizer + + quantizer = MXFP8Quantizer(fp8_dtype=tex.DType.kFloat8E4M3) + return quantizer(tensor.cuda().contiguous()) + + +def _to_nvfp4(tensor: torch.Tensor): + """Convert a BF16 tensor to NVFP4Tensor (Blackwell+ only).""" + from transformer_engine.pytorch.tensor.nvfp4_tensor import NVFP4Quantizer + + quantizer = NVFP4Quantizer( + rowwise=True, + columnwise=True, + with_rht=False, + with_post_rht_amax=False, + with_2d_quantization=True, + stochastic_rounding=False, + with_random_sign_mask=False, + ) + return quantizer(tensor.cuda().contiguous()) + + +@pytest.mark.skipif(not HAVE_TE, reason="TransformerEngine not available") +class TestStreamCkptDequant: + """Unit tests for streaming per-tensor dequantize during ckpt load.""" + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + # --------------------------------------------------------------- + # Baseline: FP8 (delayed scaling) save/load equivalence + amax safety + # --------------------------------------------------------------- + + @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) + def test_fp8_save_load_content_equivalence(self, tmp_path_dist_ckpt, stream_ckpt_dequant): + """Loaded FP8 contents must match regardless of which dequantize path is used.""" + Utils.initialize_model_parallel(1, 1) + + fill_val = 0.5 + + def get_fp8_tensor(val): + return _to_float8(torch.full((8,), val, dtype=torch.bfloat16, device='cuda')) + + def get_state_dict(val): + return { + 'w': ShardedTensor.from_rank_offsets( + 'w', get_fp8_tensor(val), replica_id=Utils.rank + ) + } + + with TempNamedDir(tmp_path_dist_ckpt / f'fp8_eq_{stream_ckpt_dequant}') as ckpt_dir: + save(get_state_dict(fill_val), ckpt_dir, TorchDistSaveShardedStrategy()) + + # Fresh state dict with a different fill — the load must overwrite it. + sd_to_load = get_state_dict(99.0) + strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) + loaded = load(sd_to_load, ckpt_dir, strategy) + # Dequantize the loaded tensor (may be Float8 or BF16 depending on path) + loaded_w = loaded['w'] + if isinstance(loaded_w, QuantizedTensor): + loaded_w = loaded_w.dequantize() + # fill_val (0.5) is exactly representable in FP8 E4M3 and the per-tensor + # scale is a power of 2, so the round-trip is numerically lossless modulo + # bf16 rounding. Tight tolerance catches real regressions. + torch.testing.assert_close( + loaded_w, + torch.full((8,), fill_val, dtype=torch.bfloat16, device='cuda'), + rtol=1e-3, + atol=1e-3, + ) + + def test_fp8_amax_history_not_polluted(self, tmp_path_dist_ckpt): + """Delayed-scaling amax must be snapshotted & restored across a streaming load.""" + Utils.initialize_model_parallel(1, 1) + + def get_fp8_tensor(val): + return _to_float8(torch.full((8,), val, dtype=torch.bfloat16, device='cuda')) + + sd_to_save = { + 'w': ShardedTensor.from_rank_offsets('w', get_fp8_tensor(0.25), replica_id=Utils.rank) + } + + with TempNamedDir(tmp_path_dist_ckpt / 'fp8_amax') as ckpt_dir: + save(sd_to_save, ckpt_dir, TorchDistSaveShardedStrategy()) + + # Rebuild destination with a known (distinct) amax value we can check + # survives the streaming load. + dst = ShardedTensor.from_rank_offsets('w', get_fp8_tensor(99.0), replica_id=Utils.rank) + q = getattr(dst.data, "_quantizer", None) + if q is None or not isinstance(getattr(q, "amax", None), torch.Tensor): + pytest.skip("This TE build's Float8Tensor has no quantizer.amax scalar") + sentinel = 42.0 + q.amax.fill_(sentinel) + pre_load_amax = q.amax.detach().clone() + + strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=True) + loaded = load({'w': dst}, ckpt_dir, strategy) + + # The loaded tensor is the same QuantizedTensor object; its quantizer.amax + # must be exactly what we put in before the load. + loaded_q = getattr(loaded['w'], "_quantizer", None) + assert loaded_q is not None + assert torch.equal(loaded_q.amax, pre_load_amax), ( + f"amax was not restored after streaming load; " + f"before={pre_load_amax.item()} after={loaded_q.amax.item()}" + ) + + # --------------------------------------------------------------- + # MXFP8: exercises both the streaming dequant AND the view-based + # _unwrap_pyt_sharded_tensor fix (without it, ten[0] on MXFP8 OOMs). + # --------------------------------------------------------------- + + @pytest.mark.skipif( + not HAVE_MXFP8_HW, + reason="MXFP8 requires TransformerEngine MXFP8Tensor and Blackwell+ (arch 10+)", + ) + @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) + def test_mxfp8_save_load_content_equivalence(self, tmp_path_dist_ckpt, stream_ckpt_dequant): + Utils.initialize_model_parallel(1, 1) + + # MXFP8 requires 2D with last-dim aligned to block size (32). + fill_val = 0.25 + + def get_mxfp8_tensor(val): + return _to_mxfp8(torch.full((64, 128), val, dtype=torch.bfloat16, device='cuda')) + + def get_state_dict(val): + return { + 'w': ShardedTensor.from_rank_offsets( + 'w', get_mxfp8_tensor(val), replica_id=Utils.rank + ) + } + + with TempNamedDir(tmp_path_dist_ckpt / f'mxfp8_eq_{stream_ckpt_dequant}') as ckpt_dir: + save(get_state_dict(fill_val), ckpt_dir, TorchDistSaveShardedStrategy()) + + sd_to_load = get_state_dict(99.0) + strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) + loaded = load(sd_to_load, ckpt_dir, strategy) + loaded_w = loaded['w'] + if isinstance(loaded_w, QuantizedTensor): + loaded_w = loaded_w.dequantize() + # fill_val (0.25) is exactly representable in FP8 E4M3, and MXFP8 stores + # block scales in E8M0 (power-of-2), so the per-block scale is exact and + # every element encodes to the same FP8 code. Round-trip is near-lossless. + torch.testing.assert_close( + loaded_w, + torch.full((64, 128), fill_val, dtype=torch.bfloat16, device='cuda'), + rtol=1e-3, + atol=1e-3, + ) + + # --------------------------------------------------------------- + # NVFP4: round-trip under both paths. Same invariants as MXFP8 but + # with NVFP4Tensor — validates that `is_float8tensor` (which binds to + # QuantizedTensor under TE 2.x) correctly covers the FP4 path, that + # NVFP4Tensor.view works inside _unwrap_pyt_sharded_tensor, and that + # BF16->NVFP4 copy through QuantizedTensor.__torch_dispatch__ -> quantize_ + # produces correct values. Requires Blackwell+ for the FP4 kernels. + # --------------------------------------------------------------- + + @pytest.mark.skipif( + not HAVE_NVFP4_HW, + reason="NVFP4 requires TransformerEngine NVFP4Tensor and Blackwell+ (arch 10+)", + ) + @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) + def test_nvfp4_save_load_content_equivalence(self, tmp_path_dist_ckpt, stream_ckpt_dequant): + Utils.initialize_model_parallel(1, 1) + + # NVFP4BlockScaling uses 16-element blocks along the last dim; use a + # shape that's a multiple of both common block sizes. + fill_val = 0.25 + + def get_nvfp4_tensor(val): + return _to_nvfp4(torch.full((64, 128), val, dtype=torch.bfloat16, device='cuda')) + + def get_state_dict(val): + return { + 'w': ShardedTensor.from_rank_offsets( + 'w', get_nvfp4_tensor(val), replica_id=Utils.rank + ) + } + + with TempNamedDir(tmp_path_dist_ckpt / f'nvfp4_eq_{stream_ckpt_dequant}') as ckpt_dir: + save(get_state_dict(fill_val), ckpt_dir, TorchDistSaveShardedStrategy()) + + sd_to_load = get_state_dict(99.0) + strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) + loaded = load(sd_to_load, ckpt_dir, strategy) + loaded_w = loaded['w'] + if isinstance(loaded_w, QuantizedTensor): + loaded_w = loaded_w.dequantize() + # For a constant block every FP4 code is identical and the dominant error + # source is the per-block scale being stored in FP8 E4M3 (unlike MXFP8's + # power-of-2 E8M0). That rounding is bounded below ~1% relative; 1e-2 is + # tight enough to catch real bugs and loose enough to absorb E4M3 scale + # rounding + bf16 output rounding. + torch.testing.assert_close( + loaded_w, + torch.full((64, 128), fill_val, dtype=torch.bfloat16, device='cuda'), + rtol=1e-2, + atol=1e-2, + ) + + # --------------------------------------------------------------- + # Corner cases + # --------------------------------------------------------------- + + @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) + def test_plain_tensor_untouched_by_streaming_path( + self, tmp_path_dist_ckpt, stream_ckpt_dequant + ): + """Non-quantized tensors in the state dict must round-trip losslessly under either path.""" + Utils.initialize_model_parallel(1, 1) + + src = torch.arange(64, dtype=torch.bfloat16, device='cuda') + sd_to_save = {'w': ShardedTensor.from_rank_offsets('w', src.clone(), replica_id=Utils.rank)} + + with TempNamedDir(tmp_path_dist_ckpt / f'plain_{stream_ckpt_dequant}') as ckpt_dir: + save(sd_to_save, ckpt_dir, TorchDistSaveShardedStrategy()) + + dst = { + 'w': ShardedTensor.from_rank_offsets( + 'w', torch.zeros_like(src), replica_id=Utils.rank + ) + } + strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) + loaded = load(dst, ckpt_dir, strategy) + # Plain BF16 must round-trip exactly. + torch.testing.assert_close(loaded['w'], src) + + def test_default_is_on(self): + """The default for stream_ckpt_dequant must be True (streaming path is now default).""" + strat = TorchDistLoadShardedStrategy() + assert ( + strat.stream_ckpt_dequant is True + ), "Default must be True; users opt out via --no-stream-ckpt-dequant." + planner = MCoreLoadPlanner() + assert planner.stream_ckpt_dequant is True + + def test_planner_state_cleanup_after_load(self, tmp_path_dist_ckpt): + """``_intermediate_read_items`` must be empty after a streaming load completes. + + Lingering entries would indicate a scratch tensor we forgot to drop, defeating + the memory win. + """ + Utils.initialize_model_parallel(1, 1) + + def get_fp8_tensor(val): + return _to_float8(torch.full((32,), val, dtype=torch.bfloat16, device='cuda')) + + sd_to_save = { + f'w{i}': ShardedTensor.from_rank_offsets( + f'w{i}', get_fp8_tensor(0.125), replica_id=Utils.rank + ) + for i in range(4) + } + + with TempNamedDir(tmp_path_dist_ckpt / 'planner_cleanup') as ckpt_dir: + save(sd_to_save, ckpt_dir, TorchDistSaveShardedStrategy()) + + # Instrument: intercept MCoreLoadPlanner to capture the live instance. + captured: list[MCoreLoadPlanner] = [] + original_init = MCoreLoadPlanner.__init__ + + def capturing_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + captured.append(self) + + MCoreLoadPlanner.__init__ = capturing_init # type: ignore[assignment] + try: + dst = { + f'w{i}': ShardedTensor.from_rank_offsets( + f'w{i}', get_fp8_tensor(99.0), replica_id=Utils.rank + ) + for i in range(4) + } + load(dst, ckpt_dir, TorchDistLoadShardedStrategy(stream_ckpt_dequant=True)) + finally: + MCoreLoadPlanner.__init__ = original_init # type: ignore[assignment] + + assert len(captured) == 1 + assert captured[0]._intermediate_read_items == {}, ( + f"Planner left intermediate state after load: " + f"{list(captured[0]._intermediate_read_items.keys())}" + ) + + def test_streaming_flag_forwards_through_fpsl_wrapper(self): + """FullyParallelLoadStrategyWrapper must surface the base strategy's flag.""" + from megatron.core.dist_checkpointing.strategies.fully_parallel import ( + FullyParallelLoadStrategyWrapper, + ) + + base_off = TorchDistLoadShardedStrategy(stream_ckpt_dequant=False) + base_on = TorchDistLoadShardedStrategy(stream_ckpt_dequant=True) + # parallelization_group left default -> GroupMember.WORLD; that's fine since + # we're only reading the forwarded property, not calling load(). + wrapped_off = FullyParallelLoadStrategyWrapper(base_off) + wrapped_on = FullyParallelLoadStrategyWrapper(base_on) + assert wrapped_off.stream_ckpt_dequant is False + assert wrapped_on.stream_ckpt_dequant is True diff --git a/tests/unit_tests/dist_checkpointing/utils.py b/tests/unit_tests/dist_checkpointing/utils.py index ba774b34fd2..9a8b502eba0 100644 --- a/tests/unit_tests/dist_checkpointing/utils.py +++ b/tests/unit_tests/dist_checkpointing/utils.py @@ -150,6 +150,7 @@ def init_checkpointing_mock_args(args, ckpt_dir, fully_parallel=False): args.no_save_optim = False args.no_save_rng = False args.ckpt_assume_constant_structure = False + args.stream_ckpt_dequant = True args.ckpt_load_validate_sharding_integrity = True args.log_progress = False args.auto_detect_ckpt_format = False diff --git a/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py b/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py index 1c998181b50..7ded4abd1a5 100644 --- a/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py +++ b/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py @@ -140,6 +140,7 @@ def create_args(): args.vocab_file = None args.add_position_embedding = False args.ckpt_assume_constant_structure = False + args.stream_ckpt_dequant = True args.ckpt_load_validate_sharding_integrity = True args.dist_ckpt_strictness = "assume_ok_unexpected" args.fp16 = False diff --git a/tests/unit_tests/test_checkpointing.py b/tests/unit_tests/test_checkpointing.py index 2e717e4424f..cf1b6a76539 100644 --- a/tests/unit_tests/test_checkpointing.py +++ b/tests/unit_tests/test_checkpointing.py @@ -214,6 +214,7 @@ def create_ckpt_load_args(create_args): args.tensor_model_parallel_size = 1 args.pipeline_model_parallel_size = 1 args.ckpt_assume_constant_structure = False + args.stream_ckpt_dequant = True args.ckpt_fully_parallel_save = False args.ckpt_fully_parallel_load = False args.ckpt_load_validate_sharding_integrity = True From 6f7bcd48f4fccfea74fbd2385502b2fa8bb05251 Mon Sep 17 00:00:00 2001 From: Tom Long Date: Tue, 28 Jul 2026 00:45:49 -0700 Subject: [PATCH 123/290] Populate dp process group in auto-built ProcessGroupCollection in pipeline schedules (#5901) Signed-off-by: ilml Signed-off-by: Tom Long Co-authored-by: Claude Fable 5 --- megatron/core/pipeline_parallel/schedules.py | 84 ++++++++------------ 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/megatron/core/pipeline_parallel/schedules.py b/megatron/core/pipeline_parallel/schedules.py index e67c498e2cc..29d36ab2c7d 100644 --- a/megatron/core/pipeline_parallel/schedules.py +++ b/megatron/core/pipeline_parallel/schedules.py @@ -669,6 +669,30 @@ def check_first_val_step(first_val_step, forward_only, cond): return cond +def _build_default_pg_collection() -> ProcessGroupCollection: + """Build a ``ProcessGroupCollection`` from the global ``parallel_state`` defaults. + + Used by the schedule entry points as the fallback when the caller does not + supply a ``pg_collection`` explicitly. + """ + pg_collection = ProcessGroupCollection() + pg_collection.tp = parallel_state.get_tensor_model_parallel_group() + pg_collection.cp = parallel_state.get_context_parallel_group() + pg_collection.embd = parallel_state.get_embedding_group(check_initialized=False) + pg_collection.pos_embd = parallel_state.get_position_embedding_group(check_initialized=False) + pg_collection.pp = parallel_state.get_pipeline_model_parallel_group() + pg_collection.dp_cp = parallel_state.get_data_parallel_group( + with_context_parallel=True, partial_data_parallel=False + ) + pg_collection.tp_dp_cp = parallel_state.get_tensor_and_data_parallel_group( + with_context_parallel=True + ) + pg_collection.dp = parallel_state.get_data_parallel_group( + with_context_parallel=False, partial_data_parallel=False + ) + return pg_collection + + def forward_backward_no_pipelining( *, forward_step_func, @@ -689,23 +713,7 @@ def forward_backward_no_pipelining( """Run forward and backward passes with no pipeline parallelism""" if pg_collection is None: - tp_group = parallel_state.get_tensor_model_parallel_group() - cp_group = parallel_state.get_context_parallel_group() - embd_group = parallel_state.get_embedding_group(check_initialized=False) - pp_group = parallel_state.get_pipeline_model_parallel_group() - pos_emb_group = parallel_state.get_position_embedding_group(check_initialized=False) - pg_collection = ProcessGroupCollection() - pg_collection.tp = tp_group - pg_collection.cp = cp_group - pg_collection.embd = embd_group - pg_collection.pos_embd = pos_emb_group - pg_collection.pp = pp_group - pg_collection.dp_cp = parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=False - ) - pg_collection.tp_dp_cp = parallel_state.get_tensor_and_data_parallel_group( - with_context_parallel=True - ) + pg_collection = _build_default_pg_collection() elif pg_collection is not None: assert hasattr(pg_collection, 'tp'), "pg_collection must have tp" @@ -1017,25 +1025,10 @@ def forward_backward_pipelining_with_interleaving( p2p_communicator = P2PCommunicator( pp_group=parallel_state.get_pipeline_model_parallel_group(), config=config ) - tp_group = parallel_state.get_tensor_model_parallel_group() - cp_group = parallel_state.get_context_parallel_group() + pg_collection = _build_default_pg_collection() + tp_group = pg_collection.tp + cp_group = pg_collection.cp cp_size = cp_group.size() - embd_group = parallel_state.get_embedding_group(check_initialized=False) - pp_group = parallel_state.get_pipeline_model_parallel_group() - pos_emb_group = parallel_state.get_position_embedding_group(check_initialized=False) - - pg_collection = ProcessGroupCollection() - pg_collection.tp = tp_group - pg_collection.cp = cp_group - pg_collection.embd = embd_group - pg_collection.pos_embd = pos_emb_group - pg_collection.pp = pp_group - pg_collection.dp_cp = parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=False - ) - pg_collection.tp_dp_cp = parallel_state.get_tensor_and_data_parallel_group( - with_context_parallel=True - ) elif p2p_communicator is not None and pg_collection is not None: model_type = get_model_type(model[0]) @@ -2176,25 +2169,10 @@ def forward_backward_pipelining_without_interleaving( p2p_communicator = P2PCommunicator( pp_group=parallel_state.get_pipeline_model_parallel_group(), config=config ) - tp_group = parallel_state.get_tensor_model_parallel_group() - cp_group = parallel_state.get_context_parallel_group() + pg_collection = _build_default_pg_collection() + tp_group = pg_collection.tp + cp_group = pg_collection.cp cp_size = cp_group.size() - embd_group = parallel_state.get_embedding_group(check_initialized=False) - pos_emb_group = parallel_state.get_position_embedding_group(check_initialized=False) - pp_group = parallel_state.get_pipeline_model_parallel_group() - - pg_collection = ProcessGroupCollection() - pg_collection.tp = tp_group - pg_collection.pp = pp_group - pg_collection.embd = embd_group - pg_collection.pos_embd = pos_emb_group - pg_collection.cp = cp_group - pg_collection.dp_cp = parallel_state.get_data_parallel_group( - with_context_parallel=True, partial_data_parallel=False - ) - pg_collection.tp_dp_cp = parallel_state.get_tensor_and_data_parallel_group( - with_context_parallel=True - ) elif p2p_communicator is not None and pg_collection is not None: assert hasattr(p2p_communicator, 'config'), "p2p_communicator must have a config" From bacd3404cc9cbbe7df2e935fb2e70ac55659cae0 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Tue, 28 Jul 2026 11:59:01 +0200 Subject: [PATCH 124/290] fix(cuda-graphs): MB-928 align DDP initialization with capture stream (#6021) Signed-off-by: svcnemo-autobot --- megatron/training/models/dist_utils.py | 22 +++--- megatron/training/training.py | 18 +++-- .../training/models/test_dist_utils.py | 48 ++++++++++++ .../transformer/test_full_cuda_graph.py | 74 ++++++++++++++++++- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/megatron/training/models/dist_utils.py b/megatron/training/models/dist_utils.py index ac4575deb68..2a83e995e5a 100644 --- a/megatron/training/models/dist_utils.py +++ b/megatron/training/models/dist_utils.py @@ -1,19 +1,17 @@ # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. import logging - - -logger = logging.getLogger(__name__) - from typing import Any, Callable import torch + from megatron.core import tensor_parallel from megatron.core.distributed import ( DistributedDataParallel, DistributedDataParallelConfig, FullyShardedDataParallel, ) +from megatron.core.full_cuda_graph import get_shared_capture_stream from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer try: @@ -36,6 +34,8 @@ correct_amax_history_if_needed = None +logger = logging.getLogger(__name__) + def unimodal_build_distributed_models( build_model_func: Callable, @@ -294,11 +294,15 @@ def _ddp_wrap( if not ddp_config.overlap_grad_reduce: ddp_config.bucket_size = None - # DDP initialization is required to be on a side-stream for the full-iteration CUDA graph. - # this side-stream may be nested if being called from within the get_model function, but it - # is here in case someone wants to use this directly outside of get_model. - ddp_stream = torch.cuda.Stream() + if get_model_config(model[0]).cuda_graph_impl == "full_iteration": + # DDP initialization must use the full-iteration capture stream so its retained + # AccumulateGrad nodes do not reference a different, non-capturing stream. + ddp_stream = get_shared_capture_stream() + else: + # Preserve a dedicated initialization stream for all other implementations. + ddp_stream = torch.cuda.Stream() ddp_stream.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(ddp_stream): dp_init_kwargs = {} if not use_torch_fsdp2: @@ -344,7 +348,7 @@ def _ddp_wrap( wrapped_model.append(wrapped_chunk) model = wrapped_model - # Critical: ensure side-stream work completes before touching params on default stream + # Ensure initialization-stream work completes before touching params on the default stream. torch.cuda.current_stream().wait_stream(ddp_stream) # Broadcast params from data parallel src rank to other data parallel ranks. diff --git a/megatron/training/training.py b/megatron/training/training.py index 66e9bfed9f8..c73012f91cd 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -54,7 +54,7 @@ ) from megatron.core.enums import ModelType from megatron.core.fp8_utils import correct_amax_history_if_needed -from megatron.core.full_cuda_graph import FullCudaGraphWrapper +from megatron.core.full_cuda_graph import FullCudaGraphWrapper, get_shared_capture_stream from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.inference.unified_memory import create_unified_mempool from megatron.core.models.gpt.experimental_attention_variant_module_specs import ( @@ -1853,12 +1853,15 @@ def build_model(): for disable in per_chunk_disable_bucketing ] - # Setup stream for ddp initialization. The side-stream may be necessary for cuda graph - # capture support with DDP, but we sync it with the current stream to avoid races. - ddp_stream = torch.cuda.Stream() - # Wait for the default stream to complete before starting ddp_stream + if config.cuda_graph_impl == "full_iteration": + # DDP initialization must use the full-iteration capture stream so its retained + # AccumulateGrad nodes do not reference a different, non-capturing stream. + ddp_stream = get_shared_capture_stream() + else: + # Preserve a dedicated initialization stream for all other implementations. + ddp_stream = torch.cuda.Stream() ddp_stream.wait_stream(torch.cuda.current_stream()) - # Make ddp_stream start after whatever the default stream already queued + with torch.cuda.stream(ddp_stream): model = wrap_model_chunks_with_ddp( model, @@ -1875,8 +1878,7 @@ def build_model(): bucket_sizes=per_chunk_bucket_sizes, disable_bucketing_per_chunk=per_chunk_disable_bucketing, ) - # End of setup_stream - # Critical: ensure side-stream work completes before touching params on default stream + # Ensure initialization-stream work completes before touching params on the default stream. torch.cuda.current_stream().wait_stream(ddp_stream) # Broadcast params from data parallel src rank to other data parallel ranks. diff --git a/tests/unit_tests/training/models/test_dist_utils.py b/tests/unit_tests/training/models/test_dist_utils.py index d444cb21148..822e83f9591 100644 --- a/tests/unit_tests/training/models/test_dist_utils.py +++ b/tests/unit_tests/training/models/test_dist_utils.py @@ -500,6 +500,54 @@ def test_returns_list_of_wrapped_modules( assert isinstance(result, list) assert len(result) == 2 + @patch("megatron.training.models.dist_utils.DistributedDataParallel") + @patch("megatron.training.models.dist_utils.get_model_config") + @patch("torch.cuda.stream", new_callable=MagicMock) + @patch("torch.cuda.current_stream") + @patch("megatron.training.models.dist_utils.get_shared_capture_stream") + def test_uses_full_iteration_capture_stream_for_ddp_initialization( + self, mock_shared_stream, mock_current_stream, mock_stream_context, mock_config, mock_ddp + ): + mock_stream_context.return_value.__enter__ = Mock(return_value=None) + mock_stream_context.return_value.__exit__ = Mock(return_value=False) + shared_stream = mock_shared_stream.return_value + mock_config.return_value.cuda_graph_impl = "full_iteration" + + _ddp_wrap(self.model, False, self.ddp_config, False, pg_collection=self.pg) + + shared_stream.wait_stream.assert_called_once_with(mock_current_stream.return_value) + mock_stream_context.assert_called_once_with(shared_stream) + mock_current_stream.return_value.wait_stream.assert_called_once_with(shared_stream) + + @pytest.mark.parametrize("cuda_graph_impl", ["none", "local", "transformer_engine"]) + @patch("megatron.training.models.dist_utils.DistributedDataParallel") + @patch("megatron.training.models.dist_utils.get_model_config") + @patch("torch.cuda.stream", new_callable=MagicMock) + @patch("torch.cuda.current_stream") + @patch("torch.cuda.Stream") + @patch("megatron.training.models.dist_utils.get_shared_capture_stream") + def test_uses_dedicated_stream_for_other_cuda_graph_implementations( + self, + mock_shared_stream, + mock_stream, + mock_current_stream, + mock_stream_context, + mock_config, + mock_ddp, + cuda_graph_impl, + ): + mock_stream_context.return_value.__enter__ = Mock(return_value=None) + mock_stream_context.return_value.__exit__ = Mock(return_value=False) + mock_config.return_value.cuda_graph_impl = cuda_graph_impl + dedicated_stream = mock_stream.return_value + + _ddp_wrap(self.model, False, self.ddp_config, False, pg_collection=self.pg) + + mock_shared_stream.assert_not_called() + dedicated_stream.wait_stream.assert_called_once_with(mock_current_stream.return_value) + mock_stream_context.assert_called_once_with(dedicated_stream) + mock_current_stream.return_value.wait_stream.assert_called_once_with(dedicated_stream) + @patch("megatron.training.models.dist_utils.TorchFullyShardedDataParallel") @patch("megatron.training.models.dist_utils.HAVE_FSDP2", False) @patch("megatron.training.models.dist_utils.get_model_config") diff --git a/tests/unit_tests/transformer/test_full_cuda_graph.py b/tests/unit_tests/transformer/test_full_cuda_graph.py index 312ae467304..037b9dde287 100644 --- a/tests/unit_tests/transformer/test_full_cuda_graph.py +++ b/tests/unit_tests/transformer/test_full_cuda_graph.py @@ -1,23 +1,95 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +import warnings +from unittest.mock import Mock, patch + import pytest import torch from pytest_mock import mocker import megatron.core.pipeline_parallel.schedules as schedule from megatron.core import ModelParallelConfig -from megatron.core.full_cuda_graph import FullCudaGraphWrapper +from megatron.core.full_cuda_graph import FullCudaGraphWrapper, get_shared_capture_stream from megatron.core.tensor_parallel.random import ( HAVE_TE, initialize_rng_tracker, model_parallel_cuda_manual_seed, ) from megatron.core.utils import is_te_min_version +from megatron.training.models.dist_utils import _ddp_wrap from tests.unit_tests.test_utilities import Utils rank = Utils.rank +def test_ddp_grad_accumulators_share_full_cuda_graph_stream(): + """Retained DDP AccumulateGrad nodes must use the full-iteration capture stream.""" + + class RetainingDataParallel(torch.nn.Module): + """Minimal DDP wrapper that retains parameter AccumulateGrad nodes.""" + + def __init__(self, *, module, **_): + super().__init__() + self.module = module + self.grad_accumulators = [] + for param in module.parameters(): + expanded_param = param.expand_as(param) + grad_accumulator = expanded_param.grad_fn.next_functions[0][0] + grad_accumulator.register_hook(lambda *_: None) + self.grad_accumulators.append(grad_accumulator) + + def forward(self, inputs): + """Run the wrapped module.""" + return self.module(inputs) + + assert torch.autograd.graph.set_warn_on_accumulate_grad_stream_mismatch is not None + model = torch.nn.Linear(4, 4, device="cuda") + model.config = Mock(cuda_graph_impl="full_iteration") + ddp_config = Mock( + num_buckets=None, + bucket_size=1024, + overlap_grad_reduce=True, + use_distributed_optimizer=False, + ) + process_groups = Mock() + with patch( + "megatron.training.models.dist_utils.DistributedDataParallel", RetainingDataParallel + ): + wrapped_model = _ddp_wrap( + [model], + data_parallel_random_init=False, + ddp_config=ddp_config, + overlap_param_gather_with_optimizer_step=False, + pg_collection=process_groups, + )[0] + + capture_stream = get_shared_capture_stream() + current_stream = torch.cuda.current_stream() + capture_stream.wait_stream(current_stream) + static_input = torch.ones(2, 4, device="cuda") + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + with torch.cuda.stream(capture_stream): + wrapped_model(static_input).sum().backward() + wrapped_model.zero_grad(set_to_none=False) + + cuda_graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(cuda_graph, stream=capture_stream): + wrapped_model(static_input).sum().backward() + + cuda_graph.replay() + torch.cuda.synchronize() + + stream_mismatch_warnings = [ + warning + for warning in caught_warnings + if "AccumulateGrad node's stream does not match" in str(warning.message) + ] + assert not stream_mismatch_warnings + assert all(param.grad is not None for param in wrapped_model.parameters()) + + @pytest.mark.skipif( not (HAVE_TE and is_te_min_version("1.5.0")), reason="use_te_rng_tracker requires TransformerEngine version >= 1.5", From 8a424b83777d4bdc8c3c857b1cb1d9ce141818af Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Tue, 28 Jul 2026 14:24:58 +0200 Subject: [PATCH 125/290] Enforce that the number of optimizer shards used in layout computation is the same used during the training iteration (#6048) Signed-off-by: Deepak Narayanan --- .../core/distributed/param_and_grad_buffer.py | 1 + megatron/core/optimizer/distrib_optimizer.py | 13 +++++ .../core/optimizer/layer_wise_optimizer.py | 53 ++++++++++++------- megatron/core/optimizer/param_layout.py | 6 +-- megatron/training/models/dist_utils.py | 18 ++++++- megatron/training/training.py | 18 ++++++- .../distributed/test_param_and_grad_buffer.py | 18 +++++-- .../training/models/test_dist_utils.py | 6 +++ 8 files changed, 105 insertions(+), 28 deletions(-) diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index 15247c2be53..50fa566d1b6 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -1071,6 +1071,7 @@ def __init__( param_layout = _compute_default_per_buffer_param_layout(self.params, bucket_size) self.param_index_map = param_layout.param_index_map self.bucket_indices = param_layout.bucket_indices + self.num_optimizer_shards = param_layout.num_optimizer_shards per_bucket_numel_unpadded = param_layout.per_bucket_numel_unpadded # Check if this buffer contains NVFP4 params. diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py index 0e0520e6157..782d3263570 100644 --- a/megatron/core/optimizer/distrib_optimizer.py +++ b/megatron/core/optimizer/distrib_optimizer.py @@ -211,6 +211,18 @@ def _build_model_gbuf_range(cls, param_and_grad_buffer: _ParamAndGradBuffer, buc data_parallel_rank = param_and_grad_buffer.data_parallel_group.rank() data_parallel_world_size = param_and_grad_buffer.data_parallel_group.size() + # The layout records how many shards it was built for. That count has to match the group + # the reduce-scatter and all-gather run over, which is the intra-instance group when + # there are several optimizer instances. If the layout was sized by a larger group, the + # trailing shards of every bucket belong to no rank: those params are never updated and + # drop out of grad-norm, num-zeros and params-norm, which sum over owned shards only. + num_optimizer_shards = param_and_grad_buffer.num_optimizer_shards + assert num_optimizer_shards is None or num_optimizer_shards == data_parallel_world_size, ( + f"Parameter layout was built for {num_optimizer_shards} optimizer shards but the " + f"buffer's data-parallel group has {data_parallel_world_size} ranks. Size the layout " + f"by the group the optimizer shards over." + ) + bucket = param_and_grad_buffer.buckets[bucket_index] gbuf_size = bucket.grad_data.numel() assert ( @@ -572,6 +584,7 @@ def _finalize_bucket(param_end_index, bucket_start_index, bucket_id): bucket_indices=bucket_indices, per_bucket_numel_unpadded=per_bucket_numel_unpadded, param_indices=param_indices if param_indices is not None else [], + num_optimizer_shards=data_parallel_world_size, ) @staticmethod diff --git a/megatron/core/optimizer/layer_wise_optimizer.py b/megatron/core/optimizer/layer_wise_optimizer.py index f39e4d44169..a132e1be3a1 100644 --- a/megatron/core/optimizer/layer_wise_optimizer.py +++ b/megatron/core/optimizer/layer_wise_optimizer.py @@ -445,6 +445,27 @@ def __init__( self.pg_collection = pg_collection + # The data-parallel groups this optimizer shards parameters over. Cached here so the + # sharding, all-gather and broadcast paths read one attribute instead of reaching back + # into pg_collection at every use. + self.dp_cp = getattr(pg_collection, 'dp_cp', None) if pg_collection is not None else None + self.expt_dp = ( + getattr(pg_collection, 'expt_dp', None) if pg_collection is not None else None + ) + + # LayerWise assigns whole params to ranks of the full dp_cp group and all-gathers over + # that same group, so it has no notion of optimizer instances. With more than one + # instance, DDP reduce-scatters gradients over the smaller intra-instance group, so a + # rank would be asked to update params whose gradients it does not hold. Reject the + # combination instead of silently training on partial gradients. + intra_dp_cp = ( + getattr(pg_collection, 'intra_dp_cp', None) if pg_collection is not None else None + ) + assert intra_dp_cp is None or get_pg_size(intra_dp_cp) == get_pg_size(self.dp_cp), ( + "LayerWiseDistributedOptimizer does not support " + "num_distributed_optimizer_instances > 1." + ) + full_param_layouts = None if model_chunks is not None: full_param_layouts = [ @@ -527,13 +548,13 @@ def shard_params(self, optimizers, full_param_layouts=None): chunk). ``None`` triggers the legacy fallback. """ # Simplify when dp_cp group size is 1. - dp_cp_size = get_pg_size(self.pg_collection.dp_cp) + dp_cp_size = get_pg_size(self.dp_cp) if dp_cp_size == 1: self.dp_cp_params_list = None self.expt_dp_params_list = None return - expt_dp_size = get_pg_size(self.pg_collection.expt_dp) + expt_dp_size = get_pg_size(self.expt_dp) if full_param_layouts is not None: self._shard_params_from_layout(optimizers, full_param_layouts, dp_cp_size, expt_dp_size) @@ -542,8 +563,8 @@ def shard_params(self, optimizers, full_param_layouts=None): def _shard_params_from_layout(self, optimizers, full_param_layouts, dp_cp_size, expt_dp_size): """Derive shard assignments from the param layout.""" - dp_cp_rank = get_pg_rank(self.pg_collection.dp_cp) - expt_dp_rank = get_pg_rank(self.pg_collection.expt_dp) + dp_cp_rank = get_pg_rank(self.dp_cp) + expt_dp_rank = get_pg_rank(self.expt_dp) self.dp_cp_params_list = [[] for _ in range(dp_cp_size)] self.expt_dp_params_list = [[] for _ in range(expt_dp_size)] @@ -641,12 +662,12 @@ def _shard_params_ping_pong(self, optimizers, dp_cp_size, expt_dp_size): # Assign params to rank in ping-pong style loop. for p, group_index in param_list: if param_groups[group_index].get("is_expert_parallel", False): - if expt_dp_loop[expt_dp_idx] == get_pg_rank(self.pg_collection.expt_dp): + if expt_dp_loop[expt_dp_idx] == get_pg_rank(self.expt_dp): param_groups_this_rank[group_index].append(p) self.expt_dp_params_list[expt_dp_loop[expt_dp_idx]].append(p) expt_dp_idx = (expt_dp_idx + 1) % len(expt_dp_loop) else: - if dp_cp_loop[dp_cp_idx] == get_pg_rank(self.pg_collection.dp_cp): + if dp_cp_loop[dp_cp_idx] == get_pg_rank(self.dp_cp): param_groups_this_rank[group_index].append(p) self.dp_cp_params_list[dp_cp_loop[dp_cp_idx]].append(p) dp_cp_idx = (dp_cp_idx + 1) % len(dp_cp_loop) @@ -680,9 +701,7 @@ def set_bucket_layerwise_params_list(self, model_chunks): if not _bucket_is_managed_by_layer_wise_optimizer(bucket): continue if self.dp_cp_params_list is not None: - bucket_params_list = [ - [] for _ in range(get_pg_size(self.pg_collection.dp_cp)) - ] + bucket_params_list = [[] for _ in range(get_pg_size(self.dp_cp))] for bucket_list, full_params_list in zip( bucket_params_list, self.dp_cp_params_list ): @@ -700,9 +719,7 @@ def set_bucket_layerwise_params_list(self, model_chunks): if not _bucket_is_managed_by_layer_wise_optimizer(bucket): continue if self.expt_dp_params_list is not None: - bucket_params_list = [ - [] for _ in range(get_pg_size(self.pg_collection.expt_dp)) - ] + bucket_params_list = [[] for _ in range(get_pg_size(self.expt_dp))] for bucket_list, full_params_list in zip( bucket_params_list, self.expt_dp_params_list ): @@ -765,9 +782,9 @@ def _allgather_helper(params_list, group): if self.pg_collection is None: return if self.dp_cp_params_list: - _allgather_helper(self.dp_cp_params_list, self.pg_collection.dp_cp) + _allgather_helper(self.dp_cp_params_list, self.dp_cp) if self.expt_dp_params_list: - _allgather_helper(self.expt_dp_params_list, self.pg_collection.expt_dp) + _allgather_helper(self.expt_dp_params_list, self.expt_dp) @torch.no_grad() def broadcast_params(self): @@ -776,15 +793,15 @@ def broadcast_params(self): if self.dp_cp_params_list is None: return for i, params in enumerate(self.dp_cp_params_list): - src_global_rank = torch.distributed.get_global_rank(self.pg_collection.dp_cp, i) + src_global_rank = torch.distributed.get_global_rank(self.dp_cp, i) for p in params: - torch.distributed.broadcast(p, src_global_rank, self.pg_collection.dp_cp) + torch.distributed.broadcast(p, src_global_rank, self.dp_cp) if self.expt_dp_params_list is None: return for i, params in enumerate(self.expt_dp_params_list): - src_global_rank = torch.distributed.get_global_rank(self.pg_collection.expt_dp, i) + src_global_rank = torch.distributed.get_global_rank(self.expt_dp, i) for p in params: - torch.distributed.broadcast(p, src_global_rank, self.pg_collection.expt_dp) + torch.distributed.broadcast(p, src_global_rank, self.expt_dp) @torch.no_grad() def get_grad_norm(self): diff --git a/megatron/core/optimizer/param_layout.py b/megatron/core/optimizer/param_layout.py index 808bda1941b..9d2dd4db365 100644 --- a/megatron/core/optimizer/param_layout.py +++ b/megatron/core/optimizer/param_layout.py @@ -79,9 +79,9 @@ class PerBufferParamLayout: param_indices: The index of each param among same-dtype params (using the "fake" high-precision dtype for FP8/NVFP4 params). Needed for loading non-native-fp8 checkpoints in native-fp8 mode. Order matches param_index_map iteration order. - num_optimizer_shards: Number of shards the bucket boundaries were aligned to. Set only - by ``LayerWiseDistributedOptimizer``, to recover a param's shard index; ``None`` for - ``DistributedOptimizer``, which takes the count from the buffer's data_parallel_group. + num_optimizer_shards: Number of optimizer shards. Set by the distributed optimizer + that computes the layout so that shard assignment at runtime uses the same + value. ``None`` for non-distributed-optimizer layouts. """ param_index_map: Dict[torch.nn.Parameter, Tuple[int, int, int]] = field(default_factory=dict) diff --git a/megatron/training/models/dist_utils.py b/megatron/training/models/dist_utils.py index 2a83e995e5a..46e562edc18 100644 --- a/megatron/training/models/dist_utils.py +++ b/megatron/training/models/dist_utils.py @@ -328,13 +328,27 @@ def _ddp_wrap( if disable_bucketing or pp_rank > 0 else ddp_config.bucket_size ) + # Size the layout by the group the optimizer actually shards over, which is + # the intra-instance group when there are several optimizer instances. Using + # the full dp_cp would report more shards than the reduce-scatter uses and + # leave the trailing shard of every bucket owned by no rank. + intra_dp_cp_group = getattr(pg_collection, "intra_dp_cp", None) + intra_expt_dp_group = getattr(pg_collection, "intra_expt_dp", None) chunk_kwargs["full_param_layout"] = ( DistributedOptimizer.compute_full_param_layout( all_params, effective_bucket_size, - pg_collection.dp_cp.size(), + ( + intra_dp_cp_group + if intra_dp_cp_group is not None + else pg_collection.dp_cp + ).size(), ddp_config, - expert_data_parallel_world_size=pg_collection.expt_dp.size(), + expert_data_parallel_world_size=( + intra_expt_dp_group + if intra_expt_dp_group is not None + else pg_collection.expt_dp + ).size(), ) ) diff --git a/megatron/training/training.py b/megatron/training/training.py index c73012f91cd..57b8efe9390 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -1654,8 +1654,22 @@ def wrap_model_chunks_with_ddp( "wrap_model_chunks_with_ddp requires a dp_cp process group to size " "the distributed-optimizer parameter layout" ) - data_parallel_world_size = get_pg_size(layout_pgs.dp_cp) - expert_data_parallel_world_size = get_pg_size(getattr(layout_pgs, "expt_dp", None)) + # The distributed optimizer shards each bucket over the intra-instance group, which + # is what DDP hands to the buffer as its data_parallel_group. Size the layout by that + # same group, otherwise the layout reports more shards than the reduce-scatter uses + # and the trailing shards of every bucket end up owned by no rank. intra_dp_cp is + # the full dp_cp when num_distributed_optimizer_instances is 1, so this only differs + # when there are several instances. + intra_dp_cp_group = getattr(layout_pgs, "intra_dp_cp", None) + intra_expt_dp_group = getattr(layout_pgs, "intra_expt_dp", None) + data_parallel_world_size = get_pg_size( + intra_dp_cp_group if intra_dp_cp_group is not None else layout_pgs.dp_cp + ) + expert_data_parallel_world_size = get_pg_size( + intra_expt_dp_group + if intra_expt_dp_group is not None + else getattr(layout_pgs, "expt_dp", None) + ) for i, (chunk, bucket_size) in enumerate(zip(model_chunks, bucket_sizes)): all_params = [p for p in chunk.parameters() if p.requires_grad] per_chunk_layouts[i] = compute_layout( diff --git a/tests/unit_tests/distributed/test_param_and_grad_buffer.py b/tests/unit_tests/distributed/test_param_and_grad_buffer.py index e00509067a5..9c510fe0e44 100644 --- a/tests/unit_tests/distributed/test_param_and_grad_buffer.py +++ b/tests/unit_tests/distributed/test_param_and_grad_buffer.py @@ -78,12 +78,19 @@ def get_model_and_buffers( # Wrap with DistributedDataParallel, and get underlying buffer. # Use dummy TransformerConfig with mostly default values. Avoid divide-by-zero # errors for num_attention_heads and num_layers. - # Pre-compute parameter layouts for the distributed optimizer. + # Pre-compute parameter layouts for the distributed optimizer. Size the layout by the group + # the optimizer shards over, which is the intra-instance group when there are several + # optimizer instances. This is the same group DDP hands to the buffer below. full_param_layout = None if use_distributed_optimizer: all_params = [p for p in model.parameters() if p.requires_grad] full_param_layout = DistributedOptimizer.compute_full_param_layout( - all_params, bucket_size, parallel_state.get_data_parallel_world_size(), ddp_config + all_params, + bucket_size, + parallel_state.get_data_parallel_world_size( + with_context_parallel=True, partial_data_parallel=True + ), + ddp_config, ) model = DistributedDataParallel( TransformerConfig(num_attention_heads=1, num_layers=1), @@ -1016,7 +1023,12 @@ def test_expert_parallel_params_get_separate_buffers(use_distributed_optimizer: if use_distributed_optimizer: all_params = [p for p in model.parameters() if p.requires_grad] full_param_layout = DistributedOptimizer.compute_full_param_layout( - all_params, bucket_size, parallel_state.get_data_parallel_world_size(), ddp_config + all_params, + bucket_size, + parallel_state.get_data_parallel_world_size( + with_context_parallel=True, partial_data_parallel=True + ), + ddp_config, ) ddp_model = DistributedDataParallel( diff --git a/tests/unit_tests/training/models/test_dist_utils.py b/tests/unit_tests/training/models/test_dist_utils.py index 822e83f9591..3d1cb6e0978 100644 --- a/tests/unit_tests/training/models/test_dist_utils.py +++ b/tests/unit_tests/training/models/test_dist_utils.py @@ -33,6 +33,9 @@ def _make_pg(): pg.pp.size.return_value = 1 pg.dp_cp.size.return_value = 1 pg.expt_dp.size.return_value = 1 + # With a single optimizer instance the intra-instance groups are the full groups. + pg.intra_dp_cp.size.return_value = 1 + pg.intra_expt_dp.size.return_value = 1 return pg @@ -665,6 +668,9 @@ def setup_method(self): self.pg = _make_pg() self.pg.dp_cp.size.return_value = 4 self.pg.expt_dp.size.return_value = 2 + # Single optimizer instance, so the intra-instance groups match the full groups. + self.pg.intra_dp_cp.size.return_value = 4 + self.pg.intra_expt_dp.size.return_value = 2 self._opt_patcher = patch("megatron.training.models.dist_utils.DistributedOptimizer") self._opt = self._opt_patcher.start() self._opt.compute_full_param_layout.return_value = "LAYOUT" From 4da9212c36f7052762f6e4db419deb3f83e4c58e Mon Sep 17 00:00:00 2001 From: Fei Wu <33940270+YangFei1990@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:30:10 -0700 Subject: [PATCH 126/290] NCCL EP zero copy (#5735) Signed-off-by: YangFei1990 Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Xin Yao --- megatron/core/models/common/utils.py | 1 + .../core/models/gpt/fine_grained_callables.py | 8 +- megatron/core/pipeline_parallel/utils.py | 47 +++++- megatron/core/transformer/moe/experts.py | 42 +++++- megatron/core/transformer/moe/fused_a2a.py | 35 ++++- megatron/core/transformer/moe/moe_layer.py | 11 +- .../core/transformer/moe/token_dispatcher.py | 142 +++++++++++++++--- .../core/transformer/transformer_config.py | 6 +- .../a2a_overlap/test_schedule_layer_1f1b.py | 97 ++++++++++++ .../models/test_hybrid_moe_model.py | 2 +- .../transformer/moe/test_paged_stashing.py | 23 ++- .../transformer/moe/test_token_dispatcher.py | 130 +++++++++++++++- 12 files changed, 499 insertions(+), 45 deletions(-) diff --git a/megatron/core/models/common/utils.py b/megatron/core/models/common/utils.py index 186a0c882fc..d1b7c54fefb 100644 --- a/megatron/core/models/common/utils.py +++ b/megatron/core/models/common/utils.py @@ -252,6 +252,7 @@ def __init__( weak_method(self.backward_impl), free_input=free_input, name=name, + ncclep_zero_copy=config.moe_ncclep_zero_copy, ) self.layer_state = layer_state self.chunk_state = chunk_state diff --git a/megatron/core/models/gpt/fine_grained_callables.py b/megatron/core/models/gpt/fine_grained_callables.py index 61648d7b602..ba1fd3af499 100644 --- a/megatron/core/models/gpt/fine_grained_callables.py +++ b/megatron/core/models/gpt/fine_grained_callables.py @@ -10,7 +10,7 @@ from megatron.core.pipeline_parallel.fine_grained_activation_offload import ( FineGrainedActivationOffloadingInterface as off_interface, ) -from megatron.core.pipeline_parallel.utils import ScheduleNode +from megatron.core.pipeline_parallel.utils import ScheduleNode, StageDispatchBwdGrad from megatron.core.transformer.module import GraphableMegatronModule from megatron.core.transformer.moe.moe_layer import MoELayer from megatron.core.transformer.transformer_layer import TransformerLayer, make_viewless_tensor @@ -167,6 +167,12 @@ def submodule_dispatch_forward( dispatched_tokens, dispatched_probs = layer.mlp.dispatch(local_tokens, probs) + if enable_ncclep and layer.config.moe_ncclep_zero_copy: + # Insert an identity node as the sole consumer of the dispatch output, so the + # dispatch-backward gets the symm buffer instead of a non-symm AccumulateGrad clone. + # Must stay inside this node's graph segment (before the next node detaches it). + dispatched_tokens = StageDispatchBwdGrad.apply(dispatched_tokens, token_dispatcher) + # `dispatched_probs` is needed by backward pass of swiglu, therefore it's # passed to moe_forward within `layer_state` to avoid the free_input process # of the input tensors. diff --git a/megatron/core/pipeline_parallel/utils.py b/megatron/core/pipeline_parallel/utils.py index 0593693501c..3ffcc058b02 100644 --- a/megatron/core/pipeline_parallel/utils.py +++ b/megatron/core/pipeline_parallel/utils.py @@ -17,9 +17,46 @@ nvtx_range_push, ) +try: + from transformer_engine.pytorch.ep import is_symm_backed +except ImportError: + is_symm_backed = None + logger = logging.getLogger(__name__) +class StageDispatchBwdGrad(torch.autograd.Function): + """1F1B + NCCL-EP zero-copy only: redirect the dispatch-backward grad into the persistent + symm buffer so the one-sided ``dispatch_bwd`` can consume it. + + Under the 1F1B overlap schedule the dispatch output is consumed by the next node, which + detaches it into a leaf; autograd therefore hands ``dispatch_bwd`` a non-symm + ``AccumulateGrad`` clone. Applying this identity node to the dispatch output — while it is + still inside the dispatch node's own graph segment — makes it the sole consumer, moving that + accumulation to *our* output; the backward then does a single plain->symm copy into the + dispatcher's ``_zc_bwd_token_buf``. That buffer is free to stage into precisely because + ``get_expert_zero_copy_buffers`` withholds it from the op-fuser under overlap. + Forward is identity (no numeric effect). + """ + + @staticmethod + def forward(ctx, dispatched_tokens, token_dispatcher): # type: ignore[override] + """Identity forward; stashes the dispatcher so backward can reach its symm buffer.""" + ctx.token_dispatcher = token_dispatcher + return dispatched_tokens + + @staticmethod + def backward(ctx, grad): # type: ignore[override] + """Stage the incoming gradient into the symm dispatch-backward buffer.""" + buf = ctx.token_dispatcher._comm_manager._zc_bwd_token_buf + assert buf is not None, "zero-copy staging buffer not allocated before dispatch-backward" + assert ( + buf.shape == grad.shape + ), f"dispatch-bwd grad {tuple(grad.shape)} != staging buffer {tuple(buf.shape)}" + buf.copy_(grad) + return buf, None + + def is_pp_first_stage(pp_group: torch.distributed.ProcessGroup): """Return True if in the first pipeline model-parallel stage, False otherwise.""" return get_pg_rank(pp_group) == 0 @@ -156,6 +193,7 @@ def __init__( backward_func: Optional[Callable] = None, free_input: bool = False, name: str = "schedule_node", + ncclep_zero_copy: bool = False, ): """Initialize a schedule node. @@ -180,6 +218,7 @@ def __init__( self.stream = stream self.event = event self.free_input = free_input + self.ncclep_zero_copy = ncclep_zero_copy self.inputs = None self.outputs = None @@ -228,7 +267,13 @@ def _forward(self, *inputs): for input in inputs: if input is not None: input.record_stream(self.stream) - input.untyped_storage().resize_(0) + # Skip symmetric-memory (zero-copy EP) buffers + if not ( + self.ncclep_zero_copy + and is_symm_backed is not None + and is_symm_backed(input) + ): + input.untyped_storage().resize_(0) return self.output diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index 197115ebb10..7fc0725b9de 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -61,9 +61,18 @@ import transformer_engine as te from megatron.core.extensions.transformer_engine import Fp8Padding, Fp8Unpadding + + try: + from transformer_engine.pytorch.ops.basic.grouped_linear import ( + GRAD_INPUT_BUFFER_KEY, + OUTPUT_BUFFER_KEY, + ) + except ImportError: + GRAD_INPUT_BUFFER_KEY = OUTPUT_BUFFER_KEY = None else: te = None # type: ignore[assignment, misc] Fp8Padding, Fp8Unpadding = None, None + GRAD_INPUT_BUFFER_KEY = OUTPUT_BUFFER_KEY = None try: import flashinfer.fused_moe as fused_moe @@ -609,6 +618,8 @@ def _fused_forward( permuted_local_hidden_states: torch.Tensor, tokens_per_expert: torch.Tensor, permuted_probs: torch.Tensor, + output_buffer: Optional[torch.Tensor] = None, + grad_input_buffer: Optional[torch.Tensor] = None, ) -> torch.Tensor: """Forward pass using Transformer Engine operation fuser API.""" @@ -665,10 +676,23 @@ def _fused_forward( fine_grained_activation_offloading, permuted_local_hidden_states, offload_name ) with fused_group_mlp_manager as permuted_local_hidden_states: + # NCCL-EP zero-copy is active exactly when ``output_buffer`` is not None, and then the + # fused-MLP input aliases the persistent symm buffer (also the fc2 output combine + # reads), whose storage is non-resizable — so skip the force-release in that case. forced_released_tensors = ( - [permuted_local_hidden_states] if fine_grained_activation_offloading else [] + [permuted_local_hidden_states] + if fine_grained_activation_offloading and output_buffer is None + else [] ) with stash_context: + # NCCL-EP zero-copy: route the fc2 output (fwd combine reads it one-sided) and the + # fc1 dgrad (bwd dispatch scatters it one-sided) into caller-provided symm buffers. + # op_kwargs keys are basic-op indices into [fc1, activation, fc2]: 0=fc1, -1=fc2. + op_kwargs = {} + if output_buffer is not None: + op_kwargs[-1] = {OUTPUT_BUFFER_KEY: output_buffer} + if grad_input_buffer is not None: + op_kwargs[0] = {GRAD_INPUT_BUFFER_KEY: grad_input_buffer} # Call fused impl fc2_extra_inputs = ( (tokens_per_expert, permuted_probs) @@ -680,6 +704,7 @@ def _fused_forward( tokens_per_expert, # FC1 permuted_probs, # Scaled activation *fc2_extra_inputs, # FC2 splits and, for bias, its per-token scale + **({"op_kwargs": op_kwargs} if op_kwargs else {}), ) output = fused_group_mlp_manager.group_offload( output, forced_released_tensors=forced_released_tensors @@ -705,6 +730,8 @@ def forward( permuted_local_hidden_states: torch.Tensor, tokens_per_expert: torch.Tensor, permuted_probs: torch.Tensor, + output_buffer: Optional[torch.Tensor] = None, + grad_input_buffer: Optional[torch.Tensor] = None, ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: """Forward of TEGroupedMLP @@ -713,6 +740,10 @@ def forward( local experts. tokens_per_expert (torch.Tensor): The number of tokens per expert. permuted_probs (torch.Tensor): The permuted probs of each token produced by the router. + output_buffer (torch.Tensor, optional): Preallocated buffer to write the fc2 output into + (NCCL-EP zero-copy fwd combine); only the fused op-fuser path supports it. + grad_input_buffer (torch.Tensor, optional): Preallocated buffer to write the fc1 dgrad + into (NCCL-EP zero-copy bwd dispatch); only the fused op-fuser path supports it. Return: output (torch.Tensor): The output of the local experts. @@ -721,10 +752,17 @@ def forward( # Call fused impl if enabled if self._with_fused_impl: output = self._fused_forward( - permuted_local_hidden_states, tokens_per_expert, permuted_probs + permuted_local_hidden_states, + tokens_per_expert, + permuted_probs, + output_buffer, + grad_input_buffer, ) output_bias = None return output, output_bias + assert ( + output_buffer is None and grad_input_buffer is None + ), "output_buffer/grad_input_buffer require the TE op-fuser (fused) path" # Apply padding if needed unpadded_tokens_per_expert = None diff --git a/megatron/core/transformer/moe/fused_a2a.py b/megatron/core/transformer/moe/fused_a2a.py index 09e50c9f4ad..444760655a6 100644 --- a/megatron/core/transformer/moe/fused_a2a.py +++ b/megatron/core/transformer/moe/fused_a2a.py @@ -670,6 +670,12 @@ def nccl_ep_finalize(): if HAVE_TE_EP: + def alloc_ep_symm_buffer(shape, dtype, ep_group): + """Allocate one persistent NCCL symm-mem buffer (per-buffer collective rendezvous). mcore's + zero-copy buffers are all persistent and non-pool; the symm mem-pool is used only by TE for + the per-call recv buffers it recycles.""" + return te_ep.symm_mem_alloc(shape, dtype, ep_group) + def new_nccl_ep_buffer( top_k, max_tokens_per_rank, @@ -680,8 +686,9 @@ def new_nccl_ep_buffer( ): """Build a fresh TE EpBuffer for one dispatch/combine pair. - The buffer owns handle_mem (the routing table dispatch writes and combine reads) and - the receive buffers; a new one is built per dispatch and dropped after combine. + The buffer owns handle_mem (the routing table dispatch writes and combine reads); a new one + is built per dispatch and dropped after combine. Payload symm buffers are not owned here — + they are caller-supplied to dispatch/combine or allocated on the fly by TE. """ return te_ep.EpBuffer( top_k=top_k, @@ -692,7 +699,9 @@ def new_nccl_ep_buffer( alignment=alignment, ) - def nccl_ep_dispatch(buffer, tokens, topk_idx, topk_weights): + def nccl_ep_dispatch( + buffer, tokens, topk_idx, topk_weights, recv_tokens=None, recv_topk_weights=None + ): """Autograd-aware prepare + dispatch via TransformerEngine NCCL EP. Args: @@ -702,6 +711,9 @@ def nccl_ep_dispatch(buffer, tokens, topk_idx, topk_weights): topk_idx (torch.Tensor): ``int64`` ``[num_local_tokens, top_k]`` global expert ids per token. topk_weights (torch.Tensor): ``float32`` ``[num_local_tokens, top_k]`` weights. + recv_tokens, recv_topk_weights (torch.Tensor, optional): caller-owned symm dispatch + recv buffers (fp8 zero-copy). Left None, TE allocates them (bf16 zero-copy: symm + mem-pool; normal: plain). Returns: tuple: ``(recv_tokens, tokens_per_expert, dispatched_probs)``: @@ -716,11 +728,16 @@ def nccl_ep_dispatch(buffer, tokens, topk_idx, topk_weights): ``tokens_per_expert`` is non-differentiable. """ recv_tokens, dispatched_probs, tokens_per_expert = te_ep.ep_dispatch( - buffer, tokens, topk_idx, topk_weights + buffer, + tokens, + topk_idx, + topk_weights, + recv_tokens=recv_tokens, + recv_topk_weights=recv_topk_weights, ) return recv_tokens, tokens_per_expert, dispatched_probs - def nccl_ep_combine(buffer, expert_out, num_local_tokens=None): + def nccl_ep_combine(buffer, expert_out, num_local_tokens=None, grad_out=None): """Autograd-aware combine via TransformerEngine NCCL EP (no scatter step). Args: @@ -729,14 +746,20 @@ def nccl_ep_combine(buffer, expert_out, num_local_tokens=None): already weighted. num_local_tokens (int): Rows of the result (local token count for this forward). When None, TE uses ``buffer.max_tokens_per_rank``. + grad_out (torch.Tensor, optional): caller-owned symm buffer the backward scatters the + expert_out grad into (zero-copy). Left None, TE allocates it (bf16: symm mem-pool; + normal: plain). Returns: torch.Tensor: ``[num_local_tokens, hidden]`` combined output, in local token order. """ - return te_ep.ep_combine(buffer, expert_out, num_local_tokens=num_local_tokens) + return te_ep.ep_combine( + buffer, expert_out, num_local_tokens=num_local_tokens, grad_out=grad_out + ) else: + alloc_ep_symm_buffer = None new_nccl_ep_buffer = None nccl_ep_dispatch = None nccl_ep_combine = None diff --git a/megatron/core/transformer/moe/moe_layer.py b/megatron/core/transformer/moe/moe_layer.py index deebd3472ea..48e78775a84 100644 --- a/megatron/core/transformer/moe/moe_layer.py +++ b/megatron/core/transformer/moe/moe_layer.py @@ -546,8 +546,17 @@ def routed_experts_compute(self, hidden_states: torch.Tensor, probs: torch.Tenso dispatched_input, tokens_per_expert, permuted_probs, routing_map=routing_map ) else: + # NCCL-EP zero-copy: experts write fc2 output and fc1 dgrad straight into the combine / + # dispatch symm buffers. Passed only when set (non-TEGroupedMLP experts don't accept + # these kwargs). + output_buffer, grad_input_buffer = self.token_dispatcher.get_expert_zero_copy_buffers() + expert_kwargs = {} + if output_buffer is not None: + expert_kwargs["output_buffer"] = output_buffer + if grad_input_buffer is not None: + expert_kwargs["grad_input_buffer"] = grad_input_buffer expert_output, mlp_bias = apply_module(self.experts)( - dispatched_input, tokens_per_expert, permuted_probs + dispatched_input, tokens_per_expert, permuted_probs, **expert_kwargs ) assert mlp_bias is None, f"mlp_bias is not supported for {type(self.token_dispatcher)}" output = self.token_dispatcher.combine_preprocess(expert_output) diff --git a/megatron/core/transformer/moe/token_dispatcher.py b/megatron/core/transformer/moe/token_dispatcher.py index 683450a4a28..5743a047960 100644 --- a/megatron/core/transformer/moe/token_dispatcher.py +++ b/megatron/core/transformer/moe/token_dispatcher.py @@ -2,6 +2,7 @@ import logging import os +import warnings from abc import ABC, abstractmethod from typing import List, Optional, Tuple @@ -20,6 +21,7 @@ from megatron.core.transformer.enums import CudaGraphModule from megatron.core.transformer.moe.fused_a2a import ( HYBRIDEP_TOKEN_ALIGNMENT, + alloc_ep_symm_buffer, ensure_nccl_ep_bootstrapped, fused_combine, fused_dispatch, @@ -214,6 +216,16 @@ def set_shared_experts(self, shared_experts): self.shared_experts = shared_experts self.use_nccl_stream = True + def get_expert_zero_copy_buffers(self): + """Buffers the experts should write their output / grad input into, if any. + + Returns: + A ``(output_buffer, grad_input_buffer)`` tuple. ``(None, None)`` unless the + dispatcher supports zero-copy, in which case the experts write straight into + the communication buffers instead of into fresh allocations. + """ + return None, None + class MoEAllGatherTokenDispatcher(MoETokenDispatcher): """ @@ -1466,6 +1478,20 @@ class _NCCLEPManager(_DispatchManager): lazily on the first dispatch, when the local token count is known. """ + # Zero-copy shared symm buffers, allocated once and reused across all layers/microbatches + # (class-level so every per-layer manager shares one set). _zc_fwd_token_buf is the forward symm + # buffer combine reads (the fc2 output); _zc_bwd_token_buf holds the backward grad. + # - fp8/fp4 (mxfp8 CuTe DSL grouped GEMM, Blackwell+): recv_tokens dies after FC1 quantizes it, + # so _zc_fwd_token_buf doubles as the dispatch recv_tokens; mcore also holds the dispatch + # probs (_zc_recv_topk_weights_buf) -- TE allocates nothing. + # - bf16 (op-fuser GroupedLinear GEMM, Hopper+): recv_tokens is the saved activation and + # can't double-duty, so TE pools the per-call recv_tokens/topk; _zc_fwd_token_buf holds only + # the fc2 output. + # TODO: move all to TE pool based allocation when symm memory pool supports cuda graph + _zc_fwd_token_buf = None + _zc_bwd_token_buf = None + _zc_recv_topk_weights_buf = None + def __init__( self, group: torch.distributed.ProcessGroup, @@ -1498,29 +1524,36 @@ def __init__( self.alignment = get_align_size_for_quantization(config) self.rank_capacity_factor = config.moe_expert_rank_capacity_factor self.static_shape = config.moe_ncclep_static_shape - if config.moe_ncclep_use_symm_mem: - raise NotImplementedError( - "moe_ncclep_use_symm_mem (symm-mem / zero-copy EP payload buffers) is not " - "supported yet." + self.zero_copy = config.moe_ncclep_zero_copy + self._zc_quant = self.zero_copy and bool(config.fp8 or config.fp4) + if self.zero_copy and not self.static_shape: + raise ValueError( + "moe_ncclep_zero_copy requires moe_ncclep_static_shape " + "(fixed [recv_capacity, hidden] symm buffers)." ) if self.static_shape: - if torch.cuda.get_device_capability()[0] < 10: + # static shape needs a fused grouped GEMM that consumes ragged per-expert counts on + # device (no host-side split narrowing): moe_grouped_gemm selects the grouped experts + # and use_transformer_engine_op_fuser fuses FC1+act+FC2 over them (fp8/fp4 via the CuTe + # DSL fused grouped MLP, bf16 via the op-fuser GroupedLinear grouped-tensor path). + if not (config.use_transformer_engine_op_fuser and config.moe_grouped_gemm): raise ValueError( - "moe_ncclep_static_shape=True requires an sm100+ (Blackwell or later) GPU with " - "a CuTe DSL / device-offset grouped GEMM; leave it False (dynamic shape) on " - "older GPUs." - ) - if not (config.use_transformer_engine_op_fuser or config.moe_grouped_gemm): - raise ValueError( - "moe_ncclep_static_shape=True requires the fused grouped GEMM; enable " - "use_transformer_engine_op_fuser (or moe_grouped_gemm)." - ) - if int(os.environ.get("NVTE_CUTEDSL_FUSED_GROUPED_MLP", "0")) <= 0: - raise ValueError( - "moe_ncclep_static_shape=True requires the CuTe DSL grouped GEMM; set " - "NVTE_CUTEDSL_FUSED_GROUPED_MLP=1 (the expert grouped GEMM must consume ragged " - "per-expert counts on device)." + "moe_ncclep_static_shape=True requires BOTH use_transformer_engine_op_fuser " + "and moe_grouped_gemm (the fused grouped GEMM over device-side " + "per-expert counts)." ) + if config.fp8 or config.fp4: + if torch.cuda.get_device_capability()[0] < 10: + raise ValueError( + "moe_ncclep_static_shape=True with fp8/fp4 requires an sm100+ (Blackwell+) " + "GPU for the CuTe DSL grouped GEMM; leave it False on older GPUs." + ) + if int(os.environ.get("NVTE_CUTEDSL_FUSED_GROUPED_MLP", "0")) <= 0: + raise ValueError( + "moe_ncclep_static_shape=True with fp8/fp4 requires the CuTe DSL grouped " + "GEMM; set NVTE_CUTEDSL_FUSED_GROUPED_MLP=1 (the expert grouped GEMM must " + "consume ragged per-expert counts on device)." + ) if nccl_ep_dispatch is None: raise ImportError( @@ -1586,8 +1619,36 @@ def _ensure_bootstrap(self): if self.config.moe_flex_dispatcher_num_sms is not None else 0 ), - zero_copy=False, + zero_copy=self.zero_copy, ) + if self.zero_copy and _NCCLEPManager._zc_bwd_token_buf is None: + # Allocate once, shared across all managers. These are all persistent. + if self.config.overlap_moe_expert_parallel_comm: + # The 1F1B overlap schedule detaches the dispatch input, so autograd hands the + # dispatch-backward a non-symm clone of the grad_input buffer + warnings.warn( + "moe_ncclep_zero_copy + overlap_moe_expert_parallel_comm (1F1B EP overlap): " + "dispatch-backward gradient is not symm-mem-backed under the overlap schedule, " + "so it is staged into a symm buffer with one extra copy per dispatch-backward.", + stacklevel=2, + ) + assert ( + not torch.cuda.is_current_stream_capturing() + ), "zero-copy symm buffers must be allocated before CUDA-graph capture" + rc, h = self._recv_capacity, self.hidden_dim + _NCCLEPManager._zc_bwd_token_buf = alloc_ep_symm_buffer( + (rc, h), torch.bfloat16, self.group + ) + # The forward buffer combine reads (fc2 output). fp8 also feeds it to dispatch as + # recv_tokens (dead after FC1 quantize, so it double-duties); bf16 uses it only for fc2. + _NCCLEPManager._zc_fwd_token_buf = alloc_ep_symm_buffer( + (rc, h), torch.bfloat16, self.group + ) + if self._zc_quant: + # fp8 also owns the dispatch probs buffer (bf16 pools it per-call in TE). + _NCCLEPManager._zc_recv_topk_weights_buf = alloc_ep_symm_buffer( + (rc,), torch.float32, self.group + ) self._bootstrapped = True def dispatch( @@ -1616,10 +1677,18 @@ def dispatch( # tokens_per_expert: [num_local_experts] # dispatched_probs: [recv_capacity_per_rank] recv_tokens, tokens_per_expert, dispatched_probs = nccl_ep_dispatch( - self._buffer, hidden_states, topk_idx, topk_weights + self._buffer, + hidden_states, + topk_idx, + topk_weights, + recv_tokens=_NCCLEPManager._zc_fwd_token_buf if self._zc_quant else None, + recv_topk_weights=_NCCLEPManager._zc_recv_topk_weights_buf, ) self.tokens_per_expert = tokens_per_expert.to(torch.int64) - self.dispatched_probs = dispatched_probs + # fp8 zero-copy: dispatched_probs aliases the recv_topk_weights symm buffer, which the + # next layer's dispatch reuses; copy it out so it stays valid through this layer's backward. + # bf16 gets a fresh per-call pool buffer (not shared), so no copy is needed. + self.dispatched_probs = dispatched_probs.clone() if self._zc_quant else dispatched_probs return recv_tokens def get_permuted_hidden_states_by_experts(self, hidden_states: torch.Tensor) -> torch.Tensor: @@ -1658,7 +1727,10 @@ def combine( ) -> torch.Tensor: # hidden_states: [recv_capacity_per_rank, H] -> [num_local_tokens, H] hidden_states = nccl_ep_combine( - self._buffer, hidden_states, num_local_tokens=self.num_local_tokens + self._buffer, + hidden_states, + num_local_tokens=self.num_local_tokens, + grad_out=_NCCLEPManager._zc_bwd_token_buf, ) # Drop the buffer; backward keeps handle_mem alive via save_for_backward. self._buffer = None @@ -1728,6 +1800,30 @@ def __init__( "Please set --moe-flex-dispatcher-backend to deepep, hybridep, or ncclep" ) + def get_expert_zero_copy_buffers(self): + """NCCL-EP zero-copy: ``(output_buffer, grad_input_buffer)`` — the shared symm buffers the + experts write the fc2 output / fc1 dgrad into, so combine (fwd) and dispatch (bwd) read and + scatter them one-sided. ``(None, None)`` for every other backend/mode. + + Returned detached: the op-fuser calls requires_grad_() on its output and returns it, + so handing it the persistent buffer would permanently mark the shared classvar as requiring + grad and break the next layer's reuse. The detached view shares storage (zero-copy intact). + """ + + def _detached(name): + buf = getattr(self._comm_manager, name, None) + return buf.detach() if buf is not None else None + + # output_buffer (fc2 out / combine in) = _zc_fwd_token_buf; grad_input_buffer (fc1 dgrad / + # dispatch-bwd scatter) = _zc_bwd_token_buf. + # Under 1F1B overlap, feeding a symm grad_input_buffer is wasted: the overlap schedule's + # AccumulateGrad clones the fc1 dgrad into a plain buffer anyway. Return None so the + # op-fuser writes a plain dgrad; + dispatch_grad_input = ( + None if self.config.overlap_moe_expert_parallel_comm else _detached("_zc_bwd_token_buf") + ) + return _detached("_zc_fwd_token_buf"), dispatch_grad_input + def _initialize_metadata(self, routing_map: torch.Tensor, probs: torch.Tensor) -> torch.Tensor: """ Initialize the routing map and probs to a unified format covering the TPxEP group. diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 0b94e8eac66..4f9de546161 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -960,11 +960,11 @@ class TransformerConfig(ModelParallelConfig): later); the dispatcher asserts this. On older GPUs leave it False (dynamic shape). Defaults to False (narrow to the received tokens).""" - moe_ncclep_use_symm_mem: bool = False + moe_ncclep_zero_copy: bool = False """For the 'ncclep' flex dispatcher: use the NCCL symmetric-memory zero-copy IO path (ep_bootstrap zero_copy + symm-mem-backed receive/combine buffers) instead of the default HBM - staged-copy path. NOT SUPPORTED YET -- the dispatcher rejects this if set; the cross-stream - reuse ordering for the persistent symm-mem buffer is not implemented. Leave False.""" + staged-copy path, saving one copy on the wire. Requires moe_ncclep_static_shape and the fused op + (use_transformer_engine_op_fuser). Defaults to False.""" moe_mlp_glu_interleave_size: Optional[int] = None """When set, GLU activations in the MoE grouped MLP layer will use a diff --git a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py index 01bda68b4ca..fe36e052e03 100644 --- a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py @@ -3,6 +3,7 @@ import pytest import torch +import torch.nn.functional as F from megatron.core.fp8_utils import get_fp8_context from megatron.core.models.common.model_chunk_schedule_plan import TransformerLayerSchedulePlan @@ -31,6 +32,29 @@ pytestmark = pytest.mark.flaky_in_dev +def is_nccl_ep_zero_copy_available(): + """Zero-copy needs the newer TE symm-mem APIs (symm_mem_alloc/is_symm_backed), absent in a plain + NCCL-EP build.""" + from megatron.core.transformer.moe.fused_a2a import HAVE_TE_EP + + if not HAVE_TE_EP: + return False + try: + from transformer_engine.pytorch.ep import is_symm_backed, symm_mem_alloc # noqa: F401 + except ImportError: + return False + return True + + +def is_op_fuser_available(): + """The static-shape/zero-copy path runs the TE op-fuser grouped GEMM (needs TE>=2.14 ops).""" + try: + from transformer_engine.pytorch.ops import GroupedLinear, ScaledSwiGLU # noqa: F401 + except ImportError: + return False + return is_te_min_version("2.14.0") + + def run_transformer_layer_ref_with_capture(model, input_tensors, iterations): """ Runs the model in reference mode and captures outputs and gradients. @@ -445,6 +469,79 @@ def test_transformer_layer_overlap(self, dispatcher_type, flex_backend, fp8_flag comp_res = compare_captures(capture_ref, capture_a2a_overlap, True) assert comp_res[0], f"[rank {torch.distributed.get_rank()}] {comp_res[1]}" + @pytest.mark.skipif(not is_te_min_version("1.9.0.dev0"), reason="Requires TE >= 1.9.0.dev0") + @pytest.mark.skipif( + not is_nccl_ep_zero_copy_available(), reason="NCCL EP zero-copy TE API is not available" + ) + @pytest.mark.skipif( + not is_op_fuser_available(), reason="op-fuser (static-shape/zero-copy) needs TE>=2.14" + ) + def test_transformer_layer_overlap_zero_copy(self): + """ncclEP zero-copy under 1F1B a2a overlap must match the non-overlap reference. + + Zero-copy stays enabled in both runs, so this isolates the overlap schedule. It also + compares the two ways zero-copy makes the dispatch-backward gradient symm-mem-backed: + the reference gets it from the op-fuser's ``grad_input_buffer``, the overlap run from + ``StageDispatchBwdGrad`` staging into the same buffer (plus the free_input symm guard). + bf16 op-fuser (SwiGLU, tp=1) -- no fp8/Blackwell dependency. + """ + extra_kwargs = {} + apply_flex_backend_kwargs(extra_kwargs, "flex", "ncclep") + extra_kwargs.update( + moe_ncclep_zero_copy=True, + moe_ncclep_static_shape=True, + use_transformer_engine_op_fuser=True, + gated_linear_unit=True, + activation_func=F.silu, + overlap_moe_expert_parallel_comm=True, + ) + config = get_test_config(extra_kwargs=extra_kwargs) + microbatches = 4 + from megatron.core.transformer.moe.fused_a2a import nccl_ep_finalize + from megatron.core.transformer.moe.token_dispatcher import _NCCLEPManager + + try: + with deterministic_mode(): + transformer_layer_spec = get_gpt_decoder_block_spec( + config=config, use_transformer_engine=True + ) + gpt_model = GPTModel( + config=config, + transformer_layer_spec=transformer_layer_spec, + vocab_size=100, + pre_process=True, + post_process=True, + max_sequence_length=300, + ) + params = reset_model(gpt_model) + input_tensors = [build_data() for _ in range(microbatches)] + + # The reference runs the layer directly instead of through the 1F1B schedule, so it + # must declare overlap=False: that is what makes get_expert_zero_copy_buffers hand + # the op-fuser the symm grad_input_buffer for the fc1 dgrad. Under overlap=True the + # buffer is withheld (the schedule detaches the dispatch output, so autograd would + # discard it) and StageDispatchBwdGrad supplies the symm gradient instead. + config.overlap_moe_expert_parallel_comm = False + capture_ref = run_transformer_layer_ref_with_capture( + gpt_model, input_tensors, microbatches + ) + config.overlap_moe_expert_parallel_comm = True + + reset_model(gpt_model, params) + capture_a2a_overlap = run_transformer_layer_a2a_overlap_with_capture( + gpt_model, input_tensors, microbatches + ) + comp_res = compare_captures(capture_ref, capture_a2a_overlap, True) + assert comp_res[0], f"[rank {torch.distributed.get_rank()}] {comp_res[1]}" + finally: + # zero-copy sets process-global ncclEP state (ep bootstrap mode + shared symm + # classvars). Reset in a finally: on failure the leaked classvars would otherwise make + # every later ncclEP test in this process fail too, hiding the real error. + nccl_ep_finalize() + _NCCLEPManager._zc_fwd_token_buf = None + _NCCLEPManager._zc_bwd_token_buf = None + _NCCLEPManager._zc_recv_topk_weights_buf = None + @pytest.mark.skipif(not is_te_min_version("1.9.0.dev0"), reason="Requires TE >= 1.9.0.dev0") @pytest.mark.parametrize("dispatcher_type,flex_backend", get_valid_dispatcher_configs()) @pytest.mark.parametrize("fp8_flag", get_valid_fp8_flags()) diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index b63c094b790..eb871568046 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -194,7 +194,7 @@ "moe_layer_freq": 1, "moe_layer_recompute": False, "moe_ncclep_static_shape": False, - "moe_ncclep_use_symm_mem": False, + "moe_ncclep_zero_copy": False, "moe_pad_expert_input_to_capacity": False, "moe_pad_experts_for_cuda_graph_inference": False, "moe_paged_stash": False, diff --git a/tests/unit_tests/transformer/moe/test_paged_stashing.py b/tests/unit_tests/transformer/moe/test_paged_stashing.py index 0a985bc7e03..013e5111f2b 100644 --- a/tests/unit_tests/transformer/moe/test_paged_stashing.py +++ b/tests/unit_tests/transformer/moe/test_paged_stashing.py @@ -118,6 +118,7 @@ def __init__( moe_permute_fusion=kwargs.get("moe_permute_fusion", False), moe_flex_dispatcher_backend=kwargs.get("moe_flex_dispatcher_backend", None), moe_ncclep_static_shape=kwargs.get("moe_ncclep_static_shape", False), + moe_ncclep_zero_copy=kwargs.get("moe_ncclep_zero_copy", False), moe_grouped_gemm=kwargs.get("moe_grouped_gemm", False), moe_paged_stash=kwargs.get("moe_paged_stash", False), moe_expert_rank_capacity_factor=kwargs.get("moe_expert_rank_capacity_factor", None), @@ -185,6 +186,18 @@ def is_hybrid_ep_available(): return HAVE_HYBRIDEP +def is_nccl_ep_zero_copy_available(): + """Zero-copy needs the newer TE symm-mem APIs (symm_mem_alloc/is_symm_backed), absent in a plain + NCCL-EP build.""" + if not is_nccl_ep_available(): + return False + try: + from transformer_engine.pytorch.ep import is_symm_backed, symm_mem_alloc # noqa: F401 + except ImportError: + return False + return True + + def is_nccl_ep_available(): from megatron.core.transformer.moe.fused_a2a import HAVE_TE_EP @@ -452,10 +465,15 @@ def teardown_method(self, method): # NCCL EP static-shape paged stashing aborts in dev CI with a pybind11 GIL dec_ref failure. @pytest.mark.flaky_in_dev @pytest.mark.internal - def test_forward_backward_4_layers(self): - """Test paged stashing with 4 MoE layers on ncclep static shape: two passes match.""" + @pytest.mark.parametrize("zero_copy", [False, True]) + def test_forward_backward_4_layers(self, zero_copy): + """Test paged stashing with 4 MoE layers on ncclep static shape: two passes match. + + zero_copy=True additionally exercises the ncclEP symm-mem zero-copy IO under paged stash.""" if not is_nccl_ep_available(): pytest.skip("NCCL EP is not available") + if zero_copy and not is_nccl_ep_zero_copy_available(): + pytest.skip("NCCL EP zero-copy TE API is not available") config.ENABLE_EXPERIMENTAL = True @@ -482,6 +500,7 @@ def test_forward_backward_4_layers(self): moe_router_padding_for_quantization=True, gated_linear_unit=True, activation_func=F.silu, + moe_ncclep_zero_copy=zero_copy, ) seq_length = 1024 diff --git a/tests/unit_tests/transformer/moe/test_token_dispatcher.py b/tests/unit_tests/transformer/moe/test_token_dispatcher.py index a558cd2dc2d..8034ec345aa 100644 --- a/tests/unit_tests/transformer/moe/test_token_dispatcher.py +++ b/tests/unit_tests/transformer/moe/test_token_dispatcher.py @@ -4,9 +4,14 @@ import pytest import torch +import torch.nn.functional as F from megatron.core import config, parallel_state -from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_submodules +from megatron.core.fp8_utils import get_fp8_context +from megatron.core.models.gpt.gpt_layer_specs import ( + get_gpt_layer_local_submodules, + get_gpt_layer_with_transformer_engine_spec, +) from megatron.core.transformer.moe.fused_a2a import HYBRIDEP_TOKEN_ALIGNMENT, reset_hybrid_ep_buffer from megatron.core.transformer.moe.moe_layer import MoELayer, MoESubmodules from megatron.core.transformer.moe.moe_utils import get_capacity @@ -96,6 +101,13 @@ def __init__( moe_permute_fusion=kwargs.get("moe_permute_fusion", False), moe_flex_dispatcher_backend=kwargs.get("moe_flex_dispatcher_backend", None), moe_expert_rank_capacity_factor=kwargs.get("moe_expert_rank_capacity_factor", None), + moe_ncclep_static_shape=kwargs.get("moe_ncclep_static_shape", False), + moe_ncclep_zero_copy=kwargs.get("moe_ncclep_zero_copy", False), + use_transformer_engine_op_fuser=kwargs.get("use_transformer_engine_op_fuser", False), + gated_linear_unit=kwargs.get("gated_linear_unit", False), + activation_func=kwargs.get("activation_func", F.gelu), + fp8=kwargs.get("fp8", None), + fp8_recipe=kwargs.get("fp8_recipe", "delayed"), calculate_per_token_loss=kwargs.get("calculate_per_token_loss", False), ) @@ -103,14 +115,20 @@ def __init__( self.moe_layer = self.new_moe_layer() def new_moe_layer(self, **kargs): - submodules = get_submodules( - get_gpt_layer_local_submodules( + new_config = dataclasses.replace(self.config, **kargs) + if new_config.use_transformer_engine_op_fuser: + # op-fuser needs the TE grouped-MLP experts (they accept output_buffer/grad_input_buffer + # for the ncclEP zero-copy path); the local spec yields SequentialMLP, which does not. + mlp_spec = get_gpt_layer_with_transformer_engine_spec( + num_experts=new_config.num_moe_experts, moe_grouped_gemm=new_config.moe_grouped_gemm + ).submodules.mlp + else: + mlp_spec = get_gpt_layer_local_submodules( num_experts=self.config.num_moe_experts, moe_grouped_gemm=self.config.moe_grouped_gemm, ).mlp - ) + submodules = get_submodules(mlp_spec) assert isinstance(submodules, MoESubmodules) - new_config = dataclasses.replace(self.config, **kargs) moe_layer = MoELayer(new_config, submodules).cuda().to(dtype=self.test_dtype) moe_layer.set_layer_number(0) return moe_layer @@ -162,6 +180,52 @@ def dispatcher_dropless_test(self): hidden_states.grad, ans ), "Restored hidden states do not match original hidden states" + @pytest.mark.internal + def moe_layer_zero_copy_parity_test(self): + """Full MoE-layer fwd+bwd with ncclEP zero-copy OFF then ON (identical weights), asserting + parity. Runs the real op-fuser experts so fc2-out/fc1-dgrad are written straight into the + symm combine/dispatch buffers (verified via is_symm_backed) -- the pure permute/unpermute + harness cannot exercise this path.""" + from transformer_engine.pytorch.ep import is_symm_backed + + from megatron.core.transformer.moe.fused_a2a import nccl_ep_finalize + from megatron.core.transformer.moe.token_dispatcher import _NCCLEPManager + + torch.manual_seed(42) + x = torch.randn((32, 8, self.config.hidden_size), dtype=self.test_dtype).cuda() + + def run(layer): + inp = x.clone().detach().requires_grad_(True) + out, _ = layer(inp) # full fwd: dispatch -> op-fuser experts -> combine + out.sum().backward() # bwd: dispatch-bwd reads the symm grad buffer + return out.detach(), inp.grad.detach() + + def reset_ep(): + # zero_copy mode is fixed at ep_bootstrap (process-global); finalize + drop the shared + # symm classvars so the next layer re-bootstraps in the other mode. + nccl_ep_finalize() + _NCCLEPManager._zc_fwd_token_buf = None + _NCCLEPManager._zc_bwd_token_buf = None + _NCCLEPManager._zc_recv_topk_weights_buf = None + + ref_layer = self.new_moe_layer(moe_ncclep_zero_copy=False) + out_ref, grad_ref = run(ref_layer) + + reset_ep() + zc_layer = self.new_moe_layer(moe_ncclep_zero_copy=True) + zc_layer.load_state_dict(ref_layer.state_dict()) # identical weights + out_zc, grad_zc = run(zc_layer) + + # the combine forward buffer must be an allocated, registered symm window (zero-copy engaged) + fwd_buf = _NCCLEPManager._zc_fwd_token_buf + assert fwd_buf is not None, "zero-copy forward symm buffer was not allocated" + assert is_symm_backed(fwd_buf), "zero-copy forward buffer is not symm-mem-backed" + reset_ep() + + assert not torch.isnan(out_zc).any() and not torch.isnan(grad_zc).any() + torch.testing.assert_close(out_zc, out_ref, rtol=1e-2, atol=1e-2) + torch.testing.assert_close(grad_zc, grad_ref, rtol=1e-2, atol=1e-2) + @pytest.mark.internal def dispatcher_capacity_test(self): moe_layer = self.moe_layer @@ -428,6 +492,27 @@ def is_nccl_ep_available(): return HAVE_TE_EP +def is_nccl_ep_zero_copy_available(): + """Zero-copy needs the newer TE symm-mem APIs (symm_mem_alloc/is_symm_backed), which a plain + NCCL-EP build lacks -- gate zero-copy tests on these separately from is_nccl_ep_available().""" + if not is_nccl_ep_available(): + return False + try: + from transformer_engine.pytorch.ep import is_symm_backed, symm_mem_alloc # noqa: F401 + except ImportError: + return False + return True + + +def is_op_fuser_available(): + """The static-shape/zero-copy path runs the TE op-fuser grouped GEMM (needs TE>=2.14 ops).""" + try: + from transformer_engine.pytorch.ops import GroupedLinear, ScaledSwiGLU # noqa: F401 + except ImportError: + return False + return is_te_min_version("2.14.0") + + def test_hybridep_pad_uneven_dispatch_inputs_metadata(monkeypatch): manager = _HybridEPManager.__new__(_HybridEPManager) manager.group = object() @@ -543,6 +628,41 @@ def test_forward_backward( # reset experimental flag to False config.ENABLE_EXPERIMENTAL = False + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.skipif( + not is_nccl_ep_zero_copy_available(), reason="NCCL EP zero-copy TE API is not available" + ) + @pytest.mark.skipif( + not is_op_fuser_available(), reason="op-fuser (static-shape/zero-copy) needs TE>=2.14" + ) + @pytest.mark.internal + @pytest.mark.timeout(120) + @pytest.mark.parametrize("tp_size,ep_size", [(1, 8)]) + def test_forward_backward_zero_copy(self, tp_size, ep_size): + # zero-copy requires static_shape, which requires BOTH op-fuser and grouped_gemm; bf16 so no + # fp8/Blackwell dependency. The op-fuser needs tp=1 and a SwiGLU activation. Parity: the + # zero-copy IO path must match the staged (no-zc) path. + container = MoEModelTestContainer( + tp_size=tp_size, + ep_size=ep_size, + pp_size=1, + num_moe_experts=8, + moe_router_topk=2, + moe_router_load_balancing_type="aux_loss", + moe_token_dispatcher_type="flex", + moe_flex_dispatcher_backend="ncclep", + moe_grouped_gemm=True, + use_transformer_engine_op_fuser=True, + moe_ncclep_static_shape=True, + gated_linear_unit=True, + activation_func=F.silu, + # ncclep sizes a per-rank recv buffer from this and overflow HARD-TRAPS; size generously. + moe_expert_rank_capacity_factor=8.0, + hidden_size=1024, + test_dtype=torch.bfloat16, + ) + container.moe_layer_zero_copy_parity_test() + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") @pytest.mark.internal @pytest.mark.timeout(120) From 7557c029edaac1b5d8a0ecdce74f7e70075a31f8 Mon Sep 17 00:00:00 2001 From: Siddhartha Raman Sundara Raman Date: Tue, 28 Jul 2026 09:25:47 -0500 Subject: [PATCH 127/290] Optimize unit metadata for fused shared experts (#6053) Signed-off-by: Siddhartha Raman Sundara Raman --- .../core/transformer/moe/shared_experts.py | 28 +++++++++++++++---- .../transformer/moe/test_shared_experts.py | 9 +++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/megatron/core/transformer/moe/shared_experts.py b/megatron/core/transformer/moe/shared_experts.py index 0f5108995cc..027d0a780ff 100644 --- a/megatron/core/transformer/moe/shared_experts.py +++ b/megatron/core/transformer/moe/shared_experts.py @@ -383,6 +383,8 @@ def __init__( ) self._fused_grouped_swiglu_ops = None self._fused_grouped_swiglu_recipe = None + self._fused_grouped_swiglu_unit_scale = None + self._fused_grouped_swiglu_tokens_per_expert = {} self._validate_fused_grouped_swiglu() def _validate_fused_grouped_swiglu(self) -> None: @@ -468,7 +470,12 @@ def _make_fused_grouped_swiglu_ops(self) -> torch.nn.Module: op._glu_interleave_size = glu_interleave_size ops.append(op) - ops.append(te.pytorch.ops.ScaledSwiGLU(glu_interleave_size=glu_interleave_size)) + activation_op = te.pytorch.ops.ScaledSwiGLU(glu_interleave_size=glu_interleave_size) + # Shared experts are not router-gated. Mark this fused-op instance so + # TE can omit the optional forward cuDNN probability tensor without + # changing the semantics of routed single-group MLPs. + activation_op._grouped_mlp_unit_activation_scale = True + ops.append(activation_op) fc2_weight = self.linear_fc2.weight op = te.pytorch.ops.GroupedLinear( @@ -506,10 +513,21 @@ def _fused_grouped_swiglu_no_comm(self, hidden_states: torch.Tensor) -> torch.Te hidden_size = hidden_states.size(-1) hidden_states_2d = hidden_states.view(-1, hidden_size) total_tokens = hidden_states_2d.size(0) - tokens_per_expert = torch.full( - (1,), total_tokens, dtype=torch.long, device=hidden_states.device - ) - scales = torch.ones(total_tokens, device=hidden_states.device, dtype=hidden_states.dtype) + tokens_key = (hidden_states.device, total_tokens) + tokens_per_expert = self._fused_grouped_swiglu_tokens_per_expert.get(tokens_key) + if tokens_per_expert is None: + tokens_per_expert = torch.tensor( + [total_tokens], dtype=torch.long, device=hidden_states.device + ) + self._fused_grouped_swiglu_tokens_per_expert[tokens_key] = tokens_per_expert + scales = self._fused_grouped_swiglu_unit_scale + if ( + scales is None + or scales.device != hidden_states.device + or scales.dtype != hidden_states.dtype + ): + scales = torch.ones(1, device=hidden_states.device, dtype=hidden_states.dtype) + self._fused_grouped_swiglu_unit_scale = scales recipe = self._get_fused_grouped_swiglu_recipe() if self._fused_grouped_swiglu_ops is None: diff --git a/tests/unit_tests/transformer/moe/test_shared_experts.py b/tests/unit_tests/transformer/moe/test_shared_experts.py index 8c84aae7097..798313f188c 100644 --- a/tests/unit_tests/transformer/moe/test_shared_experts.py +++ b/tests/unit_tests/transformer/moe/test_shared_experts.py @@ -139,6 +139,8 @@ def _fake_shared_expert(**config_kwargs): shared_expert.tp_group = object() shared_expert._fused_grouped_swiglu_ops = None shared_expert._fused_grouped_swiglu_recipe = None + shared_expert._fused_grouped_swiglu_unit_scale = None + shared_expert._fused_grouped_swiglu_tokens_per_expert = {} return shared_expert @@ -227,6 +229,7 @@ def test_make_fused_grouped_swiglu_ops_builds_grouped_pipeline(monkeypatch): assert isinstance(activation_op, _FakeTEScaledSwiGLU) assert activation_op.glu_interleave_size == 32 + assert activation_op._grouped_mlp_unit_activation_scale is True assert isinstance(fc2_op, _FakeTEGroupedLinear) assert fc2_op.kwargs["num_groups"] == 1 @@ -279,12 +282,16 @@ def test_fused_grouped_swiglu_no_comm_flattens_and_caches_fused_ops(monkeypatch) (ops,) = shared_expert._fused_grouped_swiglu_ops hidden_states_2d, tokens_per_expert, scales, tokens_per_expert_again = ops.args + shared_expert._fused_grouped_swiglu_no_comm(torch.randn_like(hidden_states)) + _, cached_tokens_per_expert, cached_scales, _ = ops.args assert output.shape == hidden_states.shape assert shared_expert._fused_grouped_swiglu_recipe.__class__ is _FakeMXFP8Recipe assert hidden_states_2d.shape == (6, 4) assert tokens_per_expert.tolist() == [6] assert tokens_per_expert_again is tokens_per_expert - torch.testing.assert_close(scales, torch.ones(6)) + torch.testing.assert_close(scales, torch.ones(1)) + assert cached_tokens_per_expert is tokens_per_expert + assert cached_scales is scales def test_backward_dw_dispatches_fused_children_and_original_reduce_hooks(monkeypatch): From 053fa0713b839548caf194022131a6cda6403784 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Tue, 28 Jul 2026 16:26:31 +0200 Subject: [PATCH 128/290] build: AUT-1117 serialize uv dependency installation (#6090) Signed-off-by: kunlunl Signed-off-by: svcnemo-autobot Co-authored-by: kunlunl --- docker/Dockerfile.ci.dev | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile.ci.dev b/docker/Dockerfile.ci.dev index e8d7efa6cc8..bd0bd78325b 100644 --- a/docker/Dockerfile.ci.dev +++ b/docker/Dockerfile.ci.dev @@ -60,7 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \ if [ "$(uname -m)" = "aarch64" ]; then export MAX_JOBS=8 fi - uv sync -v \ + # Prevent overlapping CUTLASS base and CUDA wheel payloads from interleaving. + UV_CONCURRENT_INSTALLS=1 uv sync -v \ --extra ${IMAGE_TYPE} --extra mlm --extra ssm --extra te ${FLASH_MLA_GROUP} --link-mode copy --locked \ --no-install-package torch \ --no-install-package torchvision \ From 541d5eef0971e73195531cb1097a8296ec67734e Mon Sep 17 00:00:00 2001 From: Lawrence McAfee <85179052+lmcafee-nvidia@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:40:44 -0400 Subject: [PATCH 129/290] Extend dynamic inference asynchronous scheduling support (#5939) Signed-off-by: Lawrence McAfee --- .../advanced/gpt_dynamic_inference.py | 7 +- megatron/core/inference/config.py | 7 +- .../inference/contexts/dynamic_context.py | 403 +++-- .../core/inference/engines/dynamic_engine.py | 273 ++- megatron/core/inference/sampling/base.py | 24 +- .../inference/sampling/flashinfer_sampling.py | 43 +- .../core/inference/sampling/torch_sampling.py | 35 +- .../text_generation_controller.py | 1506 +++++++++++++---- megatron/training/arguments.py | 8 +- megatron/training/config/inference_config.py | 7 +- .../golden_values_dev_dgx_h100.json | 104 ++ .../model_config.yaml | 74 + .../prompts.jsonl | 3 + .../contexts/test_dynamic_context.py | 311 +++- .../contexts/test_dynamic_prefix_caching.py | 43 +- .../engines/test_cg_admission_gating.py | 1 + .../inference/engines/test_dynamic_engine.py | 416 ++++- .../test_dynamic_engine_async_sched.py | 330 +++- .../engines/test_hybrid_prefix_caching_e2e.py | 36 +- .../inference/test_inference_config.py | 28 +- .../test_text_generation_controller.py | 1259 +++++++++++--- 21 files changed, 4025 insertions(+), 893 deletions(-) create mode 100644 tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/golden_values_dev_dgx_h100.json create mode 100644 tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml create mode 100644 tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/prompts.jsonl diff --git a/examples/inference/advanced/gpt_dynamic_inference.py b/examples/inference/advanced/gpt_dynamic_inference.py index 83d4f4b2638..b316f37eaa8 100644 --- a/examples/inference/advanced/gpt_dynamic_inference.py +++ b/examples/inference/advanced/gpt_dynamic_inference.py @@ -128,7 +128,10 @@ def _process_step_result(result): """Process a single engine step result, updating bookkeeping state.""" nonlocal total_output_tokens, num_requests_finished - is_decode_only = engine.is_decode_only + decode_only = engine.decode_only + is_decode_only = ( + decode_only.launched if decode_only.launched is not None else decode_only.consumed + ) # Record cuda_graph_request_count. cuda_graph_request_count = result["cuda_graph_request_count"] @@ -228,7 +231,7 @@ def _process_step_result(result): add_times.append(get_curr_time(do_broadcast=False) - add_start) # Step inference engine (i.e., generate a token for each active request). - # Before step, we haven't done the scheduling, so we cannot know the is_decode_only + # The engine reports the consumed and launched decode-only states after scheduling. try: result = engine.step_modern() except EngineSuspendedError as e: diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index c9bdfb50575..46d2dbca1b0 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -140,11 +140,8 @@ class AsyncScheduleMode(str, Enum): LEGACY = "legacy" """Resolve requests before preparing the next forward pass.""" - SERIAL = "serial" - """Prepare and forward speculatively before resolving the sampled requests.""" - - OVERLAP = "overlap" - """Overlap async scheduling prepare/sample and forward/resolve phases.""" + ASYNC = "async" + """Overlap asynchronous scheduling phases by reordering them to prepare-before-resolve.""" @dataclass diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 3cc8f5c72df..102a5e5e55b 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -320,6 +320,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC f"num_speculative_tokens ({self.num_speculative_tokens}) must be < " f"block_size_tokens ({inference_config.block_size_tokens})" ) + self._async_sched_token_offsets = None # Cache the PP group we should use for PP collectives inside the context. # If the model provides a pg_collection with a pp group, prefer it. @@ -1006,6 +1007,9 @@ def initialize_all_tensors(self) -> None: device='cpu', pin_memory=True, ) + self._async_sched_token_offsets = torch.arange( + self.num_speculative_tokens + 1, device='cpu' + ) # Track request metadata. Backed by pinned CPU memory: bookkeeping is # CPU-resident; GPU consumers read from the active-slice mirror in @@ -2575,8 +2579,10 @@ def transfer_bookkeeping_to_gpu( return done_event - def copy_async_sched_sample_to_forward(self, sampled_tokens_cuda: Tensor) -> None: - """Populate GPU input token IDs from sampled CUDA tokens for async scheduled decode. + def copy_async_sched_sample_to_forward( + self, sampled_tokens_cuda: Tensor, sampled_mtp_tokens_cuda: Optional[Tensor] = None + ) -> None: + """Populate GPU input token IDs from sampled CUDA tokens for async scheduling. Async scheduling keeps sampled tokens GPU-resident for the next decode forward. CPU bookkeeping is prepared independently and published later; @@ -2586,16 +2592,33 @@ def copy_async_sched_sample_to_forward(self, sampled_tokens_cuda: Tensor) -> Non Args: sampled_tokens_cuda (Tensor): 1D CUDA tensor containing one sampled token per active decode request. + sampled_mtp_tokens_cuda (Optional[Tensor]): MTP draft tokens with shape + ``[num_speculative_tokens, active_request_count]``. """ active_request_count = self.total_request_count - self.paused_request_count - self.gpu_view.token_to_input_ids[:active_request_count].copy_( - sampled_tokens_cuda, non_blocking=True + if self.num_speculative_tokens > 0: + expected_shape = (self.num_speculative_tokens, active_request_count) + if sampled_mtp_tokens_cuda is None or tuple(sampled_mtp_tokens_cuda.shape) != ( + expected_shape + ): + actual_shape = ( + None if sampled_mtp_tokens_cuda is None else sampled_mtp_tokens_cuda.shape + ) + raise RuntimeError( + f"Expected MTP draft token shape {expected_shape}, got {actual_shape}." + ) + + tokens_per_request = self.num_speculative_tokens + 1 + token_count = active_request_count * tokens_per_request + grouped_tokens = self.gpu_view.token_to_input_ids[:token_count].view( + active_request_count, tokens_per_request ) - if active_request_count < self.padded_active_token_count: - self.gpu_view.token_to_input_ids[ - active_request_count : self.padded_active_token_count - ].zero_() + grouped_tokens[:, 0].copy_(sampled_tokens_cuda, non_blocking=True) + if sampled_mtp_tokens_cuda is not None: + grouped_tokens[:, 1:].copy_(sampled_mtp_tokens_cuda.transpose(0, 1), non_blocking=True) + if token_count < self.padded_active_token_count: + self.gpu_view.token_to_input_ids[token_count : self.padded_active_token_count].zero_() def reset_tensors(self) -> None: """Fill all bookkeeping tensors with sentinel values.""" @@ -2623,7 +2646,9 @@ def reset_tensors(self) -> None: self.token_to_block_idx.fill_(-1) self.token_to_local_position_within_kv_block.fill_(0) - def reset_metadata(self, preserve_prefix_cache: bool = False) -> None: + def reset_metadata( + self, preserve_prefix_cache: bool = False, *, preserve_counters: bool = False + ) -> None: """Reset all bookkeeping state: counters, block allocator, attention/mamba state. This must be called after ``initialize_all_tensors()`` and after any @@ -2636,17 +2661,19 @@ def reset_metadata(self, preserve_prefix_cache: bool = False) -> None: step state -- wiping the allocator there would destroy cross-request prefix reuse for any subsequent request (the engine idles between requests at low concurrency, especially with EP > 1). + preserve_counters: When True, keep engine-step, prefix-cache clock, + prefill-token, and async-scheduling counters intact. """ - # No cache to preserve when prefix caching is off: fall back to a full - # reset so the disabled path is byte-identical to the original behavior. + # There is no prefix-cache state to preserve when caching is disabled. preserve_prefix_cache = preserve_prefix_cache and self.enable_prefix_caching # Reset request/token counts. self.total_request_count = 0 self.active_token_count = 0 - self.lifetime_prefill_token_count = 0 - self.async_sched_step_count = 0 - self.async_sched_compaction_step_count = 0 + if not preserve_counters: + self.lifetime_prefill_token_count = 0 + self.async_sched_step_count = 0 + self.async_sched_compaction_step_count = 0 self.paused_request_count = 0 self.batch_dimensions = InferenceBatchDimensions( token_count=0, prefill_req_count=0, decode_req_count=0 @@ -2675,7 +2702,9 @@ def reset_metadata(self, preserve_prefix_cache: bool = False) -> None: token_count=0, prefill_req_count=0, decode_req_count=0 ) - def reset(self, preserve_prefix_cache: bool = False) -> None: + def reset( + self, preserve_prefix_cache: bool = False, *, preserve_counters: bool = False + ) -> None: """Reset entire context. This method does: @@ -2689,28 +2718,26 @@ def reset(self, preserve_prefix_cache: bool = False) -> None: Args: preserve_prefix_cache: When True, keep the KV and Mamba prefix-cache - state (hash indices, cached blocks/slots, LRU clock) intact. Used by + state (hash indices and cached blocks/slots) intact. Used by the idle ``dummy_forward`` path so an idle step between requests does not destroy cross-request prefix reuse. + preserve_counters: When True, keep engine-step, prefix-cache clock, + prefill-token, and async-scheduling counters intact. """ - # No cache to preserve when prefix caching is off: fall back to a full - # reset so the disabled path is byte-identical to the original behavior. + # There is no prefix-cache state to preserve when caching is disabled. preserve_prefix_cache = preserve_prefix_cache and self.enable_prefix_caching self.reset_tensors() - self.reset_metadata(preserve_prefix_cache=preserve_prefix_cache) + self.reset_metadata( + preserve_prefix_cache=preserve_prefix_cache, preserve_counters=preserve_counters + ) - # Reset lifetime counters (not reset in reset_metadata, which is also - # called during suspend/resume where these must persist). - if not preserve_prefix_cache: + if not preserve_counters: self.step_count = 0 self.prefix_cache_lru_clock = 0 - # Reset Mamba cache state - if self.mamba_slot_allocator is not None: - self.mamba_slot_allocator.reset() - # When preserving prefix cache (idle dummy_forward), keep step_count - # monotonic so the engine's periodic logging cadence - # (step_count % logging_step_interval) still fires for short requests. + # Reset Mamba cache state. + if not preserve_prefix_cache and self.mamba_slot_allocator is not None: + self.mamba_slot_allocator.reset() def current_input_and_position_ids( self, *, num_warmup_tokens: Optional[int] = None @@ -3553,17 +3580,42 @@ def evict_overflow_paused_requests( return evict_request_ids + def _get_async_sched_rows_requiring_new_block(self) -> Tensor: + """Return active request rows that need a block during the next prepare. + + Returns: + Tensor: Boolean mask over active request rows. + """ + active_slice = slice(self.paused_request_count, self.total_request_count) + tokens_per_request = self.num_speculative_tokens + 1 + return ( + self.request_last_kv_block_offset[active_slice] + tokens_per_request + >= self.block_size_tokens + ) + + def can_prepare_requests(self) -> bool: + """Return whether requests can be prepared without lifecycle changes. + + Returns: + bool: Whether all requests are active decode requests and the active + KV-block pool can satisfy the exact next-step allocation demand. + """ + if self.num_prefill_requests != 0 or self.paused_request_count != 0: + return False + + rows_requiring_new_block = self._get_async_sched_rows_requiring_new_block() + num_new_blocks = int(rows_requiring_new_block.sum().item()) + return num_new_blocks <= self.kv_block_allocator.get_active_avail() + def prepare_requests(self) -> None: """Speculatively prepare active decode requests for the next forward pass. Async scheduling only supports decode-only steps with no pause, - evict, or resume lifecycle changes. If preparing the next token would - require one of those lifecycle changes, this method raises and the caller - should treat async scheduling as unsupported for that workload. + evict, or resume lifecycle changes. If preparation cannot allocate the + required KV blocks without a lifecycle change, this method raises. The + prepared decode layout establishes the active token count. """ active_request_count = self.total_request_count - self.paused_request_count - if self.num_speculative_tokens != 0: - raise RuntimeError("Async scheduling does not support speculative tokens.") if self.num_prefill_requests != 0: raise RuntimeError("Async scheduling only supports decode-only steps.") if self.paused_request_count != 0: @@ -3574,10 +3626,13 @@ def prepare_requests(self) -> None: return active_slice = slice(0, active_request_count) - rows_requiring_new_block = ( - self.request_last_kv_block_offset[active_slice] >= self.block_size_tokens - 1 - ) - num_new_blocks = rows_requiring_new_block.sum().item() + tokens_per_request = self.num_speculative_tokens + 1 + last_block_offsets = self.request_last_kv_block_offset[active_slice] + token_offsets = self._async_sched_token_offsets + rows_requiring_new_block = self._get_async_sched_rows_requiring_new_block() + num_new_blocks = int(rows_requiring_new_block.sum().item()) + + block_ids = None if num_new_blocks > 0: active_block_count_avail = self.kv_block_allocator.get_active_avail() if num_new_blocks > active_block_count_avail: @@ -3587,74 +3642,123 @@ def prepare_requests(self) -> None: if block_ids is None: raise RuntimeError("Async scheduling cannot evict requests to allocate new blocks.") + self.active_token_count = active_request_count * tokens_per_request + active_token_slice = slice(0, self.active_token_count) + grouped_token_block_ids = self.token_to_block_idx[active_token_slice].view( + active_request_count, tokens_per_request + ) + grouped_token_block_ids.copy_(self.request_last_kv_block_id[active_slice, None]) + + if block_ids is not None: row_idx = torch.nonzero(rows_requiring_new_block, as_tuple=True)[0] col_idx = self.request_kv_block_counts[row_idx] self.request_to_kv_block_ids[row_idx, col_idx] = block_ids self.request_kv_block_counts[row_idx] += 1 self.request_last_kv_block_id[row_idx] = block_ids + grouped_token_block_ids[row_idx] = torch.where( + last_block_offsets[row_idx, None] + 1 + token_offsets[None, :] + >= self.block_size_tokens, + block_ids[:, None], + grouped_token_block_ids[row_idx], + ) self.request_kv_length_offsets[active_slice].add_(self.request_query_lengths[active_slice]) - self.request_query_lengths[active_slice].fill_(1) - + self.request_query_lengths[active_slice].fill_(tokens_per_request) self.request_last_kv_block_offset[active_slice] = ( - self.request_last_kv_block_offset[active_slice] + 1 + last_block_offsets + tokens_per_request ) % self.block_size_tokens - self.active_token_count = active_request_count - self.token_to_pos_ids[:active_request_count] = self.request_kv_length_offsets[active_slice] - self.token_to_request_idx[:active_request_count] = torch.arange( - active_request_count, device='cpu' + token_positions = ( + self.request_kv_length_offsets[active_slice, None] + token_offsets[None, :] + ) + token_request_idxs = torch.arange(active_request_count, device='cpu').repeat_interleave( + tokens_per_request ) - self.token_to_position_in_request[:active_request_count] = self.token_to_pos_ids[ - :active_request_count + self.token_to_pos_ids[active_token_slice] = token_positions.flatten() + self.token_to_request_idx[active_token_slice] = token_request_idxs + self.token_to_position_in_request[active_token_slice] = self.token_to_pos_ids[ + active_token_slice ] - self.token_to_local_position_within_kv_block[:active_request_count] = ( - self.token_to_pos_ids[:active_request_count] % self.block_size_tokens + self.token_to_local_position_within_kv_block[active_token_slice] = ( + self.token_to_pos_ids[active_token_slice] % self.block_size_tokens ) - self.token_to_block_idx[:active_request_count] = self.request_last_kv_block_id[active_slice] - def commit_sampled_tokens(self, sampled_tokens_cpu: Tensor) -> None: + def commit_sampled_tokens( + self, sampled_tokens_cpu: Tensor, sampled_mtp_tokens_cpu: Optional[Tensor] = None + ) -> None: """Commit sampled CPU token IDs to the prepared request state. - This updates the CPU source of truth used by resolution. Async + This establishes the post-resolution active token count and populates + the CPU input-ID staging rows in survivor order. Overlapped async scheduling has already copied the same samples into the live GPU input view for the speculative forward. Args: sampled_tokens_cpu (Tensor): Sampled CPU token for each active request. + sampled_mtp_tokens_cpu (Optional[Tensor]): MTP draft tokens with shape + ``[num_speculative_tokens, active_request_count]``. """ assert sampled_tokens_cpu.device == torch.device( 'cpu' ), "Sampled tokens must be on the CPU before they are committed." + if sampled_mtp_tokens_cpu is not None: + assert sampled_mtp_tokens_cpu.device == torch.device( + 'cpu' + ), "MTP draft tokens must be on the CPU before they are committed." + active_request_count = self.total_request_count - self.paused_request_count if sampled_tokens_cpu.numel() != active_request_count: raise RuntimeError( f"Expected {active_request_count} new tokens, got {sampled_tokens_cpu.numel()}." ) - self.token_to_input_ids[:active_request_count] = sampled_tokens_cpu + expected_mtp_shape = (self.num_speculative_tokens, active_request_count) + if self.num_speculative_tokens == 0: + if sampled_mtp_tokens_cpu is not None and sampled_mtp_tokens_cpu.numel() != 0: + raise RuntimeError( + "Received MTP draft tokens when speculative decoding is disabled." + ) + else: + if sampled_mtp_tokens_cpu is None or tuple(sampled_mtp_tokens_cpu.shape) != ( + expected_mtp_shape + ): + actual_shape = ( + None if sampled_mtp_tokens_cpu is None else sampled_mtp_tokens_cpu.shape + ) + raise RuntimeError( + f"Expected MTP draft token shape {expected_mtp_shape}, got {actual_shape}." + ) + + tokens_per_request = self.num_speculative_tokens + 1 + active_token_count = active_request_count * tokens_per_request + self.active_token_count = active_token_count + grouped_tokens = self.token_to_input_ids[:active_token_count].view( + active_request_count, tokens_per_request + ) + grouped_tokens[:, 0] = sampled_tokens_cpu + if sampled_mtp_tokens_cpu is not None: + grouped_tokens[:, 1:] = sampled_mtp_tokens_cpu.transpose(0, 1) - def resolve_requests(self, active_requests_mask: Tensor) -> Tensor: + def resolve_requests(self, active_requests_mask: Tensor) -> Tuple[Tensor, Tensor]: """Resolve finished requests after an async scheduling forward pass. - Async scheduling supports only request completion. The active request rows - and current decode-token rows are compacted in survivor order so any - following legacy or async scheduling step sees a consistent context. + Prefill requests transition to decode during resolution. Request rows use + the same hole-filling order as ``update_requests`` so seeded sampling stays + consistent with legacy scheduling. Token tensors and the active token + count are left untouched; prepare rebuilds derived token metadata and the + controller commits sampled input IDs after resolution. Args: active_requests_mask (Tensor): 1D mask marking requests that remain active. Returns: - Tensor: Request IDs for requests that finished during resolution. + Tuple[Tensor, Tensor]: Request IDs that finished and source row indices + for surviving requests in their resolved destination order. """ if active_requests_mask.is_cuda: active_requests_mask = active_requests_mask.cpu() - if self.num_speculative_tokens != 0: - raise RuntimeError("Async scheduling does not support speculative tokens.") - if self.num_prefill_requests != 0: - raise RuntimeError("Async scheduling only supports decode-only steps.") if self.paused_request_count != 0: raise RuntimeError("Async scheduling does not support paused requests.") @@ -3665,29 +3769,38 @@ def resolve_requests(self, active_requests_mask: Tensor) -> Tensor: f"got {active_requests_mask.numel()}." ) - survivor_idxs = torch.nonzero(active_requests_mask == 1, as_tuple=True)[0] + self.num_prefill_requests = 0 + self.request_in_prefill_status_tensor[self.request_in_prefill_status_tensor == 1] = 0 + finished_idxs = torch.nonzero(active_requests_mask == 0, as_tuple=True)[0] finished_request_ids = self.request_ids[finished_idxs].clone() + active_request_count = int(active_requests_mask.sum().item()) + survivor_idxs = torch.arange(active_request_count, device='cpu') + finished_idxs_on_left = torch.nonzero( + active_requests_mask[:active_request_count] == 0, as_tuple=True + )[0] + active_idxs_on_right = ( + torch.nonzero(active_requests_mask[active_request_count:] == 1, as_tuple=True)[0] + + active_request_count + ) + assert finished_idxs_on_left.numel() == active_idxs_on_right.numel() + survivor_idxs[finished_idxs_on_left] = active_idxs_on_right + self.reset_attention_state() if finished_idxs.numel() > 0: self.release_memory_blocks_from_request_indexes(finished_idxs) - active_request_count = survivor_idxs.numel() if active_request_count == 0: self.request_to_kv_block_ids.fill_(-1) self.total_request_count = 0 - self.active_token_count = 0 self.reset_mamba_state() - return finished_request_ids + return finished_request_ids, survivor_idxs dst_idxs = torch.arange(active_request_count, device='cpu') if not torch.equal(survivor_idxs, dst_idxs): self.request_kv_length_offsets[dst_idxs] = self.request_kv_length_offsets[survivor_idxs] - self.request_in_prefill_status_tensor[dst_idxs] = self.request_in_prefill_status_tensor[ - survivor_idxs - ] self.request_query_lengths[dst_idxs] = self.request_query_lengths[survivor_idxs] self.request_output_lengths[dst_idxs] = self.request_output_lengths[survivor_idxs] self.request_ids[dst_idxs] = self.request_ids[survivor_idxs] @@ -3697,25 +3810,18 @@ def resolve_requests(self, active_requests_mask: Tensor) -> Tensor: self.request_last_kv_block_offset[dst_idxs] = self.request_last_kv_block_offset[ survivor_idxs ] + if self.is_hybrid_model: + self.mamba_metadata.request_to_mamba_state_idx[dst_idxs] = ( + self.mamba_metadata.request_to_mamba_state_idx[survivor_idxs] + ) for metadata_tensor in self.request_metadata.values(): metadata_tensor[dst_idxs] = metadata_tensor[survivor_idxs] - - self.token_to_input_ids[dst_idxs] = self.token_to_input_ids[survivor_idxs] - self.token_to_pos_ids[dst_idxs] = self.token_to_pos_ids[survivor_idxs] - self.token_to_block_idx[dst_idxs] = self.token_to_block_idx[survivor_idxs] - self.token_to_local_position_within_kv_block[dst_idxs] = ( - self.token_to_local_position_within_kv_block[survivor_idxs] - ) - self.token_to_position_in_request[dst_idxs] = self.token_to_position_in_request[ - survivor_idxs - ] - - self.token_to_request_idx[:active_request_count] = dst_idxs stale_slice = slice(active_request_count, old_active_request_count) self.request_to_kv_block_ids[stale_slice] = -1 + if self.is_hybrid_model: + self.mamba_metadata.request_to_mamba_state_idx[stale_slice] = -1 self.total_request_count = active_request_count - self.active_token_count = active_request_count - return finished_request_ids + return finished_request_ids, survivor_idxs def update_requests( self, @@ -4180,25 +4286,83 @@ def _processed_log_probs( n_active: int, active_query_lengths: Optional[Tensor], sampling: Optional[Sampling], + row_to_request: Optional[Tensor] = None, ) -> Tensor: - """Sample the logprobs if desired.""" + """Calculate raw or sampling-processed per-row log probabilities. + + Args: + logits (Tensor): Raw logits with shape `[num_rows, vocab_size]`. + n_active (int): Number of active requests represented by the rows. + active_query_lengths (Optional[Tensor]): CPU token counts used to + map prefill rows to active requests, or `None` for decode. + sampling (Optional[Sampling]): Backend providing processed logprobs. + row_to_request (Optional[Tensor]): Explicit CPU mapping from each + logit row to an active request. + + Returns: + Tensor: Per-row log probabilities over the vocabulary. + """ if self.config.logprobs_mode == "raw_logprobs": return F.log_softmax(logits, dim=-1) assert sampling is not None, "processed_logprobs requires a sampling backend" # Map each logits row to its active request. - request_idx = torch.arange(n_active, device=logits.device) - row_to_request = ( - request_idx - if active_query_lengths is None - else request_idx.repeat_interleave(active_query_lengths) + if row_to_request is None and active_query_lengths is not None: + row_to_request = torch.arange(n_active).repeat_interleave(active_query_lengths) + return sampling.log_probs_kernel(logits, self, token_to_request_index=row_to_request) + + def calculate_log_probs_tensors( + self, + logits: Tensor, + new_tokens: Tensor, + only_last_token_logits: Optional[bool] = False, + sampling: Optional[Sampling] = None, + row_to_request: Optional[Tensor] = None, + ) -> Tuple[Tensor, Tensor]: + """Calculate selected-token and full-distribution log probabilities. + + Args: + logits (Tensor): Raw model output logits with shape + `[1, sequence_length, vocab_size]`. + new_tokens (Tensor): Newly sampled tokens for active requests. + only_last_token_logits (Optional[bool]): Whether logits contain only + each request's final token row. + sampling (Optional[Sampling]): Sampling backend used for processed + log probabilities. + row_to_request (Optional[Tensor]): Explicit CPU mapping from each + logit row to an active request. + + Returns: + Tuple[Tensor, Tensor]: Selected-token log probabilities flattened in + active-token order and the full per-row log-probability tensor. + """ + logits_squeezed = logits.squeeze(0) + n_active = self.total_request_count - self.paused_request_count + + if only_last_token_logits or self.is_decode_only(): + seq_idx = torch.arange(len(new_tokens), dtype=torch.int32, device=logits.device) + active_logits = logits_squeezed[: len(new_tokens)].float() + log_probs = self._processed_log_probs( + active_logits, n_active, None, sampling, row_to_request + ) + return log_probs[seq_idx, new_tokens], log_probs + + logits_squeezed = logits_squeezed.float() + active_slice = slice(self.paused_request_count, self.total_request_count) + active_query_lengths_cpu = self.request_query_lengths[active_slice] + active_query_lengths_gpu = self.gpu_view.request_query_lengths[:n_active] + + # Shift away each request's first prompt token, then insert its sampled token. + active_token_ids = self.gpu_view.token_to_input_ids[: self.active_token_count].roll(-1, 0) + new_token_idx = active_query_lengths_gpu.cumsum(0) - 1 + active_token_ids[new_token_idx] = new_tokens + + log_probs = self._processed_log_probs( + logits_squeezed, n_active, active_query_lengths_cpu, sampling ) - md = self.active_request_metadata - temperature = md["temperature"][:n_active].to(logits.device, torch.float32)[row_to_request] - top_k = md["top_k"][:n_active].to(logits.device, torch.long)[row_to_request] - top_p = md["top_p"][:n_active].to(logits.device, torch.float32)[row_to_request] - return sampling.log_probs_kernel(logits, temperature, top_k, top_p) + seq_idx = torch.arange(self.active_token_count, device=log_probs.device) + return log_probs[seq_idx, active_token_ids], log_probs def calculate_log_probs( self, @@ -4223,58 +4387,15 @@ def calculate_log_probs( log_probs (Tensor): Used to compute top n logprobs later if required. """ - # Calculate log_probs (sequence_length x vocab_size) - logits_squeezed = logits.squeeze(0).float() - n_active = self.total_request_count - self.paused_request_count + selected_log_probs, log_probs = self.calculate_log_probs_tensors( + logits, new_tokens, only_last_token_logits=only_last_token_logits, sampling=sampling + ) if only_last_token_logits or self.is_decode_only(): - seq_idx = torch.arange(len(new_tokens), dtype=torch.int32, device=logits.device) - log_probs = self._processed_log_probs( - logits_squeezed[seq_idx], n_active, None, sampling - ) - selected_log_probs = log_probs[seq_idx, new_tokens] return [[lp] for lp in selected_log_probs.tolist()], log_probs - # Get the selected token ids for all tokens. - # We shift the active token window left by one to remove the first prompt token for - # prefill requests and then set the token ids explicitly for the newly generated tokens. - # This is necessary because we calculate the log probs *before* updating the request metadata. - # - # Example (decode & prefill mix): - # - # active_query_lengths: [ 1 | 1 | 2 | 5 ] - # - # new_tokens : [ 52 | 12 | 3 | 86 ] - # - # seq_idx : [ 0 | 1 | 2 3 | 4 5 6 7 8 ] - # - # new_token_idx : [ 0 | 1 | 3 | 8 ] - # - # active_token_ids before left shift: - # : [ 31 | 75 | 45 16 | 90 12 72 24 88 ] - # - # active_token_ids after shift: - # : [ XX | XX | 16 XX | 12 72 24 88 XX ] (XX = undefined) - # - # active_token_ids[new_token_idx] = new_tokens - # : [ 52 | 12 | 16 3 | 12 72 24 88 86 ] - active_token_ids = self.gpu_view.token_to_input_ids[: self.active_token_count].roll(-1, 0) - active_query_lengths = self.gpu_view.request_query_lengths[:n_active] - - new_token_idx = active_query_lengths.cumsum(0) - 1 - active_token_ids[new_token_idx] = new_tokens - - # Compute (possibly processed) log-probs over all active-token rows. - log_probs = self._processed_log_probs( - logits_squeezed, n_active, active_query_lengths, sampling - ) - - # Extract the log probs for only the selected tokens. - # (sequence_length x vocab_size) -> (sequence_length) - seq_idx = torch.arange(self.active_token_count, device=log_probs.device) - selected_log_probs = log_probs[seq_idx, active_token_ids] - - # Split the log probs across request boundaries + active_slice = slice(self.paused_request_count, self.total_request_count) + active_query_lengths = self.request_query_lengths[active_slice] selected_log_probs_list = selected_log_probs.cpu().split( active_query_lengths.tolist(), dim=0 ) diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 0e9105fbd59..833b65dd15f 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -44,6 +44,8 @@ ) from megatron.core.inference.sampling_params import SamplingParams from megatron.core.inference.text_generation_controllers.text_generation_controller import ( + DecodeOnly, + DynamicBatchControllerStepResult, TextGenerationController, ) from megatron.core.inference.utils import Counter, InferenceMode, await_process_call @@ -146,6 +148,41 @@ def format_mem_bytes(mem_bytes): return "%d bytes" % mem_bytes +def _get_decode_only_log_state( + mode: AsyncScheduleMode, decode_only: DecodeOnly +) -> Tuple[str, Optional[bool]]: + """Build the console transition label and color state for one inference step. + + Args: + mode (AsyncScheduleMode): Active scheduling mode. + decode_only (DecodeOnly): Decode-only state for the consumed and launched forwards. + + Returns: + Tuple[str, Optional[bool]]: Current step label, including the previous + step when it differs, and whether to use decode coloring. + """ + if mode == AsyncScheduleMode.LEGACY: + is_decode_only = bool(decode_only) + return ("decode" if is_decode_only else "non-decode"), is_decode_only + + current_decode_only = ( + decode_only.launched if decode_only.launched is not None else decode_only.consumed + ) + if current_decode_only is None: + return "idle", None + + step_type = "decode" if current_decode_only else "non-decode" + if ( + decode_only.consumed is not None + and decode_only.launched is not None + and decode_only.consumed != decode_only.launched + ): + previous_step_type = "decode" if decode_only.consumed else "non-decode" + step_type = f"{step_type} (prev: {previous_step_type})" + + return step_type, current_decode_only + + def _cuda_graph_mempool_bytes() -> Tuple[int, int]: """Return (reserved, allocated) bytes belonging to the global CUDA graph mempool. @@ -329,6 +366,7 @@ def reset(self) -> None: self.capture_stats = None # Runtime state. + self.decode_only = DecodeOnly(consumed=None, launched=None) self._loop = get_asyncio_loop(getattr(self, "_loop", None)) self._cond = asyncio.Condition() self._state_events = {k: asyncio.Event() for k in self._STATE_EVENTS} @@ -997,51 +1035,31 @@ def _validate_async_sched_support_for_config(self) -> None: Raises if the config does not support async scheduling. """ - if self.context.config.async_sched_mode == AsyncScheduleMode.LEGACY: + mode = self.context.config.async_sched_mode + if mode == AsyncScheduleMode.LEGACY: return + if mode != AsyncScheduleMode.ASYNC: + raise AssertionError(f"Unexpected async scheduling mode: {mode}") model_config = self.controller.inference_wrapped_model.model.config - if self.num_speculative_tokens > 0: - raise ValueError("Async scheduling does not support speculative tokens.") - if self.context.is_hybrid_model: - raise ValueError("Async scheduling does not support hybrid/Mamba models.") - if self.context.enable_prefix_caching: - raise ValueError("Async scheduling does not support prefix caching.") - if not self.materialize_only_last_token_logits: - raise ValueError("Async scheduling requires materialize_only_last_token_logits=True.") - if model_config.expert_model_parallel_size > 1: - raise ValueError("Async scheduling does not support expert parallelism.") - if model_config.num_moe_experts is not None: - raise ValueError("Async scheduling does not support MoE models.") + if self.num_speculative_tokens > self.controller.num_mtp_depths: + raise ValueError("Async scheduling requires one MTP depth per speculative token.") if model_config.moe_enable_routing_replay: raise ValueError("Async scheduling does not support routing replay.") - def _validate_async_sched_support_for_request(self, request: DynamicInferenceRequest) -> None: - """Validate request-level restrictions for async scheduling. - - Args: - request (DynamicInferenceRequest): Request being added to the engine. - """ - if self.context.config.async_sched_mode == AsyncScheduleMode.LEGACY: - return - - sampling_params = request.sampling_params - if sampling_params.top_k != 1 or sampling_params.top_p != 0.0: - raise ValueError( - "Async scheduling only supports greedy sampling " - "(SamplingParams.top_k == 1 and top_p == 0.0)." - ) - if sampling_params.return_log_probs or sampling_params.top_n_logprobs > 0: - raise ValueError("Async scheduling does not support log probabilities.") - if sampling_params.stop_words: - raise ValueError("Async scheduling does not support stop words.") - def _add_request( self, request: DynamicInferenceRequest ) -> asyncio.Future[DynamicInferenceRequest]: + """Add a request to the engine. + + Args: + request (DynamicInferenceRequest): Request to add. + + Returns: + asyncio.Future[DynamicInferenceRequest]: Future completed when the request finishes. + """ request_id = request.request_id - self._validate_async_sched_support_for_request(request) # Add request to self.requests. If the engine has previously been # suspended, then the request may already exist. @@ -1213,6 +1231,7 @@ def post_process_requests( sample: torch.Tensor, accepted_tokens: torch.Tensor, log_probs: torch.Tensor, + consumed_chunked_prefill_request_id: int, top_n_logprobs: Optional[Dict[int, List[Tuple[torch.Tensor, torch.Tensor]]]] = None, pre_fwd_active_token_count: Optional[int] = None, pre_fwd_step_count: Optional[int] = None, @@ -1229,8 +1248,13 @@ def post_process_requests( sample: Tensor: The newly generated token for each request accepted_tokens: Tensor: The additional accepted tokens for each request log_probs: (List): Log probs for each request + consumed_chunked_prefill_request_id (int): Chunked-prefill request ID + associated with the consumed forward, or -1 if it had no partial chunk. top_n_logprobs: (Dict): Top-n log probs for each request. Maps request_idx to list of (top_n_logprobs, top_n_indices) tuples. + pre_fwd_active_token_count (Optional[int]): Active token count for the + consumed forward. + pre_fwd_step_count (Optional[int]): Step count for the consumed forward. finished_routing_block_ids: (Dict[int, List[int]]): Block IDs for finished requests, saved before update_requests released them. Used for per-block routing reconstruction. @@ -1287,7 +1311,7 @@ def post_process_requests( num_stop_word_trim = 0 is_prefill = len(request.generated_tokens) == 0 - if request_id != self.context.chunked_prefill_request_id: + if request_id != consumed_chunked_prefill_request_id: # Skip appending token for requests being finished due to stop words # (they already have their final token from the previous step) # If the request already has more tokens, then we only append as much as is necessary @@ -1441,7 +1465,7 @@ def post_process_requests( if not request.generated_log_probs: request.generated_log_probs = [] - is_chunked_prefill = request_id == self.context.chunked_prefill_request_id + is_chunked_prefill = request_id == consumed_chunked_prefill_request_id is_prefill = len(request.generated_log_probs) == 0 if request.sampling_params.skip_prompt_log_probs: @@ -1607,8 +1631,8 @@ def get_prefix_coordination_metrics(self) -> dict: """ return {"waits": self._prefix_coordination_waits} - def schedule_waiting_requests(self): - """Tries to schedule any requests in the waiting pool.""" + def schedule_waiting_requests(self) -> None: + """Try to schedule requests from the waiting pool.""" # Keep track of which requests get scheduled. waiting_before = set(self.waiting_request_ids) if self.enable_chunked_prefill: @@ -1624,10 +1648,72 @@ def schedule_waiting_requests(self): if req.kv_cache_epoch is None: req.kv_cache_epoch = [(0, self._generation_epoch)] - def schedule_non_chunked_prefill(self): + def _can_schedule_non_chunked_prefill(self, req, *, record_cg_wait: bool) -> bool: + """Return whether the queue-head request can be admitted now. + + Args: + req: Queue-head inference request. + record_cg_wait (bool): Whether a CUDA-graph miss should update the + request's wait counter. + + Returns: + bool: Whether all request, token, KV-cache, and CUDA-graph checks pass. """ - Perform the same original scheduling logic for non-chunked runs + if not all(self.context.check_availability(req)): + return False + + if not self._cg_admission_gating_active(): + return True + + candidate = InferenceBatchDimensions( + token_count=self.context.active_token_count + len(req.remaining_prompt_tokens), + prefill_req_count=self.context.num_prefill_requests + 1, + decode_req_count=self.context.num_decode_requests, + ) + if record_cg_wait: + return self._cg_admission_check(req, candidate) + return self._matches_cg_admission(candidate) + + def _can_schedule_chunked_prefill(self, req) -> bool: + """Return whether the queue-head request can admit at least one prompt token. + + Args: + req: Queue-head inference request. + + Returns: + bool: Whether request, token, and KV-cache capacity permit a chunk. """ + request_can_be_added, _, kv_cache_available = self.context.check_availability(req) + is_continuing_chunk = self.context.chunked_prefill_request_id == req.request_id + token_capacity_available = self.context.active_token_count < self.context.max_tokens + return ( + (is_continuing_chunk or request_can_be_added) + and kv_cache_available + and token_capacity_available + ) + + def _should_run_async_sched_overlap(self) -> bool: + """Return whether this step should use overlap ordering. + + Returns: + bool: Whether the next step can use overlap ordering. + """ + # No-overlap also handles the first decode-only forward after prefill: + # pending prefill output must be resolved before preparing its decode rows. + # Paused requests and insufficient KV capacity likewise require complete + # lifecycle bookkeeping before preparing the next batch. + if not self.context.can_prepare_requests(): + return False + if not self.waiting_request_ids: + return True + + req = self.get_request(self.waiting_request_ids[0]) + if self.enable_chunked_prefill: + return not self._can_schedule_chunked_prefill(req) + return not self._can_schedule_non_chunked_prefill(req, record_cg_wait=False) + + def schedule_non_chunked_prefill(self) -> None: + """Schedule non-chunked prefill requests.""" prefix_caching_enabled = self.context.enable_prefix_caching if prefix_caching_enabled: pending_block_hashes = set() @@ -1647,24 +1733,7 @@ def schedule_non_chunked_prefill(self): pending_request_ids.append(self.waiting_request_ids.popleft()) continue - request_can_be_added, request_tokens_can_be_added, kv_cache_available = ( - self.context.check_availability(req) - ) - if request_can_be_added and request_tokens_can_be_added and kv_cache_available: - # CUDA graph-aware admission gating: defer if the resulting batch shape lacks a - # matching captured CG. Non-chunked admit takes the request whole, so the - # candidate token_count is active + remaining_prompt_tokens. - if self._cg_admission_gating_active(): - candidate = InferenceBatchDimensions( - token_count=( - self.context.active_token_count + len(req.remaining_prompt_tokens) - ), - prefill_req_count=self.context.num_prefill_requests + 1, - decode_req_count=self.context.num_decode_requests, - ) - if not self._cg_admission_check(req, candidate): - break - + if self._can_schedule_non_chunked_prefill(req, record_cg_wait=True): # Add these hashes to pending. if prefix_caching_enabled: for block_hash in req.precomputed_block_hashes: @@ -1755,6 +1824,28 @@ def _cg_admission_check(self, req, candidate: InferenceBatchDimensions) -> bool: Caller is responsible for breaking the scheduler loop on False. Passes match_ep_token_counts=False so this local admission probe doesn't force a per-attempt NCCL all-reduce — the step-time matcher does its own EP sync. + + Args: + req: Request whose CUDA-graph wait state should be updated. + candidate (InferenceBatchDimensions): Candidate batch after admission. + + Returns: + bool: Whether a compatible captured graph exists. + """ + if self._matches_cg_admission(candidate): + req.cg_wait_iters = 0 + return True + self._register_cg_wait(req) + return False + + def _matches_cg_admission(self, candidate: InferenceBatchDimensions) -> bool: + """Return whether a candidate batch matches a captured CUDA graph. + + Args: + candidate (InferenceBatchDimensions): Candidate batch after admission. + + Returns: + bool: Whether a compatible captured graph exists. """ matched = CUDAGraphBatchDimensionBuilder.match_graph_config( real_batch_dim=candidate, @@ -1762,11 +1853,7 @@ def _cg_admission_check(self, req, candidate: InferenceBatchDimensions) -> bool: strict=self.context.is_hybrid_model, match_ep_token_counts=False, ) - if matched is not None: - req.cg_wait_iters = 0 - return True - self._register_cg_wait(req) - return False + return matched is not None def schedule_chunked_prefill(self): """ @@ -1813,11 +1900,8 @@ def schedule_chunked_prefill(self): # Use remaining prompt tokens for scheduling decisions remaining_len = len(req.remaining_prompt_tokens) - token_partially_can_be_added = self.context.active_token_count < self.context.max_tokens - request_can_be_added, _, kv_cache_available = self.context.check_availability(req) - request_can_be_added = is_continuing_chunked_prefill or request_can_be_added - if request_can_be_added and kv_cache_available and token_partially_can_be_added: + if self._can_schedule_chunked_prefill(req): # How many tokens we can admit this step. token_budget = self.context.max_tokens - self.context.active_token_count @@ -1946,15 +2030,15 @@ def schedule_chunked_prefill(self): else: self.waiting_request_ids.extendleft(reversed(pending_request_ids)) - async def async_forward(self) -> Tuple[Dict, Dict, float]: + async def async_forward(self) -> Tuple[Optional[Dict], Dict, float]: """Uses `asyncio` for continuous generation. Sleeps when no requests are available, until new requests have been added. Returns: A tuple comprised of: step_result (Optional[Dict]): The result of the step. - context_state (Dict): A tuple consisting of the state of the context. - is_decode_only, total/paused request count, active token count. + context_state (Dict): Decode-only state, total/paused request + count, and active token count. step_time (float): How long this step took. """ @@ -1962,8 +2046,22 @@ async def async_forward(self) -> Tuple[Dict, Dict, float]: if self.state in (EngineState.SUSPENDED, EngineState.SUSPENDING): raise EngineSuspendedError(self.context.step_count) - # schedule requests - self.schedule_waiting_requests() + mode = self.context.config.async_sched_mode + if mode == AsyncScheduleMode.LEGACY: + self.schedule_waiting_requests() + step_nvtx_range = "Decode" if self.context.num_prefill_requests == 0 else "Prefill" + controller_kwargs = {} + elif mode == AsyncScheduleMode.ASYNC: + run_async_overlap = self._should_run_async_sched_overlap() + step_nvtx_range = "AsyncOverlap" if run_async_overlap else "AsyncNoOverlap" + controller_kwargs = { + "run_async_overlap": run_async_overlap, + "schedule_waiting_requests": ( + None if run_async_overlap else self.schedule_waiting_requests + ), + } + else: + raise AssertionError(f"Unexpected async scheduling mode: {mode}") # The print block (async_bookkeep) and metrics block both fire on this # condition after step_count is incremented. Predict it up-front so we @@ -1974,10 +2072,8 @@ async def async_forward(self) -> Tuple[Dict, Dict, float]: and (self.context.step_count + 1) % self.logging_step_interval == 0 ) - is_decode_only = self.context.is_decode_only() if will_log_this_step: pre_step_context_state = { - "is_decode_only": is_decode_only, "max_requests": self.context.max_requests, "total_request_count": self.context.total_request_count, "paused_request_count": self.context.paused_request_count, @@ -1992,15 +2088,21 @@ async def async_forward(self) -> Tuple[Dict, Dict, float]: "active_token_count": self.context.active_token_count, "step_count": self.context.step_count, } + pre_step_context_state["chunked_prefill_request_id"] = ( + self.context.chunked_prefill_request_id + ) # Generate tokens. - nvtx_range_push("Prefill" if not is_decode_only else "Decode") - # TODO @TDE: Account for this line when overlapping forward and bookkeep. - self.is_decode_only = is_decode_only + nvtx_range_push(step_nvtx_range) if will_log_this_step: self.step_start_event.record() - result = await self.controller.async_generate_output_tokens_dynamic_batch() + controller_result: DynamicBatchControllerStepResult = ( + await self.controller.async_generate_output_tokens_dynamic_batch(**controller_kwargs) + ) + self.decode_only = controller_result.decode_only + pre_step_context_state["decode_only"] = self.decode_only + result = controller_result.output if will_log_this_step: self.step_end_event.record() self.step_end_event.synchronize() @@ -2010,7 +2112,7 @@ async def async_forward(self) -> Tuple[Dict, Dict, float]: self.context.step_count += 1 self.context.prefix_cache_lru_clock += 1 - nvtx_range_pop("Prefill" if not is_decode_only else "Decode") + nvtx_range_pop(step_nvtx_range) if will_log_this_step: kvcache_util_stats = ( @@ -2043,7 +2145,8 @@ async def async_bookkeep( Args: step_result (Optional[Dict]): The result of the step. - context_state (Dict): is_decode_only, total/paused request count, active token count. + context_state (Dict): Decode-only state, total/paused request count, + and active token count. step_time (float): How long this step took. Returns: @@ -2083,7 +2186,8 @@ async def async_bookkeep( sample, accepted_tokens, log_probs, - top_n_logprobs, + consumed_chunked_prefill_request_id=context_state["chunked_prefill_request_id"], + top_n_logprobs=top_n_logprobs, pre_fwd_active_token_count=context_state.get("active_token_count"), pre_fwd_step_count=context_state.get("step_count"), finished_routing_block_ids=finished_routing_block_ids, @@ -2215,7 +2319,10 @@ async def async_bookkeep( nvtx_range_push("cuda_memory_stats") mem = torch.cuda.memory_stats() nvtx_range_pop("cuda_memory_stats") - step_type = "decode" if context_state["is_decode_only"] else "non-decode" + decode_only = context_state["decode_only"] + step_type, color_decode_only = _get_decode_only_log_state( + self.context.config.async_sched_mode, decode_only + ) output_str = ( "* rank %d | step %d | %s ... time: %.3f ms%s ... " "reqs: a %d/%d, p %d, w %d, f %d, e %d ... " @@ -2308,7 +2415,7 @@ async def async_bookkeep( msa.max_slots - msa.free_count, msa.max_slots, ) - if context_state["is_decode_only"]: + if color_decode_only: output_str = f"\033[94m{output_str}\033[0m" logging.info(output_str) diff --git a/megatron/core/inference/sampling/base.py b/megatron/core/inference/sampling/base.py index 9092e0130e0..a011b7ab08d 100644 --- a/megatron/core/inference/sampling/base.py +++ b/megatron/core/inference/sampling/base.py @@ -25,6 +25,7 @@ def sample_kernel( no_top_p: bool, gather_indices: Optional[Tensor] = None, token_to_request_index: Optional[Tensor] = None, + output: Optional[Tensor] = None, eager: bool = False, cache_key: Any = None, ) -> Tensor: @@ -41,6 +42,7 @@ def sample_kernel( gather_indices: If provided, only sample from `logits[gather_indices[:n], :]`. token_to_request_index: Per-token request mapping; when set, sampling parameters are gathered per-token instead of per-request. + output: Optional caller-owned destination tensor of shape `[n]`. eager, cache_key: Accepted for API symmetry; ignored (no CUDA graph). Returns: @@ -63,12 +65,24 @@ def sample_speculative( """Sample tokens for the speculative-verify path. Decode requests contribute `1 + num_speculative_tokens` rows; prefill requests contribute 1. - Builds the per-token request mapping and dispatches to `sample_kernel`. - The `sample_kernel` is forced eager so its own `CudaGraphManager` wrapper does not fire. + Builds the per-token request mapping and dispatches to the return-valued `sample_kernel`. When `gather_indices` is supplied, the kernel selects via `logits[gather_indices[:n], :]`. When `gather_indices` is None, `required_logits` is expected to be already pre-gathered to the layout described above (e.g. when `materialize_only_last_token_logits=True` upstream). + + Args: + required_logits: Logits containing base and speculative rows. + num_decode: Number of decode requests. + num_prefill: Number of prefill requests. + num_speculative_tokens: Number of draft tokens per decode request. + context: The active DynamicInferenceContext. + gather_indices: Optional rows to gather from `required_logits`. + eager: Whether to bypass a wrapped CUDA graph. + cache_key: CUDA graph lookup key. + + Returns: + Sampled token IDs for all required base and speculative rows. """ # CudaGraphManager consumes these args, if it exists. del eager, cache_key @@ -106,13 +120,15 @@ def sample_speculative( @abstractmethod def log_probs_kernel( - self, logits: Tensor, temperature: Tensor, top_k: Tensor, top_p: Tensor + self, logits: Tensor, context, *, token_to_request_index: Optional[Tensor] = None ) -> Tensor: """Per-row log-probs of the distribution this backend samples from. Args: logits: `[num_rows, vocab_size]` raw logits. - temperature, top_k, top_p: `[num_rows]` per-row sampling params. + context: The active DynamicInferenceContext. + token_to_request_index: Optional per-row request mapping. When + omitted, each logits row maps to the request at the same index. Returns: `[num_rows, vocab_size]` log-probs; filtered-out tokens are `-inf`. diff --git a/megatron/core/inference/sampling/flashinfer_sampling.py b/megatron/core/inference/sampling/flashinfer_sampling.py index 399135b1a64..95c28125751 100644 --- a/megatron/core/inference/sampling/flashinfer_sampling.py +++ b/megatron/core/inference/sampling/flashinfer_sampling.py @@ -49,6 +49,7 @@ def sample_kernel( no_top_p: bool, gather_indices: Optional[Tensor] = None, token_to_request_index: Optional[Tensor] = None, + output: Optional[Tensor] = None, eager: bool = False, cache_key: Any = None, ) -> Tensor: @@ -65,10 +66,11 @@ def sample_kernel( gather_indices: When set, sample from `logits[gather_indices[:n], :]`. token_to_request_index: When set, sampling parameters are gathered per-token rather than per-request (speculative decoding path). + output: Optional caller-owned destination tensor of shape `[n]`. eager, cache_key: Accepted for API symmetry; ignored (no CUDA graph). Returns: - Sampled token ids of shape `[n]`. + Sampled token IDs in `output`, or a newly allocated tensor when it is not provided. """ del eager, cache_key @@ -106,21 +108,21 @@ def sample_kernel( # multinomial forces a device-to-host sync, whereas sampling_from_probs # stays on-device and keeps the RNG's philox offset advancing per launch. probs = torch.softmax(scaled, dim=-1) - return flashinfer.sampling.sampling_from_probs( + sampled_tokens = flashinfer.sampling.sampling_from_probs( probs, deterministic=True, generator=self._rng ).long() elif no_top_k: # Top-p only -> dedicated exact nucleus kernel. probs = torch.softmax(scaled, dim=-1) top_p_safe = top_p.masked_fill(top_p == 0.0, 1.0) - return flashinfer.sampling.top_p_sampling_from_probs( + sampled_tokens = flashinfer.sampling.top_p_sampling_from_probs( probs, top_p_safe, deterministic=True, generator=self._rng ).long() elif no_top_p: # Top-k only -> dedicated exact top-k kernel. probs = torch.softmax(scaled, dim=-1) top_k_safe = top_k.masked_fill(top_k == 0, self._vocab_size) - return flashinfer.sampling.top_k_sampling_from_probs( + sampled_tokens = flashinfer.sampling.top_k_sampling_from_probs( probs, top_k_safe, deterministic=True, generator=self._rng ).long() else: @@ -128,14 +130,41 @@ def sample_kernel( # kernel, fed the temperature-scaled logits. top_k_safe = top_k.masked_fill(top_k == 0, self._vocab_size) top_p_safe = top_p.masked_fill(top_p == 0.0, 1.0) - return flashinfer.sampling.top_k_top_p_sampling_from_logits( + sampled_tokens = flashinfer.sampling.top_k_top_p_sampling_from_logits( scaled, top_k_safe, top_p_safe, deterministic=True, generator=self._rng ).long() + if output is None: + return sampled_tokens + output.copy_(sampled_tokens) + return output + def log_probs_kernel( - self, logits: Tensor, temperature: Tensor, top_k: Tensor, top_p: Tensor + self, logits: Tensor, context, *, token_to_request_index: Optional[Tensor] = None ) -> Tensor: - """Per-row log-probs of the FlashInfer top-k / top-p sampling distribution.""" + """Per-row log-probs of the FlashInfer top-k / top-p sampling distribution. + + Args: + logits (Tensor): Raw logits with shape `[num_rows, vocab_size]`. + context: Active dynamic inference context providing GPU sampling metadata. + token_to_request_index (Optional[Tensor]): Optional mapping from each + logits row to its request index. + + Returns: + Tensor: Per-row log probabilities for the processed distribution. + """ + gpu_view = context.gpu_view + if token_to_request_index is None: + num_rows = logits.size(0) + temperature = gpu_view.temperature[:num_rows] + top_k = gpu_view.top_k[:num_rows] + top_p = gpu_view.top_p[:num_rows] + else: + token_to_request_index = token_to_request_index.to(logits.device, non_blocking=True) + temperature = gpu_view.temperature[token_to_request_index] + top_k = gpu_view.top_k[token_to_request_index] + top_p = gpu_view.top_p[token_to_request_index] + temperature = temperature.clamp(min=1e-6) probs = torch.softmax(logits / temperature.unsqueeze(1), dim=-1) diff --git a/megatron/core/inference/sampling/torch_sampling.py b/megatron/core/inference/sampling/torch_sampling.py index b4f8f1acc4b..e76d18059d8 100644 --- a/megatron/core/inference/sampling/torch_sampling.py +++ b/megatron/core/inference/sampling/torch_sampling.py @@ -114,14 +114,34 @@ def sample_from_logits( return sampled def log_probs_kernel( - self, logits: Tensor, temperature: Tensor, top_k: Tensor, top_p: Tensor + self, logits: Tensor, context, *, token_to_request_index: Optional[Tensor] = None ) -> Tensor: """Per-row log-probs of the temperature, top-k/top-p sampling distribution. Buckets rows by identical (temperature, top_k, top_p) and reuses `filter_logits` - (the same filter as `sample_from_logits`) so log-probs match how this backend - samples. `temperature`/`top_k`/`top_p` are per-row `[num_rows]` tensors. + (the same filter as `sample_from_logits`) so log-probs match how this backend samples. + + Args: + logits (Tensor): Raw logits with shape `[num_rows, vocab_size]`. + context: Active dynamic inference context providing CPU sampling metadata. + token_to_request_index (Optional[Tensor]): Optional CPU mapping from + each logits row to its request index. + + Returns: + Tensor: Per-row log probabilities for the processed distribution. """ + active_request_count = context.total_request_count - context.paused_request_count + metadata = context.active_request_metadata + if token_to_request_index is None: + temperature = metadata["temperature"][:active_request_count] + top_k = metadata["top_k"][:active_request_count] + top_p = metadata["top_p"][:active_request_count] + else: + assert not token_to_request_index.is_cuda + temperature = metadata["temperature"][:active_request_count][token_to_request_index] + top_k = metadata["top_k"][:active_request_count][token_to_request_index] + top_p = metadata["top_p"][:active_request_count][token_to_request_index] + temps = temperature.tolist() top_ks = top_k.tolist() top_ps = top_p.tolist() @@ -148,10 +168,11 @@ def sample_kernel( no_top_p: bool, gather_indices: Optional[Tensor] = None, token_to_request_index: Optional[Tensor] = None, + output: Optional[Tensor] = None, eager: bool = False, cache_key: Any = None, ) -> Tensor: - """Bucket active requests by `(temperature, top_k, top_p)` and sample each bucket. + """Bucket active requests by sampling parameters and sample each bucket. Args: logits: Logits tensor of shape `[>=n, vocab_size]`. @@ -163,11 +184,12 @@ def sample_kernel( gather_indices: When set, sample from `logits[gather_indices[:n], :]`. token_to_request_index: When set, the loop dispatches per-token rather than per-request (used by the speculative path). + output: Optional caller-owned destination tensor of shape `[n]`. eager: Accepted for API symmetry; ignored (TorchSampling has no graph wrapper). cache_key: Accepted for API symmetry; ignored. Returns: - Sampled token ids of shape `[n]`. + Sampled token IDs in `output`, or a newly allocated tensor when it is not provided. """ del eager, cache_key, no_top_k, no_top_p @@ -191,7 +213,8 @@ def sample_kernel( if gather_indices is not None: logits = logits[gather_indices[:n], :] - output = torch.empty(n, device=logits.device, dtype=torch.int64) + if output is None: + output = torch.empty(n, device=logits.device, dtype=torch.int64) token_list = [] indices_list = [] for idx_tensor, (_, temp, top_k, top_p) in zip(bucket_index_tensors, buckets): diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 6385bb2bf70..8b96633a580 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -6,7 +6,7 @@ import functools from collections import defaultdict from dataclasses import dataclass -from typing import Any, Dict, List, Optional, OrderedDict, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, OrderedDict, Tuple, Union import numpy as np import torch @@ -75,49 +75,135 @@ @dataclass class AsyncScheduleLogitsState: - """Track logits submitted for the next async-scheduling sample. - - When ``is_valid`` is true, ``ready_event`` marks when the logits are - sampleable. The event may represent either forward completion or survivor - compaction completion. - """ + """Track logits submitted for the next async-scheduling sample.""" is_valid: bool = False cuda_graph_request_count: Optional[int] = None - ready_event: Optional[torch.cuda.Event] = None + token_row_indices: Optional[Tensor] = None def set_pending( - self, - cuda_graph_request_count: Optional[int], - ready_event: Optional[torch.cuda.Event] = None, + self, cuda_graph_request_count: Optional[int], token_row_indices: Optional[Tensor] = None ) -> None: - """Record logits that become sampleable when their event completes. + """Record logits submitted for the next sample. Args: cuda_graph_request_count (Optional[int]): CUDA graph request count for the pending logits, or `None` when CUDA graphs were not used. - ready_event (Optional[torch.cuda.Event]): Event marking completion - of the forward or survivor compaction producing the logits. + token_row_indices (Optional[Tensor]): Original GPU input row for each + logical token row in the pending forward. """ self.is_valid = True self.cuda_graph_request_count = cuda_graph_request_count - self.ready_event = ready_event + self.token_row_indices = token_row_indices def clear(self) -> None: """Clear the pending logits state.""" self.is_valid = False self.cuda_graph_request_count = None - self.ready_event = None + self.token_row_indices = None + + +@dataclass +class _AsyncScheduleSampleResult: + """GPU samples, reusable CPU views, and readiness events for one async step.""" + + sampled_tokens_gpu: Tensor + sampled_tokens_cpu_view: Tensor + sampled_mtp_tokens_gpu: Optional[Tensor] + sampled_mtp_tokens_cpu_view: Optional[Tensor] + accepted_tokens_cpu_view: Optional[Tensor] + accepted_counts_gpu: Optional[Tensor] + accepted_counts_cpu_view: Optional[Tensor] + accepted_counts_cpu_ready_event: Optional[torch.cuda.Event] + sample_cpu_ready_event: Optional[torch.cuda.Event] + + +@dataclass(frozen=True) +class DecodeOnly: + """Decode-only state for the consumed and launched forwards. + + Attributes: + consumed: Whether the consumed output came from a decode-only forward, + or ``None`` when no output was consumed. + launched: Whether the launched forward is decode-only, or ``None`` when + no real forward was launched. + """ + + consumed: Optional[bool] + launched: Optional[bool] + + def __bool__(self) -> bool: + """Return the shared decode-only state when both forwards agree. + + Returns: + bool: The common consumed and launched decode-only state. + + Raises: + ValueError: If either forward is absent or the two states differ. + """ + if self.consumed is None or self.launched is None or self.consumed != self.launched: + raise ValueError( + "Decode-only state is ambiguous: " + f"consumed={self.consumed}, launched={self.launched}." + ) + return self.consumed + + +@dataclass(frozen=True) +class DynamicBatchControllerStepResult: + """Result of one dynamic-batching controller step. + + Attributes: + decode_only: Decode-only state for the consumed and launched forwards. + output: Sampled-step output, or ``None`` when no output was produced. + primer_only: Whether the step launched only an async-scheduling primer. + """ + + decode_only: DecodeOnly + output: Optional[Dict] = None + primer_only: bool = False @dataclass -class _AsyncScheduleResolveResult: - """State produced by async scheduling request resolution.""" +class _AsyncScheduleRequestResult: + """Request state produced by async scheduling bookkeeping.""" sampled_tokens_cpu: Tensor + accepted_tokens_cpu: Optional[Tensor] active_request_ids: Tensor finished_request_ids: Tensor - compaction_done_event: Optional[torch.cuda.Event] + survivor_idxs: Optional[Tensor] = None + newly_paused_request_ids: Optional[Tensor] = None + evict_request_ids: Optional[Tensor] = None + + +@dataclass +class _AsyncScheduleLogProbsGPUResult: + """GPU logprob outputs awaiting transfer to CPU.""" + + selected_log_probs: Tensor + top_n_log_probs: Optional[Tensor] + top_n_token_ids: Optional[Tensor] + row_counts: List[int] + top_n_counts: List[int] + skip_prompt_log_probs: List[bool] + num_decode_requests: int + gpu_ready_event: Optional[torch.cuda.Event] + + +@dataclass +class _AsyncScheduleLogProbsTransfer: + """Transient CPU views retaining their GPU sources until D2H completes.""" + + selected_log_probs_cpu_view: Tensor + top_n_log_probs_cpu_view: Optional[Tensor] + top_n_token_ids_cpu_view: Optional[Tensor] + row_counts: List[int] + top_n_counts: List[int] + skip_prompt_log_probs: List[bool] + num_decode_requests: int + cpu_ready_event: Optional[torch.cuda.Event] + gpu_result: _AsyncScheduleLogProbsGPUResult # pylint: disable=line-too-long @@ -236,17 +322,22 @@ def _init_dynamic_sampling_tensors(self): else: self._all_logits_cuda = None self._async_sched_logits = AsyncScheduleLogitsState() - # This buffer has a stable address across legacy-prefill, async-decode, + # This buffer has a stable address across legacy, no-overlap, overlap, # and MTP routing. Sampling producers must copy into it rather than rebind it. self._sampled_tokens_cuda = torch.empty(max_requests, dtype=torch.int64, device=device) - self._async_sched_sample_values_cuda = torch.empty( - max_requests, dtype=logits_dtype, device=device - ) self._async_sched_sampled_tokens_cpu_buffer = torch.empty( max_requests, dtype=torch.int64, device="cpu", pin_memory=True ) + self._async_sched_selected_log_probs_cpu_buffer = torch.empty( + context.max_tokens, dtype=torch.float32, device="cpu", pin_memory=True + ) + self._async_sched_top_n_log_probs_cpu_buffer = None + self._async_sched_top_n_token_ids_cpu_buffer = None + self._async_sched_top_n_capacity = 0 self._async_sched_sample_gpu_ready_event = torch.cuda.Event() self._async_sched_sample_cpu_ready_event = torch.cuda.Event() + self._async_sched_log_probs_gpu_ready_event = torch.cuda.Event() + self._async_sched_log_probs_cpu_ready_event = torch.cuda.Event() self._async_sched_copy_stream = torch.cuda.Stream(device=device) # Sampling backend: provides the sampling kernel. @@ -273,10 +364,17 @@ def _init_mtp_sampling_tensors(self): Addresses must be stable across steps for CUDA graph capture. """ + self._mtp_resolved_padded_count = None if not self.num_speculative_tokens: self._sampled_mtp_tokens_cuda = None self._accepted_tokens_per_request = None self._last_accepted_seq_indices = None + self._async_sched_mtp_token_row_indices = None + self._async_sched_sampled_mtp_tokens_cpu_buffer = None + self._async_sched_accepted_tokens_cpu_buffer = None + self._async_sched_accepted_counts_cpu_buffer = None + self._async_sched_mtp_verification_gpu_ready_event = None + self._async_sched_accepted_counts_cpu_ready_event = None return context = self.inference_wrapped_model.inference_context @@ -285,6 +383,7 @@ def _init_mtp_sampling_tensors(self): self._sampled_mtp_tokens_cuda = torch.empty( [self.num_speculative_tokens, max_requests], dtype=torch.int64, device=device ) + self._async_sched_mtp_token_row_indices = torch.arange(context.max_tokens, device=device) self._accepted_tokens_per_request = ( torch.ones( [max_requests, self.num_speculative_tokens], dtype=torch.int64, device=device @@ -302,6 +401,23 @@ def _init_mtp_sampling_tensors(self): self._mtp_position_ids_buf = torch.empty( [1, max_requests], dtype=torch.int64, device=device ) + self._async_sched_sampled_mtp_tokens_cpu_buffer = torch.empty( + [self.num_speculative_tokens, max_requests], + dtype=torch.int64, + device="cpu", + pin_memory=True, + ) + self._async_sched_accepted_tokens_cpu_buffer = torch.empty( + [max_requests, self.num_speculative_tokens], + dtype=torch.int64, + device="cpu", + pin_memory=True, + ) + self._async_sched_accepted_counts_cpu_buffer = torch.empty( + max_requests, dtype=torch.int64, device="cpu", pin_memory=True + ) + self._async_sched_mtp_verification_gpu_ready_event = torch.cuda.Event() + self._async_sched_accepted_counts_cpu_ready_event = torch.cuda.Event() @staticmethod def tokenize_prompt(tokenizer, prompt: str, add_BOS: bool = False) -> List[int]: @@ -743,7 +859,7 @@ def _dynamic_step_forward_logits(self, input_ids: Tensor, position_ids: Tensor): else: self._all_logits_cuda = logits - def _rewind_kv_cache(self) -> tuple: + def _rewind_kv_cache(self, accepted_counts_cpu: Optional[Tensor] = None) -> tuple: """Update the KV cache bookkeeping for speculative decoding. After forward pass with speculative tokens, some tokens may be rejected. @@ -752,8 +868,12 @@ def _rewind_kv_cache(self) -> tuple: CPU source-of-truth tensors in place); the Mamba hybrid-model state update stays on GPU because it operates on GPU-resident state buffers. - Returns (blocks_to_release, remove_mask) for the caller to release blocks - back to the allocator outside the compiled graph. + Args: + accepted_counts_cpu (Optional[Tensor]): Accepted MTP draft counts already + copied to CPU. When omitted, this method performs the legacy D2H copy. + + Returns: + tuple: Blocks detached by rewind and the mask selecting valid block IDs. """ context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count @@ -761,12 +881,13 @@ def _rewind_kv_cache(self) -> tuple: # accepted_counts is the only GPU input; D2H a small slice so the # CPU rewind can read its values via .tolist() inside a Python loop. - accepted_tokens_per_request_cpu = self._accepted_token_counts_per_request[ - :active_request_count - ].cpu() + if accepted_counts_cpu is None: + accepted_counts_cpu = self._accepted_token_counts_per_request[ + :active_request_count + ].cpu() blocks_to_release, remove_mask = rewind_kv_cache( - accepted_counts=accepted_tokens_per_request_cpu, + accepted_counts=accepted_counts_cpu, prefill_status=context.request_in_prefill_status_tensor[active_request_slice], last_kv_block_offset=context.request_last_kv_block_offset[active_request_slice], kv_length_offsets=context.request_kv_length_offsets[active_request_slice], @@ -828,7 +949,7 @@ def _sample_from_logits_2d(self, logits_2d: Tensor) -> Tensor: eager=True, ) - def _compute_serial_mtp_and_sample(self): + def _compute_serial_mtp_and_sample(self, base_position: Optional[Tensor] = None) -> None: """Compute MTP logits serially after verification and sample speculative tokens. This ensures that MTP predictions are always conditioned on verified tokens. @@ -839,6 +960,10 @@ def _compute_serial_mtp_and_sample(self): When sequence parallelism is active, hidden states are kept in SP format (scattered along the first dimension) between MTP depths to avoid a redundant gather + scatter round-trip per depth. + + Args: + base_position (Optional[Tensor]): GPU position of the first new MTP draft + for each request. Legacy scheduling derives it from rewound CPU state. """ nvtx_range_push("mtp-spec-decoding/serial-mtp-init") context = self.inference_wrapped_model.inference_context @@ -866,26 +991,23 @@ def _compute_serial_mtp_and_sample(self): else: last_accepted_hidden = None - # Compute position IDs for the next tokens. - # After rewind, request_kv_length_offsets has been adjusted. Read from - # CPU context (post-rewind values), NOT gpu_view (stale pre-rewind snapshot). - # The next position to predict is: adjusted_offset + processed_tokens. - cuda_device = torch.cuda.current_device() - adjusted_offsets = context.request_kv_length_offsets[active_slice].to( - cuda_device, non_blocking=True - ) - processed_tokens = context.request_query_lengths[active_slice].to( - cuda_device, non_blocking=True - ) - # Cast to int64 to match CUDA graph capture dtype expectations. - base_position = (adjusted_offsets + processed_tokens).to(torch.int64) + if base_position is None: + # Legacy scheduling derives positions from post-rewind CPU state. + cuda_device = torch.cuda.current_device() + adjusted_offsets = context.request_kv_length_offsets[active_slice].to( + cuda_device, non_blocking=True + ) + processed_tokens = context.request_query_lengths[active_slice].to( + cuda_device, non_blocking=True + ) + base_position = (adjusted_offsets + processed_tokens).to(torch.int64) # Start with the freshly sampled base token. next_token_ids = self._sampled_tokens_cuda[:active_request_count].clone() current_hidden = last_accepted_hidden if has_mtp else None # Compute padding needed to make batch compatible with SP and CUDA graphs. - if getattr(self, '_mtp_resolved_padded_count', None) is not None: + if self._mtp_resolved_padded_count is not None: # CUDA-graph path: use the EP-synced padded count. padded_count = self._mtp_resolved_padded_count assert not self._sp_enabled or padded_count % self._tp_size == 0 @@ -998,9 +1120,15 @@ def _verify_speculative_tokens( num_speculative_tokens=self.num_speculative_tokens, ) - def _dynamic_step_sample_logits_and_verify_tokens(self, input_ids: Tensor): - """ - Sample tokens from logits for dynamic batching with speculative tokens and verify the tokens. + def _dynamic_step_sample_logits_and_verify_tokens( + self, input_ids: Tensor, token_row_indices: Optional[Tensor] = None + ) -> None: + """Sample MTP logits and verify pending draft tokens. + + Args: + input_ids (Tensor): Input token storage used by the pending forward. + token_row_indices (Optional[Tensor]): Original GPU input row for each + current logical token row after survivor compaction. """ context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count @@ -1053,7 +1181,13 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, input_ids: Tensor): # Verify speculative tokens against input tokens. nvtx_range_push("mtp-spec-decoding/verify/verify-tokens") - input_tokens_required = input_ids[0, required_logit_indices] + input_row_indices = required_logit_indices + if token_row_indices is not None: + actual_required_count = active_request_count * (self.num_speculative_tokens + 1) + input_row_indices = token_row_indices[ + required_logit_indices[:actual_required_count].long() + ] + input_tokens_required = input_ids[0, input_row_indices] last_one_indices, accepted_tokens_mask, input_tokens_required = ( self._verify_speculative_tokens( output_tokens, @@ -1069,7 +1203,7 @@ def _dynamic_step_sample_logits_and_verify_tokens(self, input_ids: Tensor): self._prepare_speculative_tokens_for_next_forward_pass( num_decode_requests, output_tokens, - required_logit_indices, + input_row_indices, last_one_indices, accepted_tokens_mask, input_tokens_required, @@ -1129,19 +1263,15 @@ def _dynamic_step_sample_logits(self): else context.gpu_view.active_request_last_token_idxs ) no_top_k, no_top_p = self._active_requests_sampling_filter_flags(active_request_count) - sampled_tokens = self._sampling.sample_kernel( + self._sampling.sample_kernel( self._all_logits_cuda.squeeze(0), n, context, gather_indices=gather_indices, no_top_k=no_top_k, no_top_p=no_top_p, + output=self._sampled_tokens_cuda[:n], ) - # Copy into the stable `max_requests` buffer rather than rebinding it. The spec - # (`sampled_tokens_buf`) and async-scheduling (`torch.max(out=...)`) paths both - # write into this buffer in place and rely on its address staying fixed, so this - # path must keep the same contract (see the `__init__` allocation comment). - self._sampled_tokens_cuda[:n].copy_(sampled_tokens) def _active_requests_sampling_filter_flags( self, active_request_count: Optional[int] = None @@ -1173,13 +1303,16 @@ def _dynamic_step_log_probs_bookkeeping(self) -> Tuple[bool, bool]: Returns: return_log_probs (bool): Whether to return the sampled log_probs. + return_top_n_logprobs (bool): Whether to return top-n log_probs. """ context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count return ( - (context.active_request_metadata["return_log_probs"][:active_request_count]).any(), - (context.active_request_metadata["top_n_logprobs"][:active_request_count] > 0).any(), + bool(context.active_request_metadata["return_log_probs"][:active_request_count].any()), + bool( + (context.active_request_metadata["top_n_logprobs"][:active_request_count] > 0).any() + ), ) def _router_record_bookkeeping(self) -> Optional[np.ndarray]: @@ -1572,40 +1705,17 @@ def _dynamic_step_calculate_top_n_logprobs( return top_n_results if top_n_results else None - @torch.inference_mode() - def dummy_forward(self): - """Perform a dummy forward pass. This is used in expert model parallelism - on ranks that do not have any real requests. It may run in eager mode.""" + def _run_dummy_base_forward(self, input_ids: Tensor, position_ids: Tensor) -> None: + """Run the base-model portion of an expert-parallel dummy step. - context = self.inference_wrapped_model.inference_context - - # attempt to use cuda-graph if possible - input_ids, position_ids, _ = self._dynamic_step_context_init(is_dummy_forward=True) + Args: + input_ids (Tensor): Dummy input token IDs. + position_ids (Tensor): Dummy input position IDs. + """ self._dynamic_step_forward_logits(input_ids, position_ids) - # Disable MoE padding for MTP computation, unless CUDA graphs - # are active (the graphs were captured with padding enabled). - if self.model_config.moe_pad_experts_for_cuda_graph_inference: - if not context.using_cuda_graph_this_step(): - unwrapped_model = unwrap_model(self.inference_wrapped_model.model) - set_decode_expert_padding(unwrapped_model, False) - - # When speculative decoding is active, the real EP ranks perform serial - # MTP forward passes after the main forward pass. MTP layers may contain - # MoE sublayers (inherited from the decoder spec), which require EP - # all-to-all collectives. The dummy rank must participate in these - # collectives to avoid a hang. - self._dummy_serial_mtp_forward() - - # clear the context of any temporary state from the dummy forward, but - # preserve prefix-cache state: a dummy forward runs when the engine is idle - # (e.g. between requests, or to keep EP collectives alive with EP > 1) and - # must not wipe cached KV/Mamba prefixes, or cross-request prefix reuse would - # be destroyed every time the engine briefly idles. - context.reset(preserve_prefix_cache=True) - @torch.inference_mode() - def _dummy_serial_mtp_forward(self): + def _run_dummy_serial_mtp_forward(self) -> None: """Run dummy MTP forward passes to participate in EP collectives. When speculative decoding is active and MTP layers contain MoE sublayers @@ -1625,19 +1735,19 @@ def _dummy_serial_mtp_forward(self): return context = self.inference_wrapped_model.inference_context - has_mtp = self._is_last_pp_stage and context.mtp_decoder_hidden_states is not None + unwrapped_model = self._unwrapped_model + has_mtp = self._is_last_pp_stage and hasattr(unwrapped_model, "mtp") if not has_mtp and not self.model_is_pipeline_parallel: # No MTP on this rank and no PP broadcast to participate in. return - unwrapped_model = self._unwrapped_model device = torch.cuda.current_device() dtype = self.model_config.params_dtype hidden_size = self.model_config.hidden_size # Use precomputed MTP CUDA graph batch size when available; # otherwise use minimal SP-compatible size. - if getattr(self, '_mtp_resolved_padded_count', None) is not None: + if self._mtp_resolved_padded_count is not None: padded_count = self._mtp_resolved_padded_count assert not self._sp_enabled or padded_count % self._tp_size == 0 elif has_mtp: @@ -1687,6 +1797,58 @@ def _dummy_serial_mtp_forward(self): ) nvtx_range_pop(f"mtp-spec-decoding/dummy-depth-{depth}") + def _run_dummy_legacy_step(self, input_ids: Tensor, position_ids: Tensor) -> None: + """Run a legacy dummy step in base-forward then MTP order. + + Args: + input_ids (Tensor): Dummy input token IDs. + position_ids (Tensor): Dummy input position IDs. + """ + context = self.inference_wrapped_model.inference_context + self._run_dummy_base_forward(input_ids, position_ids) + + # Disable MoE padding for MTP computation, unless CUDA graphs + # are active (the graphs were captured with padding enabled). + if self.model_config.moe_pad_experts_for_cuda_graph_inference: + if not context.using_cuda_graph_this_step(): + unwrapped_model = unwrap_model(self.inference_wrapped_model.model) + set_decode_expert_padding(unwrapped_model, False) + + self._run_dummy_serial_mtp_forward() + + def _run_dummy_async_sched_step(self, input_ids: Tensor, position_ids: Tensor) -> None: + """Run an async-scheduling dummy step in MTP then base-forward order. + + Args: + input_ids (Tensor): Dummy input token IDs. + position_ids (Tensor): Dummy input position IDs. + """ + context = self.inference_wrapped_model.inference_context + if self.model_config.moe_pad_experts_for_cuda_graph_inference: + if not context.using_cuda_graph_this_step(): + set_decode_expert_padding(self._unwrapped_model, False) + + self._run_dummy_serial_mtp_forward() + self._run_dummy_base_forward(input_ids, position_ids) + + @torch.inference_mode() + def dummy_forward(self) -> None: + """Run the mode-specific dummy step used by idle expert-parallel ranks.""" + context = self.inference_wrapped_model.inference_context + input_ids, position_ids, _ = self._dynamic_step_context_init(is_dummy_forward=True) + + if context.config.async_sched_mode == AsyncScheduleMode.LEGACY: + self._run_dummy_legacy_step(input_ids, position_ids) + elif context.config.async_sched_mode == AsyncScheduleMode.ASYNC: + self._run_dummy_async_sched_step(input_ids, position_ids) + else: + raise AssertionError( + f"Unexpected async scheduling mode: {context.config.async_sched_mode}" + ) + + # Clear temporary dummy state while preserving reusable prefix state and counters. + context.reset(preserve_prefix_cache=True, preserve_counters=True) + def _transfer_samples_to_cpu(self, active_request_count: int) -> tuple: """Batch GPU-to-CPU transfer of sampled tokens. @@ -1705,6 +1867,27 @@ def _transfer_samples_to_cpu(self, active_request_count: int) -> tuple: sampled_mtp_tokens_cpu = None return sampled_tokens_cpu, sampled_mtp_tokens_cpu + def _apply_stop_word_finished_ids( + self, active_request_ids: Tensor, active_request_mask: Tensor + ) -> None: + """Mark requests whose generated output matched a stop word as finished. + + Args: + active_request_ids (Tensor): IDs for requests active during the current step. + active_request_mask (Tensor): Mask updated in place for requests that remain active. + """ + if self._get_stop_word_finished_ids_callback is None: + return + + request_ids = active_request_ids.tolist() + stop_word_finished_ids = self._get_stop_word_finished_ids_callback(request_ids) + if not stop_word_finished_ids: + return + + for idx, request_id in enumerate(request_ids): + if request_id in stop_word_finished_ids: + active_request_mask[idx] = 0 + def _dynamic_step_context_bookkeeping(self) -> Dict[str, Tensor]: """Update the dynamic inference context after sampling. @@ -1751,15 +1934,8 @@ def _dynamic_step_context_bookkeeping(self) -> Dict[str, Tensor]: != context.active_request_metadata["termination_id"][:active_request_count] ).byte() & torch.less(active_sequence_lengths, max_sequence_lengths).byte() - # Mark requests as finished if they hit stop words - # (detected in previous step's post_process_requests) - if self._get_stop_word_finished_ids_callback is not None: - request_ids_list = active_request_ids.tolist() - stop_word_finished_ids = self._get_stop_word_finished_ids_callback(request_ids_list) - if stop_word_finished_ids: - for idx, request_id in enumerate(request_ids_list): - if request_id in stop_word_finished_ids: - active_request_mask[idx] = 0 + # Apply stop words detected during the previous engine bookkeeping step. + self._apply_stop_word_finished_ids(active_request_ids, active_request_mask) finished_idxs = ( torch.nonzero(active_request_mask == 0, as_tuple=True)[0] + context.paused_request_count @@ -1804,9 +1980,12 @@ def _dynamic_step_context_bookkeeping(self) -> Dict[str, Tensor]: # Begin async scheduling methods # ------------------------------------------------------------------------- - def _validate_async_sched_support_for_step(self) -> None: + def _validate_async_sched_support_for_step(self, run_async_overlap: bool) -> None: """Validate controller/context state for async scheduling. + Args: + run_async_overlap (bool): Whether this step uses overlap ordering. + Raises if the current step does not support async scheduling. """ context = self.inference_wrapped_model.inference_context @@ -1814,67 +1993,70 @@ def _validate_async_sched_support_for_step(self) -> None: if context.active_token_count == 0 and active_request_count == 0: return - if context.paused_request_count != 0: - raise RuntimeError("Async scheduling does not support paused requests.") - if context.chunked_prefill_request_id != -1: - raise RuntimeError("Async scheduling does not support chunked prefill.") + if run_async_overlap and context.paused_request_count != 0: + raise RuntimeError("Async scheduling overlap does not support paused requests.") - def _compact_async_sched_logits(self, survivor_idxs: Tensor) -> Optional[torch.cuda.Event]: - """Compact cached logits from old active-row order into survivor order. + def _compact_async_sched_logits(self, survivor_idxs: Tensor) -> None: + """Compact pending logits and sampling metadata into survivor order. Args: survivor_idxs (Tensor): Active-row indices for requests that remain active after async scheduling. - - Returns: - Optional[torch.cuda.Event]: Event marking compaction completion, or - `None` when no GPU compaction was needed. """ if survivor_idxs.numel() == 0: self._async_sched_logits.clear() - return None + return + + tokens_per_request = self.num_speculative_tokens + 1 + pending_token_row_indices = self._async_sched_logits.token_row_indices identity_idxs = torch.arange(survivor_idxs.numel(), device=survivor_idxs.device) if torch.equal(survivor_idxs, identity_idxs): - return None + survivor_token_row_indices = ( + pending_token_row_indices[: survivor_idxs.numel() * tokens_per_request] + if pending_token_row_indices is not None + else None + ) + self._async_sched_logits.set_pending( + self._async_sched_logits.cuda_graph_request_count, survivor_token_row_indices + ) + return - survivor_idxs_cuda = survivor_idxs.to(self._all_logits_cuda.device) - compacted_logits = self._all_logits_cuda[:, survivor_idxs_cuda, :].contiguous() + token_offsets = torch.arange(tokens_per_request, device=survivor_idxs.device) + survivor_token_idxs = ( + survivor_idxs[:, None] * tokens_per_request + token_offsets[None, :] + ).flatten() + survivor_token_idxs_cuda = survivor_token_idxs.to(self._all_logits_cuda.device) + survivor_token_row_indices = ( + pending_token_row_indices[survivor_token_idxs_cuda] + if pending_token_row_indices is not None + else None + ) + + compacted_logits = self._all_logits_cuda[:, survivor_token_idxs_cuda, :].contiguous() if self._enable_cuda_graph: - self._all_logits_cuda[:, : survivor_idxs.numel(), :].copy_(compacted_logits) + self._all_logits_cuda[:, : survivor_token_idxs.numel(), :].copy_(compacted_logits) else: self._all_logits_cuda = compacted_logits - compaction_done_event = self._record_fresh_async_sched_event(self._all_logits_cuda) + context = self.inference_wrapped_model.inference_context + gpu_view = context.gpu_view + survivor_count = survivor_idxs.numel() + survivor_idxs_cpu = survivor_idxs.to("cpu") + survivor_idxs_cuda = survivor_idxs.to(gpu_view.temperature.device) + for label in ("temperature", "top_k", "top_p"): + compacted_metadata = context.active_request_metadata[label][survivor_idxs_cpu] + context.active_request_metadata[label][:survivor_count].copy_(compacted_metadata) + compacted_temperature = gpu_view.temperature[survivor_idxs_cuda].contiguous() + compacted_top_k = gpu_view.top_k[survivor_idxs_cuda].contiguous() + compacted_top_p = gpu_view.top_p[survivor_idxs_cuda].contiguous() + gpu_view.temperature[:survivor_count].copy_(compacted_temperature) + gpu_view.top_k[:survivor_count].copy_(compacted_top_k) + gpu_view.top_p[:survivor_count].copy_(compacted_top_p) + self._async_sched_logits.set_pending( - self._async_sched_logits.cuda_graph_request_count, compaction_done_event + self._async_sched_logits.cuda_graph_request_count, survivor_token_row_indices ) - return compaction_done_event - - def _record_fresh_async_sched_event( - self, reference_tensor: Optional[Tensor] = None - ) -> Optional[torch.cuda.Event]: - """Record a fresh event on the current CUDA stream when CUDA work is active. - - Forward and compaction events can remain in the logits state across - controller steps, so each operation owns a fresh event. Transfer events - are reused separately because they are synchronized within each step. - - Args: - reference_tensor (Optional[Tensor]): Tensor used to determine whether - CUDA work is active. - - Returns: - Optional[torch.cuda.Event]: Recorded CUDA event, or `None` when no - CUDA work is active. - """ - if reference_tensor is not None and not reference_tensor.is_cuda: - return None - if not torch.cuda.is_available(): - return None - event = torch.cuda.Event() - event.record() - return event @staticmethod def _synchronize_async_sched_event(event: Optional[torch.cuda.Event]) -> None: @@ -1887,87 +2069,493 @@ def _synchronize_async_sched_event(event: Optional[torch.cuda.Event]) -> None: if event is not None: event.synchronize() - def _copy_async_sched_sample_to_cpu( - self, sampled_tokens_gpu: Tensor + def _copy_async_sched_accepted_counts_to_cpu( + self, accepted_counts_gpu: Tensor ) -> Tuple[Tensor, Optional[torch.cuda.Event]]: - """Start copying sampled tokens to CPU and return a view plus ready event. + """Start copying MTP acceptance counts into their reusable CPU buffer. + + Args: + accepted_counts_gpu (Tensor): Accepted MTP draft count per active request. + + Returns: + Tuple[Tensor, Optional[torch.cuda.Event]]: Transient CPU view and its + copy-completion event. + """ + if not accepted_counts_gpu.is_cuda: + return accepted_counts_gpu.cpu(), None + + accepted_counts_cpu = self._async_sched_accepted_counts_cpu_buffer[ + : accepted_counts_gpu.numel() + ] + with torch.cuda.stream(self._async_sched_copy_stream): + self._async_sched_copy_stream.wait_event( + self._async_sched_mtp_verification_gpu_ready_event + ) + accepted_counts_cpu.copy_(accepted_counts_gpu, non_blocking=True) + self._async_sched_accepted_counts_cpu_ready_event.record(self._async_sched_copy_stream) + return accepted_counts_cpu, self._async_sched_accepted_counts_cpu_ready_event + + def _copy_async_sched_sample_to_cpu( + self, + sampled_tokens_gpu: Tensor, + sampled_mtp_tokens_gpu: Optional[Tensor] = None, + accepted_tokens_gpu: Optional[Tensor] = None, + ) -> Tuple[Tensor, Optional[Tensor], Optional[Tensor], Optional[torch.cuda.Event]]: + """Start copying async sampling outputs into reusable CPU buffers. Args: - sampled_tokens_gpu (Tensor): Sampled token IDs for active requests. + sampled_tokens_gpu (Tensor): Sampled base token IDs for active requests. + sampled_mtp_tokens_gpu (Optional[Tensor]): Generated MTP draft token IDs. + accepted_tokens_gpu (Optional[Tensor]): Accepted pending MTP draft token IDs. Returns: - Tuple[Tensor, Optional[torch.cuda.Event]]: A transient view into - the reusable pinned CPU sample buffer and its copy-completion - event. The caller must synchronize the event and clone the view - before retaining it beyond this step. + Tuple[Tensor, Optional[Tensor], Optional[Tensor], Optional[torch.cuda.Event]]: + Transient CPU views for base, draft, and accepted tokens plus the + copy-completion event. """ if not sampled_tokens_gpu.is_cuda: - return sampled_tokens_gpu.cpu(), None + return ( + sampled_tokens_gpu.cpu(), + sampled_mtp_tokens_gpu.cpu() if sampled_mtp_tokens_gpu is not None else None, + accepted_tokens_gpu.cpu() if accepted_tokens_gpu is not None else None, + None, + ) + + sample_cpu = self._async_sched_sampled_tokens_cpu_buffer[: sampled_tokens_gpu.numel()] + sampled_mtp_tokens_cpu = None + if sampled_mtp_tokens_gpu is not None: + sampled_mtp_tokens_cpu = self._async_sched_sampled_mtp_tokens_cpu_buffer[ + :, : sampled_tokens_gpu.numel() + ] + accepted_tokens_cpu = None + if accepted_tokens_gpu is not None: + accepted_tokens_cpu = self._async_sched_accepted_tokens_cpu_buffer[ + : sampled_tokens_gpu.numel() + ] - buffer = self._async_sched_sampled_tokens_cpu_buffer - sample_cpu = buffer[: sampled_tokens_gpu.numel()] with torch.cuda.stream(self._async_sched_copy_stream): self._async_sched_copy_stream.wait_event(self._async_sched_sample_gpu_ready_event) sample_cpu.copy_(sampled_tokens_gpu, non_blocking=True) + if sampled_mtp_tokens_gpu is not None: + sampled_mtp_tokens_cpu.copy_(sampled_mtp_tokens_gpu, non_blocking=True) + if accepted_tokens_gpu is not None: + accepted_tokens_cpu.copy_(accepted_tokens_gpu, non_blocking=True) self._async_sched_sample_cpu_ready_event.record(self._async_sched_copy_stream) - return sample_cpu, self._async_sched_sample_cpu_ready_event + return ( + sample_cpu, + sampled_mtp_tokens_cpu, + accepted_tokens_cpu, + self._async_sched_sample_cpu_ready_event, + ) def _build_async_sched_request_state( - self, sampled_tokens_cpu: Tensor - ) -> Tuple[Tensor, Tensor, Tensor, Tensor]: - """Build request IDs and active/finished row sets after prepare. + self, sampled_tokens_cpu: Tensor, resolved_sequence_lengths: Tensor + ) -> Tuple[Tensor, Tensor, Tensor]: + """Build request IDs and the active/finished mask for resolution. Args: sampled_tokens_cpu (Tensor): Sampled CPU token IDs for active requests. + resolved_sequence_lengths (Tensor): Sequence lengths after accepting + current output and before preparing unverified successor tokens. Returns: - Tuple[Tensor, Tensor, Tensor, Tensor]: Active request IDs, finished - request IDs, active-request mask, and survivor row indices. + Tuple[Tensor, Tensor, Tensor]: Active request IDs, finished request + IDs, and the active-request mask. """ context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count active_request_slice = slice(context.paused_request_count, context.total_request_count) active_request_ids = context.request_ids[active_request_slice].long() - active_sequence_lengths = context.get_active_sequence_lengths() max_sequence_lengths = context.get_max_sequence_lengths() active_request_mask = ( sampled_tokens_cpu != context.request_metadata["termination_id"][active_request_slice] - ).byte() & torch.less(active_sequence_lengths, max_sequence_lengths).byte() + ).byte() & torch.less(resolved_sequence_lengths, max_sequence_lengths).byte() + + self._apply_stop_word_finished_ids(active_request_ids, active_request_mask) + + if context.chunked_prefill_request_id != -1: + chunked_prefill_rows = torch.nonzero( + active_request_ids == context.chunked_prefill_request_id, as_tuple=True + )[0] + assert ( + chunked_prefill_rows.numel() == 1 + ), "The active chunked-prefill request must have exactly one row." + active_request_mask[chunked_prefill_rows[0]] = 1 finished_idxs = ( torch.nonzero(active_request_mask == 0, as_tuple=True)[0] + context.paused_request_count ) finished_request_ids = context.request_ids[finished_idxs].clone() - survivor_idxs = torch.nonzero(active_request_mask == 1, as_tuple=True)[0] assert sampled_tokens_cpu.numel() == active_request_count - return active_request_ids, finished_request_ids, active_request_mask, survivor_idxs + return active_request_ids, finished_request_ids, active_request_mask + + def _run_async_sched_sample(self) -> _AsyncScheduleSampleResult: + """Sample active requests and start transferring their tokens to CPU. + + Returns: + _AsyncScheduleSampleResult: Base-token samples and transfer state. + """ + context = self.inference_wrapped_model.inference_context + active_request_count = context.total_request_count - context.paused_request_count + + range_push("sampling") + self._dynamic_step_sample_logits() + sampled_tokens_gpu = self._sampled_tokens_cuda[:active_request_count] + if sampled_tokens_gpu.is_cuda: + self._async_sched_sample_gpu_ready_event.record( + torch.cuda.current_stream(sampled_tokens_gpu.device) + ) + range_pop() + + sampled_tokens_cpu, _, _, sample_cpu_ready_event = self._copy_async_sched_sample_to_cpu( + sampled_tokens_gpu + ) + return _AsyncScheduleSampleResult( + sampled_tokens_gpu=sampled_tokens_gpu, + sampled_tokens_cpu_view=sampled_tokens_cpu, + sampled_mtp_tokens_gpu=None, + sampled_mtp_tokens_cpu_view=None, + accepted_tokens_cpu_view=None, + accepted_counts_gpu=None, + accepted_counts_cpu_view=None, + accepted_counts_cpu_ready_event=None, + sample_cpu_ready_event=sample_cpu_ready_event, + ) - def _run_async_sched_sample(self) -> Tensor: - """Sample active requests and record when their GPU tokens are ready. + def _run_async_sched_sample_mtp(self) -> _AsyncScheduleSampleResult: + """Verify pending MTP logits and generate the next draft tokens. Returns: - Tensor: GPU token samples for the active requests. + _AsyncScheduleSampleResult: Base, draft, accepted-token, and transfer state. """ context = self.inference_wrapped_model.inference_context active_request_count = context.total_request_count - context.paused_request_count + token_row_indices = self._async_sched_logits.token_row_indices + if token_row_indices is None: + raise RuntimeError("Pending async MTP logits are missing token-row indices.") - # Sample. range_push("sampling") + pending_input_ids = context.gpu_view.token_to_input_ids.unsqueeze(0) + self._dynamic_step_sample_logits_and_verify_tokens( + pending_input_ids, token_row_indices=token_row_indices + ) + accepted_counts_gpu = self._accepted_token_counts_per_request[:active_request_count] + if accepted_counts_gpu.is_cuda: + self._async_sched_mtp_verification_gpu_ready_event.record( + torch.cuda.current_stream(accepted_counts_gpu.device) + ) + accepted_counts_cpu, accepted_counts_cpu_ready_event = ( + self._copy_async_sched_accepted_counts_to_cpu(accepted_counts_gpu) + ) + + base_position = (context.gpu_view.token_to_pos_ids[self._last_accepted_seq_indices] + 1).to( + torch.int64 + ) + self._compute_serial_mtp_and_sample(base_position=base_position) sampled_tokens_gpu = self._sampled_tokens_cuda[:active_request_count] - torch.max( - self._all_logits_cuda.squeeze(0)[:active_request_count], - dim=-1, - out=(self._async_sched_sample_values_cuda[:active_request_count], sampled_tokens_gpu), + sampled_mtp_tokens_gpu = self._sampled_mtp_tokens_cuda[:, :active_request_count] + accepted_tokens_gpu = ( + self._accepted_tokens_per_request[:active_request_count] + if context.num_decode_requests > 0 + else None ) if sampled_tokens_gpu.is_cuda: - current_stream = torch.cuda.current_stream(sampled_tokens_gpu.device) - self._async_sched_sample_gpu_ready_event.record(current_stream) + self._async_sched_sample_gpu_ready_event.record( + torch.cuda.current_stream(sampled_tokens_gpu.device) + ) range_pop() - # Return the sampling result. - return sampled_tokens_gpu + sampled_tokens_cpu, sampled_mtp_tokens_cpu, accepted_tokens_cpu, sample_cpu_ready_event = ( + self._copy_async_sched_sample_to_cpu( + sampled_tokens_gpu, sampled_mtp_tokens_gpu, accepted_tokens_gpu + ) + ) + return _AsyncScheduleSampleResult( + sampled_tokens_gpu=sampled_tokens_gpu, + sampled_tokens_cpu_view=sampled_tokens_cpu, + sampled_mtp_tokens_gpu=sampled_mtp_tokens_gpu, + sampled_mtp_tokens_cpu_view=sampled_mtp_tokens_cpu, + accepted_tokens_cpu_view=accepted_tokens_cpu, + accepted_counts_gpu=accepted_counts_gpu, + accepted_counts_cpu_view=accepted_counts_cpu, + accepted_counts_cpu_ready_event=accepted_counts_cpu_ready_event, + sample_cpu_ready_event=sample_cpu_ready_event, + ) + + def _run_async_sched_mtp_rewind(self, sample_result: _AsyncScheduleSampleResult) -> None: + """Rewind rejected MTP KV state before preparing the successor. + + Args: + sample_result (_AsyncScheduleSampleResult): Verified MTP sampling state. + """ + accepted_counts_cpu = sample_result.accepted_counts_cpu_view + if accepted_counts_cpu is None: + raise RuntimeError("Async MTP sampling did not produce accepted-token counts.") + + self._synchronize_async_sched_event(sample_result.accepted_counts_cpu_ready_event) + + blocks_to_release, remove_mask = self._rewind_kv_cache(accepted_counts_cpu) + context = self.inference_wrapped_model.inference_context + context.kv_block_allocator.release_memory_blocks(blocks_to_release[remove_mask]) + + def _run_async_sched_log_probs( + self, sample_result: _AsyncScheduleSampleResult + ) -> Optional[_AsyncScheduleLogProbsGPUResult]: + """Calculate selected and top-n log probabilities on the GPU. + + Args: + sample_result (_AsyncScheduleSampleResult): Sampled and accepted + tokens for active requests. + + Returns: + Optional[_AsyncScheduleLogProbsGPUResult]: GPU logprob outputs and + their completion event, or `None` when no request needs logprobs. + """ + return_log_probs, return_top_n_logprobs = self._dynamic_step_log_probs_bookkeeping() + if not return_log_probs and not return_top_n_logprobs: + return None + + context = self.inference_wrapped_model.inference_context + active_request_count = context.total_request_count - context.paused_request_count + num_decode_requests = context.num_decode_requests + num_prefill_requests = active_request_count - num_decode_requests + tokens_per_request = self.num_speculative_tokens + 1 + sampled_tokens_gpu = sample_result.sampled_tokens_gpu + + if context.config.materialize_only_last_token_logits: + prefill_row_counts = [1] * num_prefill_requests + else: + active_slice = slice(context.paused_request_count, context.total_request_count) + prefill_row_counts = context.request_query_lengths[ + active_slice.start + num_decode_requests : active_slice.stop + ].tolist() + row_counts = [tokens_per_request] * num_decode_requests + prefill_row_counts + + if self.num_speculative_tokens == 0: + selected_log_probs, log_probs = context.calculate_log_probs_tensors( + self._all_logits_cuda, + sampled_tokens_gpu, + only_last_token_logits=context.config.materialize_only_last_token_logits, + sampling=self._sampling, + ) + else: + accepted_counts_gpu = sample_result.accepted_counts_gpu + if accepted_counts_gpu is None or self._accepted_tokens_per_request is None: + raise RuntimeError("Async MTP sampling did not produce accepted-token state.") + + decode_samples = sampled_tokens_gpu[:num_decode_requests] + decode_tokens = torch.cat( + ( + self._accepted_tokens_per_request[:num_decode_requests].clamp(min=0), + decode_samples.unsqueeze(1), + ), + dim=1, + ) + decode_tokens.scatter_( + 1, + accepted_counts_gpu[:num_decode_requests].unsqueeze(1), + decode_samples.unsqueeze(1), + ) + + if context.config.materialize_only_last_token_logits: + prefill_tokens = sampled_tokens_gpu[num_decode_requests:] + else: + decode_token_count = num_decode_requests * tokens_per_request + prefill_tokens = context.gpu_view.token_to_input_ids[ + decode_token_count : context.active_token_count + ].roll(-1, 0) + prefill_lengths_gpu = context.gpu_view.request_query_lengths[ + num_decode_requests:active_request_count + ] + prefill_last_token_idxs = prefill_lengths_gpu.cumsum(0) - 1 + prefill_tokens[prefill_last_token_idxs] = sampled_tokens_gpu[num_decode_requests:] + + selected_tokens = torch.cat((decode_tokens.flatten(), prefill_tokens)) + logit_count = sum(row_counts) + logits = self._all_logits_cuda[:, :logit_count, :] + row_to_request = torch.arange(active_request_count).repeat_interleave( + torch.tensor(row_counts) + ) + selected_log_probs, log_probs = context.calculate_log_probs_tensors( + logits, + selected_tokens, + only_last_token_logits=True, + sampling=self._sampling, + row_to_request=row_to_request, + ) + + top_n_counts = ( + context.active_request_metadata["top_n_logprobs"][:active_request_count].tolist() + if return_top_n_logprobs + else [0] * active_request_count + ) + skip_prompt_log_probs = context.active_request_metadata["skip_prompt_log_probs"][ + :active_request_count + ].tolist() + max_top_n = max(top_n_counts, default=0) + if max_top_n > 0: + top_n_result = torch.topk(log_probs[: sum(row_counts)], k=max_top_n, dim=-1) + top_n_log_probs = top_n_result.values + top_n_token_ids = top_n_result.indices + else: + top_n_log_probs = None + top_n_token_ids = None + + gpu_ready_event = None + if selected_log_probs.is_cuda: + current_stream = torch.cuda.current_stream(selected_log_probs.device) + self._async_sched_log_probs_gpu_ready_event.record(current_stream) + gpu_ready_event = self._async_sched_log_probs_gpu_ready_event + + return _AsyncScheduleLogProbsGPUResult( + selected_log_probs=selected_log_probs, + top_n_log_probs=top_n_log_probs, + top_n_token_ids=top_n_token_ids, + row_counts=row_counts, + top_n_counts=top_n_counts, + skip_prompt_log_probs=skip_prompt_log_probs, + num_decode_requests=num_decode_requests, + gpu_ready_event=gpu_ready_event, + ) + + def _copy_async_sched_log_probs_to_cpu( + self, gpu_result: Optional[_AsyncScheduleLogProbsGPUResult] + ) -> Optional[_AsyncScheduleLogProbsTransfer]: + """Start selected and top-n logprob transfers to reusable CPU buffers. + + Args: + gpu_result (Optional[_AsyncScheduleLogProbsGPUResult]): GPU outputs + produced by the current sampling step. + + Returns: + Optional[_AsyncScheduleLogProbsTransfer]: Transient CPU views, + transfer-completion event, and retained GPU sources, or `None`. + """ + if gpu_result is None: + return None + + selected_log_probs = gpu_result.selected_log_probs + selected_shape = selected_log_probs.shape + selected_size = selected_log_probs.numel() + max_top_n = max(gpu_result.top_n_counts, default=0) + if max_top_n > self._async_sched_top_n_capacity: + context = self.inference_wrapped_model.inference_context + buffer_size = context.max_tokens * max_top_n + self._async_sched_top_n_log_probs_cpu_buffer = torch.empty( + buffer_size, dtype=torch.float32, device="cpu", pin_memory=True + ) + self._async_sched_top_n_token_ids_cpu_buffer = torch.empty( + buffer_size, dtype=torch.int64, device="cpu", pin_memory=True + ) + self._async_sched_top_n_capacity = max_top_n + + selected_log_probs_cpu_view = self._async_sched_selected_log_probs_cpu_buffer[ + :selected_size + ].view(selected_shape) + if max_top_n > 0: + top_n_size = selected_size * max_top_n + top_n_log_probs_cpu_view = self._async_sched_top_n_log_probs_cpu_buffer[ + :top_n_size + ].view(*selected_shape, max_top_n) + top_n_token_ids_cpu_view = self._async_sched_top_n_token_ids_cpu_buffer[ + :top_n_size + ].view(*selected_shape, max_top_n) + else: + top_n_log_probs_cpu_view = None + top_n_token_ids_cpu_view = None + + cpu_ready_event = None + if selected_log_probs.is_cuda: + assert gpu_result.gpu_ready_event is not None + with torch.cuda.stream(self._async_sched_copy_stream): + self._async_sched_copy_stream.wait_event(gpu_result.gpu_ready_event) + selected_log_probs_cpu_view.copy_(selected_log_probs, non_blocking=True) + if max_top_n > 0: + assert gpu_result.top_n_log_probs is not None + assert gpu_result.top_n_token_ids is not None + assert top_n_log_probs_cpu_view is not None + assert top_n_token_ids_cpu_view is not None + top_n_log_probs_cpu_view.copy_(gpu_result.top_n_log_probs, non_blocking=True) + top_n_token_ids_cpu_view.copy_(gpu_result.top_n_token_ids, non_blocking=True) + self._async_sched_log_probs_cpu_ready_event.record(self._async_sched_copy_stream) + cpu_ready_event = self._async_sched_log_probs_cpu_ready_event + else: + selected_log_probs_cpu_view.copy_(selected_log_probs) + if max_top_n > 0: + assert gpu_result.top_n_log_probs is not None + assert gpu_result.top_n_token_ids is not None + assert top_n_log_probs_cpu_view is not None + assert top_n_token_ids_cpu_view is not None + top_n_log_probs_cpu_view.copy_(gpu_result.top_n_log_probs) + top_n_token_ids_cpu_view.copy_(gpu_result.top_n_token_ids) + + return _AsyncScheduleLogProbsTransfer( + selected_log_probs_cpu_view=selected_log_probs_cpu_view, + top_n_log_probs_cpu_view=top_n_log_probs_cpu_view, + top_n_token_ids_cpu_view=top_n_token_ids_cpu_view, + row_counts=gpu_result.row_counts, + top_n_counts=gpu_result.top_n_counts, + skip_prompt_log_probs=gpu_result.skip_prompt_log_probs, + num_decode_requests=gpu_result.num_decode_requests, + cpu_ready_event=cpu_ready_event, + gpu_result=gpu_result, + ) + + @staticmethod + def _materialize_async_sched_log_probs( + transfer: Optional[_AsyncScheduleLogProbsTransfer], + accepted_counts_cpu: Optional[Tensor] = None, + ) -> Tuple[Optional[List[List[float]]], Optional[Dict[int, List[Tuple[Tensor, Tensor]]]]]: + """Convert completed CPU transfer views to the legacy result format. + + Args: + transfer (Optional[_AsyncScheduleLogProbsTransfer]): Completed + logprob transfer for the current step. + accepted_counts_cpu (Optional[Tensor]): Accepted MTP draft count per + active request, or `None` for one-token decoding. + + Returns: + Tuple containing selected logprobs per request and optional top-n + values/token IDs per request. + """ + if transfer is None: + return None, None + + accepted_counts = accepted_counts_cpu.tolist() if accepted_counts_cpu is not None else None + row_offset = 0 + log_probs = [] + top_n_logprobs = {} + for request_idx, (row_count, top_n, skip_prompt) in enumerate( + zip(transfer.row_counts, transfer.top_n_counts, transfer.skip_prompt_log_probs) + ): + is_decode = request_idx < transfer.num_decode_requests + emitted_count = ( + accepted_counts[request_idx] + 1 + if is_decode and accepted_counts is not None + else row_count + ) + emitted_slice = slice(row_offset, row_offset + emitted_count) + log_probs.append(transfer.selected_log_probs_cpu_view[emitted_slice].tolist()) + + if top_n > 0: + assert transfer.top_n_log_probs_cpu_view is not None + assert transfer.top_n_token_ids_cpu_view is not None + if not is_decode and skip_prompt: + top_n_row_idxs = [row_offset + row_count - 1] + else: + top_n_row_idxs = range(row_offset, row_offset + emitted_count) + top_n_logprobs[request_idx] = [ + ( + transfer.top_n_log_probs_cpu_view[token_idx, :top_n].clone(), + transfer.top_n_token_ids_cpu_view[token_idx, :top_n].clone(), + ) + for token_idx in top_n_row_idxs + ] + row_offset += row_count + + return log_probs, top_n_logprobs or None def _run_async_sched_prepare(self) -> Tuple[Tensor, Tensor]: """Prepare decode requests and return live GPU forward-input views. @@ -1998,18 +2586,20 @@ def _run_async_sched_publish_bookkeeping(self) -> Optional[torch.cuda.Event]: skip_token_input_ids=True, record_done_event=True ) + def _commit_mamba_intermediate_states(self) -> None: + """Commit prefix-cacheable Mamba states produced by the current forward.""" + context = self.inference_wrapped_model.inference_context + if context.is_hybrid_model and context.mamba_slot_allocator is not None: + context.mamba_slot_allocator.commit_intermediate_states() + def _run_async_sched_forward( self, input_ids_gpu_view: Tensor, position_ids_gpu_view: Tensor - ) -> Optional[torch.cuda.Event]: + ) -> None: """Run one dynamic forward pass and cache logits for async scheduling. Args: input_ids_gpu_view (Tensor): Live GPU view of the input token IDs. position_ids_gpu_view (Tensor): Live GPU view of the position IDs. - - Returns: - Optional[torch.cuda.Event]: Event marking forward completion, or - `None` when no CUDA work was recorded. """ context = self.inference_wrapped_model.inference_context cuda_graph_request_count = ( @@ -2019,16 +2609,24 @@ def _run_async_sched_forward( # Forward. range_push("forward_pass") self._dynamic_step_forward_logits(input_ids_gpu_view, position_ids_gpu_view) + self._commit_mamba_intermediate_states() range_pop() - # Record forward completion. - forward_done_event = self._record_fresh_async_sched_event(self._all_logits_cuda) + # Record the logits and identity mapping for this forward's input rows. + token_row_indices = None + if self._async_sched_mtp_token_row_indices is not None: + token_row_indices = self._async_sched_mtp_token_row_indices[ + : context.active_token_count + ] + self._async_sched_logits.set_pending(cuda_graph_request_count, token_row_indices) - # Record the logits that this forward will produce. - self._async_sched_logits.set_pending(cuda_graph_request_count, forward_done_event) + def _run_dummy_async_sched_base_step(self) -> None: + """Run the base-forward half of an async EP step after local work finishes.""" + context = self.inference_wrapped_model.inference_context - # Return the forward-done event. - return forward_done_event + input_ids, position_ids, _ = self._dynamic_step_context_init(is_dummy_forward=True) + self._run_dummy_base_forward(input_ids, position_ids) + context.reset(preserve_prefix_cache=True, preserve_counters=True) def _run_async_sched_forward_primer(self) -> Tuple[bool, Optional[torch.cuda.Event]]: """Launch the initial forward when no valid logits state exists. @@ -2045,115 +2643,267 @@ def _run_async_sched_forward_primer(self) -> Tuple[bool, Optional[torch.cuda.Eve input_ids_gpu_view, position_ids_gpu_view, bookkeeping_done_event = ( self._dynamic_step_context_init(record_bookkeeping_done_event=True) ) + if self.num_speculative_tokens > 0 and self.model_config.expert_model_parallel_size > 1: + self._run_dummy_serial_mtp_forward() self._run_async_sched_forward(input_ids_gpu_view, position_ids_gpu_view) return True, bookkeeping_done_event def _run_async_sched_resolve( - self, - sampled_tokens_cpu_view: Tensor, - forward_done_event: Optional[torch.cuda.Event], - overlap: bool, - ) -> _AsyncScheduleResolveResult: + self, sample_result: _AsyncScheduleSampleResult, resolved_sequence_lengths: Tensor + ) -> _AsyncScheduleRequestResult: """Resolve request state and compact speculative forward logits. Args: - sampled_tokens_cpu_view (Tensor): Transient view of sampled tokens - in the reusable pinned CPU buffer. - forward_done_event (Optional[torch.cuda.Event]): Event marking - speculative forward completion. - overlap (bool): Whether the speculative forward may still be running. + sample_result (_AsyncScheduleSampleResult): Sampling outputs in reusable CPU views. + resolved_sequence_lengths (Tensor): Sequence lengths after accepting + current output and before preparing unverified successor tokens. Returns: - _AsyncScheduleResolveResult: Sampled tokens, resolved request row - sets, and any logits-compaction completion event. + _AsyncScheduleRequestResult: Sampled tokens, resolved request row + sets, and survivor indices. """ context = self.inference_wrapped_model.inference_context # Clone the transient D2H view before the next step can reuse its buffer. range_push("active_request_mask") - sampled_tokens_cpu = sampled_tokens_cpu_view.clone() - context.commit_sampled_tokens(sampled_tokens_cpu) - (active_request_ids, finished_request_ids, active_request_mask, survivor_idxs) = ( - self._build_async_sched_request_state(sampled_tokens_cpu) + sampled_tokens_cpu = sample_result.sampled_tokens_cpu_view.clone() + accepted_tokens_cpu = ( + sample_result.accepted_tokens_cpu_view.clone() + if sample_result.accepted_tokens_cpu_view is not None + else None + ) + active_request_ids, finished_request_ids, active_request_mask = ( + self._build_async_sched_request_state(sampled_tokens_cpu, resolved_sequence_lengths) ) range_pop() - # Finish the speculative forward before releasing finished-request resources. - if overlap and survivor_idxs.numel() < active_request_ids.numel(): - self._synchronize_async_sched_event(forward_done_event) - # Resolve CPU request lifecycle state. range_push("resolve_requests") - resolved_finished_request_ids = context.resolve_requests(active_request_mask) + resolved_finished_request_ids, survivor_idxs = context.resolve_requests(active_request_mask) range_pop() assert torch.equal(finished_request_ids, resolved_finished_request_ids) - # Compact only when survivor rows moved. - compaction_done_event = self._compact_async_sched_logits(survivor_idxs) + # Enqueue compaction behind the successor forward on the current CUDA stream. + self._compact_async_sched_logits(survivor_idxs) # Return the resolution result. - return _AsyncScheduleResolveResult( + return _AsyncScheduleRequestResult( sampled_tokens_cpu=sampled_tokens_cpu, + accepted_tokens_cpu=accepted_tokens_cpu, active_request_ids=active_request_ids, finished_request_ids=finished_request_ids, - compaction_done_event=compaction_done_event, + survivor_idxs=survivor_idxs, ) - async def _run_async_sched_step(self, *, overlap: bool) -> Optional[Dict]: - """Run one decode-only step using the async scheduling path. + def _run_async_sched_update_requests( + self, sample_result: _AsyncScheduleSampleResult, resolved_sequence_lengths: Tensor + ) -> _AsyncScheduleRequestResult: + """Run complete request lifecycle bookkeeping for a no-overlap step. + + Args: + sample_result (_AsyncScheduleSampleResult): Sampling outputs in reusable CPU views. + resolved_sequence_lengths (Tensor): Sequence lengths after accepting + the current output. + + Returns: + _AsyncScheduleRequestResult: Stable sampled output and lifecycle results. + """ + context = self.inference_wrapped_model.inference_context + + sampled_tokens_cpu = sample_result.sampled_tokens_cpu_view.clone() + accepted_tokens_cpu = ( + sample_result.accepted_tokens_cpu_view.clone() + if sample_result.accepted_tokens_cpu_view is not None + else None + ) + active_request_ids, finished_request_ids, active_request_mask = ( + self._build_async_sched_request_state(sampled_tokens_cpu, resolved_sequence_lengths) + ) - The first decode step launches and completes a forward primer so logits - exist. Steady-state overlap follows this schedule:: + mutable_sampled_tokens_cpu = sampled_tokens_cpu.clone() + mutable_sampled_mtp_tokens_cpu = ( + sample_result.sampled_mtp_tokens_cpu_view.clone() + if sample_result.sampled_mtp_tokens_cpu_view is not None + else None + ) - CPU: prepare request state N+1 - compute stream: forward N -> sample N -> copy input N+1 - -> publish metadata N+1 -> forward N+1 - copy stream: wait for sample/input copy -> copy sample N to CPU - CPU: wait for required copies -> resolve N - while forward N+1 continues + range_push("update_requests") + update_result = context.update_requests( + active_request_mask, mutable_sampled_tokens_cpu, mutable_sampled_mtp_tokens_cpu + ) + range_pop() + update_result = update_result or {} - Serial mode uses the same operation order but host-synchronizes at each - boundary. Input and position tensors are live GPU views populated by - stream-ordered copies before forward execution. CPU resolution cannot - mutate bookkeeping until its H2D completes, and finished-request - resources cannot be released until the forward using them completes. + return _AsyncScheduleRequestResult( + sampled_tokens_cpu=sampled_tokens_cpu, + accepted_tokens_cpu=accepted_tokens_cpu, + active_request_ids=active_request_ids, + finished_request_ids=finished_request_ids, + newly_paused_request_ids=update_result.get("newly_paused_request_ids"), + evict_request_ids=update_result.get("evict_request_ids"), + ) + + def _build_async_sched_step_result( + self, + request_result: _AsyncScheduleRequestResult, + cuda_graph_request_count: Optional[int], + decode_only: DecodeOnly, + log_probs: Optional[List[List[float]]], + top_n_logprobs: Optional[Dict[int, List[Tuple[Tensor, Tensor]]]], + *, + count_compaction: bool, + ) -> DynamicBatchControllerStepResult: + """Build the public result and update async-scheduling counters. Args: - overlap (bool): Whether to submit the next forward before waiting - for current-step GPU work. + request_result (_AsyncScheduleRequestResult): Completed request bookkeeping. + cuda_graph_request_count (Optional[int]): CUDA graph request count used + by the consumed forward. + decode_only (DecodeOnly): Decode-only state for the consumed and + launched forwards. + log_probs (Optional[List[List[float]]]): Selected-token log probabilities + grouped by active request. + top_n_logprobs (Optional[Dict[int, List[Tuple[Tensor, Tensor]]]]): Top-n + log probabilities and token IDs grouped by active request. + count_compaction (bool): Whether finished requests discarded successor rows. Returns: - Optional[Dict]: Step result for sampled and finished requests, or - `None` when no requests are active. + DynamicBatchControllerStepResult: Completed sampled-step result. """ context = self.inference_wrapped_model.inference_context + context.async_sched_step_count += 1 + if count_compaction and request_result.finished_request_ids.numel() > 0: + context.async_sched_compaction_step_count += 1 + + return DynamicBatchControllerStepResult( + decode_only=decode_only, + output={ + "active_request_ids": request_result.active_request_ids, + "finished_request_ids": request_result.finished_request_ids, + "sample": request_result.sampled_tokens_cpu, + "finished_routing_block_ids": {}, + "newly_paused_request_ids": request_result.newly_paused_request_ids, + "evict_request_ids": request_result.evict_request_ids, + "accepted_tokens": request_result.accepted_tokens_cpu, + "log_probs": log_probs, + "top_n_logprobs": top_n_logprobs, + "cuda_graph_request_count": cuda_graph_request_count, + }, + ) - # Validate async scheduling support. - self._validate_async_sched_support_for_step() + async def _run_async_sched_step_no_overlap( + self, *, schedule_waiting_requests: Optional[Callable[[], None]] + ) -> DynamicBatchControllerStepResult: + """Run ``sample/MTP -> update -> admit -> forward``. - # Clear pending logits and stop when there is no active work. - active_request_count = context.total_request_count - context.paused_request_count - if context.active_token_count == 0 and active_request_count == 0: - self._async_sched_logits.clear() - return None + The first call in an active chain has no pending output. It skips the + first two phases, admits requests, and launches a primer-only forward. - # ------------------------------------------------------------------------- - # Primer - # ------------------------------------------------------------------------- - # Launch the forward primer if no existing logits state can be reused. - primer_launched, primer_bookkeeping_done_event = self._run_async_sched_forward_primer() + Args: + schedule_waiting_requests (Optional[Callable[[], None]]): Engine callback + that admits eligible non-chunked prefill requests. + + Returns: + DynamicBatchControllerStepResult: Primer-only state or sampled output. + """ + context = self.inference_wrapped_model.inference_context + had_pending_forward = self._async_sched_logits.is_valid + consumed_decode_only = context.is_decode_only() if had_pending_forward else None + launched_decode_only = None + request_result = None + cuda_graph_request_count = None + log_probs_transfer = None + + with torch.inference_mode(): + if had_pending_forward: + cuda_graph_request_count = self._async_sched_logits.cuda_graph_request_count + + # ------------------------------------------------------------------------- + # Sample/MTP + # ------------------------------------------------------------------------- + if self.num_speculative_tokens > 0: + sample_result = self._run_async_sched_sample_mtp() + self._run_async_sched_mtp_rewind(sample_result) + else: + sample_result = self._run_async_sched_sample() + + log_probs_gpu_result = self._run_async_sched_log_probs(sample_result) + log_probs_transfer = self._copy_async_sched_log_probs_to_cpu(log_probs_gpu_result) + + self._synchronize_async_sched_event(sample_result.sample_cpu_ready_event) + + # ------------------------------------------------------------------------- + # Update + # ------------------------------------------------------------------------- + resolved_sequence_lengths = context.get_active_sequence_lengths() + 1 + + self._async_sched_logits.clear() + request_result = self._run_async_sched_update_requests( + sample_result, resolved_sequence_lengths + ) + + # ------------------------------------------------------------------------- + # Admit + # ------------------------------------------------------------------------- + # This is the only async-scheduling admission mutation point. + if schedule_waiting_requests is not None: + schedule_waiting_requests() + + # ------------------------------------------------------------------------- + # Forward + # ------------------------------------------------------------------------- + active_request_count = context.total_request_count - context.paused_request_count + if active_request_count > 0: + if had_pending_forward: + input_ids, position_ids, _ = self._dynamic_step_context_init() + launched_decode_only = context.is_decode_only() + self._run_async_sched_forward(input_ids, position_ids) + else: + primer_launched, bookkeeping_done_event = self._run_async_sched_forward_primer() + assert primer_launched, "Initial no-overlap step must launch a forward primer." + launched_decode_only = context.is_decode_only() + self._synchronize_async_sched_event(bookkeeping_done_event) + elif had_pending_forward and self.model_config.expert_model_parallel_size > 1: + self._run_dummy_async_sched_base_step() + + decode_only = DecodeOnly(consumed=consumed_decode_only, launched=launched_decode_only) + if not had_pending_forward: + assert active_request_count > 0, "Async no-overlap admission did not add a request." + return DynamicBatchControllerStepResult(decode_only=decode_only, primer_only=True) + + if log_probs_transfer is not None: + self._synchronize_async_sched_event(log_probs_transfer.cpu_ready_event) + log_probs, top_n_logprobs = self._materialize_async_sched_log_probs( + log_probs_transfer, + sample_result.accepted_counts_cpu_view if self.num_speculative_tokens > 0 else None, + ) + result = self._build_async_sched_step_result( + request_result, + cuda_graph_request_count, + decode_only, + log_probs, + top_n_logprobs, + count_compaction=False, + ) + await asyncio.sleep(0) + return result + + async def _run_async_sched_step_overlap(self) -> DynamicBatchControllerStepResult: + """Run ``prepare -> sample -> forward -> resolve`` with one token per request. + + Returns: + DynamicBatchControllerStepResult: Completed sampled-step result. + """ + context = self.inference_wrapped_model.inference_context + assert self._async_sched_logits.is_valid, "Async overlap requires pending logits." + consumed_decode_only = context.is_decode_only() with torch.inference_mode(): - current_logits_ready_event = self._async_sched_logits.ready_event cuda_graph_request_count = self._async_sched_logits.cuda_graph_request_count - # Serial mode waits for logits; overlap only waits for a new primer's H2D source read. - if not overlap: - self._synchronize_async_sched_event(current_logits_ready_event) - elif primer_launched: - self._synchronize_async_sched_event(primer_bookkeeping_done_event) + resolved_sequence_lengths = context.get_active_sequence_lengths() + 1 # ------------------------------------------------------------------------- # Prepare @@ -2162,24 +2912,23 @@ async def _run_async_sched_step(self, *, overlap: bool) -> Optional[Dict]: range_push("prepare_requests") input_ids_gpu_view, position_ids_gpu_view = self._run_async_sched_prepare() range_pop() + launched_decode_only = context.is_decode_only() + decode_only = DecodeOnly(consumed=consumed_decode_only, launched=launched_decode_only) + assert ( + consumed_decode_only and launched_decode_only + ), "Async overlap requires decode-only consumed and launched work." # ------------------------------------------------------------------------- # Sample # ------------------------------------------------------------------------- # Enqueue sampling behind the current logits-producing work. - sampled_tokens_gpu = self._run_async_sched_sample() + sample_result = self._run_async_sched_sample() # Populate the next forward's input-ID view directly from GPU samples. - context.copy_async_sched_sample_to_forward(sampled_tokens_gpu) + context.copy_async_sched_sample_to_forward(sample_result.sampled_tokens_gpu) - # Start D2H after sampling; it may overlap the GPU input-ID copy. - sampled_tokens_cpu_view, sample_cpu_ready_event = self._copy_async_sched_sample_to_cpu( - sampled_tokens_gpu - ) - - # Serial mode needs the CPU sample before proceeding. - if not overlap: - self._synchronize_async_sched_event(sample_cpu_ready_event) + log_probs_gpu_result = self._run_async_sched_log_probs(sample_result) + log_probs_transfer = self._copy_async_sched_log_probs_to_cpu(log_probs_gpu_result) # ------------------------------------------------------------------------- # Forward @@ -2189,79 +2938,145 @@ async def _run_async_sched_step(self, *, overlap: bool) -> Optional[Dict]: bookkeeping_done_event = self._run_async_sched_publish_bookkeeping() range_pop() - # Serial mode completes publication before submitting the forward. - if not overlap: - self._synchronize_async_sched_event(bookkeeping_done_event) - - # The compute stream orders both input updates before forward N+1. range_push("async_sched_forward_pass") - forward_done_event = self._run_async_sched_forward( - input_ids_gpu_view, position_ids_gpu_view - ) + self._run_async_sched_forward(input_ids_gpu_view, position_ids_gpu_view) range_pop() - # Serial mode completes forward N+1 before resolving N. - if not overlap: - self._synchronize_async_sched_event(forward_done_event) - # ------------------------------------------------------------------------- # Resolve # ------------------------------------------------------------------------- - # Resolution reads the CPU sample and mutates the H2D source buffer. - if overlap: - self._synchronize_async_sched_event(sample_cpu_ready_event) - self._synchronize_async_sched_event(bookkeeping_done_event) - - # Resolve N while forward N+1 continues unless finished resources are needed. - resolve_result = self._run_async_sched_resolve( - sampled_tokens_cpu_view, forward_done_event, overlap - ) + # Wait for the CPU sample and the published bookkeeping snapshot. + self._synchronize_async_sched_event(sample_result.sample_cpu_ready_event) + self._synchronize_async_sched_event(bookkeeping_done_event) - # Serial mode completes any survivor compaction before returning. - if not overlap: - self._synchronize_async_sched_event(resolve_result.compaction_done_event) + # Resolve N while forward N+1 continues. + resolve_result = self._run_async_sched_resolve(sample_result, resolved_sequence_lengths) - # Count async steps and steps that logically discarded speculative rows. - context.async_sched_step_count += 1 - if resolve_result.finished_request_ids.numel() > 0: - context.async_sched_compaction_step_count += 1 + # Commit CPU input IDs in the resolved survivor order. + context.commit_sampled_tokens( + resolve_result.sampled_tokens_cpu[resolve_result.survivor_idxs] + ) - result = { - "active_request_ids": resolve_result.active_request_ids, - "finished_request_ids": resolve_result.finished_request_ids, - "sample": resolve_result.sampled_tokens_cpu, - "finished_routing_block_ids": {}, - "newly_paused_request_ids": None, - "evict_request_ids": None, - "accepted_tokens": None, - "log_probs": None, - "top_n_logprobs": None, - "cuda_graph_request_count": cuda_graph_request_count, - } + if log_probs_transfer is not None: + self._synchronize_async_sched_event(log_probs_transfer.cpu_ready_event) + log_probs, top_n_logprobs = self._materialize_async_sched_log_probs(log_probs_transfer) + result = self._build_async_sched_step_result( + resolve_result, + cuda_graph_request_count, + decode_only, + log_probs, + top_n_logprobs, + count_compaction=True, + ) # Yield only after resolution is complete and forward N+1 is already submitted. await asyncio.sleep(0) + return result + + async def _run_async_sched_step_overlap_mtp(self) -> DynamicBatchControllerStepResult: + """Run ``sample/MTP -> prepare -> forward -> resolve`` with MTP. + + Returns: + DynamicBatchControllerStepResult: Completed sampled-step result. + """ + context = self.inference_wrapped_model.inference_context + assert self._async_sched_logits.is_valid, "Async MTP overlap requires pending logits." + consumed_decode_only = context.is_decode_only() + + with torch.inference_mode(): + cuda_graph_request_count = self._async_sched_logits.cuda_graph_request_count + + # ------------------------------------------------------------------------- + # Sample/MTP + # ------------------------------------------------------------------------- + # Verify pending drafts, sample replacements, and rewind rejected KV state. + sample_result = self._run_async_sched_sample_mtp() + self._run_async_sched_mtp_rewind(sample_result) + resolved_sequence_lengths = context.get_active_sequence_lengths() + 1 + log_probs_gpu_result = self._run_async_sched_log_probs(sample_result) + log_probs_transfer = self._copy_async_sched_log_probs_to_cpu(log_probs_gpu_result) + + # ------------------------------------------------------------------------- + # Prepare + # ------------------------------------------------------------------------- + # Prepare CPU state and live GPU views using the verified sequence lengths. + range_push("prepare_requests") + input_ids_gpu_view, position_ids_gpu_view = self._run_async_sched_prepare() + range_pop() + launched_decode_only = context.is_decode_only() + decode_only = DecodeOnly(consumed=consumed_decode_only, launched=launched_decode_only) + assert ( + consumed_decode_only and launched_decode_only + ), "Async MTP overlap requires decode-only consumed and launched work." + # Populate the next forward with the sampled base and draft tokens. + context.copy_async_sched_sample_to_forward( + sample_result.sampled_tokens_gpu, sample_result.sampled_mtp_tokens_gpu + ) + + # ------------------------------------------------------------------------- + # Forward + # ------------------------------------------------------------------------- + # Publish positions and metadata without overwriting GPU-resident input IDs. + range_push("async_sched_transfer_bookkeeping_to_gpu") + bookkeeping_done_event = self._run_async_sched_publish_bookkeeping() + range_pop() + + range_push("async_sched_forward_pass") + self._run_async_sched_forward(input_ids_gpu_view, position_ids_gpu_view) + range_pop() + + # ------------------------------------------------------------------------- + # Resolve + # ------------------------------------------------------------------------- + # Wait for the CPU samples and the published bookkeeping snapshot. + self._synchronize_async_sched_event(sample_result.sample_cpu_ready_event) + self._synchronize_async_sched_event(bookkeeping_done_event) + + resolve_result = self._run_async_sched_resolve(sample_result, resolved_sequence_lengths) + + # Commit CPU input IDs in the resolved survivor order. + survivor_idxs = resolve_result.survivor_idxs + sampled_mtp_tokens_cpu = ( + sample_result.sampled_mtp_tokens_cpu_view[:, survivor_idxs] + if sample_result.sampled_mtp_tokens_cpu_view is not None + else None + ) + context.commit_sampled_tokens( + resolve_result.sampled_tokens_cpu[survivor_idxs], sampled_mtp_tokens_cpu + ) + + if log_probs_transfer is not None: + self._synchronize_async_sched_event(log_probs_transfer.cpu_ready_event) + log_probs, top_n_logprobs = self._materialize_async_sched_log_probs( + log_probs_transfer, sample_result.accepted_counts_cpu_view + ) + result = self._build_async_sched_step_result( + resolve_result, + cuda_graph_request_count, + decode_only, + log_probs, + top_n_logprobs, + count_compaction=True, + ) + await asyncio.sleep(0) return result # ------------------------------------------------------------------------- # End async scheduling methods # ------------------------------------------------------------------------- - async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Optional[Dict]: + async def _run_legacy_step( + self, skip_bookkeeping: Optional[bool] = False + ) -> DynamicBatchControllerStepResult: """Forward step the model and update the inference context. Args: skip_bookkeeping (Optional[bool]): If true, skip the context bookkeeping step. Returns: - (Optional[Dict]): A dictionary containing: - active_request_ids (Tensor): Current active request IDs. - newly_paused_request_ids (Tensor): Newly paused request IDs. - finished_request_ids (Tensor): Finished request IDs. - sample (Tensor): New sample. - log_probs (Optional[Tensor]): Log probabilities of the new sample, if requested. - cuda_graph_request_count (Optional[int]): Size of cuda graph used for this step. + DynamicBatchControllerStepResult: Legacy sampled-step output and its + decode-only state. """ context = self.inference_wrapped_model.inference_context self._async_sched_logits.clear() @@ -2269,10 +3084,13 @@ async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Op # No tokens and no active requests? if context.active_token_count == 0 and active_request_count == 0: - return None + return DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=None, launched=None) + ) with torch.inference_mode(): input_ids, position_ids, _ = self._dynamic_step_context_init() + is_decode_only = context.is_decode_only() cuda_graph_request_count = ( context.padded_active_request_count @@ -2294,8 +3112,7 @@ async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Op # may swap request indices. The Python lists tracking EOS block IDs # and intermediate offsets are not swapped along with tensors, so # commit must run while indices are still valid. - if context.is_hybrid_model and context.mamba_slot_allocator is not None: - context.mamba_slot_allocator.commit_intermediate_states() + self._commit_mamba_intermediate_states() # Collect flat routing indices and scatter them into per-block storage. # Must be done before update_requests while token-to-block mappings are valid. @@ -2409,41 +3226,78 @@ async def _run_legacy_step(self, skip_bookkeeping: Optional[bool] = False) -> Op self._accepted_tokens_per_request.fill_(-1) self._accepted_token_counts_per_request.fill_(0) ret.update(request_bookkeeping) - return ret + return DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=is_decode_only, launched=is_decode_only), output=ret + ) async def async_generate_output_tokens_dynamic_batch( - self, skip_bookkeeping: Optional[bool] = False - ) -> Optional[Dict]: + self, + skip_bookkeeping: Optional[bool] = False, + *, + run_async_overlap: bool = True, + schedule_waiting_requests: Optional[Callable[[], None]] = None, + ) -> DynamicBatchControllerStepResult: """Forward step the model and update the inference context. Args: skip_bookkeeping (Optional[bool]): If true, skip context bookkeeping on the legacy path. + run_async_overlap (bool): Whether to run the overlap ordering. + schedule_waiting_requests (Optional[Callable[[], None]]): Engine callback + used by the no-overlap path to admit eligible prefill requests. Returns: - Optional[Dict]: Step result for sampled and finished requests, or - `None` when no requests are active. + DynamicBatchControllerStepResult: One controller-step result. """ context = self.inference_wrapped_model.inference_context mode = context.config.async_sched_mode - if mode == AsyncScheduleMode.LEGACY or context.num_prefill_requests != 0: + if mode == AsyncScheduleMode.LEGACY: return await self._run_legacy_step(skip_bookkeeping) - if mode == AsyncScheduleMode.SERIAL: - assert not skip_bookkeeping, "Async scheduling requires request bookkeeping." - return await self._run_async_sched_step(overlap=False) - if mode == AsyncScheduleMode.OVERLAP: - assert not skip_bookkeeping, "Async scheduling requires request bookkeeping." - return await self._run_async_sched_step(overlap=True) - raise AssertionError(f"Unexpected async scheduling mode: {mode}") + if mode != AsyncScheduleMode.ASYNC: + raise AssertionError(f"Unexpected async scheduling mode: {mode}") + + assert not skip_bookkeeping, "Async scheduling requires request bookkeeping." + self._validate_async_sched_support_for_step(run_async_overlap) + + active_request_count = context.total_request_count - context.paused_request_count + if context.active_token_count == 0 and active_request_count == 0 and run_async_overlap: + self._async_sched_logits.clear() + return DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=None, launched=None) + ) + + if not run_async_overlap or not self._async_sched_logits.is_valid: + return await self._run_async_sched_step_no_overlap( + schedule_waiting_requests=schedule_waiting_requests + ) + if self.num_speculative_tokens > 0: + return await self._run_async_sched_step_overlap_mtp() + return await self._run_async_sched_step_overlap() @torch.inference_mode() def generate_output_tokens_dynamic_batch( self, loop: Optional[asyncio.AbstractEventLoop] = None ) -> Optional[Dict]: - """Synchronous wrapper for `self.async_generate_output_tokens_dynamic_batch.""" + """Synchronously run dynamic batching through any primer-only calls. + + Args: + loop (Optional[asyncio.AbstractEventLoop]): Event loop used to run + the asynchronous controller. + + Returns: + Optional[Dict]: Step output, or `None` when no work is active. + """ loop = get_asyncio_loop(loop) - return loop.run_until_complete(self.async_generate_output_tokens_dynamic_batch()) + while True: + context = self.inference_wrapped_model.inference_context + result = loop.run_until_complete( + self.async_generate_output_tokens_dynamic_batch( + run_async_overlap=context.can_prepare_requests() + ) + ) + if not result.primer_only: + return result.output def _update_top_n_logprobs_dict( self, diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 8b9571f736b..fd30984b38a 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2147,13 +2147,11 @@ def _add_inference_args(parser): '--deterministic-mode also uses the same seed on every DP rank.') group.add_argument('--inference-dynamic-batching-async-sched-mode', type=str, default='legacy', - choices=['legacy', 'serial', 'overlap'], + choices=['legacy', 'async'], help='Async scheduling mode for dynamic batching. ' '"legacy" (default) preserves the existing resolve-before-prepare ' - 'path. "serial" speculatively prepares and forwards decode-only ' - 'steps before resolving finished requests. "overlap" uses the same ' - 'async scheduling path while overlapping prepare/sample and ' - 'forward/resolve phases.') + 'path. "async" overlaps asynchronous scheduling phases by reordering ' + 'them to prepare-before-resolve.') group.add_argument('--inference-dynamic-batching-logprobs-mode', type=str, default='raw_logprobs', choices=['raw_logprobs', 'processed_logprobs'], diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index 59f545b8525..017ba3966c1 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -141,11 +141,10 @@ class InferenceSetupConfig: generation seed. Disable with --use-same-sampling-seed-across-dp-ranks. Also forced off when --deterministic-mode is enabled.""" - inference_dynamic_batching_async_sched_mode: Literal["legacy", "serial", "overlap"] = "legacy" + inference_dynamic_batching_async_sched_mode: Literal["legacy", "async"] = "legacy" """Async scheduling mode for dynamic batching. "legacy" (default) preserves the - existing resolve-before-prepare path. "serial" speculatively prepares and forwards decode-only - steps before resolving finished requests. "overlap" uses the same async scheduling path while - overlapping prepare/sample and forward/resolve phases.""" + existing resolve-before-prepare path. "async" overlaps asynchronous scheduling phases by + reordering them to prepare-before-resolve.""" inference_dynamic_batching_logprobs_mode: Literal["raw_logprobs", "processed_logprobs"] = ( "raw_logprobs" diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/golden_values_dev_dgx_h100.json new file mode 100644 index 00000000000..38f7ec167de --- /dev/null +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/golden_values_dev_dgx_h100.json @@ -0,0 +1,104 @@ +{ + "0": { + "generated_tokens": [ + 2157, + 1395, + 1605, + 1046, + 2157, + 1395, + 1261, + 3535, + 2478, + 1636, + 1710, + 2012, + 1261, + 6854, + 1435, + 1261, + 1289, + 1490, + 3206, + 1044, + 1321, + 2478, + 1636, + 1710, + 2012, + 1261, + 6854, + 1435, + 1261, + 15353 + ] + }, + "1": { + "generated_tokens": [ + 2157, + 1395, + 1605, + 1046, + 2157, + 1395, + 1261, + 3535, + 2478, + 1636, + 1710, + 2012, + 1261, + 6854, + 1435, + 1261, + 1289, + 1490, + 3206, + 1044, + 1321, + 1636, + 1710, + 2012, + 1261, + 6854, + 1435, + 1261, + 1289, + 1490 + ] + }, + "2": { + "generated_tokens": [ + 2157, + 1395, + 1605, + 1046, + 2157, + 1395, + 1261, + 3535, + 2478, + 1636, + 1710, + 2012, + 1261, + 6854, + 1435, + 1261, + 1289, + 1490, + 3206, + 1044, + 1321, + 2478, + 1636, + 1710, + 2012, + 1261, + 6854, + 1435, + 1261, + 15353 + ] + } +} diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml new file mode 100644 index 00000000000..2a02eaf9bae --- /dev/null +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml @@ -0,0 +1,74 @@ +ENV_VARS: + CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 + NCCL_ALGO: Ring + CUBLAS_WORKSPACE_CONFIG: :4096:8 + TRITON_CACHE_AUTOTUNING: 0 + MAMBA_DETERMINISTIC: 1 +TEST_TYPE: frozen-start +MODE: inference +MODEL_ARGS: + --log-num-zeros-in-grad: true + --log-validation-ppl-to-tensorboard: true + --log-timers-to-tensorboard: true + --log-memory-to-tensorboard: true + --timing-log-level: 0 + --load: ${CHECKPOINT_LOAD_PATH}/model/mamba_hybrid_2b/dcp/mcore-v1_bf16/checkpoint + --tokenizer-model: ${CHECKPOINT_LOAD_PATH}/model/mamba_hybrid_2b/dcp/mcore-v1_bf16/multiMixV8.gpt4o_nc_sd.500000.128k.vocab.json + --tokenizer-type: TikTokenizer + --tiktoken-pattern: v2 + --distributed-backend: nccl + --log-interval: 1 + --transformer-impl: transformer_engine + --tensor-model-parallel-size: 1 + --pipeline-model-parallel-size: 1 + --expert-model-parallel-size: 1 + --use-mcore-models: true + --model-provider: hybrid + --init-method-std: 0.0198 + --untie-embeddings-and-output-weights: true + --disable-bias-linear: true + --init-method-std: 0.014 + --position-embedding-type: none + --hidden-size: 2048 + --ffn-hidden-size: 11264 + --num-attention-heads: 16 + --kv-channels: 128 + --hybrid-layer-pattern: M-M-M-M*-M-M-M-M*-M-M-M-M-M*-M-M-M-M-M*-M-M-M-M-M- + --spec: megatron.core.models.hybrid.hybrid_layer_specs hybrid_stack_spec + --normalization: RMSNorm + --swiglu: true + --attention-dropout: 0.0 + --hidden-dropout: 0.0 + --seq-length: 4096 + --max-position-embeddings: 4096 + --micro-batch-size: 1 + --ckpt-format: torch_dist + --ckpt-fully-parallel-save: true + --ckpt-fully-parallel-load: true + --ckpt-assume-constant-structure: true + --dist-ckpt-strictness: log_unexpected + --bf16: true + --attention-backend: flash + --no-create-attention-mask-in-dataloader: true + --num-workers: 8 + --use-checkpoint-args: true + --no-use-tokenizer-model-from-checkpoint-args: true + --no-load-optim: true + --deterministic-mode: true + --save-interval: 2000 + --temperature: 1.0 + --top_k: 1 + --num-tokens-to-generate: 30 + --max-tokens-to-oom: 3600000 + --inference-max-seq-length: 4096 + --output-path: ${INFERENCE_OUTPUT_PATH} + --prompt-file: ./tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/prompts.jsonl + --incoming-requests-per-step: 1 + --inference-repeat-n: 2 + --no-record-throughput: true + --mamba-inference-conv-states-dtype: fp32 + --mamba-inference-ssm-states-dtype: fp32 + --inference-dynamic-batching-async-sched-mode: async +METRICS: + - "generated_tokens" diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/prompts.jsonl b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/prompts.jsonl new file mode 100644 index 00000000000..e5869299d3a --- /dev/null +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/prompts.jsonl @@ -0,0 +1,3 @@ +{"text":"Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies."} +{"text":"Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies."} +{"text":"Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies."} diff --git a/tests/unit_tests/inference/contexts/test_dynamic_context.py b/tests/unit_tests/inference/contexts/test_dynamic_context.py index 8487c779814..21ca613512c 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_context.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_context.py @@ -2,6 +2,7 @@ import contextlib import math +from types import SimpleNamespace from unittest import mock import pytest @@ -37,6 +38,22 @@ def rounder_override(n): DynamicInferenceContext.REQUEST_ROUNDER = original_request_rounder +@pytest.mark.parametrize( + "using_cuda_graph, num_prefill_requests, padded_prefill_requests, expected", + [(False, 0, 1, True), (False, 1, 0, False), (True, 1, 0, True), (True, 0, 1, False)], +) +def test_is_decode_only_uses_current_execution_snapshot( + using_cuda_graph, num_prefill_requests, padded_prefill_requests, expected +): + """Decode-only classification follows the eager or CUDA graph execution state.""" + context = DynamicInferenceContext.__new__(DynamicInferenceContext) + context._using_cuda_graph_this_step = using_cuda_graph + context.num_prefill_requests = num_prefill_requests + context.padded_batch_dimensions = mock.Mock(prefill_req_count=padded_prefill_requests) + + assert context.is_decode_only() is expected + + class TestDynamicContext: @classmethod @@ -392,7 +409,10 @@ def test_transfer_bookkeeping_to_gpu_can_skip_input_token_ids(self): @pytest.mark.internal @rounder_override(8) - def test_copy_async_sched_sample_to_forward_populates_active_and_clears_padding(self): + @pytest.mark.parametrize("num_speculative_tokens", [0, 2]) + def test_copy_async_sched_sample_to_forward_populates_active_and_clears_padding( + self, num_speculative_tokens + ): ctx = self._get_dynamic_context( params_dtype=torch.float32, num_layers=2, @@ -403,24 +423,37 @@ def test_copy_async_sched_sample_to_forward_populates_active_and_clears_padding( block_size_tokens=4, max_tokens=32, max_requests=8, + num_speculative_tokens=num_speculative_tokens, ) ctx.total_request_count = 3 ctx.paused_request_count = 0 ctx.num_prefill_requests = 0 - ctx.active_token_count = 3 - ctx.padded_active_token_count = 8 + token_count = 3 * (num_speculative_tokens + 1) + ctx.active_token_count = token_count + ctx.padded_active_token_count = 12 device = ctx.gpu_view.token_to_input_ids.device - ctx.gpu_view.token_to_input_ids[:8] = torch.full( - (8,), 777, dtype=torch.int64, device=device + ctx.gpu_view.token_to_input_ids[:12] = torch.full( + (12,), 777, dtype=torch.int64, device=device ) sampled_tokens_cuda = torch.tensor([90, 91, 92], dtype=torch.int64, device=device) + sampled_mtp_tokens_cuda = ( + torch.tensor([[100, 101, 102], [110, 111, 112]], device=device) + if num_speculative_tokens > 0 + else None + ) - ctx.copy_async_sched_sample_to_forward(sampled_tokens_cuda) + ctx.copy_async_sched_sample_to_forward(sampled_tokens_cuda, sampled_mtp_tokens_cuda) - assert torch.equal(ctx.gpu_view.token_to_input_ids[:3], sampled_tokens_cuda) + expected_tokens = ( + sampled_tokens_cuda + if sampled_mtp_tokens_cuda is None + else torch.tensor([90, 100, 110, 91, 101, 111, 92, 102, 112], device=device) + ) + assert torch.equal(ctx.gpu_view.token_to_input_ids[:token_count], expected_tokens) assert torch.equal( - ctx.gpu_view.token_to_input_ids[3:8].cpu(), torch.zeros(5, dtype=torch.int64) + ctx.gpu_view.token_to_input_ids[token_count:12].cpu(), + torch.zeros(12 - token_count, dtype=torch.int64), ) @pytest.mark.internal @@ -443,6 +476,9 @@ def test_reset(self, is_hybrid_model: bool): # Initialize all variables dynamic_context.total_request_count = 10 dynamic_context.active_token_count = 10 + dynamic_context.step_count = 4 + dynamic_context.prefix_cache_lru_clock = 5 + dynamic_context.lifetime_prefill_token_count = 6 dynamic_context.async_sched_step_count = 6 dynamic_context.async_sched_compaction_step_count = 7 dynamic_context.paused_request_count = 5 @@ -473,6 +509,9 @@ def test_reset(self, is_hybrid_model: bool): # Assert all variables are reset to zero or their default values assert dynamic_context.total_request_count == 0 assert dynamic_context.active_token_count == 0 + assert dynamic_context.step_count == 0 + assert dynamic_context.prefix_cache_lru_clock == 0 + assert dynamic_context.lifetime_prefill_token_count == 0 assert dynamic_context.async_sched_step_count == 0 assert dynamic_context.async_sched_compaction_step_count == 0 assert dynamic_context.paused_request_count == 0 @@ -979,7 +1018,7 @@ def test_update_request(self, is_hybrid_model: bool): ) ) - def _get_async_sched_context(self): + def _get_async_sched_context(self, num_speculative_tokens=0, is_hybrid_model=False): return self._get_dynamic_context( params_dtype=torch.float32, num_layers=2, @@ -990,6 +1029,9 @@ def _get_async_sched_context(self): block_size_tokens=4, max_tokens=32, max_requests=8, + num_speculative_tokens=num_speculative_tokens, + is_hybrid_model=is_hybrid_model, + layer_type_list=[Symbols.MAMBA, Symbols.ATTENTION], ) @staticmethod @@ -1009,6 +1051,7 @@ def _setup_async_sched_decode_rows( active_slice = slice(0, active_request_count) ctx.request_ids[active_slice] = torch.tensor(request_ids, dtype=torch.int32) + ctx.request_in_prefill_status_tensor[active_slice] = 0 ctx.request_query_lengths[active_slice] = 1 ctx.request_output_lengths[active_slice] = 16 ctx.request_kv_length_offsets[active_slice] = torch.tensor(kv_offsets, dtype=torch.int32) @@ -1032,6 +1075,10 @@ def _setup_async_sched_decode_rows( ctx.token_to_local_position_within_kv_block[active_slice] = ( ctx.token_to_pos_ids[active_slice] % ctx.block_size_tokens ) + if ctx.is_hybrid_model: + mamba_slots = ctx.mamba_metadata.batch_allocate_slots(active_request_count) + assert mamba_slots is not None + ctx.mamba_metadata.request_to_mamba_state_idx[active_slice] = mamba_slots @pytest.mark.internal @rounder_override(8) @@ -1081,6 +1128,71 @@ def test_async_sched_prepare_requests_success( assert ctx.request_kv_block_counts[0] == 2 assert ctx.token_to_block_idx[0] == ctx.request_last_kv_block_id[0] + @pytest.mark.internal + @rounder_override(8) + @pytest.mark.parametrize( + "num_speculative_tokens, last_block_offsets, active_avail, expected", + [ + (0, [0, 1], 0, True), + (0, [3, 1], 0, False), + (0, [3, 1], 1, True), + (2, [1, 2], 1, False), + (2, [1, 2], 2, True), + ], + ) + def test_async_sched_can_prepare_requests_exact_block_demand( + self, num_speculative_tokens, last_block_offsets, active_avail, expected + ): + """Overlap capacity counts only requests crossing a block boundary.""" + ctx = self._get_async_sched_context(num_speculative_tokens=num_speculative_tokens) + self._setup_async_sched_decode_rows( + ctx, active_request_count=len(last_block_offsets), last_block_offsets=last_block_offsets + ) + ctx.kv_block_allocator.get_active_avail = mock.Mock(return_value=active_avail) + + assert ctx.can_prepare_requests() is expected + + @pytest.mark.internal + @rounder_override(8) + @pytest.mark.parametrize("state", ["prefill", "paused"]) + def test_async_sched_cannot_prepare_requests_with_lifecycle_state(self, state): + """Overlap preparation rejects state requiring lifecycle bookkeeping.""" + ctx = self._get_async_sched_context() + self._setup_async_sched_decode_rows(ctx, active_request_count=2) + if state == "prefill": + ctx.num_prefill_requests = 1 + else: + ctx.paused_request_count = 1 + + assert not ctx.can_prepare_requests() + + @pytest.mark.internal + @rounder_override(8) + def test_async_sched_prepare_capacity_recovers_after_pause_resume(self): + """No-overlap bookkeeping restores overlap eligibility after resuming a request.""" + ctx = self._get_async_sched_context() + self._setup_async_sched_decode_rows( + ctx, active_request_count=2, last_block_offsets=[ctx.block_size_tokens - 1, 0] + ) + ctx.kv_block_allocator.active_count = ctx.kv_block_allocator.get_active_used() + ctx.kv_block_allocator.total_avail = 0 + ctx.kv_block_allocator.paused_count = 100 + + assert not ctx.can_prepare_requests() + + ctx.update_requests( + active_requests_mask=torch.tensor([1, 1]), new_tokens=torch.tensor([90, 91]) + ) + + assert ctx.paused_request_count == 1 + assert not ctx.can_prepare_requests() + + ctx.kv_block_allocator.total_avail = 1 + ctx.update_requests(active_requests_mask=torch.tensor([0]), new_tokens=torch.tensor([92])) + + assert ctx.paused_request_count == 0 + assert ctx.can_prepare_requests() + @pytest.mark.internal @rounder_override(8) def test_async_sched_commit_sampled_tokens(self): @@ -1100,19 +1212,25 @@ def test_async_sched_commit_sampled_tokens(self): if torch.cuda.is_available(): with pytest.raises(AssertionError, match="must be on the CPU"): ctx.commit_sampled_tokens(sampled_tokens_cpu.cuda()) + ctx.active_token_count = 5 ctx.commit_sampled_tokens(sampled_tokens_cpu) + assert ctx.active_token_count == 2 assert torch.equal(ctx.token_to_input_ids[:2], sampled_tokens_cpu) with pytest.raises(RuntimeError, match="Expected 2 new tokens"): ctx.commit_sampled_tokens(torch.tensor([90], dtype=torch.int64)) + ctx.total_request_count = 0 + ctx.active_token_count = 2 + ctx.commit_sampled_tokens(torch.empty(0, dtype=torch.int64)) + assert ctx.active_token_count == 0 + @pytest.mark.internal @rounder_override(8) @pytest.mark.parametrize( "setup, expected_message", [ - (lambda ctx: setattr(ctx, "num_speculative_tokens", 1), "speculative"), (lambda ctx: setattr(ctx, "num_prefill_requests", 1), "decode-only"), (lambda ctx: setattr(ctx, "paused_request_count", 1), "paused"), (lambda ctx: None, "pause requests"), @@ -1138,15 +1256,26 @@ def test_async_sched_prepare_requests_errors(self, setup, expected_message): @pytest.mark.internal @rounder_override(8) + @pytest.mark.parametrize("is_hybrid_model", [False, True]) @pytest.mark.parametrize( - "mask, expected_finished_ids, expected_request_ids", - [([1, 1, 1], [], [10, 11, 12]), ([1, 0, 1], [11], [10, 12]), ([0, 0, 0], [10, 11, 12], [])], + "mask, expected_finished_ids, expected_request_ids, expected_survivor_idxs", + [ + ([1, 1, 1], [], [10, 11, 12], [0, 1, 2]), + ([1, 0, 1], [11], [10, 12], [0, 2]), + ([0, 1, 1], [10], [12, 11], [2, 1]), + ([0, 0, 0], [10, 11, 12], [], []), + ], ) def test_async_sched_resolve_requests_success( - self, mask, expected_finished_ids, expected_request_ids + self, + mask, + expected_finished_ids, + expected_request_ids, + expected_survivor_idxs, + is_hybrid_model, ): """Async scheduling resolve compacts survivors and releases finished rows.""" - ctx = self._get_async_sched_context() + ctx = self._get_async_sched_context(is_hybrid_model=is_hybrid_model) self._setup_async_sched_decode_rows( ctx, active_request_count=len(mask), @@ -1154,35 +1283,131 @@ def test_async_sched_resolve_requests_success( kv_offsets=[4, 5, 6], last_block_offsets=[0, 1, 2], ) + original_mamba_slots = ( + ctx.mamba_metadata.request_to_mamba_state_idx[: len(mask)].clone() + if is_hybrid_model + else None + ) + mamba_state_bank_ptrs = ( + (ctx.mamba_conv_states.data_ptr(), ctx.mamba_ssm_states.data_ptr()) + if is_hybrid_model + else None + ) active_mask = torch.tensor(mask, dtype=torch.int32) if torch.cuda.is_available(): active_mask = active_mask.cuda() - finished_request_ids = ctx.resolve_requests(active_mask) + token_tensors = ( + ctx.token_to_input_ids, + ctx.token_to_pos_ids, + ctx.token_to_block_idx, + ctx.token_to_local_position_within_kv_block, + ctx.token_to_request_idx, + ctx.token_to_position_in_request, + ) + active_token_count = ctx.active_token_count + token_state = tuple(tensor.clone() for tensor in token_tensors) + + finished_request_ids, survivor_idxs = ctx.resolve_requests(active_mask) assert torch.equal( finished_request_ids, torch.tensor(expected_finished_ids, dtype=torch.int32) ) + assert torch.equal(survivor_idxs, torch.tensor(expected_survivor_idxs)) assert ctx.total_request_count == len(expected_request_ids) - assert ctx.active_token_count == len(expected_request_ids) + assert ctx.active_token_count == active_token_count assert torch.equal( ctx.request_ids[: len(expected_request_ids)], torch.tensor(expected_request_ids, dtype=torch.int32), ) - assert torch.equal( - ctx.token_to_request_idx[: len(expected_request_ids)], - torch.arange(len(expected_request_ids), dtype=torch.int32), - ) + for tensor, expected in zip(token_tensors, token_state): + assert torch.equal(tensor, expected) if not expected_request_ids: assert torch.all(ctx.request_to_kv_block_ids == -1) + if is_hybrid_model: + expected_mamba_slots = original_mamba_slots[survivor_idxs] + assert torch.equal( + ctx.mamba_metadata.request_to_mamba_state_idx[: len(expected_request_ids)], + expected_mamba_slots, + ) + assert torch.all( + ctx.mamba_metadata.request_to_mamba_state_idx[len(expected_request_ids) : len(mask)] + == -1 + ) + assert mamba_state_bank_ptrs == ( + ctx.mamba_conv_states.data_ptr(), + ctx.mamba_ssm_states.data_ptr(), + ) + + @pytest.mark.internal + @rounder_override(8) + def test_async_sched_mtp_prepare_commit_and_resolve(self): + """MTP survivor tokens are committed after request resolution.""" + ctx = self._get_async_sched_context(num_speculative_tokens=2) + self._setup_async_sched_decode_rows( + ctx, + active_request_count=2, + request_ids=[10, 11], + kv_offsets=[3, 5], + last_block_offsets=[1, 3], + ) + + ctx.prepare_requests() + prepared_input_ids = ctx.token_to_input_ids.clone() + + assert ctx.active_token_count == 6 + assert torch.equal(ctx.token_to_pos_ids[:6], torch.tensor([4, 5, 6, 6, 7, 8])) + + finished_request_ids, survivor_idxs = ctx.resolve_requests(torch.tensor([0, 1])) + + assert finished_request_ids.tolist() == [10] + assert survivor_idxs.tolist() == [1] + assert ctx.request_ids[0] == 11 + assert ctx.active_token_count == 6 + assert torch.equal(ctx.token_to_input_ids, prepared_input_ids) + + sampled_tokens = torch.tensor([100, 200]) + sampled_mtp_tokens = torch.tensor([[101, 201], [102, 202]]) + ctx.commit_sampled_tokens( + sampled_tokens[survivor_idxs], sampled_mtp_tokens[:, survivor_idxs] + ) + + assert ctx.active_token_count == 3 + assert torch.equal(ctx.token_to_input_ids[:3], torch.tensor([200, 201, 202])) + + @pytest.mark.internal + @rounder_override(8) + def test_async_sched_prefill_resolves_before_decode_prepare(self): + """Resolution converts prefill survivors before prepare rebuilds decode rows.""" + ctx = self._get_async_sched_context() + self._setup_async_sched_decode_rows( + ctx, + active_request_count=2, + request_ids=[10, 11], + kv_offsets=[4, 6], + last_block_offsets=[0, 2], + ) + ctx.num_prefill_requests = 1 + ctx.request_in_prefill_status_tensor[1] = 1 + ctx.request_query_lengths[1] = 4 + ctx.active_token_count = 5 + + _, survivor_idxs = ctx.resolve_requests(torch.tensor([1, 1])) + assert ctx.active_token_count == 5 + + ctx.prepare_requests() + + assert survivor_idxs.tolist() == [0, 1] + assert ctx.num_prefill_requests == 0 + assert ctx.active_token_count == 2 + assert torch.equal(ctx.request_query_lengths[:2], torch.tensor([1, 1])) + assert torch.equal(ctx.request_kv_length_offsets[:2], torch.tensor([5, 10])) @pytest.mark.internal @rounder_override(8) @pytest.mark.parametrize( "setup, mask, expected_message", [ - (lambda ctx: setattr(ctx, "num_speculative_tokens", 1), [1, 1], "speculative"), - (lambda ctx: setattr(ctx, "num_prefill_requests", 1), [1, 1], "decode-only"), (lambda ctx: setattr(ctx, "paused_request_count", 1), [1, 1], "paused"), (lambda ctx: None, [1], "Expected active mask"), ], @@ -1488,22 +1713,38 @@ def expected_log_probs(logits, active_id_and_counts): For processed mode, each active request's params are repeated across its token count, mirroring the request->row mapping in `_processed_log_probs`. + + Args: + logits (Tensor): Raw logits for the active token rows. + active_id_and_counts: Request IDs paired with their row counts. + + Returns: + Tensor: Expected raw or sampling-processed log probabilities. """ logits_2d = logits.squeeze(0).float() if logprobs_mode == "raw_logprobs": return torch.nn.functional.log_softmax(logits_2d, dim=-1) - temperatures, top_ks, top_ps = [], [], [] + temperatures, top_ks, top_ps, request_counts = [], [], [], [] for active_id, count in active_id_and_counts: sp = request_data[active_id]["sampling"] - temperatures += [sp["temperature"]] * count - top_ks += [sp["top_k"]] * count - top_ps += [sp["top_p"]] * count - device = logits_2d.device + temperatures.append(sp["temperature"]) + top_ks.append(sp["top_k"]) + top_ps.append(sp["top_p"]) + request_counts.append(count) + expected_context = SimpleNamespace( + total_request_count=len(active_id_and_counts), + paused_request_count=0, + active_request_metadata={ + "temperature": torch.tensor(temperatures, dtype=torch.float32), + "top_k": torch.tensor(top_ks, dtype=torch.long), + "top_p": torch.tensor(top_ps, dtype=torch.float32), + }, + ) + row_to_request = torch.arange(len(request_counts)).repeat_interleave( + torch.tensor(request_counts) + ) return sampling.log_probs_kernel( - logits_2d, - torch.tensor(temperatures, device=device, dtype=torch.float32), - torch.tensor(top_ks, device=device, dtype=torch.long), - torch.tensor(top_ps, device=device, dtype=torch.float32), + logits_2d, expected_context, token_to_request_index=row_to_request ) # Populate gpu_view for calculate_log_probs (which reads from gpu_view). @@ -1560,9 +1801,9 @@ def expected_log_probs(logits, active_id_and_counts): dynamic_context.initialize_attention_state() dynamic_context.transfer_bookkeeping_to_gpu() - # Generate new logits for the decode step. Now each request contributes 1 token. + # Generate a padded decode buffer where each active request contributes 1 token. decode_logits = torch.randn( - 1, num_active_requests, vocab_size, device='cuda', dtype=torch.float32 + 1, num_active_requests + 3, vocab_size, device='cuda', dtype=torch.bfloat16 ) decode_new_tokens = torch.randint(0, 100, (num_active_requests,), device='cuda').long() decode_log_probs, decode_log_probs_full = dynamic_context.calculate_log_probs( @@ -1571,7 +1812,9 @@ def expected_log_probs(logits, active_id_and_counts): # Verify the stored decode log probabilities decode_active = [(req_id, 1) for req_id in request_data] - expected_decode_full = expected_log_probs(decode_logits, decode_active) + expected_decode_full = expected_log_probs( + decode_logits[:, :num_active_requests], decode_active + ) assert torch.allclose(decode_log_probs_full, expected_decode_full, atol=1e-6) expected_decode_log_probs = expected_decode_full.to(torch.float32) diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index 65cf85a172e..fa166af9669 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -1656,19 +1656,36 @@ def test_reset_preserves_prefix_cache_when_requested(self): assert len(ctx.kv_block_allocator.kv_hash_to_block_id) == 0 # cleared @pytest.mark.internal - def test_reset_disabled_ignores_preserve_flag(self): - # When prefix caching is disabled, preserve_prefix_cache=True still performs - # a full reset: step_count returns to 0. - ctx_off = self._ctx(enable_prefix_caching=False) - ctx_off.step_count = 7 - ctx_off.reset(preserve_prefix_cache=True) - assert ctx_off.step_count == 0 - - # With caching ON, preserve keeps step_count monotonic (for logging cadence). - ctx_on = self._ctx(enable_prefix_caching=True) - ctx_on.step_count = 7 - ctx_on.reset(preserve_prefix_cache=True) - assert ctx_on.step_count == 7 + @pytest.mark.parametrize("enable_prefix_caching", [False, True]) + @pytest.mark.parametrize("preserve_prefix_cache", [False, True]) + @pytest.mark.parametrize("preserve_counters", [False, True]) + def test_reset_counter_preservation_is_explicit( + self, enable_prefix_caching, preserve_prefix_cache, preserve_counters + ): + """Counter preservation is independent of prefix-cache configuration.""" + ctx = self._ctx(buffer_size_gb=0.01, rounder=8, enable_prefix_caching=enable_prefix_caching) + counter_values = { + "step_count": 3, + "prefix_cache_lru_clock": 4, + "lifetime_prefill_token_count": 5, + "async_sched_step_count": 6, + "async_sched_compaction_step_count": 7, + } + for name, value in counter_values.items(): + setattr(ctx, name, value) + ctx.total_request_count = 1 + ctx.active_token_count = 1 + ctx.request_ids[0] = 10 + + ctx.reset(preserve_prefix_cache=preserve_prefix_cache, preserve_counters=preserve_counters) + + expected_counters = ( + counter_values if preserve_counters else dict.fromkeys(counter_values, 0) + ) + assert {name: getattr(ctx, name) for name in counter_values} == expected_counters + assert ctx.total_request_count == 0 + assert ctx.active_token_count == 0 + assert ctx.request_ids[0] == -1 @pytest.mark.internal def test_prefill_computed_and_skipped_counters(self): diff --git a/tests/unit_tests/inference/engines/test_cg_admission_gating.py b/tests/unit_tests/inference/engines/test_cg_admission_gating.py index 236d7bb9534..74d061fd640 100644 --- a/tests/unit_tests/inference/engines/test_cg_admission_gating.py +++ b/tests/unit_tests/inference/engines/test_cg_admission_gating.py @@ -30,6 +30,7 @@ def _create_engine( engine ) engine._find_cg_chunk_size = DynamicInferenceEngine._find_cg_chunk_size.__get__(engine) + engine._matches_cg_admission = DynamicInferenceEngine._matches_cg_admission.__get__(engine) engine._cg_admission_check = DynamicInferenceEngine._cg_admission_check.__get__(engine) engine._register_cg_wait = DynamicInferenceEngine._register_cg_wait.__get__(engine) return engine diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 9c99c8d1019..7bffb5dadf0 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -126,6 +126,7 @@ class DynamicEngineTestConfig: fp8: bool = False model_provider: str = "gpt" return_log_probs: bool = False + logprobs_mode: str = "raw_logprobs" materialize_only_last_token_logits: bool = True skip_prompt_log_probs: bool = False enable_chunked_prefill: bool = False @@ -149,6 +150,9 @@ class DynamicEngineTestConfig: num_speculative_tokens: int = 0 position_embedding_type: str = "learned_absolute" sampling_backend: str = 'torch' + temperature: float = 1.0 + top_k: int = 0 + top_p: float = 0.0 async_sched_mode: AsyncScheduleMode = AsyncScheduleMode.LEGACY # Sliding-window attention config. When `window_size` is None, SWA is # disabled and all layers do full causal attention. When set to a @@ -248,6 +252,9 @@ def _build_requests(cls, test_config: DynamicEngineTestConfig) -> List[DynamicIn ), return_log_probs=test_config.return_log_probs, skip_prompt_log_probs=test_config.skip_prompt_log_probs, + temperature=test_config.temperature, + top_k=test_config.top_k, + top_p=test_config.top_p, ) if not hasattr(sampling_params, "num_tokens_total"): # Remove this if statement branch in megatron-core 0.16 @@ -310,6 +317,7 @@ def _build_inference_context( num_speculative_tokens=test_config.num_speculative_tokens, sampling_backend=test_config.sampling_backend, async_sched_mode=test_config.async_sched_mode, + logprobs_mode=test_config.logprobs_mode, ), ) @@ -1104,15 +1112,15 @@ async def test_run_engine(self): @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" ) - async def test_async_sched_run_engine_accepts_request_during_decode(self): - """Verify async decode yields so a new request can enter a running engine.""" + async def test_async_sched_run_engine_accepts_request_during_overlap(self): + """Verify async overlap yields so a new request can enter a running engine.""" with torch.inference_mode(): test_config = DynamicEngineTestConfig( num_requests=2, min_prompt_length=4, max_prompt_length=4, num_tokens_to_generate=16, - async_sched_mode=AsyncScheduleMode.OVERLAP, + async_sched_mode=AsyncScheduleMode.ASYNC, ) env = self._build_test_env(test_config) long_request, short_request = env.requests @@ -1139,6 +1147,330 @@ async def test_async_sched_run_engine_accepts_request_during_decode(self): engine_task.cancel() await engine_task + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize( + ("enable_chunked_prefill", "enable_prefix_caching"), + [(True, False), (False, True), (True, True)], + ) + @torch.inference_mode() + def test_async_sched_prefix_caching_and_chunked_prefill_e2e( + self, enable_chunked_prefill, enable_prefix_caching + ): + """Async output matches legacy for chunking, KV caching, and their combination.""" + + def run(mode): + test_config = DynamicEngineTestConfig( + num_requests=0, + num_tokens_to_generate=4, + max_sequence_length=768, + context_block_size_tokens=256, + context_max_tokens=384 if enable_chunked_prefill else 1024, + context_max_requests=4, + enable_chunked_prefill=enable_chunked_prefill, + enable_prefix_caching=enable_prefix_caching, + async_sched_mode=mode, + ) + env = self._build_test_env(test_config) + prompt = torch.arange(512, dtype=torch.int64, device="cuda") % ( + test_config.vocab_size - 1 + ) + outputs = {} + + def add_request(request_id): + env.engine.add_request( + request_id=request_id, + prompt=prompt.clone(), + sampling_params=SamplingParams( + num_tokens_to_generate=4, termination_id=-1, top_k=1, top_p=0.0 + ), + ) + + add_request(0) + env.engine.step_modern() + add_request(1) + while env.engine.has_unfinished_requests(): + result = env.engine.step_modern() + for record in result["finished_request_records"]: + request = record.merge() + outputs[request.request_id] = list(request.generated_tokens) + return env.engine, outputs + + _, legacy_outputs = run(AsyncScheduleMode.LEGACY) + async_engine, async_outputs = run(AsyncScheduleMode.ASYNC) + + assert async_outputs == legacy_outputs + assert all(len(tokens) == 4 for tokens in async_outputs.values()) + assert async_engine.context.async_sched_step_count > 0 + if enable_prefix_caching: + assert async_engine._prefill_tokens_skipped > 0 + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize( + "feature_config, sampling_backend, temperature, top_k, top_p, num_cuda_graphs", + [ + pytest.param({}, "torch", 0.8, 10, 0.0, None, id="torch-eager-top-k"), + pytest.param({}, "torch", 1.0, 0, 0.9, 2, id="torch-graphed-forward-top-p"), + pytest.param({}, "flashinfer", 1.2, 0, 0.0, None, id="flashinfer-eager-unfiltered"), + pytest.param({}, "flashinfer", 0.8, 10, 0.9, 2, id="flashinfer-graphed-forward"), + pytest.param( + { + "model_provider": "hybrid", + "num_speculative_tokens": 1, + "num_requests": 2, + "num_tokens_to_generate": 4, + }, + "torch", + 0.8, + 8, + 0.0, + None, + id="mamba-mtp-top-k", + ), + ], + ) + @torch.inference_mode() + def test_async_sched_sampling_matches_legacy( + self, feature_config, sampling_backend, temperature, top_k, top_p, num_cuda_graphs + ): + """Require seeded sampling parity across scheduling modes. + + Args: + feature_config (dict): Additional cumulative feature configuration. + sampling_backend (str): Sampling implementation under test. + temperature (float): Sampling temperature used by every request. + top_k (int): Top-k filter used by every request. + top_p (float): Top-p filter used by every request. + num_cuda_graphs (Optional[int]): Number of CUDA graph buckets, or + `None` for eager execution. + """ + if sampling_backend == "flashinfer": + pytest.importorskip("flashinfer") + if feature_config.get("model_provider") == "hybrid": + skip_if_mamba_sequence_packing_not_available("hybrid") + + common_config = dict( + num_requests=4, + min_prompt_length=4, + max_prompt_length=4, + num_tokens_to_generate=6, + num_gap_steps=0, + use_fixed_output_lengths=True, + sampling_backend=sampling_backend, + temperature=temperature, + top_k=top_k, + top_p=top_p, + context_max_requests=8, + num_cuda_graphs=num_cuda_graphs, + force_build_cuda_graphs=num_cuda_graphs is not None, + use_cuda_graphs_for_non_decode_steps=False, + ) + common_config.update(feature_config) + generated_tokens = {} + final_env = None + for mode in (AsyncScheduleMode.LEGACY, AsyncScheduleMode.ASYNC): + final_env = self._run_test(async_sched_mode=mode, **common_config) + assert all(request.status == Status.COMPLETED for request in final_env.requests) + generated_tokens[mode] = [request.generated_tokens for request in final_env.requests] + + assert ( + generated_tokens[AsyncScheduleMode.ASYNC] == generated_tokens[AsyncScheduleMode.LEGACY] + ) + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize( + "feature_config, sampling_backend, logprobs_mode, skip_prompt_log_probs, num_cuda_graphs", + [ + pytest.param({}, "torch", "raw_logprobs", False, None, id="torch-raw-prompt"), + pytest.param({}, "torch", "processed_logprobs", True, 2, id="torch-processed-graph"), + pytest.param({}, "flashinfer", "raw_logprobs", False, None, id="flashinfer-raw-prompt"), + pytest.param( + {}, "flashinfer", "processed_logprobs", True, 2, id="flashinfer-processed-graph" + ), + pytest.param( + { + "model_provider": "hybrid", + "num_speculative_tokens": 1, + "num_requests": 2, + "num_tokens_to_generate": 4, + }, + "torch", + "raw_logprobs", + True, + None, + id="mamba-mtp-raw", + ), + ], + ) + @torch.inference_mode() + def test_async_sched_log_probs_match_legacy( + self, + feature_config, + sampling_backend, + logprobs_mode, + skip_prompt_log_probs, + num_cuda_graphs, + ): + """Require prompt and generated logprob parity across scheduling modes. + + Args: + feature_config (dict): Additional cumulative feature configuration. + sampling_backend (str): Sampling implementation under test. + logprobs_mode (str): Raw or sampling-processed logprob mode. + skip_prompt_log_probs (bool): Whether to omit prompt logprobs. + num_cuda_graphs (Optional[int]): Number of CUDA graph buckets. + """ + if sampling_backend == "flashinfer": + pytest.importorskip("flashinfer") + if feature_config.get("model_provider") == "hybrid": + skip_if_mamba_sequence_packing_not_available("hybrid") + + common_config = dict( + num_requests=4, + min_prompt_length=4, + max_prompt_length=4, + num_tokens_to_generate=6, + num_gap_steps=0, + use_fixed_output_lengths=True, + model_provider="gpt", + sampling_backend=sampling_backend, + temperature=0.8, + top_k=8, + return_log_probs=True, + logprobs_mode=logprobs_mode, + materialize_only_last_token_logits=skip_prompt_log_probs, + skip_prompt_log_probs=skip_prompt_log_probs, + context_max_requests=8, + num_cuda_graphs=num_cuda_graphs, + force_build_cuda_graphs=num_cuda_graphs is not None, + use_cuda_graphs_for_non_decode_steps=False, + ) + common_config.update(feature_config) + outputs = {} + for mode in AsyncScheduleMode: + env = self._run_test(async_sched_mode=mode, **common_config) + outputs[mode] = [ + (request.generated_tokens, request.prompt_log_probs, request.generated_log_probs) + for request in env.requests + ] + + legacy_outputs = outputs[AsyncScheduleMode.LEGACY] + for legacy, actual in zip(legacy_outputs, outputs[AsyncScheduleMode.ASYNC]): + assert actual[0] == legacy[0] + assert (actual[1] or []) == pytest.approx(legacy[1] or []) + assert actual[2] == pytest.approx(legacy[2]) + + def _run_stop_word_schedule( + self, + test_config: DynamicEngineTestConfig, + stop_word: Optional[str] = None, + detokenize_stop_sequence: bool = False, + ) -> DynamicEngineTestEnv: + """Run a schedule where only the first request has a string stop word. + + Args: + test_config (DynamicEngineTestConfig): Engine configuration for the run. + stop_word (Optional[str]): Whitespace-delimited token IDs used as the stop word. + detokenize_stop_sequence (bool): Whether the completed output retains the stop word. + + Returns: + DynamicEngineTestEnv: Completed test environment. + """ + env = self._build_test_env(test_config) + env.engine.controller.tokenizer.bos = None + env.engine.controller.tokenizer.tokenize = lambda text: [ + int(token_id) for token_id in text.split() + ] + + for request_idx, request in enumerate(env.requests): + request.sampling_params.termination_id = -1 + request.sampling_params.detokenize_stop_sequence = detokenize_stop_sequence + if request_idx == 0 and stop_word is not None: + request.sampling_params.stop_words = [stop_word] + env.engine._add_request(request) + + while env.engine.has_unfinished_requests(): + env.engine.step_modern() + + return env + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize( + "sampling_backend,num_cuda_graphs,detokenize_stop_sequence", + [ + pytest.param("torch", None, True, id="torch-eager-keep"), + pytest.param("torch", 2, False, id="torch-graph-strip"), + pytest.param("flashinfer", None, False, id="flashinfer-eager-strip"), + pytest.param("flashinfer", 2, True, id="flashinfer-graph-keep"), + ], + ) + @torch.inference_mode() + def test_async_sched_stop_words_match_legacy( + self, sampling_backend, num_cuda_graphs, detokenize_stop_sequence + ): + """Require string stop-word parity while survivor requests keep decoding. + + Args: + sampling_backend (str): Sampling backend under test. + num_cuda_graphs (Optional[int]): CUDA graph bucket count, or ``None`` for eager mode. + detokenize_stop_sequence (bool): Whether completed output retains the stop word. + """ + if sampling_backend == "flashinfer": + pytest.importorskip("flashinfer") + + common_config = dict( + num_requests=4, + min_prompt_length=4, + max_prompt_length=4, + num_tokens_to_generate=8, + num_gap_steps=0, + model_provider="gpt", + sampling_backend=sampling_backend, + temperature=1.0, + top_k=1, + context_max_requests=8, + num_cuda_graphs=num_cuda_graphs, + force_build_cuda_graphs=num_cuda_graphs is not None, + use_cuda_graphs_for_non_decode_steps=False, + ) + + probe_env = self._run_stop_word_schedule( + DynamicEngineTestConfig(async_sched_mode=AsyncScheduleMode.LEGACY, **common_config) + ) + stop_word_ids = probe_env.requests[0].generated_tokens[2:4] + stop_word = " ".join(str(token_id) for token_id in stop_word_ids) + + legacy_env = self._run_stop_word_schedule( + DynamicEngineTestConfig(async_sched_mode=AsyncScheduleMode.LEGACY, **common_config), + stop_word, + detokenize_stop_sequence, + ) + async_env = self._run_stop_word_schedule( + DynamicEngineTestConfig(async_sched_mode=AsyncScheduleMode.ASYNC, **common_config), + stop_word, + detokenize_stop_sequence, + ) + + legacy_tokens = [request.generated_tokens for request in legacy_env.requests] + async_tokens = [request.generated_tokens for request in async_env.requests] + assert async_tokens == legacy_tokens + assert len(async_tokens[0]) < common_config["num_tokens_to_generate"] + if detokenize_stop_sequence: + assert async_tokens[0][-len(stop_word_ids) :] == stop_word_ids + assert async_env.engine.context.async_sched_step_count > 0 + assert async_env.engine.context.async_sched_compaction_step_count > 0 + @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" @@ -1274,8 +1606,11 @@ def test_return_log_probs(self): @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" ) + @pytest.mark.parametrize("async_sched_mode", list(AsyncScheduleMode)) @torch.inference_mode() - def test_return_prompt_log_probs_with_zero_tokens_to_generate(self): + def test_return_prompt_log_probs_with_zero_tokens_to_generate( + self, async_sched_mode: AsyncScheduleMode + ): """Prompt log probs must be returned when scoring only (num_tokens_to_generate=0). Regression test for a prefill-step trimming bug: when a request generates @@ -1285,12 +1620,16 @@ def test_return_prompt_log_probs_with_zero_tokens_to_generate(self): sampled-token log prob at the tail). The fix trims the excess *trailing* log probs instead. This is the path exercised by loglikelihood / echo evaluations (e.g. lm-eval-harness sends ``max_tokens=0``). + + Args: + async_sched_mode (AsyncScheduleMode): Scheduling mode under test. """ env = self._run_test( return_log_probs=True, materialize_only_last_token_logits=False, skip_prompt_log_probs=False, num_tokens_to_generate=0, + async_sched_mode=async_sched_mode, ) validated_any = False @@ -2287,15 +2626,22 @@ def get_log_probs(chunked: bool, max_tokens: int): @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" ) + @pytest.mark.parametrize("async_sched_mode", list(AsyncScheduleMode)) @pytest.mark.parametrize("skip_prompt_log_probs", [True, False]) @torch.inference_mode() - def test_top_n_logprobs_dynamic(self, skip_prompt_log_probs: bool): - """ - Test that top_n_logprobs are computed correctly in dynamic batching mode. + def test_top_n_logprobs_dynamic( + self, skip_prompt_log_probs: bool, async_sched_mode: AsyncScheduleMode + ): + """Test that top_n_logprobs are computed correctly in dynamic batching mode. + Verifies: 1. top_n_logprobs are returned for generated tokens 2. skip_prompt_log_probs controls whether prompt top-n logprobs are skipped 3. The top-n values are consistent with the selected token's log prob + + Args: + skip_prompt_log_probs (bool): Whether to omit prompt top-n logprobs. + async_sched_mode (AsyncScheduleMode): Scheduling mode under test. """ # Build test environment with multiple requests of varying lengths test_config = DynamicEngineTestConfig( @@ -2304,6 +2650,7 @@ def test_top_n_logprobs_dynamic(self, skip_prompt_log_probs: bool): max_prompt_length=12, num_tokens_to_generate=4, materialize_only_last_token_logits=False, + async_sched_mode=async_sched_mode, ) env = self._build_test_env(test_config) @@ -4406,7 +4753,8 @@ def mock_compute_mtp_wrong( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" ) @torch.inference_mode() - def test_speculative_decoding_logprobs_with_stop_word_trim(self): + @pytest.mark.parametrize("async_sched_mode", list(AsyncScheduleMode)) + def test_speculative_decoding_logprobs_with_stop_word_trim(self, async_sched_mode): """Test that log probs are correctly trimmed when a stop word lands in the middle of a speculative batch. @@ -4415,6 +4763,9 @@ def test_speculative_decoding_logprobs_with_stop_word_trim(self): generates [5, 6, 7] in one step, token 7 is truncated. The corresponding log prob for token 7 must also be removed so that len(generated_log_probs) == len(generated_tokens). + + Args: + async_sched_mode (AsyncScheduleMode): Scheduling mode under test. """ test_config = DynamicEngineTestConfig( num_requests=0, @@ -4424,6 +4775,7 @@ def test_speculative_decoding_logprobs_with_stop_word_trim(self): num_speculative_tokens=2, materialize_only_last_token_logits=False, model_provider="gpt", + async_sched_mode=async_sched_mode, ) env = self._build_test_env(test_config) @@ -4466,6 +4818,7 @@ def mock_compute_mtp_single_step( detokenize_stop_sequence=True, return_log_probs=True, top_k=1, + top_n_logprobs=2, ), ) @@ -4480,6 +4833,7 @@ def mock_compute_mtp_single_step( finished_req = finished_records[0].merge() assert finished_req.status == Status.COMPLETED + assert finished_req.generated_tokens == [5, 6] assert finished_req.generated_tokens[-1] == 6, ( f"Expected last token to be stop word 6, " f"got {finished_req.generated_tokens[-1]}. " @@ -4499,6 +4853,9 @@ def mock_compute_mtp_single_step( assert isinstance(lp, float) assert lp <= 0.0, f"Token {j}: log prob {lp} > 0" + assert finished_req.generated_top_n_logprobs is not None + assert len(finished_req.generated_top_n_logprobs) == len(finished_req.generated_tokens) + @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" @@ -5045,6 +5402,49 @@ def _build_test_env(cls, test_config): ) return super()._build_test_env(test_config) + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @pytest.mark.parametrize( + "async_sched_mode", [AsyncScheduleMode.LEGACY, AsyncScheduleMode.ASYNC] + ) + @torch.inference_mode() + def test_non_greedy_sampling_with_mamba_mtp_ep(self, async_sched_mode): + """Run cumulative Mamba, MTP, and EP sampling support to completion. + + Args: + async_sched_mode (AsyncScheduleMode): Scheduling mode under test. + """ + skip_if_mamba_sequence_packing_not_available("hybrid") + if int(os.environ.get("WORLD_SIZE", "1")) < 2: + pytest.skip("Test requires at least 2 GPUs") + + env = self._run_test( + num_requests=2, + min_prompt_length=4, + max_prompt_length=4, + num_tokens_to_generate=4, + num_gap_steps=0, + use_fixed_output_lengths=True, + model_provider="hybrid", + expert_model_parallel_size=2, + num_speculative_tokens=1, + sampling_backend="torch", + temperature=0.8, + top_k=8, + return_log_probs=True, + skip_prompt_log_probs=True, + context_max_requests=8, + async_sched_mode=async_sched_mode, + ) + + assert all(request.status == Status.COMPLETED for request in env.requests) + assert all( + len(request.generated_log_probs) == len(request.generated_tokens) + for request in env.requests + ) + @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py b/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py index abab280497f..b8eb78b2403 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py @@ -1,5 +1,7 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import asyncio +from collections import deque from types import SimpleNamespace from unittest import mock @@ -7,29 +9,43 @@ from megatron.core.inference.config import AsyncScheduleMode from megatron.core.inference.engines import DynamicInferenceEngine +from megatron.core.inference.engines.dynamic_engine import EngineState, _get_decode_only_log_state from megatron.core.inference.sampling_params import SamplingParams +from megatron.core.inference.text_generation_controllers.text_generation_controller import ( + DecodeOnly, + DynamicBatchControllerStepResult, +) -def _make_engine(async_sched_mode=AsyncScheduleMode.SERIAL, **overrides): +def _make_engine(async_sched_mode=AsyncScheduleMode.ASYNC, **overrides): engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) context = SimpleNamespace( config=SimpleNamespace(async_sched_mode=async_sched_mode), is_hybrid_model=False, enable_prefix_caching=False, + num_prefill_requests=0, + can_prepare_requests=mock.Mock(return_value=True), + active_token_count=0, + max_tokens=8, + chunked_prefill_request_id=-1, ) model_config = SimpleNamespace( expert_model_parallel_size=1, num_moe_experts=None, moe_enable_routing_replay=False ) engine.context = context engine.controller = SimpleNamespace( - inference_wrapped_model=SimpleNamespace(model=SimpleNamespace(config=model_config)) + inference_wrapped_model=SimpleNamespace(model=SimpleNamespace(config=model_config)), + num_mtp_depths=0, ) + engine.enable_chunked_prefill = False engine.num_speculative_tokens = 0 engine.materialize_only_last_token_logits = True for name, value in overrides.items(): if name.startswith("context_"): setattr(context, name.removeprefix("context_"), value) + elif name.startswith("controller_"): + setattr(engine.controller, name.removeprefix("controller_"), value) elif name.startswith("model_config_"): setattr(model_config, name.removeprefix("model_config_"), value) else: @@ -42,14 +58,32 @@ def _make_engine(async_sched_mode=AsyncScheduleMode.SERIAL, **overrides): [ ({"async_sched_mode": AsyncScheduleMode.LEGACY, "num_speculative_tokens": 1}, False), ({}, False), - ({"async_sched_mode": AsyncScheduleMode.OVERLAP}, False), + ({"enable_chunked_prefill": True}, False), ({"num_speculative_tokens": 1}, True), - ({"async_sched_mode": AsyncScheduleMode.OVERLAP, "num_speculative_tokens": 1}, True), - ({"context_is_hybrid_model": True}, True), - ({"context_enable_prefix_caching": True}, True), - ({"materialize_only_last_token_logits": False}, True), - ({"model_config_expert_model_parallel_size": 2}, True), - ({"model_config_num_moe_experts": 4}, True), + ({"num_speculative_tokens": 1, "controller_num_mtp_depths": 1}, False), + ({"context_is_hybrid_model": True}, False), + ( + { + "context_is_hybrid_model": True, + "num_speculative_tokens": 1, + "controller_num_mtp_depths": 1, + "model_config_expert_model_parallel_size": 2, + "model_config_num_moe_experts": 4, + }, + False, + ), + ({"context_enable_prefix_caching": True}, False), + ( + { + "enable_chunked_prefill": True, + "context_enable_prefix_caching": True, + "context_is_hybrid_model": True, + }, + False, + ), + ({"materialize_only_last_token_logits": False}, False), + ({"model_config_expert_model_parallel_size": 2}, False), + ({"model_config_num_moe_experts": 4}, False), ({"model_config_moe_enable_routing_replay": True}, True), ], ) @@ -65,40 +99,266 @@ def test_validate_async_sched_support_for_config(overrides, should_raise): @pytest.mark.parametrize( - "async_sched_mode, sampling_params, should_raise", + "can_prepare, has_waiting, availability, expected", [ - (AsyncScheduleMode.LEGACY, SamplingParams(top_k=0, top_p=0.5), False), - (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0), False), - (AsyncScheduleMode.OVERLAP, SamplingParams(top_k=1, top_p=0.0), False), - (AsyncScheduleMode.SERIAL, SamplingParams(top_k=0, top_p=0.0), True), - (AsyncScheduleMode.OVERLAP, SamplingParams(top_k=0, top_p=0.0), True), - (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.5), True), - (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0, return_log_probs=True), True), - (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0, top_n_logprobs=1), True), - (AsyncScheduleMode.SERIAL, SamplingParams(top_k=1, top_p=0.0, stop_words=["END"]), True), + (False, False, (False, False, False), False), + (True, False, (True, True, True), True), + (True, True, (False, True, True), True), + (True, True, (True, True, True), False), ], ) -def test_validate_async_sched_support_for_request(async_sched_mode, sampling_params, should_raise): - """Ensure engine request validation accepts only supported async scheduling requests.""" - engine = _make_engine(async_sched_mode=async_sched_mode) - request = SimpleNamespace(sampling_params=sampling_params) +def test_should_run_async_sched_overlap(can_prepare, has_waiting, availability, expected): + """The overlap probe observes prefill eligibility without admitting the request.""" + engine = _make_engine() + engine.context.can_prepare_requests.return_value = can_prepare + engine.context.check_availability = mock.Mock(return_value=availability) + engine.waiting_request_ids = deque([10] if has_waiting else []) + request = SimpleNamespace(remaining_prompt_tokens=[1, 2], cg_wait_iters=3) + engine.get_request = mock.Mock(return_value=request) + engine._cg_admission_gating_active = mock.Mock(return_value=False) - if should_raise: - with pytest.raises(ValueError, match="Async scheduling"): - engine._validate_async_sched_support_for_request(request) + assert engine._should_run_async_sched_overlap() is expected + engine.context.can_prepare_requests.assert_called_once_with() + assert list(engine.waiting_request_ids) == ([10] if has_waiting else []) + assert request.cg_wait_iters == 3 + + +def test_async_sched_overlap_probe_uses_non_mutating_cuda_graph_match(): + """A scheduling probe does not update CUDA-graph wait accounting.""" + engine = _make_engine() + engine.context.active_token_count = 2 + engine.context.num_prefill_requests = 0 + engine.context.num_decode_requests = 2 + engine.context.check_availability = mock.Mock(return_value=(True, True, True)) + engine._cg_admission_gating_active = mock.Mock(return_value=True) + engine._matches_cg_admission = mock.Mock(return_value=False) + engine._cg_admission_check = mock.Mock() + request = SimpleNamespace(remaining_prompt_tokens=[1, 2], cg_wait_iters=7) + + assert not engine._can_schedule_non_chunked_prefill(request, record_cg_wait=False) + engine._matches_cg_admission.assert_called_once() + engine._cg_admission_check.assert_not_called() + assert request.cg_wait_iters == 7 + + +@pytest.mark.parametrize( + "availability, active_token_count, chunked_prefill_request_id, expected", + [ + ((True, False, True), 7, -1, True), + ((False, False, True), 7, 10, True), + ((True, True, False), 7, -1, False), + ((True, True, True), 8, -1, False), + ], +) +def test_can_schedule_chunked_prefill( + availability, active_token_count, chunked_prefill_request_id, expected +): + """The chunk probe requires request, KV-cache, and partial-token capacity.""" + engine = _make_engine(enable_chunked_prefill=True) + engine.context.active_token_count = active_token_count + engine.context.chunked_prefill_request_id = chunked_prefill_request_id + engine.context.check_availability = mock.Mock(return_value=availability) + request = SimpleNamespace(request_id=10) + + assert engine._can_schedule_chunked_prefill(request) is expected + + +def test_async_sched_overlap_probe_routes_schedulable_chunk_to_no_overlap(): + """A schedulable chunk is admitted only after no-overlap lifecycle bookkeeping.""" + engine = _make_engine(enable_chunked_prefill=True) + engine.context.active_token_count = 2 + engine.context.check_availability = mock.Mock(return_value=(True, False, True)) + engine.waiting_request_ids = deque([10]) + engine.get_request = mock.Mock(return_value=SimpleNamespace(request_id=10)) + + assert not engine._should_run_async_sched_overlap() + + +@pytest.mark.parametrize( + "mode, run_async_overlap, decode_only, primer_only, expected_schedule_calls, " + "expected_nvtx_range", + [ + ( + AsyncScheduleMode.LEGACY, + None, + DecodeOnly(consumed=False, launched=False), + False, + 1, + "Prefill", + ), + ( + AsyncScheduleMode.LEGACY, + None, + DecodeOnly(consumed=True, launched=True), + False, + 1, + "Decode", + ), + ( + AsyncScheduleMode.ASYNC, + True, + DecodeOnly(consumed=True, launched=True), + False, + 0, + "AsyncOverlap", + ), + ( + AsyncScheduleMode.ASYNC, + False, + DecodeOnly(consumed=False, launched=True), + False, + 0, + "AsyncNoOverlap", + ), + ( + AsyncScheduleMode.ASYNC, + False, + DecodeOnly(consumed=None, launched=False), + True, + 0, + "AsyncNoOverlap", + ), + ( + AsyncScheduleMode.ASYNC, + False, + DecodeOnly(consumed=True, launched=None), + False, + 0, + "AsyncNoOverlap", + ), + ], +) +def test_async_forward_routes_one_controller_iteration( + mode, run_async_overlap, decode_only, primer_only, expected_schedule_calls, expected_nvtx_range +): + """Primer-only work crosses the engine boundary without an internal controller loop.""" + engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) + engine.state = EngineState.RUNNING + engine.logging_step_interval = 0 + engine.metrics_writer = None + engine.schedule_waiting_requests = mock.Mock() + engine._should_run_async_sched_overlap = mock.Mock(return_value=run_async_overlap) + engine.context = SimpleNamespace( + config=SimpleNamespace(async_sched_mode=mode), + step_count=4, + prefix_cache_lru_clock=7, + active_token_count=2, + num_prefill_requests=1 if expected_nvtx_range == "Prefill" else 0, + chunked_prefill_request_id=17, + is_decode_only=mock.Mock(return_value=decode_only.launched), + ) + output = None if primer_only else {"sample": "tokens"} + engine.controller = SimpleNamespace( + async_generate_output_tokens_dynamic_batch=mock.AsyncMock( + return_value=DynamicBatchControllerStepResult( + decode_only=decode_only, output=output, primer_only=primer_only + ) + ) + ) + + with ( + mock.patch( + "megatron.core.inference.engines.dynamic_engine.nvtx_range_push" + ) as nvtx_range_push, + mock.patch( + "megatron.core.inference.engines.dynamic_engine.nvtx_range_pop" + ) as nvtx_range_pop, + ): + result, context_state, _ = asyncio.run(engine.async_forward()) + + assert result is output + assert context_state["decode_only"] == decode_only + assert context_state["chunked_prefill_request_id"] == 17 + assert engine.decode_only == decode_only + assert not hasattr(engine, "is_decode_only") + assert engine.context.step_count == 5 + assert engine.context.prefix_cache_lru_clock == 8 + assert engine.schedule_waiting_requests.call_count == expected_schedule_calls + nvtx_range_push.assert_called_once_with(expected_nvtx_range) + nvtx_range_pop.assert_called_once_with(expected_nvtx_range) + if mode == AsyncScheduleMode.LEGACY: + engine._should_run_async_sched_overlap.assert_not_called() + engine.controller.async_generate_output_tokens_dynamic_batch.assert_awaited_once_with() else: - engine._validate_async_sched_support_for_request(request) + engine._should_run_async_sched_overlap.assert_called_once_with() + engine.controller.async_generate_output_tokens_dynamic_batch.assert_awaited_once_with( + run_async_overlap=run_async_overlap, + schedule_waiting_requests=( + None if run_async_overlap else engine.schedule_waiting_requests + ), + ) + engine.context.is_decode_only.assert_not_called() -def test_add_request_runs_async_sched_request_validation(): - """Ensure request validation is called before mutating engine request state.""" +def test_async_bookkeep_uses_consumed_chunked_prefill_request_id(): + """Post-processing classifies output using the chunk ID from its consumed forward.""" engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) - engine._validate_async_sched_support_for_request = mock.Mock( - side_effect=RuntimeError("validated") + engine.track_paused_request_events = False + engine.post_process_requests = mock.Mock(return_value=([10], [])) + engine.failed_request_ids = set() + engine.requests = {} + engine.use_coordinator = False + engine.context = SimpleNamespace(enable_prefix_caching=False, step_count=1) + engine.logging_step_interval = 0 + engine.num_speculative_tokens = 0 + step_result = { + "active_request_ids": [10], + "finished_request_ids": [], + "sample": [20], + "accepted_tokens": None, + "log_probs": None, + "cuda_graph_request_count": None, + } + context_state = { + "active_token_count": 4, + "step_count": 0, + "chunked_prefill_request_id": 10, + "kv_stats": None, + } + + with ( + mock.patch("megatron.core.inference.engines.dynamic_engine.nvtx_range_push"), + mock.patch("megatron.core.inference.engines.dynamic_engine.nvtx_range_pop"), + ): + asyncio.run(engine.async_bookkeep(step_result, context_state, 0.0)) + + assert ( + engine.post_process_requests.call_args.kwargs["consumed_chunked_prefill_request_id"] == 10 ) - request = SimpleNamespace(request_id=10) - with pytest.raises(RuntimeError, match="validated"): - engine._add_request(request) - engine._validate_async_sched_support_for_request.assert_called_once_with(request) +@pytest.mark.parametrize( + "mode, decode_only, expected", + [ + ( + AsyncScheduleMode.LEGACY, + DecodeOnly(consumed=False, launched=False), + ("non-decode", False), + ), + (AsyncScheduleMode.LEGACY, DecodeOnly(consumed=True, launched=True), ("decode", True)), + ( + AsyncScheduleMode.ASYNC, + DecodeOnly(consumed=False, launched=False), + ("non-decode", False), + ), + (AsyncScheduleMode.ASYNC, DecodeOnly(consumed=True, launched=True), ("decode", True)), + ( + AsyncScheduleMode.ASYNC, + DecodeOnly(consumed=False, launched=True), + ("decode (prev: non-decode)", True), + ), + ( + AsyncScheduleMode.ASYNC, + DecodeOnly(consumed=True, launched=False), + ("non-decode (prev: decode)", False), + ), + (AsyncScheduleMode.ASYNC, DecodeOnly(consumed=None, launched=False), ("non-decode", False)), + (AsyncScheduleMode.ASYNC, DecodeOnly(consumed=None, launched=True), ("decode", True)), + (AsyncScheduleMode.ASYNC, DecodeOnly(consumed=False, launched=None), ("non-decode", False)), + (AsyncScheduleMode.ASYNC, DecodeOnly(consumed=True, launched=None), ("decode", True)), + (AsyncScheduleMode.ASYNC, DecodeOnly(consumed=None, launched=None), ("idle", None)), + ], +) +def test_get_decode_only_log_state(mode, decode_only, expected): + """Console logging reports transitions and colors the latest available phase.""" + assert _get_decode_only_log_state(mode, decode_only) == expected diff --git a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py index 0e92304c6dc..ddb2960850b 100644 --- a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py +++ b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py @@ -40,6 +40,7 @@ from megatron.core import parallel_state from megatron.core.inference.config import ( + AsyncScheduleMode, InferenceConfig, MambaInferenceStateConfig, PrefixCachingEvictionPolicy, @@ -195,6 +196,7 @@ def _build_engine( enable_chunked_prefill=False, max_tokens=None, max_requests=None, + async_sched_mode=AsyncScheduleMode.LEGACY, ): set_rounder(request_rounder) inference_config_kwargs = dict( @@ -202,17 +204,18 @@ def _build_engine( buffer_size_gb=buffer_size_gb, block_size_tokens=BLOCK_SIZE, mamba_inference_state_config=mamba_config, - materialize_only_last_token_logits=False, + materialize_only_last_token_logits=async_sched_mode == AsyncScheduleMode.ASYNC, enable_prefix_caching=enable_prefix_caching, + enable_chunked_prefill=enable_chunked_prefill, unified_memory_level=0, num_cuda_graphs=num_cuda_graphs, sampling_backend='torch', - enable_chunked_prefill=enable_chunked_prefill, + async_sched_mode=async_sched_mode, ) if max_tokens is not None: - inference_config_kwargs['max_tokens'] = max_tokens + inference_config_kwargs["max_tokens"] = max_tokens if max_requests is not None: - inference_config_kwargs['max_requests'] = max_requests + inference_config_kwargs["max_requests"] = max_requests if enable_prefix_caching: inference_config_kwargs.update( prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, @@ -438,6 +441,31 @@ def test_mamba_prefix_caching_e2e(self): ), f"req {req_id}: pc=off {off_outputs[req_id]} != pc=on {on_outputs[req_id]}" assert off_prefill == 3800 and on_prefill == 2008 and on_prefill < off_prefill + @torch.inference_mode() + def test_async_sched_mamba_prefix_caching_with_chunked_prefill_e2e(self): + """Async combined chunking and Mamba prefix caching matches legacy output.""" + skip_if_mamba_sequence_packing_not_available() + model = self._create_model() + mamba_config = MambaInferenceStateConfig.from_model(model) + prompts = self._create_prompts()[:3] + + legacy_outputs, legacy_prefill = self._run_simple( + model, mamba_config, prompts, enable_pc=False + ) + async_outputs, async_prefill = self._run_simple( + model, + mamba_config, + prompts, + enable_pc=True, + enable_chunked_prefill=True, + max_tokens=400, + max_requests=4, + async_sched_mode=AsyncScheduleMode.ASYNC, + ) + + assert async_outputs == legacy_outputs + assert async_prefill < legacy_prefill + @pytest.mark.parametrize("num_cuda_graphs", [None, 2]) @torch.inference_mode() def test_mamba_prefix_caching_multi_group_e2e(self, num_cuda_graphs): diff --git a/tests/unit_tests/inference/test_inference_config.py b/tests/unit_tests/inference/test_inference_config.py index 5e591c8fe81..4f4dd780b64 100644 --- a/tests/unit_tests/inference/test_inference_config.py +++ b/tests/unit_tests/inference/test_inference_config.py @@ -26,10 +26,10 @@ def test_mutual_exclusivity_with_transformer_config(self): "async_sched_mode, expected", [ (None, AsyncScheduleMode.LEGACY), - ("serial", AsyncScheduleMode.SERIAL), - (AsyncScheduleMode.SERIAL, AsyncScheduleMode.SERIAL), - ("overlap", AsyncScheduleMode.OVERLAP), - (AsyncScheduleMode.OVERLAP, AsyncScheduleMode.OVERLAP), + ("legacy", AsyncScheduleMode.LEGACY), + (AsyncScheduleMode.LEGACY, AsyncScheduleMode.LEGACY), + ("async", AsyncScheduleMode.ASYNC), + (AsyncScheduleMode.ASYNC, AsyncScheduleMode.ASYNC), ], ) def test_async_sched_mode_default_and_coercion(self, async_sched_mode, expected): @@ -37,16 +37,24 @@ def test_async_sched_mode_default_and_coercion(self, async_sched_mode, expected) kwargs = {} if async_sched_mode is None else {"async_sched_mode": async_sched_mode} assert InferenceConfig(**kwargs).async_sched_mode == expected - def test_async_sched_mode_rejects_invalid_value(self): + @pytest.mark.parametrize("invalid_mode", ["serial", "overlap", "invalid"]) + def test_async_sched_mode_rejects_invalid_value(self, invalid_mode): """Ensure invalid async scheduling modes fail during config construction.""" with pytest.raises(ValueError): - InferenceConfig(async_sched_mode="invalid") + InferenceConfig(async_sched_mode=invalid_mode) def test_async_sched_argparse_plumbing(self): """Ensure the CLI exposes async scheduling mode.""" parser = _add_inference_args(ArgumentParser()) - args = parser.parse_args(["--inference-dynamic-batching-async-sched-mode", "overlap"]) - assert args.inference_dynamic_batching_async_sched_mode == "overlap" + args = parser.parse_args(["--inference-dynamic-batching-async-sched-mode", "async"]) + assert args.inference_dynamic_batching_async_sched_mode == "async" + + @pytest.mark.parametrize("invalid_mode", ["serial", "overlap"]) + def test_async_sched_argparse_rejects_removed_modes(self, invalid_mode): + """Ensure the CLI rejects removed async scheduling modes.""" + parser = _add_inference_args(ArgumentParser()) + with pytest.raises(SystemExit): + parser.parse_args(["--inference-dynamic-batching-async-sched-mode", invalid_mode]) def test_inference_setup_config_maps_async_sched_mode(self): """Ensure declarative inference config maps async scheduling mode to runtime config.""" @@ -56,7 +64,7 @@ def test_inference_setup_config_maps_async_sched_mode(self): pg_collection="pg", decoder=SimpleNamespace(layer_type_list=None), ) - setup_config = InferenceSetupConfig(inference_dynamic_batching_async_sched_mode="overlap") + setup_config = InferenceSetupConfig(inference_dynamic_batching_async_sched_mode="async") inference_config = setup_config.to_inference_config( model=model, @@ -66,7 +74,7 @@ def test_inference_setup_config_maps_async_sched_mode(self): verbose=False, ) - assert inference_config.async_sched_mode == AsyncScheduleMode.OVERLAP + assert inference_config.async_sched_mode == AsyncScheduleMode.ASYNC def test_offset_sampling_seed_argparse_plumbing(self): """Ensure the CLI can select a shared sampling seed across DP ranks.""" diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index 0f59b679bb7..ce3e8cdb0e3 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -34,6 +34,8 @@ from megatron.core.inference.sampling_params import SamplingParams from megatron.core.inference.text_generation_controllers.text_generation_controller import ( AsyncScheduleLogitsState, + DecodeOnly, + DynamicBatchControllerStepResult, TextGenerationController, ) from megatron.core.inference.utils import InferenceMode @@ -196,9 +198,18 @@ def setup_model( def _make_async_sched_context(total_request_count=2, paused_request_count=0): metadata_len = max(total_request_count, 1) - return SimpleNamespace( + request_metadata = { + "temperature": torch.ones(metadata_len), + "top_k": torch.ones(metadata_len, dtype=torch.int64), + "top_p": torch.zeros(metadata_len), + "return_log_probs": torch.zeros(metadata_len, dtype=torch.bool), + "top_n_logprobs": torch.zeros(metadata_len, dtype=torch.int64), + "skip_prompt_log_probs": torch.zeros(metadata_len, dtype=torch.bool), + "termination_id": torch.full((metadata_len,), 99, dtype=torch.int64), + } + context = SimpleNamespace( config=SimpleNamespace( - materialize_only_last_token_logits=True, async_sched_mode=AsyncScheduleMode.SERIAL + materialize_only_last_token_logits=True, async_sched_mode=AsyncScheduleMode.ASYNC ), is_hybrid_model=False, enable_prefix_caching=False, @@ -208,14 +219,22 @@ def _make_async_sched_context(total_request_count=2, paused_request_count=0): chunked_prefill_request_id=-1, num_prefill_requests=0, padded_active_request_count=8, + step_count=0, + prefix_cache_lru_clock=0, + lifetime_prefill_token_count=0, request_ids=torch.arange(10, 10 + metadata_len, dtype=torch.int32), - request_metadata={ - "top_k": torch.ones(metadata_len, dtype=torch.int64), - "top_p": torch.zeros(metadata_len), - "return_log_probs": torch.zeros(metadata_len, dtype=torch.bool), - "top_n_logprobs": torch.zeros(metadata_len, dtype=torch.int64), - "termination_id": torch.full((metadata_len,), 99, dtype=torch.int64), + request_metadata=request_metadata, + active_request_metadata={ + label: metadata.clone() for label, metadata in request_metadata.items() }, + gpu_view=SimpleNamespace( + temperature=request_metadata["temperature"].clone(), + top_k=request_metadata["top_k"].to(torch.int32).clone(), + top_p=request_metadata["top_p"].clone(), + active_request_last_token_idxs=torch.arange(metadata_len, dtype=torch.int32), + request_query_lengths=torch.ones(metadata_len, dtype=torch.int32), + token_to_input_ids=torch.arange(32, dtype=torch.int64), + ), async_sched_step_count=0, async_sched_compaction_step_count=0, get_active_sequence_lengths=mock.Mock( @@ -226,12 +245,20 @@ def _make_async_sched_context(total_request_count=2, paused_request_count=0): ), prepare_requests=mock.Mock(), commit_sampled_tokens=mock.Mock(), - resolve_requests=mock.Mock(return_value=torch.empty(0, dtype=torch.int32)), + update_requests=mock.Mock(return_value={}), + resolve_requests=mock.Mock( + return_value=(torch.empty(0, dtype=torch.int32), torch.arange(metadata_len)) + ), copy_async_sched_sample_to_forward=mock.Mock(), + reset=mock.Mock(), transfer_bookkeeping_to_gpu=mock.Mock(return_value="bookkeeping"), using_cuda_graph_this_step=mock.Mock(return_value=False), max_requests=metadata_len, + max_tokens=32, + request_query_lengths=torch.ones(metadata_len, dtype=torch.int32), ) + context.is_decode_only = mock.Mock(side_effect=lambda: context.num_prefill_requests == 0) + return context def _make_async_sched_controller(context=None, model_config=None): @@ -241,6 +268,7 @@ def _make_async_sched_controller(context=None, model_config=None): expert_model_parallel_size=1, num_moe_experts=None, moe_enable_routing_replay=False, + moe_pad_experts_for_cuda_graph_inference=False, ) controller = TextGenerationController.__new__(TextGenerationController) controller.inference_wrapped_model = SimpleNamespace( @@ -249,17 +277,92 @@ def _make_async_sched_controller(context=None, model_config=None): controller.model_config = model_config controller.num_speculative_tokens = 0 controller._enable_cuda_graph = False + controller._sampling_backend = "torch" controller._async_sched_logits = AsyncScheduleLogitsState(is_valid=True) + controller._async_sched_mtp_token_row_indices = None + controller._all_logits_cuda = torch.empty(0) controller._sampled_tokens_cuda = torch.empty(context.max_requests, dtype=torch.int64) - controller._async_sched_sample_values_cuda = torch.empty( - context.max_requests, dtype=model_config.params_dtype - ) + + def sample_kernel(logits, n, _context, **kwargs): + sampled_tokens = torch.argmax(logits[:n], dim=-1) + output = kwargs.get("output") + if output is None: + return sampled_tokens + output.copy_(sampled_tokens) + return output + + controller._sampling = SimpleNamespace(sample_kernel=mock.Mock(side_effect=sample_kernel)) + controller._get_stop_word_finished_ids_callback = None controller._async_sched_sampled_tokens_cpu_buffer = torch.empty( context.max_requests, dtype=torch.int64 ) + controller._tp_size = 1 + controller._sp_enabled = False + controller._async_sched_selected_log_probs_cpu_buffer = torch.empty( + context.max_tokens, dtype=torch.float32 + ) + controller._async_sched_top_n_log_probs_cpu_buffer = None + controller._async_sched_top_n_token_ids_cpu_buffer = None + controller._async_sched_top_n_capacity = 0 return controller +@pytest.mark.parametrize( + "consumed, launched, expected", + [ + (False, False, False), + (True, True, True), + (None, None, ValueError), + (None, False, ValueError), + (None, True, ValueError), + (False, None, ValueError), + (True, None, ValueError), + (False, True, ValueError), + (True, False, ValueError), + ], +) +def test_decode_only_bool_requires_matching_forwards(consumed, launched, expected): + """Boolean conversion is valid only for matching consumed and launched forwards.""" + decode_only = DecodeOnly(consumed=consumed, launched=launched) + + if expected is ValueError: + with pytest.raises(ValueError, match="ambiguous"): + bool(decode_only) + else: + assert bool(decode_only) is expected + + +@pytest.mark.parametrize("num_prefill_requests, expected", [(1, False), (0, True)]) +def test_legacy_step_reports_matching_decode_only_state(num_prefill_requests, expected): + """Legacy consumes and launches the same computational batch.""" + context = _make_async_sched_context(total_request_count=1) + context.config.async_sched_mode = AsyncScheduleMode.LEGACY + context.num_prefill_requests = num_prefill_requests + context.num_decode_requests = 1 - num_prefill_requests + context.kv_block_allocator = SimpleNamespace(store_routing_per_block=mock.Mock()) + controller = _make_async_sched_controller(context) + controller._dynamic_step_context_init = mock.Mock( + return_value=(torch.tensor([1]), torch.tensor([0]), None) + ) + controller._dynamic_step_forward_logits = mock.Mock() + controller._router_record_bookkeeping = mock.Mock(return_value=None) + controller._dynamic_step_log_probs_bookkeeping = mock.Mock(return_value=(False, False)) + controller._dynamic_step_sample_logits = mock.Mock() + controller._dynamic_step_context_bookkeeping = mock.Mock( + return_value={"sample": torch.tensor([2])} + ) + + with mock.patch( + "megatron.core.inference.text_generation_controllers." + "text_generation_controller.get_moe_router_tracer", + return_value=None, + ): + result = asyncio.run(controller._run_legacy_step()) + + assert result.decode_only == DecodeOnly(consumed=expected, launched=expected) + assert result.output["sample"].tolist() == [2] + + @pytest.mark.parametrize("total_request_count", [0, 2]) def test_validate_async_sched_support_for_step_success(total_request_count): context = _make_async_sched_context(total_request_count=total_request_count) @@ -267,7 +370,15 @@ def test_validate_async_sched_support_for_step_success(total_request_count): if total_request_count == 0: context.config.materialize_only_last_token_logits = False - controller._validate_async_sched_support_for_step() + controller._validate_async_sched_support_for_step(run_async_overlap=True) + + +def test_validate_async_sched_support_for_step_allows_paused_no_overlap(): + """Paused requests are handled by no-overlap lifecycle bookkeeping.""" + context = _make_async_sched_context(total_request_count=2, paused_request_count=1) + controller = _make_async_sched_controller(context) + + controller._validate_async_sched_support_for_step(run_async_overlap=False) def test_validate_async_sched_support_for_step_ignores_immutable_restrictions(): @@ -275,6 +386,7 @@ def test_validate_async_sched_support_for_step_ignores_immutable_restrictions(): context.config.materialize_only_last_token_logits = False context.is_hybrid_model = True context.enable_prefix_caching = True + context.chunked_prefill_request_id = 10 context.request_metadata["top_k"] = torch.tensor([0, 0]) context.request_metadata["top_p"] = torch.tensor([0.5, 0.5]) context.request_metadata["return_log_probs"] = torch.tensor([True, True]) @@ -288,20 +400,24 @@ def test_validate_async_sched_support_for_step_ignores_immutable_restrictions(): controller = _make_async_sched_controller(context, model_config) controller.num_speculative_tokens = 1 - controller._validate_async_sched_support_for_step() + controller._validate_async_sched_support_for_step(run_async_overlap=True) -@pytest.mark.parametrize("unsupported_case", ["paused_request", "chunked_prefill"]) -def test_validate_async_sched_support_for_step_errors(unsupported_case): +def test_validate_async_sched_support_for_step_errors_on_paused_overlap(): context = _make_async_sched_context(total_request_count=2) controller = _make_async_sched_controller(context) - if unsupported_case == "paused_request": - context.paused_request_count = 1 - elif unsupported_case == "chunked_prefill": - context.chunked_prefill_request_id = 0 + context.paused_request_count = 1 with pytest.raises(RuntimeError, match="Async scheduling"): - controller._validate_async_sched_support_for_step() + controller._validate_async_sched_support_for_step(run_async_overlap=True) + + +def test_async_sched_logits_state_rejects_removed_ready_event(): + state = AsyncScheduleLogitsState() + + assert not hasattr(state, "ready_event") + with pytest.raises(TypeError): + AsyncScheduleLogitsState(ready_event=None) @pytest.mark.parametrize( @@ -314,28 +430,44 @@ def test_validate_async_sched_support_for_step_errors(unsupported_case): ], ) def test_async_sched_logits_compaction(enable_cuda_graph, survivor_idxs, expected_compaction): - controller = _make_async_sched_controller() + context = _make_async_sched_context(total_request_count=4) + context.active_request_metadata["temperature"].copy_(torch.tensor([0.1, 0.2, 0.3, 0.4])) + context.active_request_metadata["top_k"].copy_(torch.tensor([1, 2, 3, 4])) + context.active_request_metadata["top_p"].copy_(torch.tensor([0.5, 0.6, 0.7, 0.8])) + context.gpu_view.temperature.copy_(context.active_request_metadata["temperature"]) + context.gpu_view.top_k.copy_(context.active_request_metadata["top_k"]) + context.gpu_view.top_p.copy_(context.active_request_metadata["top_p"]) + original_cpu_metadata = { + label: context.active_request_metadata[label].clone() + for label in ("temperature", "top_k", "top_p") + } + gpu_metadata = { + "temperature": context.gpu_view.temperature, + "top_k": context.gpu_view.top_k, + "top_p": context.gpu_view.top_p, + } + original_gpu_metadata = {label: metadata.clone() for label, metadata in gpu_metadata.items()} + controller = _make_async_sched_controller(context) controller._enable_cuda_graph = enable_cuda_graph controller._async_sched_logits = AsyncScheduleLogitsState( - is_valid=True, cuda_graph_request_count=8, ready_event="forward" + is_valid=True, cuda_graph_request_count=8 ) - controller._record_fresh_async_sched_event = mock.Mock(return_value="compaction") logits = torch.arange(12).reshape(1, 4, 3) controller._all_logits_cuda = logits.clone() - compaction_done_event = controller._compact_async_sched_logits(survivor_idxs) + result = controller._compact_async_sched_logits(survivor_idxs) + + assert result is None if survivor_idxs.numel() == 0: assert not controller._async_sched_logits.is_valid - assert compaction_done_event is None - controller._record_fresh_async_sched_event.assert_not_called() return if not expected_compaction: assert torch.equal(controller._all_logits_cuda, logits) - assert controller._async_sched_logits.ready_event == "forward" - assert compaction_done_event is None - controller._record_fresh_async_sched_event.assert_not_called() + for label in ("temperature", "top_k", "top_p"): + assert torch.equal(context.active_request_metadata[label], original_cpu_metadata[label]) + assert torch.equal(gpu_metadata[label], original_gpu_metadata[label]) return expected_logits = logits[:, survivor_idxs, :] @@ -346,10 +478,31 @@ def test_async_sched_logits_compaction(enable_cuda_graph, survivor_idxs, expecte assert controller._all_logits_cuda.shape == logits.shape else: assert torch.equal(controller._all_logits_cuda, expected_logits) + survivor_count = survivor_idxs.numel() + for label in ("temperature", "top_k", "top_p"): + assert torch.equal( + context.active_request_metadata[label][:survivor_count], + original_cpu_metadata[label][survivor_idxs], + ) + assert torch.equal( + gpu_metadata[label][:survivor_count], original_gpu_metadata[label][survivor_idxs] + ) assert controller._async_sched_logits.is_valid assert controller._async_sched_logits.cuda_graph_request_count == 8 - assert controller._async_sched_logits.ready_event == "compaction" - assert compaction_done_event == "compaction" + + +def test_async_sched_mtp_logits_compaction_preserves_input_rows(): + """MTP survivor logits retain the pending forward rows used for verification.""" + controller = _make_async_sched_controller(_make_async_sched_context(total_request_count=3)) + controller.num_speculative_tokens = 1 + controller._all_logits_cuda = torch.arange(18).reshape(1, 6, 3) + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=True, token_row_indices=torch.tensor([10, 11, 20, 21, 30, 31]) + ) + + controller._compact_async_sched_logits(torch.tensor([2, 1])) + + assert controller._async_sched_logits.token_row_indices.tolist() == [30, 31, 20, 21] def test_dynamic_step_context_init_returns_bookkeeping_event(): @@ -433,7 +586,7 @@ def test_run_async_sched_publish_bookkeeping_skips_gpu_input_ids(): @pytest.mark.parametrize( "using_cuda_graph, expected_cuda_graph_request_count", [(False, None), (True, 8)] ) -def test_run_async_sched_forward_records_primer_and_returns_event( +def test_run_async_sched_forward_records_pending_logits( using_cuda_graph, expected_cuda_graph_request_count ): context = _make_async_sched_context() @@ -442,7 +595,6 @@ def test_run_async_sched_forward_records_primer_and_returns_event( controller._async_sched_logits = AsyncScheduleLogitsState() controller._all_logits_cuda = torch.empty(0) controller._dynamic_step_forward_logits = mock.Mock() - controller._record_fresh_async_sched_event = mock.Mock(return_value="forward_done") input_ids = torch.tensor([[10, 11]]) position_ids = torch.tensor([[0, 1]]) @@ -456,25 +608,52 @@ def test_run_async_sched_forward_records_primer_and_returns_event( "text_generation_controller.range_pop" ), ): - forward_done_event = controller._run_async_sched_forward(input_ids, position_ids) + result = controller._run_async_sched_forward(input_ids, position_ids) controller._dynamic_step_forward_logits.assert_called_once_with(input_ids, position_ids) - controller._record_fresh_async_sched_event.assert_called_once_with(controller._all_logits_cuda) - assert forward_done_event == "forward_done" + assert result is None assert controller._async_sched_logits.is_valid assert ( controller._async_sched_logits.cuda_graph_request_count == expected_cuda_graph_request_count ) - assert controller._async_sched_logits.ready_event == "forward_done" + + +def test_run_async_sched_forward_commits_mamba_prefix_states(): + """A real async forward commits cacheable Mamba states before row resolution.""" + context = _make_async_sched_context() + context.is_hybrid_model = True + context.mamba_slot_allocator = SimpleNamespace(commit_intermediate_states=mock.Mock()) + controller = _make_async_sched_controller(context) + controller._dynamic_step_forward_logits = mock.Mock() + + controller._run_async_sched_forward(torch.tensor([[10, 11]]), torch.tensor([[0, 1]])) + + context.mamba_slot_allocator.commit_intermediate_states.assert_called_once_with() + + +def test_run_dummy_async_sched_base_step_resets_without_committing_mamba_state(): + context = _make_async_sched_context() + context.is_hybrid_model = True + context.mamba_slot_allocator = SimpleNamespace(commit_intermediate_states=mock.Mock()) + controller = _make_async_sched_controller(context) + input_ids = torch.tensor([[10]]) + position_ids = torch.tensor([[0]]) + controller._dynamic_step_context_init = mock.Mock(return_value=(input_ids, position_ids, None)) + controller._run_dummy_base_forward = mock.Mock() + + result = controller._run_dummy_async_sched_base_step() + + assert result is None + controller._run_dummy_base_forward.assert_called_once_with(input_ids, position_ids) + context.mamba_slot_allocator.commit_intermediate_states.assert_not_called() + context.reset.assert_called_once_with(preserve_prefix_cache=True, preserve_counters=True) @pytest.mark.parametrize("is_valid", [False, True]) def test_run_async_sched_forward_primer(is_valid): context = _make_async_sched_context(total_request_count=2) controller = _make_async_sched_controller(context) - controller._async_sched_logits = AsyncScheduleLogitsState( - is_valid=is_valid, ready_event="forward" if is_valid else None - ) + controller._async_sched_logits = AsyncScheduleLogitsState(is_valid=is_valid) input_ids = torch.tensor([[10, 11]]) position_ids = torch.tensor([[0, 1]]) controller._dynamic_step_context_init = mock.Mock( @@ -496,36 +675,187 @@ def test_run_async_sched_forward_primer(is_valid): controller._run_async_sched_forward.assert_called_once_with(input_ids, position_ids) -def test_async_sched_step_returns_none_without_active_requests(): +@pytest.mark.parametrize( + "mode, expected_order", + [(AsyncScheduleMode.LEGACY, ["base", "mtp"]), (AsyncScheduleMode.ASYNC, ["mtp", "base"])], +) +def test_dummy_forward_matches_mode_forward_order(mode, expected_order): + """Real and idle EP ranks issue MTP and base collectives in the same order.""" + context = _make_async_sched_context() + context.config.async_sched_mode = mode + controller = _make_async_sched_controller(context) + input_ids = torch.tensor([[1]]) + position_ids = torch.tensor([[0]]) + order = [] + controller._dynamic_step_context_init = mock.Mock(return_value=(input_ids, position_ids, None)) + controller._run_dummy_base_forward = mock.Mock(side_effect=lambda *_: order.append("base")) + controller._run_dummy_serial_mtp_forward = mock.Mock(side_effect=lambda: order.append("mtp")) + + controller.dummy_forward() + + assert order == expected_order + context.reset.assert_called_once_with(preserve_prefix_cache=True, preserve_counters=True) + + +@pytest.mark.parametrize( + "num_speculative_tokens, ep_size, expected_order", + [(0, 2, ["base"]), (2, 1, ["base"]), (2, 2, ["mtp", "base"])], +) +def test_async_sched_primer_matches_dummy_mtp_order( + num_speculative_tokens, ep_size, expected_order +): + """An EP primer inserts dummy MTP collectives only when its peers do.""" + context = _make_async_sched_context(total_request_count=1) + model_config = SimpleNamespace( + params_dtype=torch.float32, + expert_model_parallel_size=ep_size, + num_moe_experts=4 if ep_size > 1 else None, + moe_enable_routing_replay=False, + ) + controller = _make_async_sched_controller(context, model_config) + controller.num_speculative_tokens = num_speculative_tokens + controller._async_sched_logits = AsyncScheduleLogitsState() + controller._dynamic_step_context_init = mock.Mock( + return_value=(torch.tensor([[1]]), torch.tensor([[0]]), "bookkeeping") + ) + order = [] + controller._run_dummy_serial_mtp_forward = mock.Mock(side_effect=lambda: order.append("mtp")) + controller._run_async_sched_forward = mock.Mock(side_effect=lambda *_: order.append("base")) + + controller._run_async_sched_forward_primer() + + assert order == expected_order + + +def test_async_sched_router_returns_empty_result_without_active_requests(): context = _make_async_sched_context(total_request_count=0) context.active_token_count = 0 controller = _make_async_sched_controller(context) controller._async_sched_logits = AsyncScheduleLogitsState( - is_valid=True, cuda_graph_request_count=8, ready_event="forward" + is_valid=True, cuda_graph_request_count=8 ) controller._validate_async_sched_support_for_step = mock.Mock() - result = asyncio.run(controller._run_async_sched_step(overlap=False)) + result = asyncio.run(controller.async_generate_output_tokens_dynamic_batch()) - assert result is None + assert result == DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=None, launched=None) + ) assert not controller._async_sched_logits.is_valid assert controller._async_sched_logits.cuda_graph_request_count is None - assert controller._async_sched_logits.ready_event is None - controller._validate_async_sched_support_for_step.assert_called_once_with() + controller._validate_async_sched_support_for_step.assert_called_once_with(True) -def test_run_async_sched_sample_reuses_gpu_buffer(): +@pytest.mark.parametrize("logits_dtype", [torch.float32, torch.bfloat16]) +def test_run_async_sched_sample_reuses_gpu_buffer(logits_dtype): context = _make_async_sched_context(total_request_count=3) controller = _make_async_sched_controller(context) - controller._all_logits_cuda = torch.zeros(1, 3, 5) + controller._all_logits_cuda = torch.zeros(1, 3, 5, dtype=logits_dtype) expected_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) for idx, token in enumerate(expected_tokens.tolist()): controller._all_logits_cuda[0, idx, token] = 10.0 - sampled_tokens_gpu = controller._run_async_sched_sample() + result = controller._run_async_sched_sample() + + assert result.sampled_tokens_gpu.data_ptr() == controller._sampled_tokens_cuda.data_ptr() + assert torch.equal(result.sampled_tokens_gpu, expected_tokens) + assert torch.equal(result.sampled_tokens_cpu_view, expected_tokens) + controller._sampling.sample_kernel.assert_called_once() + logits, n, called_context = controller._sampling.sample_kernel.call_args.args + assert torch.equal(logits, controller._all_logits_cuda.squeeze(0)) + assert n == 3 + assert called_context is context + sample_kwargs = controller._sampling.sample_kernel.call_args.kwargs + assert set(sample_kwargs) == {"gather_indices", "no_top_k", "no_top_p", "output"} + assert sample_kwargs["gather_indices"] is None + assert not sample_kwargs["no_top_k"] + assert sample_kwargs["no_top_p"] + assert sample_kwargs["output"].data_ptr() == controller._sampled_tokens_cuda.data_ptr() + + +def test_async_sched_log_probs_materializes_decode_top_n_without_padding(): + """Async logprobs exclude padded graph rows from top-n results.""" + context = _make_async_sched_context(total_request_count=3) + context.num_decode_requests = 3 + context.active_request_metadata["return_log_probs"].fill_(True) + context.active_request_metadata["top_n_logprobs"].copy_(torch.tensor([0, 2, 1])) + context.calculate_log_probs_tensors = mock.Mock( + return_value=( + torch.tensor([-0.1, -0.2, -0.3]), + torch.tensor( + [ + [-4.0, -1.0, -3.0, -2.0], + [-0.4, -0.1, -0.3, -0.2], + [-2.0, -4.0, -1.0, -3.0], + [10.0, 9.0, 8.0, 7.0], + ] + ), + ) + ) + controller = _make_async_sched_controller(context) + controller._all_logits_cuda = torch.empty(1, 3, 4) + sampled_tokens = torch.tensor([1, 1, 2]) + + gpu_result = controller._run_async_sched_log_probs( + SimpleNamespace(sampled_tokens_gpu=sampled_tokens, accepted_counts_gpu=None) + ) + transfer = controller._copy_async_sched_log_probs_to_cpu(gpu_result) + log_probs, top_n_logprobs = controller._materialize_async_sched_log_probs(transfer) + + assert [values[0] for values in log_probs] == pytest.approx([-0.1, -0.2, -0.3]) + assert set(top_n_logprobs) == {1, 2} + assert torch.equal(top_n_logprobs[1][0][1], torch.tensor([1, 3])) + assert torch.equal(top_n_logprobs[2][0][1], torch.tensor([2])) + + +def test_async_sched_log_probs_materializes_mtp_and_prefill_rows(): + """Async logprobs preserve variable MTP acceptance and prompt row boundaries.""" + context = _make_async_sched_context(total_request_count=3) + context.config.materialize_only_last_token_logits = False + context.num_decode_requests = 2 + context.num_prefill_requests = 1 + context.active_token_count = 9 + context.request_query_lengths.copy_(torch.tensor([3, 3, 3])) + context.gpu_view.request_query_lengths.copy_(torch.tensor([3, 3, 3])) + context.gpu_view.token_to_input_ids[:9].copy_(torch.tensor([1, 2, 3, 4, 5, 6, 30, 31, 32])) + context.active_request_metadata["return_log_probs"].fill_(True) + context.active_request_metadata["top_n_logprobs"].copy_(torch.tensor([0, 0, 2])) + context.active_request_metadata["skip_prompt_log_probs"].copy_( + torch.tensor([False, False, True]) + ) + context.calculate_log_probs_tensors = mock.Mock( + return_value=( + -torch.arange(1, 10, dtype=torch.float32) / 10, + torch.arange(36, dtype=torch.float32).view(9, 4), + ) + ) + controller = _make_async_sched_controller(context) + controller.num_speculative_tokens = 2 + controller._all_logits_cuda = torch.empty(1, 9, 4) + controller._accepted_tokens_per_request = torch.tensor([[10, -1], [11, 12], [-1, -1]]) + sample_result = SimpleNamespace( + sampled_tokens_gpu=torch.tensor([20, 21, 22]), accepted_counts_gpu=torch.tensor([1, 2, 0]) + ) - assert sampled_tokens_gpu.data_ptr() == controller._sampled_tokens_cuda.data_ptr() - assert torch.equal(sampled_tokens_gpu, expected_tokens) + gpu_result = controller._run_async_sched_log_probs(sample_result) + transfer = controller._copy_async_sched_log_probs_to_cpu(gpu_result) + log_probs, top_n_logprobs = controller._materialize_async_sched_log_probs( + transfer, torch.tensor([1, 2, 0]) + ) + + assert log_probs[0] == pytest.approx([-0.1, -0.2]) + assert log_probs[1] == pytest.approx([-0.4, -0.5, -0.6]) + assert log_probs[2] == pytest.approx([-0.7, -0.8, -0.9]) + assert len(top_n_logprobs[2]) == 1 + assert torch.equal(top_n_logprobs[2][0][1], torch.tensor([3, 2])) + assert torch.equal( + context.calculate_log_probs_tensors.call_args.args[1], + torch.tensor([10, 20, 20, 11, 12, 21, 31, 32, 22]), + ) + assert torch.equal( + context.calculate_log_probs_tensors.call_args.kwargs["row_to_request"], + torch.tensor([0, 0, 0, 1, 1, 1, 2, 2, 2]), + ) @pytest.mark.internal @@ -534,8 +864,10 @@ def test_run_async_sched_sample_records_gpu_ready_event(): controller = _make_async_sched_controller(context) controller._all_logits_cuda = torch.zeros(1, 3, 5, device="cuda") controller._sampled_tokens_cuda = torch.empty(3, dtype=torch.int64, device="cuda") - controller._async_sched_sample_values_cuda = torch.empty(3, device="cuda") controller._async_sched_sample_gpu_ready_event = mock.Mock() + controller._copy_async_sched_sample_to_cpu = mock.Mock( + return_value=(torch.empty(3), None, None, "sample_cpu") + ) controller._run_async_sched_sample() @@ -545,12 +877,10 @@ def test_run_async_sched_sample_records_gpu_ready_event(): @pytest.mark.internal -def test_async_sched_event_records_and_synchronizes_cuda_work(): +def test_synchronize_async_sched_event_handles_cuda_event_and_none(): controller = _make_async_sched_controller() - - assert controller._record_fresh_async_sched_event(torch.empty(1)) is None - - event = controller._record_fresh_async_sched_event(torch.empty(1, device="cuda")) + event = torch.cuda.Event() + event.record(torch.cuda.current_stream()) controller._synchronize_async_sched_event(event) controller._synchronize_async_sched_event(None) @@ -569,8 +899,8 @@ def test_copy_async_sched_sample_to_cpu_uses_reusable_buffer_and_copy_stream(): controller._async_sched_copy_stream = torch.cuda.Stream() controller._async_sched_sample_gpu_ready_event.record(torch.cuda.current_stream()) - sampled_tokens_cpu_view, sample_cpu_ready_event = controller._copy_async_sched_sample_to_cpu( - sampled_tokens_gpu + sampled_tokens_cpu_view, sampled_mtp_tokens_cpu, accepted_tokens_cpu, sample_cpu_ready_event = ( + controller._copy_async_sched_sample_to_cpu(sampled_tokens_gpu) ) sample_cpu_ready_event.synchronize() @@ -579,107 +909,124 @@ def test_copy_async_sched_sample_to_cpu_uses_reusable_buffer_and_copy_stream(): == controller._async_sched_sampled_tokens_cpu_buffer.data_ptr() ) assert torch.equal(sampled_tokens_cpu_view, sampled_tokens_gpu.cpu()) + assert sampled_mtp_tokens_cpu is None + assert accepted_tokens_cpu is None + + +@pytest.mark.internal +def test_copy_async_sched_log_probs_to_cpu_uses_reusable_buffers_and_copy_stream(): + """Async logprob D2H copies reuse pinned storage and report completion.""" + context = _make_async_sched_context(total_request_count=3) + context.num_decode_requests = 3 + context.active_request_metadata["return_log_probs"].fill_(True) + context.active_request_metadata["top_n_logprobs"].copy_(torch.tensor([0, 2, 1])) + context.calculate_log_probs_tensors = mock.Mock( + return_value=( + torch.tensor([-0.1, -0.2, -0.3], device="cuda"), + torch.tensor( + [[-4.0, -1.0, -3.0, -2.0], [-0.4, -0.1, -0.3, -0.2], [-2.0, -4.0, -1.0, -3.0]], + device="cuda", + ), + ) + ) + controller = _make_async_sched_controller(context) + controller._all_logits_cuda = torch.empty(1, 3, 4, device="cuda") + controller._async_sched_selected_log_probs_cpu_buffer = torch.empty( + 3, dtype=torch.float32, device="cpu", pin_memory=True + ) + controller._async_sched_log_probs_gpu_ready_event = torch.cuda.Event() + controller._async_sched_log_probs_cpu_ready_event = torch.cuda.Event() + controller._async_sched_copy_stream = torch.cuda.Stream() + + gpu_result = controller._run_async_sched_log_probs( + SimpleNamespace( + sampled_tokens_gpu=torch.tensor([1, 1, 2], device="cuda"), accepted_counts_gpu=None + ) + ) + transfer = controller._copy_async_sched_log_probs_to_cpu(gpu_result) + transfer.cpu_ready_event.synchronize() + log_probs, top_n_logprobs = controller._materialize_async_sched_log_probs(transfer) + + assert [values[0] for values in log_probs] == pytest.approx([-0.1, -0.2, -0.3]) + assert torch.equal(top_n_logprobs[1][0][1], torch.tensor([1, 3])) + assert torch.equal(top_n_logprobs[2][0][1], torch.tensor([2])) + + +def test_build_async_sched_request_state_uses_resolved_lengths(): + """Resolution tests the accepted output length, not speculative prepared state.""" + context = _make_async_sched_context(total_request_count=2) + context.get_max_sequence_lengths.return_value = torch.tensor([4, 4]) + controller = _make_async_sched_controller(context) + + _, finished_request_ids, active_mask = controller._build_async_sched_request_state( + torch.tensor([1, 2]), torch.tensor([3, 4]) + ) + + assert active_mask.tolist() == [1, 0] + assert finished_request_ids.tolist() == [11] + + +def test_build_async_sched_request_state_keeps_partial_chunk_active(): + """A partial chunk ignores its provisional sample and remains schedulable.""" + context = _make_async_sched_context(total_request_count=3) + context.chunked_prefill_request_id = 12 + context.get_max_sequence_lengths.return_value = torch.tensor([4, 4, 4]) + controller = _make_async_sched_controller(context) + + _, finished_request_ids, active_mask = controller._build_async_sched_request_state( + torch.tensor([1, 2, 3]), torch.tensor([3, 4, 4]) + ) + + assert active_mask.tolist() == [1, 0, 1] + assert finished_request_ids.tolist() == [11] @pytest.mark.parametrize( - "overlap, termination_ids, expected_wait, expected_compaction_event", - [ - (True, [99, 99, 99], False, None), - (True, [99, 2, 99], True, "compaction"), - (False, [99, 2, 99], False, "compaction"), - ], + "termination_ids, stop_word_finished_ids", + [([99, 99, 99], set()), ([99, 2, 99], set()), ([99, 99, 99], {11})], ) -def test_run_async_sched_resolve_waits_only_for_finish_boundary( - overlap, termination_ids, expected_wait, expected_compaction_event +def test_run_async_sched_resolve_compacts_without_forward_sync( + termination_ids, stop_word_finished_ids ): sample_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) context = _make_async_sched_context(total_request_count=3) context.request_metadata["termination_id"] = torch.tensor(termination_ids) controller = _make_async_sched_controller(context) controller._synchronize_async_sched_event = mock.Mock() + controller._get_stop_word_finished_ids_callback = mock.Mock(return_value=stop_word_finished_ids) expected_mask = (sample_tokens != context.request_metadata["termination_id"]).byte() + for request_idx, request_id in enumerate(context.request_ids.tolist()): + if request_id in stop_word_finished_ids: + expected_mask[request_idx] = 0 expected_finished_ids = context.request_ids[expected_mask == 0].clone() - context.resolve_requests = mock.Mock(return_value=expected_finished_ids) - - def compact_logits(survivor_idxs): - identity_idxs = torch.arange(survivor_idxs.numel()) - return None if torch.equal(survivor_idxs, identity_idxs) else "compaction" + expected_survivor_idxs = torch.nonzero(expected_mask, as_tuple=True)[0] + context.resolve_requests = mock.Mock( + return_value=(expected_finished_ids, expected_survivor_idxs) + ) - controller._compact_async_sched_logits = mock.Mock(side_effect=compact_logits) + controller._compact_async_sched_logits = mock.Mock() - result = controller._run_async_sched_resolve(sample_tokens, "forward", overlap) + sample_result = SimpleNamespace( + sampled_tokens_cpu_view=sample_tokens, accepted_tokens_cpu_view=None + ) + result = controller._run_async_sched_resolve( + sample_result, context.get_active_sequence_lengths() + 1 + ) assert torch.equal(result.sampled_tokens_cpu, sample_tokens) - assert result.compaction_done_event == expected_compaction_event - if expected_wait: - controller._synchronize_async_sched_event.assert_called_once_with("forward") - else: - controller._synchronize_async_sched_event.assert_not_called() - context.commit_sampled_tokens.assert_called_once() + assert not hasattr(result, "compaction_done_event") + controller._synchronize_async_sched_event.assert_not_called() + assert torch.equal(result.survivor_idxs, expected_survivor_idxs) + controller._compact_async_sched_logits.assert_called_once_with(expected_survivor_idxs) + context.commit_sampled_tokens.assert_not_called() context.resolve_requests.assert_called_once() assert torch.equal(context.resolve_requests.call_args.args[0], expected_mask) + controller._get_stop_word_finished_ids_callback.assert_called_once_with([10, 11, 12]) -@pytest.mark.parametrize( - "overlap, has_valid_logits, expected_call_order", - [ - ( - False, - True, - [ - "wait:current", - "prepare", - "sample", - "copy_input", - "copy_sample", - "wait:sample", - "publish", - "wait:bookkeeping", - "forward", - "wait:forward", - "resolve", - "wait:compaction", - "yield", - ], - ), - ( - True, - True, - [ - "prepare", - "sample", - "copy_input", - "copy_sample", - "publish", - "forward", - "wait:sample", - "wait:bookkeeping", - "resolve", - "yield", - ], - ), - ( - True, - False, - [ - "primer", - "wait:primer_bookkeeping", - "prepare", - "sample", - "copy_input", - "copy_sample", - "publish", - "forward", - "wait:sample", - "wait:bookkeeping", - "resolve", - "yield", - ], - ), - ], -) -def test_async_sched_step_order(overlap, has_valid_logits, expected_call_order): +def test_async_sched_step_overlap_order(): + """Logprob transfer overlaps after current-logit GPU work is queued.""" sample_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) sampled_tokens_cpu = sample_tokens.clone() input_ids = torch.tensor([[101, 102, 103]]) @@ -687,20 +1034,10 @@ def test_async_sched_step_order(overlap, has_valid_logits, expected_call_order): context = _make_async_sched_context(total_request_count=3) controller = _make_async_sched_controller(context) controller._async_sched_logits = AsyncScheduleLogitsState( - is_valid=has_valid_logits, - cuda_graph_request_count=7 if has_valid_logits else None, - ready_event="current" if has_valid_logits else None, + is_valid=True, cuda_graph_request_count=7 ) - controller._validate_async_sched_support_for_step = mock.Mock() call_order = [] - def run_primer(): - if not has_valid_logits: - call_order.append("primer") - controller._async_sched_logits.set_pending(7, "current") - return not has_valid_logits, "primer_bookkeeping" if not has_valid_logits else None - - controller._run_async_sched_forward_primer = mock.Mock(side_effect=run_primer) controller._synchronize_async_sched_event = mock.Mock( side_effect=lambda event: call_order.append(f"wait:{event}") ) @@ -708,27 +1045,48 @@ def run_primer(): side_effect=lambda: call_order.append("prepare") or (input_ids, position_ids) ) controller._run_async_sched_sample = mock.Mock( - side_effect=lambda: call_order.append("sample") or sample_tokens + side_effect=lambda: call_order.append("sample") + or SimpleNamespace( + sampled_tokens_gpu=sample_tokens, + sampled_tokens_cpu_view=sampled_tokens_cpu, + sampled_mtp_tokens_gpu=None, + sampled_mtp_tokens_cpu_view=None, + accepted_tokens_cpu_view=None, + sample_cpu_ready_event="sample", + ) ) context.copy_async_sched_sample_to_forward = mock.Mock( side_effect=lambda _: call_order.append("copy_input") ) - controller._copy_async_sched_sample_to_cpu = mock.Mock( - side_effect=lambda _: call_order.append("copy_sample") or (sampled_tokens_cpu, "sample") + log_probs_gpu_result = object() + log_probs_transfer = SimpleNamespace(cpu_ready_event="log_probs") + controller._run_async_sched_log_probs = mock.Mock( + side_effect=lambda _: call_order.append("log_probs") or log_probs_gpu_result + ) + controller._copy_async_sched_log_probs_to_cpu = mock.Mock( + side_effect=lambda _: call_order.append("copy_log_probs") or log_probs_transfer + ) + controller._materialize_async_sched_log_probs = mock.Mock( + side_effect=lambda *_: call_order.append("materialize_log_probs") + or ([[0.1], [0.2], [0.3]], None) ) + context.commit_sampled_tokens = mock.Mock(side_effect=lambda *_: call_order.append("commit")) controller._run_async_sched_publish_bookkeeping = mock.Mock( side_effect=lambda: call_order.append("publish") or "bookkeeping" ) controller._run_async_sched_forward = mock.Mock( - side_effect=lambda *_: call_order.append("forward") or "forward" + side_effect=lambda *_: call_order.append("forward") ) controller._run_async_sched_resolve = mock.Mock( side_effect=lambda *_: call_order.append("resolve") or SimpleNamespace( sampled_tokens_cpu=sampled_tokens_cpu, + accepted_tokens_cpu=None, active_request_ids=context.request_ids.long(), finished_request_ids=torch.tensor([11], dtype=torch.int32), - compaction_done_event="compaction", + survivor_idxs=torch.tensor([0, 2]), + newly_paused_request_ids=None, + evict_request_ids=None, ) ) @@ -740,29 +1098,58 @@ async def yield_to_event_loop(_delay): "text_generation_controller.asyncio.sleep", side_effect=yield_to_event_loop, ): - result = asyncio.run(controller._run_async_sched_step(overlap=overlap)) + result = asyncio.run(controller._run_async_sched_step_overlap()) - assert result["sample"].tolist() == sample_tokens.tolist() - assert result["cuda_graph_request_count"] == 7 + assert result.output["sample"].tolist() == sample_tokens.tolist() + assert result.decode_only == DecodeOnly(consumed=True, launched=True) + assert result.output["log_probs"] == [[0.1], [0.2], [0.3]] + assert result.output["cuda_graph_request_count"] == 7 assert context.async_sched_step_count == 1 assert context.async_sched_compaction_step_count == 1 - assert call_order == expected_call_order + assert call_order == [ + "prepare", + "sample", + "copy_input", + "log_probs", + "copy_log_probs", + "publish", + "forward", + "wait:sample", + "wait:bookkeeping", + "resolve", + "commit", + "wait:log_probs", + "materialize_log_probs", + "yield", + ] + assert torch.equal( + context.commit_sampled_tokens.call_args.args[0], sampled_tokens_cpu[torch.tensor([0, 2])] + ) @pytest.mark.parametrize( - "termination_ids, expected_mask, expected_finished_ids, expected_compaction_count", + "termination_ids, expected_mask, expected_finished_ids, expected_survivor_idxs, " + "expected_compaction_count", [ - ([99, 99, 99], [1, 1, 1], [], 0), - ([99, 2, 99], [1, 0, 1], [11], 1), - ([99, 99, 3], [1, 1, 0], [12], 1), + ([99, 99, 99], [1, 1, 1], [], [0, 1, 2], 0), + ([99, 2, 99], [1, 0, 1], [11], [0, 2], 1), + ([1, 99, 99], [0, 1, 1], [10], [2, 1], 1), + ([1, 2, 3], [0, 0, 0], [10, 11, 12], [], 1), ], ) def test_async_sched_step_wires_sampling_through_resolution( - termination_ids, expected_mask, expected_finished_ids, expected_compaction_count + termination_ids, + expected_mask, + expected_finished_ids, + expected_survivor_idxs, + expected_compaction_count, ): context = _make_async_sched_context(total_request_count=3) context.request_metadata["termination_id"] = torch.tensor(termination_ids) - context.resolve_requests.side_effect = lambda mask: context.request_ids[mask == 0].clone() + context.resolve_requests.side_effect = lambda mask: ( + context.request_ids[mask == 0].clone(), + torch.tensor(expected_survivor_idxs, dtype=torch.long), + ) controller = _make_async_sched_controller(context) controller._all_logits_cuda = torch.zeros(1, 3, 5) sampled_tokens = torch.tensor([1, 2, 3], dtype=torch.int64) @@ -776,22 +1163,24 @@ def test_async_sched_step_wires_sampling_through_resolution( ) controller._run_async_sched_publish_bookkeeping = mock.Mock(return_value=None) controller._synchronize_async_sched_event = mock.Mock() - controller._record_fresh_async_sched_event = mock.Mock(return_value="compaction") def run_forward(*_args): - controller._async_sched_logits.set_pending(None, "forward") - return "forward" + controller._async_sched_logits.set_pending(None) controller._run_async_sched_forward = mock.Mock(side_effect=run_forward) - result = asyncio.run(controller._run_async_sched_step(overlap=False)) + step_result = asyncio.run(controller._run_async_sched_step_overlap()) + result = step_result.output assert torch.equal(result["sample"], sampled_tokens) assert result["finished_request_ids"].tolist() == expected_finished_ids context.copy_async_sched_sample_to_forward.assert_called_once() assert torch.equal(context.copy_async_sched_sample_to_forward.call_args.args[0], sampled_tokens) context.commit_sampled_tokens.assert_called_once() - assert torch.equal(context.commit_sampled_tokens.call_args.args[0], sampled_tokens) + assert torch.equal( + context.commit_sampled_tokens.call_args.args[0], + sampled_tokens[torch.tensor(expected_survivor_idxs, dtype=torch.long)], + ) assert context.resolve_requests.call_args.args[0].tolist() == expected_mask assert context.async_sched_step_count == 1 assert context.async_sched_compaction_step_count == expected_compaction_count @@ -804,16 +1193,27 @@ def test_async_sched_step_yields_after_resolution_outside_inference_mode(): controller._run_async_sched_prepare = mock.Mock( return_value=(torch.empty(1, dtype=torch.int64), torch.empty(1, dtype=torch.int64)) ) - controller._run_async_sched_sample = mock.Mock(return_value=sampled_tokens) - controller._copy_async_sched_sample_to_cpu = mock.Mock(return_value=(sampled_tokens, None)) + controller._run_async_sched_sample = mock.Mock( + return_value=SimpleNamespace( + sampled_tokens_gpu=sampled_tokens, + sampled_tokens_cpu_view=sampled_tokens, + sampled_mtp_tokens_gpu=None, + sampled_mtp_tokens_cpu_view=None, + accepted_tokens_cpu_view=None, + sample_cpu_ready_event=None, + ) + ) controller._run_async_sched_publish_bookkeeping = mock.Mock(return_value=None) controller._run_async_sched_forward = mock.Mock(return_value=None) controller._run_async_sched_resolve = mock.Mock( return_value=SimpleNamespace( sampled_tokens_cpu=sampled_tokens, + accepted_tokens_cpu=None, active_request_ids=context.request_ids.long(), finished_request_ids=torch.empty(0, dtype=torch.int32), - compaction_done_event=None, + survivor_idxs=torch.tensor([0]), + newly_paused_request_ids=None, + evict_request_ids=None, ) ) observed = [] @@ -824,46 +1224,410 @@ async def run_step(): (context.async_sched_step_count, torch.is_inference_mode_enabled()) ) ) - return await controller._run_async_sched_step(overlap=True) + return await controller._run_async_sched_step_overlap() - result = asyncio.run(run_step()) + result = asyncio.run(run_step()).output assert result["sample"].tolist() == [1] assert observed == [(1, False)] +def test_async_sched_initial_no_overlap_step_launches_primer_only(): + """Initial admission launches one primer and returns across the engine boundary.""" + context = _make_async_sched_context(total_request_count=0) + context.active_token_count = 0 + controller = _make_async_sched_controller(context) + controller._async_sched_logits = AsyncScheduleLogitsState() + call_order = [] + + def admit_request(): + call_order.append("admit") + context.total_request_count = 1 + context.active_token_count = 4 + context.num_prefill_requests = 1 + + controller._run_async_sched_forward_primer = mock.Mock( + side_effect=lambda: call_order.append("primer") or (True, "bookkeeping") + ) + controller._synchronize_async_sched_event = mock.Mock( + side_effect=lambda event: call_order.append(f"wait:{event}") + ) + + result = asyncio.run( + controller._run_async_sched_step_no_overlap(schedule_waiting_requests=admit_request) + ) + + assert result == DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=None, launched=False), primer_only=True + ) + assert call_order == ["admit", "primer", "wait:bookkeeping"] + assert context.async_sched_step_count == 0 + + +def test_run_async_sched_update_requests_preserves_pre_update_output(): + """Legacy bookkeeping may reorder working samples without corrupting step output.""" + context = _make_async_sched_context(total_request_count=3, paused_request_count=1) + context.request_metadata["termination_id"] = torch.tensor([99, 99, 2]) + context.get_max_sequence_lengths.return_value = torch.tensor([10, 10]) + controller = _make_async_sched_controller(context) + sampled_tokens = torch.tensor([1, 2]) + sampled_mtp_tokens = torch.tensor([[3, 4], [5, 6]]) + accepted_tokens = torch.tensor([7, 8]) + sample_result = SimpleNamespace( + sampled_tokens_cpu_view=sampled_tokens, + sampled_mtp_tokens_cpu_view=sampled_mtp_tokens, + accepted_tokens_cpu_view=accepted_tokens, + ) + + def update_requests(active_mask, mutable_samples, mutable_mtp_samples): + assert active_mask.tolist() == [1, 0] + mutable_samples.fill_(-1) + mutable_mtp_samples.fill_(-1) + return { + "newly_paused_request_ids": torch.tensor([11]), + "evict_request_ids": torch.tensor([10]), + } + + context.update_requests.side_effect = update_requests + + result = controller._run_async_sched_update_requests( + sample_result, resolved_sequence_lengths=torch.tensor([4, 4]) + ) + + assert result.active_request_ids.tolist() == [11, 12] + assert result.finished_request_ids.tolist() == [12] + assert result.sampled_tokens_cpu.tolist() == [1, 2] + assert result.accepted_tokens_cpu.tolist() == [7, 8] + assert result.newly_paused_request_ids.tolist() == [11] + assert result.evict_request_ids.tolist() == [10] + assert sampled_tokens.tolist() == [1, 2] + assert sampled_mtp_tokens.tolist() == [[3, 4], [5, 6]] + + +@pytest.mark.parametrize( + "consumed_prefill_requests, launched_prefill_requests", [(1, 0), (1, 1), (0, 0), (0, 1)] +) +def test_async_sched_no_overlap_updates_before_admission( + consumed_prefill_requests, launched_prefill_requests +): + """No-overlap classifies consumed and launched work around lifecycle updates.""" + context = _make_async_sched_context(total_request_count=2) + context.num_prefill_requests = consumed_prefill_requests + controller = _make_async_sched_controller(context) + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=True, cuda_graph_request_count=7 + ) + sampled_tokens = torch.tensor([1, 2]) + sample_result = SimpleNamespace( + sampled_tokens_gpu=sampled_tokens, + sampled_tokens_cpu_view=sampled_tokens, + sampled_mtp_tokens_gpu=None, + sampled_mtp_tokens_cpu_view=None, + accepted_tokens_cpu_view=None, + sample_cpu_ready_event="sample", + ) + request_result = SimpleNamespace( + sampled_tokens_cpu=sampled_tokens, + accepted_tokens_cpu=None, + active_request_ids=context.request_ids.long(), + finished_request_ids=torch.tensor([11]), + survivor_idxs=None, + newly_paused_request_ids=torch.tensor([10]), + evict_request_ids=torch.tensor([11]), + ) + input_ids = torch.empty(1, dtype=torch.int64) + position_ids = torch.empty(1, dtype=torch.int64) + call_order = [] + + controller._synchronize_async_sched_event = mock.Mock( + side_effect=lambda event: call_order.append(f"wait:{event}") + ) + controller._run_async_sched_sample = mock.Mock( + side_effect=lambda: call_order.append("sample") or sample_result + ) + + def update_request_state(*_args): + call_order.append("update") + context.num_prefill_requests = launched_prefill_requests + return request_result + + controller._run_async_sched_update_requests = mock.Mock(side_effect=update_request_state) + log_probs_gpu_result = object() + log_probs_transfer = SimpleNamespace(cpu_ready_event="log_probs") + controller._run_async_sched_log_probs = mock.Mock( + side_effect=lambda _: call_order.append("log_probs") or log_probs_gpu_result + ) + controller._copy_async_sched_log_probs_to_cpu = mock.Mock( + side_effect=lambda _: call_order.append("copy_log_probs") or log_probs_transfer + ) + controller._materialize_async_sched_log_probs = mock.Mock( + side_effect=lambda *_: call_order.append("materialize_log_probs") or ([[0.1], [0.2]], None) + ) + controller._dynamic_step_context_init = mock.Mock( + side_effect=lambda: call_order.append("context_init") or (input_ids, position_ids, None) + ) + controller._run_async_sched_forward = mock.Mock( + side_effect=lambda *_: call_order.append("forward") + ) + + async def yield_to_event_loop(_delay): + call_order.append("yield") + + with mock.patch( + "megatron.core.inference.text_generation_controllers." + "text_generation_controller.asyncio.sleep", + side_effect=yield_to_event_loop, + ): + result = asyncio.run( + controller._run_async_sched_step_no_overlap( + schedule_waiting_requests=lambda: call_order.append("admit") + ) + ) + + assert result.output["sample"].tolist() == [1, 2] + assert result.output["newly_paused_request_ids"].tolist() == [10] + assert result.output["evict_request_ids"].tolist() == [11] + assert result.decode_only == DecodeOnly( + consumed=consumed_prefill_requests == 0, launched=launched_prefill_requests == 0 + ) + assert result.output["log_probs"] == [[0.1], [0.2]] + assert call_order == [ + "sample", + "log_probs", + "copy_log_probs", + "wait:sample", + "update", + "admit", + "context_init", + "forward", + "wait:log_probs", + "materialize_log_probs", + "yield", + ] + context.resolve_requests.assert_not_called() + context.prepare_requests.assert_not_called() + context.commit_sampled_tokens.assert_not_called() + + +def test_async_sched_no_overlap_finishes_with_matching_ep_base_forward(): + """A rank that resolves its last request still matches the peer base collective.""" + context = _make_async_sched_context(total_request_count=1) + context.num_prefill_requests = 1 + model_config = SimpleNamespace( + params_dtype=torch.float32, + expert_model_parallel_size=2, + num_moe_experts=4, + moe_enable_routing_replay=False, + ) + controller = _make_async_sched_controller(context, model_config) + controller._async_sched_logits = AsyncScheduleLogitsState(is_valid=True) + sampled_tokens = torch.tensor([1]) + sample_result = SimpleNamespace( + sampled_tokens_gpu=sampled_tokens, + sampled_tokens_cpu_view=sampled_tokens, + sampled_mtp_tokens_gpu=None, + sampled_mtp_tokens_cpu_view=None, + accepted_tokens_cpu_view=None, + sample_cpu_ready_event=None, + ) + request_result = SimpleNamespace( + sampled_tokens_cpu=sampled_tokens, + accepted_tokens_cpu=None, + active_request_ids=context.request_ids.long(), + finished_request_ids=context.request_ids.clone(), + survivor_idxs=None, + newly_paused_request_ids=None, + evict_request_ids=None, + ) + controller._run_async_sched_sample = mock.Mock(return_value=sample_result) + controller._synchronize_async_sched_event = mock.Mock() + + def update_last_request(*_args): + context.total_request_count = 0 + context.active_token_count = 0 + return request_result + + controller._run_async_sched_update_requests = mock.Mock(side_effect=update_last_request) + controller._run_dummy_async_sched_base_step = mock.Mock() + controller._run_async_sched_forward = mock.Mock() + + result = asyncio.run( + controller._run_async_sched_step_no_overlap(schedule_waiting_requests=None) + ) + + assert result.output["finished_request_ids"].tolist() == [10] + controller._run_dummy_async_sched_base_step.assert_called_once_with() + controller._run_async_sched_forward.assert_not_called() + + +def test_async_sched_mtp_overlap_step_order(): + """MTP verification and rewind precede prepare while forward precedes resolve.""" + context = _make_async_sched_context(total_request_count=3) + controller = _make_async_sched_controller(context) + controller.num_speculative_tokens = 2 + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=True, cuda_graph_request_count=7 + ) + sampled_tokens = torch.tensor([1, 4, 7]) + sampled_mtp_tokens = torch.tensor([[2, 5, 8], [3, 6, 9]]) + accepted_tokens = torch.tensor([9, 10, 11]) + sample_result = SimpleNamespace( + sampled_tokens_gpu=sampled_tokens, + sampled_tokens_cpu_view=sampled_tokens, + sampled_mtp_tokens_gpu=sampled_mtp_tokens, + sampled_mtp_tokens_cpu_view=sampled_mtp_tokens, + accepted_tokens_cpu_view=accepted_tokens, + accepted_counts_cpu_view=torch.tensor([1, 1, 1]), + sample_cpu_ready_event="sample", + ) + resolve_result = SimpleNamespace( + sampled_tokens_cpu=sampled_tokens, + accepted_tokens_cpu=accepted_tokens, + active_request_ids=context.request_ids.long(), + finished_request_ids=torch.empty(0, dtype=torch.int32), + survivor_idxs=torch.tensor([2, 1]), + newly_paused_request_ids=None, + evict_request_ids=None, + ) + input_ids = torch.empty(9, dtype=torch.int64) + position_ids = torch.empty(9, dtype=torch.int64) + call_order = [] + + controller._run_async_sched_sample_mtp = mock.Mock( + side_effect=lambda: call_order.append("sample_mtp") or sample_result + ) + controller._run_async_sched_mtp_rewind = mock.Mock( + side_effect=lambda *_args, **_kwargs: call_order.append("rewind") + ) + log_probs_gpu_result = object() + log_probs_transfer = SimpleNamespace(cpu_ready_event="log_probs") + controller._run_async_sched_log_probs = mock.Mock( + side_effect=lambda _: call_order.append("log_probs") or log_probs_gpu_result + ) + controller._copy_async_sched_log_probs_to_cpu = mock.Mock( + side_effect=lambda _: call_order.append("copy_log_probs") or log_probs_transfer + ) + controller._materialize_async_sched_log_probs = mock.Mock( + side_effect=lambda *_: call_order.append("materialize_log_probs") + or ([[0.1], [0.2], [0.3]], None) + ) + controller._run_async_sched_prepare = mock.Mock( + side_effect=lambda: call_order.append("prepare") or (input_ids, position_ids) + ) + context.copy_async_sched_sample_to_forward = mock.Mock( + side_effect=lambda *_: call_order.append("copy_input") + ) + controller._run_async_sched_publish_bookkeeping = mock.Mock( + side_effect=lambda: call_order.append("publish") or "bookkeeping" + ) + controller._run_async_sched_forward = mock.Mock( + side_effect=lambda *_: call_order.append("forward") + ) + controller._synchronize_async_sched_event = mock.Mock( + side_effect=lambda event: call_order.append(f"wait:{event}") + ) + context.commit_sampled_tokens = mock.Mock(side_effect=lambda *_: call_order.append("commit")) + controller._run_async_sched_resolve = mock.Mock( + side_effect=lambda *_: call_order.append("resolve") or resolve_result + ) + + result = asyncio.run(controller._run_async_sched_step_overlap_mtp()) + + assert result.output["accepted_tokens"].tolist() == [9, 10, 11] + assert result.decode_only == DecodeOnly(consumed=True, launched=True) + assert result.output["log_probs"] == [[0.1], [0.2], [0.3]] + assert call_order == [ + "sample_mtp", + "rewind", + "log_probs", + "copy_log_probs", + "prepare", + "copy_input", + "publish", + "forward", + "wait:sample", + "wait:bookkeeping", + "resolve", + "commit", + "wait:log_probs", + "materialize_log_probs", + ] + committed_tokens, committed_mtp_tokens = context.commit_sampled_tokens.call_args.args + assert torch.equal(committed_tokens, torch.tensor([7, 4])) + assert torch.equal(committed_mtp_tokens, torch.tensor([[8, 5], [9, 6]])) + + @pytest.mark.parametrize( - "mode, num_prefill_requests, skip_bookkeeping, expected_result", + "mode, run_async_overlap, has_pending_logits, num_speculative_tokens, " + "expected_method, expected_output", [ - (AsyncScheduleMode.LEGACY, 0, False, "legacy"), - (AsyncScheduleMode.SERIAL, 1, False, "legacy"), - (AsyncScheduleMode.SERIAL, 0, False, "async"), - (AsyncScheduleMode.OVERLAP, 1, False, "legacy"), - (AsyncScheduleMode.OVERLAP, 0, False, "overlap"), + (AsyncScheduleMode.LEGACY, None, True, 0, "legacy", "legacy"), + (AsyncScheduleMode.ASYNC, False, True, 0, "no_overlap", "no_overlap"), + (AsyncScheduleMode.ASYNC, True, False, 0, "no_overlap", "no_overlap"), + (AsyncScheduleMode.ASYNC, True, True, 0, "overlap", "overlap"), + (AsyncScheduleMode.ASYNC, True, True, 2, "overlap_mtp", "overlap_mtp"), ], ) def test_async_generate_output_tokens_dynamic_batch_routes( - mode, num_prefill_requests, skip_bookkeeping, expected_result + mode, + run_async_overlap, + has_pending_logits, + num_speculative_tokens, + expected_method, + expected_output, ): context = _make_async_sched_context() context.config.async_sched_mode = mode - context.num_prefill_requests = num_prefill_requests controller = _make_async_sched_controller(context) - controller._run_legacy_step = mock.AsyncMock(return_value="legacy") - controller._run_async_sched_step = mock.AsyncMock( - side_effect=lambda *, overlap: "overlap" if overlap else "async" + controller._async_sched_logits.is_valid = has_pending_logits + controller.num_speculative_tokens = num_speculative_tokens + controller._validate_async_sched_support_for_step = mock.Mock() + controller._run_legacy_step = mock.AsyncMock( + return_value=DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=True, launched=True), output="legacy" + ) ) + controller._run_async_sched_step_no_overlap = mock.AsyncMock( + return_value=DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=False, launched=True), output="no_overlap" + ) + ) + controller._run_async_sched_step_overlap = mock.AsyncMock( + return_value=DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=True, launched=True), output="overlap" + ) + ) + controller._run_async_sched_step_overlap_mtp = mock.AsyncMock( + return_value=DynamicBatchControllerStepResult( + decode_only=DecodeOnly(consumed=True, launched=True), output="overlap_mtp" + ) + ) + schedule_waiting_requests = mock.Mock() + + kwargs = ( + {} + if run_async_overlap is None + else { + "run_async_overlap": run_async_overlap, + "schedule_waiting_requests": schedule_waiting_requests, + } + ) + result = asyncio.run(controller.async_generate_output_tokens_dynamic_batch(**kwargs)) - result = asyncio.run(controller.async_generate_output_tokens_dynamic_batch(skip_bookkeeping)) - - assert result == expected_result + assert result.output == expected_output + methods = { + "legacy": controller._run_legacy_step, + "no_overlap": controller._run_async_sched_step_no_overlap, + "overlap": controller._run_async_sched_step_overlap, + "overlap_mtp": controller._run_async_sched_step_overlap_mtp, + } + assert methods[expected_method].await_count == 1 @pytest.mark.parametrize( "mode, expected_message", [ - (AsyncScheduleMode.SERIAL, "request bookkeeping"), - (AsyncScheduleMode.OVERLAP, "request bookkeeping"), + (AsyncScheduleMode.ASYNC, "request bookkeeping"), ("unexpected", "Unexpected async scheduling mode"), ], ) @@ -872,7 +1636,9 @@ def test_async_generate_output_tokens_dynamic_batch_assertions(mode, expected_me context.config.async_sched_mode = mode controller = _make_async_sched_controller(context) controller._run_legacy_step = mock.AsyncMock() - controller._run_async_sched_step = mock.AsyncMock() + controller._run_async_sched_step_no_overlap = mock.AsyncMock() + controller._run_async_sched_step_overlap = mock.AsyncMock() + controller._run_async_sched_step_overlap_mtp = mock.AsyncMock() with pytest.raises(AssertionError, match=expected_message): asyncio.run(controller.async_generate_output_tokens_dynamic_batch(skip_bookkeeping=True)) @@ -893,6 +1659,84 @@ def teardown_class(cls): def teardown_method(self, method): InferenceMode.unset_active() + @pytest.mark.internal + def test_async_sched_no_overlap_pauses_boundary_request(self): + """No-overlap uses real lifecycle bookkeeping before forwarding survivors.""" + self.setup_model( + torch.float32, batch_size=2, static=False, block_size_tokens=4, max_requests=2 + ) + controller = self.text_generation_controller + context = controller.inference_wrapped_model.inference_context + context.reset() + + active_slice = slice(0, 2) + context.total_request_count = 2 + context.active_token_count = 2 + context.request_ids[active_slice] = torch.tensor([10, 11], dtype=torch.int32) + context.request_in_prefill_status_tensor[active_slice] = 0 + context.request_query_lengths[active_slice] = 1 + context.request_output_lengths[active_slice] = 16 + context.request_kv_length_offsets[active_slice] = 3 + context.request_last_kv_block_offset[active_slice] = torch.tensor( + [context.block_size_tokens - 1, 0], dtype=torch.int32 + ) + context.request_metadata["termination_id"][active_slice] = 99 + context.build_active_slices(2) + + block_ids = context.kv_block_allocator.allocate_memory_blocks(2) + context.request_to_kv_block_ids[active_slice, 0] = block_ids + context.request_last_kv_block_id[active_slice] = block_ids + context.request_kv_block_counts[active_slice] = 1 + context.token_to_input_ids[active_slice] = torch.tensor([80, 81]) + + # Leave room in paused storage but no capacity to keep both requests active. + context.kv_block_allocator.active_count = context.kv_block_allocator.get_active_used() + context.kv_block_allocator.paused_count = 2 + context.kv_block_allocator.total_avail = 0 + + sampled_tokens = torch.tensor([90, 91], dtype=torch.int64) + controller._async_sched_logits = AsyncScheduleLogitsState( + is_valid=True, cuda_graph_request_count=2 + ) + controller._run_async_sched_sample = mock.Mock( + return_value=SimpleNamespace( + sampled_tokens_gpu=sampled_tokens, + sampled_tokens_cpu_view=sampled_tokens, + sampled_mtp_tokens_gpu=None, + sampled_mtp_tokens_cpu_view=None, + accepted_tokens_cpu_view=None, + sample_cpu_ready_event=None, + ) + ) + forward_input_ids = torch.tensor([91]) + forward_position_ids = torch.tensor([4]) + + def initialize_survivor_forward(): + assert context.paused_request_count == 1 + assert context.request_ids[:2].tolist() == [10, 11] + assert context.token_to_input_ids[0].item() == 91 + return forward_input_ids, forward_position_ids, None + + controller._dynamic_step_context_init = mock.Mock(side_effect=initialize_survivor_forward) + controller._run_async_sched_forward = mock.Mock() + + result = asyncio.run( + controller._run_async_sched_step_no_overlap(schedule_waiting_requests=None) + ).output + + assert result["sample"].tolist() == [90, 91] + assert result["finished_request_ids"].numel() == 0 + assert result["newly_paused_request_ids"].flatten().tolist() == [10] + assert result["evict_request_ids"] is None + assert context.paused_request_count == 1 + active_request_ids = context.request_ids[ + context.paused_request_count : context.total_request_count + ] + assert active_request_ids.tolist() == [11] + controller._run_async_sched_forward.assert_called_once_with( + forward_input_ids, forward_position_ids + ) + def test_sample_from_logits(self): self.setup_model(torch.float32) @@ -988,7 +1832,7 @@ def test_sample_from_dynamic_logits( ): if backend == "flashinfer": pytest.importorskip("flashinfer") - batch_size = 15 + batch_size = 18 self.setup_model( torch.float32, batch_size=batch_size, @@ -1009,6 +1853,7 @@ def test_sample_from_dynamic_logits( (SamplingParams(top_p=0.8), [4, 1, 7]), (SamplingParams(temperature=10.0, top_k=5), [11, 5, 8]), (SamplingParams(temperature=0.0, top_k=1), [12, 13, 14]), + (SamplingParams(temperature=1.2), [15, 16, 17]), ] # For non-torch backends, test simultaneous top_k and top_p sampling. if backend != "torch": @@ -1075,10 +1920,10 @@ def test_dynamic_sampling_keeps_sampled_tokens_buffer_full_capacity(self): """`_sampled_tokens_cuda` is a single `max_requests` buffer written in place by every sampling path. The non-speculative path (`_dynamic_step_sample_logits`) writes its `active_request_count` prefix, and the async-scheduling path - (`_run_async_sched_sample`) writes its own prefix via `torch.max(out=...)`. The - buffer must retain its full capacity across successive steps regardless of each - step's active count, so a later step with more active requests than an earlier - one still has an in-bounds destination. + (`_run_async_sched_sample`) writes its own prefix through `sample_kernel`. The buffer + must retain its full capacity across successive steps regardless of each step's + active count, so a later step with more active requests than an earlier one still + has an in-bounds destination. Drive a small-batch non-speculative sample followed by a larger-batch async sample through the same buffer, and confirm the buffer keeps its capacity and @@ -1118,21 +1963,23 @@ def test_dynamic_sampling_keeps_sampled_tokens_buffer_full_capacity(self): assert controller._sampled_tokens_cuda.data_ptr() == buffer_ptr assert torch.equal(controller._sampled_tokens_cuda[:small_count], small_expected) - # Async-scheduling sample over a larger active batch through the same buffer; - # its `torch.max(out=...)` destination is `_sampled_tokens_cuda[:large_count]`. + # Async-scheduling sample over a larger active batch through the same buffer. context.total_request_count = large_count context.paused_request_count = 0 + context.active_request_metadata["temperature"][:large_count].fill_(1.0) + context.active_request_metadata["top_k"][:large_count].fill_(1) + context.active_request_metadata["top_p"][:large_count].fill_(0.0) large_expected = torch.tensor([0, 1, 2, 3, 4], device="cuda") large_logits = torch.zeros(1, large_count, self.vocab_size, device="cuda") for row, col in enumerate(large_expected.tolist()): large_logits[0, row, col] = 10.0 controller._all_logits_cuda = large_logits - sampled = controller._run_async_sched_sample() + sampled_tokens_gpu = controller._run_async_sched_sample().sampled_tokens_gpu - assert sampled.data_ptr() == controller._sampled_tokens_cuda.data_ptr() + assert sampled_tokens_gpu.data_ptr() == controller._sampled_tokens_cuda.data_ptr() assert controller._sampled_tokens_cuda.numel() == capacity - assert torch.equal(sampled, large_expected) + assert torch.equal(sampled_tokens_gpu, large_expected) @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) @pytest.mark.parametrize( @@ -2284,7 +3131,7 @@ def test_mtp_sp_padding_real_ranks(self, active_request_count): assert ctx.mtp_decoder_hidden_states is None def test_mtp_sp_padding_dummy_ranks(self): - """Test _dummy_serial_mtp_forward with real MTP layers and sequence parallelism. + """Test _run_dummy_serial_mtp_forward with real MTP layers and sequence parallelism. Creates a GPTModel with real MTP layers and SP, then runs the dummy forward path used by EP dummy ranks. Verifies the full MTP forward @@ -2312,10 +3159,10 @@ def test_mtp_sp_padding_dummy_ranks(self): ) # Run the dummy MTP forward path end-to-end. - ctrl._dummy_serial_mtp_forward() + ctrl._run_dummy_serial_mtp_forward() # Verify compute_mtp_single_step produces correctly-shaped outputs - # with the same dummy tensor shapes that _dummy_serial_mtp_forward uses. + # with the same dummy tensor shapes that _run_dummy_serial_mtp_forward uses. # padded_count == tp_size when SP is enabled. dummy_hidden = torch.zeros((1, 1, self.hidden_size), device='cuda', dtype=torch.float32) dummy_tokens = torch.zeros((1, tp_size), device='cuda', dtype=torch.long) From c922805a4a3fd700142c989a03183650468a107f Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene <34819528+tdene@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:02:01 -0500 Subject: [PATCH 130/290] Add support for non-Gym multi-turn environments (#5312) Signed-off-by: Teodor-Dumitru Ene Co-authored-by: Jorge Albericio Co-authored-by: Laura Dang --- megatron/rl/agent/api.py | 30 +- megatron/rl/agent/reward_only_agent.py | 165 ++++-- megatron/rl/rl_utils.py | 208 ++++--- tests/unit_tests/rl/test_rl_utils.py | 554 ++++++++++-------- ...rollouts.py => test_rollout_generation.py} | 207 ++++++- 5 files changed, 767 insertions(+), 397 deletions(-) rename tests/unit_tests/rl/{test_grouped_rollouts.py => test_rollout_generation.py} (65%) diff --git a/megatron/rl/agent/api.py b/megatron/rl/agent/api.py index ce5297ceca1..ffa38be06bc 100644 --- a/megatron/rl/agent/api.py +++ b/megatron/rl/agent/api.py @@ -97,14 +97,23 @@ def __getitem__(self, idx): GroupedRollouts = list[RolloutGroup] +class EpisodeResult(NamedTuple): + """All per-turn responses of one (possibly multi-turn) episode plus the final conversation.""" + + responses: list[InferenceResponse] + conversation: list[LLMChatMessage] + + class GroupRolloutParams(NamedTuple): """Returned by agent.prepare_group_rollout. One instance is created per group call and reused for all rollouts in that group. + Every rollout is an episode: run_episode generates it (one or more turns), while + build_rollout turns the completed episode into a Rollout. """ - inference_request: InferenceRequest - build_rollout: Callable[[InferenceResponse], Awaitable[Rollout]] + run_episode: Callable[[], Awaitable[EpisodeResult]] + build_rollout: Callable[[EpisodeResult], Awaitable[Rollout]] class ContrastiveRollout(AgentBaseModel): @@ -285,7 +294,7 @@ class _InferredItem(NamedTuple): """One rollout post-inference, flowing from infer to assemble.""" item: _InferWorkItem - response: InferenceResponse + episode: EpisodeResult inferred_at: float = 0.0 @@ -398,16 +407,14 @@ async def _infer_worker(self) -> None: @trace_async_exceptions(verbose=True) async def _infer_one(self, item: _InferWorkItem) -> None: - response = await self.agent.get_rollout_response( - self.request, item.params.inference_request - ) + episode = await item.params.run_episode() inferred_at = time.monotonic() self.gate.release_for("R") if item.infer_dequeued_at: self.engine_dwell.append(inferred_at - item.infer_dequeued_at) self.inferred_count += 1 await self.assemble_queue.put( - _InferredItem(item=item, response=response, inferred_at=inferred_at) + _InferredItem(item=item, episode=episode, inferred_at=inferred_at) ) async def stage_assemble(self) -> None: @@ -429,7 +436,7 @@ async def stage_assemble(self) -> None: completed = pending.pop(inferred.item.group_id) completed.sort(key=lambda item: item.item.rollout_idx) rollouts = await asyncio.gather( - *[item.item.params.build_rollout(item.response) for item in completed] + *[item.item.params.build_rollout(item.episode) for item in completed] ) self.assembled_count += 1 # NOTE: this filter is currently non-functional dead code: @@ -516,12 +523,7 @@ async def prepare_group_rollout( self, request: GroupedRolloutRequest, ) -> GroupRolloutParams: - """Return the params for one group's rollouts. - - Called once per group by _RolloutPipeline.stage_prepare. The returned - build_rollout closure is invoked once per inference response in - _RolloutPipeline.stage_assemble. - """ + """Return the params for one group's rollouts.""" ... async def get_grouped_rollouts( diff --git a/megatron/rl/agent/reward_only_agent.py b/megatron/rl/agent/reward_only_agent.py index 5548937132e..67d5ca13f57 100644 --- a/megatron/rl/agent/reward_only_agent.py +++ b/megatron/rl/agent/reward_only_agent.py @@ -15,6 +15,7 @@ ReturnsTokens, ) from .api import ( + EpisodeResult, EvaluationAgent, EvaluationRequest, EvaluationResponse, @@ -41,6 +42,7 @@ class RewardOnlyAgent(RolloutGenerator, GroupedRolloutGenerator, PassAtEvaluatio """Agent that returns rollouts generated via default inference with a fixed reward function.""" env_id: str | None = None + max_turns: int = 1 def get_dataset(self, validation: bool = False): """Return validation or train dataset.""" @@ -86,46 +88,135 @@ def _get_rank_subset( return prompts[start_idx:end_idx] - async def _rollout_from_response( - self, request: RolloutRequest | GroupedRolloutRequest, response: InferenceResponse, golden: Any - ) -> Rollout: - assert isinstance( - request.inference_interface, ReturnsRaw - ), "InferenceInterface must support raw_text return to provide rollouts." - raw_text = response.raw_text + async def get_observation( + self, + turn_idx: int, + response: InferenceResponse, + conversation: list[LLMChatMessage], + golden: Any, + ) -> tuple[str | None, bool]: + """Return (observation, done) after a generation turn. Skipped on the last turn. - response_text = response.response.content + Override to implement multi-turn interactions. Must not mutate `conversation` or `golden`! + + Args: + turn_idx: 0-based index of the turn that just completed. + response: The inference response for this turn. + conversation: Message history before this turn's response was appended. + golden: Ground-truth / task data for reward computation. + + Returns: + (observation, done): If done is True the episode ends; observation is ignored. + If done is False, observation is a non-empty string that becomes the next user message. + """ + return None, True + + async def get_trajectory_reward( + self, + responses: list[InferenceResponse], + conversation: list[LLMChatMessage], + golden: Any, + ) -> float: + """Compute a scalar reward for the full trajectory. + + Override for trajectory-level or per-turn accumulated rewards. + """ + return await self.get_reward( + responses[-1].response.content, golden, responses[-1].finish_reason + ) + + async def _run_episode( + self, + request: RolloutRequest | GroupedRolloutRequest, + *, + prompt: str | list[LLMChatMessage], + golden: Any, + ) -> EpisodeResult: + """Run one (possibly multi-turn) episode over the group's prompt. + + Every turn takes the same path: prepare_request() on the conversation so far, then + get_rollout_response(). + get_observation() is consulted only while another generation is still possible; + on continue, the reply and observation are appended. + + Runs inside the infer stage, holding one submission slot for the whole episode. + """ + conversation = prompt + responses: list[InferenceResponse] = [] + + for turn_idx in range(self.max_turns): + turn_request = request.inference_interface.prepare_request( + conversation, request.generation_args + ) + # Adopt the request's prompt as the conversation: turn 0 may start from a bare + # string, which prepare_request normalizes into a single user message. + conversation = list(turn_request.prompt) + + response = await self.get_rollout_response(request, turn_request) + responses.append(response) + + if turn_idx + 1 < self.max_turns: + observation, done = await self.get_observation( + turn_idx, response, conversation, golden + ) + if done: + break + if not observation: + raise ValueError( + "get_observation must return a non-empty observation" + ) + + conversation += [ + response.response, + LLMChatMessage(role="user", content=observation), + ] + + # The loop appends a reply only when continuing, so the final turn's reply is not in + # `conversation` yet; append it once so get_trajectory_reward sees the full dialogue. + return EpisodeResult( + responses=responses, conversation=conversation + [responses[-1].response] + ) + + async def _rollout_from_episode( + self, + request: RolloutRequest | GroupedRolloutRequest, + episode: EpisodeResult, + golden: Any, + ) -> Rollout | TokenRollout: + """Package a completed episode into a single rollout, one trajectory entry per turn. + + Calls `get_trajectory_reward()` once over all of the episode's responses. + """ + responses = episode.responses + reward = await self.get_trajectory_reward(responses, episode.conversation, golden) + problem_id = golden['problem_id'] if 'problem_id' in golden else None if isinstance(request.inference_interface, ReturnsTokens): - logprobs = response.logprobs - generation_mask = [ - True if (x >= response.prompt_length) else False - for x in range(len(response.token_ids)) - ] - rollout = TokenRollout( - trajectory=[response.token_ids], - reward=await self.get_reward(response_text, golden, response.finish_reason), - logprobs=[logprobs], - generation_mask=[generation_mask], + return TokenRollout( + trajectory=[r.token_ids for r in responses], + reward=reward, + logprobs=[r.logprobs for r in responses], + generation_mask=[ + [x >= r.prompt_length for x in range(len(r.token_ids))] + for r in responses + ], env_id=self.env_id, - problem_id=golden['problem_id'] if 'problem_id' in golden else None, - policy_epoch=[response.policy_epoch], - kv_cache_epoch=[response.kv_cache_epoch], - num_evictions=[response.num_evictions], + problem_id=problem_id, + policy_epoch=[r.policy_epoch for r in responses], + kv_cache_epoch=[r.kv_cache_epoch for r in responses], + num_evictions=[r.num_evictions for r in responses], ) else: - rollout = Rollout( - trajectory=[raw_text], - reward=await self.get_reward(response_text, golden, response.finish_reason), + return Rollout( + trajectory=[r.raw_text for r in responses], + reward=reward, env_id=self.env_id, - problem_id=golden['problem_id'] if 'problem_id' in golden else None, - policy_epoch=[response.policy_epoch], - kv_cache_epoch=[response.kv_cache_epoch], - num_evictions=[response.num_evictions], + problem_id=problem_id, + policy_epoch=[r.policy_epoch for r in responses], + kv_cache_epoch=[r.kv_cache_epoch for r in responses], + num_evictions=[r.num_evictions for r in responses], ) - return rollout - async def get_rollout_response( self, request: RolloutRequest | GroupedRolloutRequest | EvaluationRequest, @@ -140,8 +231,7 @@ async def get_reward_rollouts(self, request: RolloutRequest) -> list[Rollout]: async def _single_rollout() -> Rollout: params = await self.prepare_group_rollout(request) - response = await self.get_rollout_response(request, params.inference_request) - return await params.build_rollout(response) + return await params.build_rollout(await params.run_episode()) return list( await asyncio.gather(*[_single_rollout() for _ in range(request.num_rollouts)]) @@ -154,13 +244,10 @@ async def prepare_group_rollout( prompt, golden = await self.get_prompt(validation=request.validation) - inference_request = request.inference_interface.prepare_request( - prompt, request.generation_args - ) - + # Every rollout runs as a (possibly multi-turn) episode over the group's shared prompt. return GroupRolloutParams( - inference_request=inference_request, - build_rollout=functools.partial(self._rollout_from_response, request, golden=golden), + run_episode=functools.partial(self._run_episode, request, prompt=prompt, golden=golden), + build_rollout=functools.partial(self._rollout_from_episode, request, golden=golden), ) async def _evaluation( diff --git a/megatron/rl/rl_utils.py b/megatron/rl/rl_utils.py index a8922e69709..d4424d17f74 100644 --- a/megatron/rl/rl_utils.py +++ b/megatron/rl/rl_utils.py @@ -65,7 +65,6 @@ RewardEvaluationResult, Rollout, RolloutGroup, - Rollouts, TokenRollout, ) from megatron.rl.agent.weighted_multi_task import WeightedMultiTask @@ -275,8 +274,8 @@ def verify_model_weights_swap( class RolloutStats: rewards: list[list[float]] # inner list is for a group env_ids: list[str] # same length as len(rewards) - turn_lens: list[list[int]] # token lengths of turns, grouped. - traj_lens: list[list[int]] # all turns comprise one trajectory. + turn_lens: list[list[int]] # tokens newly added by each turn, grouped. + traj_lens: list[list[int]] # the final sequence is the full trajectory. num_turns: None | list[list[int]] # num_turns per traj advantages: None | list[list[float]] min_piold_to_inf_prob: None | float @@ -494,7 +493,10 @@ def align_unpacked_inference_logprobs( # We need to align old_logprobs and inference logprobs as the latter are only for generations for i, inf_logprobs in enumerate(inference_logprobs): - first_gen_idx = first_gen_tok[i] + if not gen_masks_for_alignment[i].any(): + # No generation tokens; nothing to align. + continue + first_gen_idx = int(first_gen_tok[i]) # We subtract -1 here because we append eod token on the train side, and we do not # get it from the inference. For the eod token, we reuse old_logprobs value. end_idx = min(first_gen_idx + len(inf_logprobs), padded_inference_logprobs.shape[1]) @@ -893,19 +895,37 @@ def compute_group_stats( for turn_traj in rollout.trajectory: detokenized_traj = tokenizer.detokenize(turn_traj) lang_rl_log( - f"Rollout: [{rollout.env_id}] [{rollout.reward} : {len(rollout.trajectory)} tokens] {detokenized_traj}" + f"Rollout: [{rollout.env_id}] [{rollout.reward} : {len(turn_traj)} tokens] {detokenized_traj}" + ) + # A turn must never exceed the model's context window. + assert len(turn_traj) <= seq_len, ( + f"Rollout too long: {len(turn_traj)} > {seq_len} " + f"(last token {turn_traj[-1]})\n{detokenized_traj}" ) - # TODO(vitalyk): how does multiturn change EOD/EOT? - assert (len(turn_traj) == seq_len) or ( - turn_traj[-1] == tokenizer.eod - ), f"Rollout is not the correct length: {len(turn_traj)} {turn_traj[-1]}\n{detokenized_traj}" + # A single-turn completion can only end in eod or be truncated at seq_len. + # Multi-turn agents can additionally end a turn on a tool-call boundary. + if len(rollout.trajectory) == 1: + assert len(turn_traj) == seq_len or turn_traj[-1] == tokenizer.eod, ( + f"Single-turn rollout under seq_length must end in eod: " + f"len={len(turn_traj)} last={turn_traj[-1]}\n{detokenized_traj}" + ) else: lang_rl_log( - f"Rollout: [{rollout.env_id}] [{rollout.reward} : {len(rollout.trajectory)} chars] {rollout.trajectory}" + f"Rollout: [{rollout.env_id}] [{rollout.reward} : {len(rollout.trajectory)} turns] {rollout.trajectory}" ) group_num_turns.append(len(rollout.trajectory)) group_rewards.append(rollout.reward) - roll_turn_lens = [len(t) for t in rollout.trajectory] + # Multi-turn TokenRollout turns re-encode the full prior conversation. + # Report the incremental tokens each turn adds (env observation + generation); + # these sum to the final conversation length. + # Single-turn and raw (string) rollouts can use the plain per-turn length. + if isinstance(rollout, TokenRollout) and len(rollout.trajectory) > 1: + cumulative = [len(t) for t in rollout.trajectory] + roll_turn_lens = [cumulative[0]] + [ + cumulative[i] - cumulative[i - 1] for i in range(1, len(cumulative)) + ] + else: + roll_turn_lens = [len(t) for t in rollout.trajectory] group_turn_lengths.extend(roll_turn_lens) group_traj_lengths.append(sum(roll_turn_lens)) assert rollout.policy_epoch, "Rollout has no policy_epoch data" @@ -1085,15 +1105,19 @@ def prep_wandb_metrics( if example_group: if tokenizer is None: raise ValueError("If you provide an example group to log, you need to provide a tokenizer too.") + # Each turn in a trajectory is a cumulative sequence (prompt + all turns so far), so + # one row per rollout: the final turn already contains the whole conversation. metrics['rollouts'] = wandb_writer.Table( columns=['Trajectories', 'Tokens', 'Rewards'], rows=[ [ - tokenizer.detokenize(turn) if isinstance(r, TokenRollout) else turn, + tokenizer.detokenize(r.trajectory[-1]) + if isinstance(r, TokenRollout) + else r.trajectory[-1], r.trajectory, r.reward, ] - for r in example_group for turn in r.trajectory + for r in example_group if r.trajectory ], ) return metrics @@ -1285,17 +1309,27 @@ def maybe_log_training_metrics( wandb_writer.log(metrics, step=current_iteration) +PAD_TURN_UNIT = (None, -1) +"""Special filler entry to pad rollout turns for logprobs calculation. +Used to equalize per-rank trajectory counts without contributing to the loss.""" + + def prepare_trajectories( - rollouts: Rollouts, tokenizer: MegatronTokenizer, seq_length: int, sequence_packing: bool, skip_bos_token: bool + rollout_turns: list[tuple[TokenRollout | Rollout | None, int]], + tokenizer: MegatronTokenizer, + seq_length: int, + skip_bos_token: bool, ): """Pad trajectories and extract the generation masks. + Args: - rollouts: Rollouts to extract trajectories from. + rollout_turns: (rollout, turn_idx) pairs; each pair becomes one trajectory. tokenizer: Tokenizer to get the padding token and potentially tokenize. seq_length: Maximum sequence length to pad to. Returns: - Trajectories and their generation masks. + Trajectories, their generation masks, and per-row inference logprobs + (unpadded tensor per real row, None per PAD row). Raises: ValueError: @@ -1336,41 +1370,47 @@ def prepare_trajectories( trajs = [] generation_masks = [] inference_logprobs = [] - for rollout in rollouts: - # traj, gen mask and logprobs are lists now. - # each list entry is a turn, single-turn environments just have a single-element list. - # We assume that all lengths of the structs above have the same lengths (number of turns). - - all_turns_trajectories = ( - copy.deepcopy(rollout.trajectory) + for rollout, turn_idx in rollout_turns: + if rollout is None: + # PAD_TURN_UNIT: inert filler so all DP ranks hold the same number of + # trajectories. All pad tokens, nothing generated, no inference logprobs. + trajs.append([tokenizer.pad] * seq_length) + generation_masks.append([False] * seq_length) + inference_logprobs.append(None) + continue + # traj, gen mask and logprobs are per-turn lists on the rollout; + # single-turn environments just have single-element lists. + # We assume that all the structs above have the same lengths (number of turns). + trajectory = ( + copy.deepcopy(rollout.trajectory[turn_idx]) if isinstance(rollout, TokenRollout) - else tokenizer.tokenize(rollout.trajectory) + else tokenizer.tokenize(rollout.trajectory)[turn_idx] ) - for turn_idx, trajectory in enumerate(all_turns_trajectories): - inf_logprobs = rollout.logprobs[turn_idx] - generation_mask = rollout.generation_mask[turn_idx] if isinstance(rollout, TokenRollout) else None - length = len(trajectory) - assert length <= seq_length, "Rollout too long, how did this happen?" - if len(trajectory) < seq_length: - assert ( - trajectory[-1] == tokenizer.eod - ), "Trajectories under a seq_length limit should have eod token at the end." - - if length < seq_length: - trajectory.extend([tokenizer.pad] * (seq_length - length)) - if generation_mask: - generation_mask.extend([False] * (seq_length - length)) - trajs.append(trajectory) - generation_masks.append(generation_mask) - - if inf_logprobs is not None: - inf_logprobs_tensor = torch.Tensor(inf_logprobs) - # Don't pad individual logprobs here - padding happens later if needed - inference_logprobs.append(inf_logprobs_tensor) - else: - inference_logprobs.append(None) + inf_logprobs = rollout.logprobs[turn_idx] + generation_mask = ( + copy.deepcopy(rollout.generation_mask[turn_idx]) + if isinstance(rollout, TokenRollout) + else None + ) + length = len(trajectory) + assert length <= seq_length, "Rollout too long, how did this happen?" + + if length < seq_length: + trajectory.extend([tokenizer.pad] * (seq_length - length)) + if generation_mask: + generation_mask.extend([False] * (seq_length - length)) + trajs.append(trajectory) + generation_masks.append(generation_mask) + + if inf_logprobs is not None: + inf_logprobs_tensor = torch.Tensor(inf_logprobs) + # Don't pad individual logprobs here - padding happens later if needed + inference_logprobs.append(inf_logprobs_tensor) + else: + inference_logprobs.append(None) - env_id_counts[rollout.env_id] += 1 + if turn_idx == 0: + env_id_counts[rollout.env_id] += 1 if torch.distributed.is_initialized(): logger.info(f"[{dist.get_rank()}] Rollout counts:") @@ -1380,20 +1420,14 @@ def prepare_trajectories( generation_masks = torch.tensor(generation_masks, dtype=torch.bool, device='cpu') trajs = torch.tensor(trajs, device='cpu') - # Only process if we have inference_logprobs - if inference_logprobs and any(lp is not None for lp in inference_logprobs): - # We need to pad all logprobs to the same size for sequence packing. - # For non-packing mode, keep as list of tensors (unpadded) - # This preserves the original behavior where each sequence can have different lengths - if sequence_packing: - inference_logprobs = _pad_nonnull_with_zeros(inference_logprobs, seq_length) - else: - inference_logprobs = None - - # Some sanity checks regarding the tokenization + # Some sanity checks regarding the tokenization. Pad units start with the pad + # token rather than bos, so the bos-equality check only applies to real rows. + real_rows = torch.tensor( + [rollout is not None for rollout, _ in rollout_turns], dtype=torch.bool + ) if not skip_bos_token: assert ( - tokenizer.bos is None or (trajs[:, 0] == tokenizer.bos).all() + tokenizer.bos is None or (trajs[real_rows][:, 0] == tokenizer.bos).all() ), "First token should be bos" else: assert ( @@ -1548,30 +1582,59 @@ def prepare_data_for_update( # We need this to correctly split the rollouts across dp groups. # And we do not actually need them grouped in anything below anyways. rollouts = [r for g in rollouts for r in g] - num_turns = [nt for g in group_stats.num_turns for nt in g] - total_turns_sampled = len(rollouts) # We might sample more than we consume in one step. samples_ratio_per_step = args.global_batch_size / (args.grpo_prompts_per_step * args.grpo_group_size) assert samples_ratio_per_step <= 1, "You cannot use more data than you sampled." - if (data_parallel_world_size := mpu.get_data_parallel_world_size()) > 0: - data_split_size = len(rollouts) // data_parallel_world_size + # Multi-turn rollouts contribute one trainable trajectory per turn, and turn counts vary. + # Flatten to single turns and split the turns across DP ranks. + + # advantages is already one entry per turn, so it is sliced with the same range. + rollout_turns = [ + (rollout, turn_idx) + for rollout in rollouts + for turn_idx in range(len(rollout.trajectory)) + ] + if not rollout_turns: + raise RuntimeError( + f"prepare_data_for_update: 0 usable trajectories from {len(rollouts)} rollout(s). " + "All rollouts have empty trajectories." + ) + + data_parallel_world_size = mpu.get_data_parallel_world_size() + # The total turn count is data-dependent, so it needs to be padded. + pad_to_multiple = data_parallel_world_size * args.micro_batch_size + if pad_n := -len(rollout_turns) % pad_to_multiple: + rollout_turns = rollout_turns + [PAD_TURN_UNIT] * pad_n + advantages = global_advantages = torch.cat( + [advantages, torch.zeros(pad_n, dtype=advantages.dtype, device=advantages.device)] + ) + total_turns_sampled = len(rollout_turns) + + has_inference_logprobs = any(isinstance(rollout, TokenRollout) for rollout, _ in rollout_turns) + + if data_parallel_world_size > 0: + data_split_size = len(rollout_turns) // data_parallel_world_size data_split_range = ( mpu.get_data_parallel_rank() * data_split_size, (mpu.get_data_parallel_rank() + 1) * data_split_size, ) - rollouts = rollouts[data_split_range[0] : data_split_range[1]] - local_num_turns = sum(num_turns[data_split_range[0] : data_split_range[1]]) - steps_before = sum(num_turns[:data_split_range[0]]) - advantages = advantages[steps_before:steps_before+local_num_turns] + rollout_turns = rollout_turns[data_split_range[0] : data_split_range[1]] + advantages = advantages[data_split_range[0] : data_split_range[1]] # First we calculate them on a global level and then we split and recalculate on a local level. # Sequence packing and reporting needs it global but non-packing wants it local. with nvtx_range("rl/prepare-trajectories", time=True): trajs, generation_masks, inference_logprobs = prepare_trajectories( - rollouts, tokenizer, args.seq_length, sequence_packing, args.rl_skip_bos_token + rollout_turns, tokenizer, args.seq_length, args.rl_skip_bos_token, ) + if not has_inference_logprobs: + inference_logprobs = None + elif sequence_packing: + # Pad each row to seq_length and stack; an all-PAD (all-None) local slice becomes an + # all-zero [num_rows, seq_length] tensor so this rank still joins the all_gather. + inference_logprobs = _pad_nonnull_with_zeros(inference_logprobs, args.seq_length) packing_context = None # Build trajectories based on sequence packing or standard processing @@ -1709,8 +1772,6 @@ def prepare_data_for_update( # Store packed inference logprobs in packing context packing_context.packed_inference_logprobs = packed_inference_logprobs.cuda() - # Only mark as having inference logprobs for IS correction if enabled - packing_context.has_inference_logprobs = args.rl_inference_logprobs_is_correction with nvtx_range("rl/create-dataloader", time=True): # @vitalyk: This function also reconfigures the data loader to count the # global_batch_size in the bins frame of reference. @@ -2233,8 +2294,9 @@ def _pad_nonnull_with_zeros(data: list[Optional[torch.Tensor]], max_len: int) -> A padded tensor which is a stacked list of padded input tensors. """ - if all([el is None for el in data]): - raise ValueError("At least one element of the data list should be not None.") + if all(el is None for el in data): + # All rows are PAD; return an all-zero tensor so that no DP rank stalls. + return torch.zeros((len(data), max_len)) padded_data = [] for chunk in data: if chunk is not None: diff --git a/tests/unit_tests/rl/test_rl_utils.py b/tests/unit_tests/rl/test_rl_utils.py index dd6c85b2125..1ce7e045468 100644 --- a/tests/unit_tests/rl/test_rl_utils.py +++ b/tests/unit_tests/rl/test_rl_utils.py @@ -14,7 +14,10 @@ from megatron.core.models.common.language_module.language_module import LanguageModule from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec from megatron.core.models.gpt.gpt_model import GPTModel -from megatron.core.num_microbatches_calculator import destroy_num_microbatches_calculator +from megatron.core.num_microbatches_calculator import ( + destroy_num_microbatches_calculator, + get_num_microbatches, +) from megatron.core.optimizer import OptimizerConfig, get_megatron_optimizer from megatron.core.pipeline_parallel import get_forward_backward_func from megatron.core.pipeline_parallel.utils import is_pp_first_stage, is_pp_last_stage @@ -89,6 +92,22 @@ def detokenize(self, tokens): return [str(tok) for tok in tokens] +def make_token_rollout(trajectory, logprobs, generation_mask=None, reward=1.0, problem_id="p"): + """TokenRollout with the per-turn staleness boilerplate derived from the turn count.""" + turns = len(trajectory) + return TokenRollout( + trajectory=trajectory, + reward=reward, + generation_mask=generation_mask, + logprobs=logprobs, + env_id='MEGAENV', + problem_id=problem_id, + policy_epoch=[[(0, 0)]] * turns, + kv_cache_epoch=[[(0, 0)]] * turns, + num_evictions=[0] * turns, + ) + + class DummyLangModule: def __init__(self, config): self.config = config @@ -390,118 +409,89 @@ def test_get_logprobs(self, initialize_model_parallel, use_sequence_packing): else: assert logprobs.shape == (BATCH, SEQ, VOCAB) - def test_grpo_loss_calculation_all_pi_eq(self): - # All policies are equal: clamping is inactive, ratios are ones. - current_logprobs = torch.ones(BATCH, SEQ) - old_logprobs = torch.ones(BATCH, SEQ) - ref_logprobs = torch.ones(BATCH, SEQ) - advantages = torch.zeros(BATCH) - loss, kl_term, ratios, entropy_term, _, _ = rl_utils.calculate_grpo_loss( - current_logprobs=current_logprobs, - old_logprobs=old_logprobs, - ref_logprobs=ref_logprobs, - advantages=advantages, - clamp_eps_lower=0.1, - clamp_eps_upper=0.1, - kl_beta=0.1, - entropy_weight=0.0, - ) - torch.testing.assert_close(loss, torch.zeros_like(loss)) - torch.testing.assert_close(kl_term, torch.zeros_like(kl_term)) - torch.testing.assert_close(ratios, torch.ones_like(ratios)) - torch.testing.assert_close(entropy_term, -torch.ones_like(ratios) * torch.e) - - def test_grpo_loss_calculation_2x_ratios(self): - # All policies are equal: clamping is inactive, ratios are ones. - current_logprobs = torch.ones(BATCH, SEQ) - old_logprobs = torch.ones(BATCH, SEQ) - torch.log(torch.tensor([2.0])) - ref_logprobs = torch.ones(BATCH, SEQ) - advantages = torch.ones(BATCH) - loss, kl_term, ratios, _, _, _ = rl_utils.calculate_grpo_loss( - current_logprobs=current_logprobs, - old_logprobs=old_logprobs, - ref_logprobs=ref_logprobs, - advantages=advantages, - clamp_eps_lower=2.1, - clamp_eps_upper=2.1, - kl_beta=0.0, - entropy_weight=0.0, - ) - # Clamping does not affect us, as 2.1 [eps] > 2 [ratio]. - # kl_beta = 0 -> we only have the non-kl term of the loss active. - torch.testing.assert_close(loss, -torch.ones_like(loss) * 2) - # pi and pi_{ref} are the same here. - torch.testing.assert_close(kl_term, torch.zeros_like(kl_term)) - # Current probs are 2x more probable than old pi. - torch.testing.assert_close(ratios, torch.ones_like(ratios) * 2) - - def test_entropy_calculation(self): - # All policies are equal: clamping is inactive, ratios are ones. - current_logprobs = torch.ones(BATCH, SEQ) - old_logprobs = torch.ones(BATCH, SEQ) - ref_logprobs = torch.ones(BATCH, SEQ) - advantages = torch.zeros(BATCH) - loss, _, ratios, entropy_term, _, _ = rl_utils.calculate_grpo_loss( - current_logprobs=current_logprobs, - old_logprobs=old_logprobs, - ref_logprobs=ref_logprobs, - advantages=advantages, - clamp_eps_lower=0.1, - clamp_eps_upper=0.1, - kl_beta=0.0, - entropy_weight=1.0, - ) - torch.testing.assert_close(loss, torch.ones_like(ratios) * torch.e) - torch.testing.assert_close(entropy_term, -torch.ones_like(ratios) * torch.e) - - def test_grpo_loss_truncation(self): - # All ratios are 2 - _, _, _, _, truncated_from_above, truncated_from_below = rl_utils.calculate_grpo_loss( + @pytest.mark.parametrize( + "ratio, advantage, clamp_eps, kl_beta, entropy_weight, expected", + [ + # All policies equal: clamping inactive, unit ratios, zero loss and kl. + pytest.param( + 1.0, + 0.0, + 0.1, + 0.1, + 0.0, + dict(loss=0.0, kl_term=0.0, ratios=1.0, entropy_term=-torch.e), + id="all_pi_eq", + ), + # Current probs 2x old; eps 2.1 > ratio keeps clamping inactive; kl_beta 0 leaves + # only the policy term, and pi == pi_ref keeps the kl term zero anyway. + pytest.param( + 2.0, 1.0, 2.1, 0.0, 0.0, dict(loss=-2.0, kl_term=0.0, ratios=2.0), id="2x_ratios" + ), + # kl_beta 0, entropy_weight 1: the loss is exactly the negated entropy term. + pytest.param( + 1.0, 0.0, 0.1, 0.0, 1.0, dict(loss=torch.e, entropy_term=-torch.e), id="entropy" + ), + ], + ) + def test_grpo_loss_calculation( + self, ratio, advantage, clamp_eps, kl_beta, entropy_weight, expected + ): + outputs = rl_utils.calculate_grpo_loss( current_logprobs=torch.ones(BATCH, SEQ), - old_logprobs=0.5 * torch.ones(BATCH, SEQ), + old_logprobs=torch.ones(BATCH, SEQ) - torch.log(torch.tensor(ratio)), ref_logprobs=torch.ones(BATCH, SEQ), - advantages=torch.zeros(BATCH), - clamp_eps_lower=0.1, - clamp_eps_upper=0.1, - kl_beta=0.1, - entropy_weight=0.0, + advantages=torch.full((BATCH,), advantage), + clamp_eps_lower=clamp_eps, + clamp_eps_upper=clamp_eps, + kl_beta=kl_beta, + entropy_weight=entropy_weight, ) - assert truncated_from_above.float().mean() == 1 - assert truncated_from_below.float().sum() == 0 + outputs = dict(zip(("loss", "kl_term", "ratios", "entropy_term"), outputs)) + for name, want in expected.items(): + torch.testing.assert_close(outputs[name], torch.full_like(outputs[name], want)) - # All ratios are 0.01 - _, _, _, _, truncated_from_above, truncated_from_below = rl_utils.calculate_grpo_loss( - current_logprobs=0.01 * torch.ones(BATCH, SEQ), - old_logprobs=torch.ones(BATCH, SEQ), - ref_logprobs=torch.ones(BATCH, SEQ), - advantages=torch.zeros(BATCH), - clamp_eps_lower=0.1, - clamp_eps_upper=0.1, - kl_beta=0.1, - entropy_weight=0.0, - ) - assert truncated_from_above.float().sum() == 0 - assert truncated_from_below.float().mean() == 1 - - # Mixed ratios: [[2., 0.5], [20., 1.]] - current_logprobs = torch.tensor([[1.0, 1.0], [1.0, 1.0]]) - old_logprobs = torch.tensor([[0.5, 2.0], [0.05, 1.0]]) - _, _, _, _, truncated_from_above, truncated_from_below = rl_utils.calculate_grpo_loss( - current_logprobs=current_logprobs, - old_logprobs=old_logprobs, - ref_logprobs=old_logprobs, - advantages=torch.zeros(BATCH), + @pytest.mark.parametrize( + "current, old, expected_above, expected_below", + [ + # Ratios uniformly above the clamp window: everything truncates from above. + pytest.param( + torch.ones(BATCH, SEQ), + 0.5 * torch.ones(BATCH, SEQ), + torch.full((BATCH, SEQ), True), + torch.full((BATCH, SEQ), False), + id="all_above", + ), + # Ratios uniformly below the clamp window: everything truncates from below. + pytest.param( + 0.01 * torch.ones(BATCH, SEQ), + torch.ones(BATCH, SEQ), + torch.full((BATCH, SEQ), False), + torch.full((BATCH, SEQ), True), + id="all_below", + ), + # Mixed: above, below, above, and a unit ratio truncates neither way. + pytest.param( + torch.ones(2, 2), + torch.tensor([[0.5, 2.0], [0.05, 1.0]]), + torch.tensor([[True, False], [True, False]]), + torch.tensor([[False, True], [False, False]]), + id="mixed", + ), + ], + ) + def test_grpo_loss_truncation(self, current, old, expected_above, expected_below): + *_, truncated_from_above, truncated_from_below = rl_utils.calculate_grpo_loss( + current_logprobs=current, + old_logprobs=old, + ref_logprobs=old, + advantages=torch.zeros(current.shape[0]), clamp_eps_lower=0.1, clamp_eps_upper=0.1, kl_beta=0.1, entropy_weight=0.0, ) - torch.testing.assert_close( - truncated_from_above, torch.tensor([[True, False], [True, False]]) - ) - torch.testing.assert_close( - truncated_from_below, torch.tensor([[False, True], [False, False]]) - ) + torch.testing.assert_close(truncated_from_above, expected_above) + torch.testing.assert_close(truncated_from_below, expected_below) @pytest.mark.parametrize( "initialize_model_parallel", @@ -513,7 +503,8 @@ def test_grpo_loss_truncation(self): indirect=["initialize_model_parallel"], ) def test_prepare_data_for_update(self, initialize_model_parallel): - """Test that getting logprobs at least does not crash.""" + """Logprobs path runs; single-turn EOD guard holds; multi-turn turns are split, + padded to a DP*microbatch multiple, and size the microbatch calculator by turns.""" world_size, dp, tp, pp = initialize_model_parallel # Here I assume that we will be consuming all data in one step. group_size = 2 @@ -531,59 +522,63 @@ def test_prepare_data_for_update(self, initialize_model_parallel): model = MockModel() tokenizer = MockTokenizer() - r1 = TokenRollout( - trajectory=[[1, 2, 3]], - reward=3.14, - generation_mask=[[False, True, True]], - logprobs=[[0.1, 0.2, 0.3]], - env_id='MEGAENV', - problem_id="2", - policy_epoch=[[(0, 0)]], - kv_cache_epoch=[[(0, 0)]], - num_evictions=[0], + # A single-turn rollout whose only turn is short and lacks eod must be rejected: + # a single-turn completion has no tool-call boundary to justify stopping early. + bad = make_token_rollout( + [[1, 2, 3]], [[0.1, 0.2, 0.3]], [[False, True, True]], reward=3.14, problem_id="2" ) - r2 = TokenRollout( - trajectory=[[1, 2, 3, 4]], - reward=0.14, - generation_mask=[[False, True, True, True]], - logprobs=[[0.1, 0.2, 0.3, -1.2]], - env_id='MEGAENV', - problem_id="2", - policy_epoch=[[(0, 0)]], - kv_cache_epoch=[[(0, 0)]], - num_evictions=[0], - ) - - rollouts = [[r1, r2] for _ in range(dp)] - try: + with pytest.raises(AssertionError, match="must end in eod"): rl_utils.prepare_data_for_update( - [model], {}, rollouts, tokenizer, sequence_packing=False, is_correction=False + [model], + {}, + [[bad] for _ in range(dp)], + tokenizer, + sequence_packing=False, + is_correction=False, ) - except AssertionError as e: - # We expect trajectories to come padded there. - assert str(e).startswith('Rollout is not the correct length') - r1 = TokenRollout( - trajectory=torch.tensor([[1, 2, 3, tokenizer.eod]], dtype=torch.float).cuda(), + # Multi-turn rollouts with uneven turn counts: a turn may stop on a tool-call + # boundary (short, no eod) and is accepted. 2 + 3 turns per group * dp groups + # = 5*dp turns, padded up to the next multiple of micro_batch_size*dp (= 2*dp) + # -> 6*dp turns. With samples_ratio 1 the calculator is sized by total turns + # (6*dp -> 3 microbatches), not by rollout count (the pre-fix bug gave 1). + mt1 = make_token_rollout( + [[1, 2, 3], [1, 2, 3, 4]], + [[-0.1, -0.2], [-0.3, -0.4]], + [[False, True, True], [False, False, True, True]], + problem_id="1", + ) + mt2 = make_token_rollout( + [[1, 2], [1, 2, 3], [1, 2, 3, 4]], + [[-0.1], [-0.2], [-0.3]], + [[False, True], [False, False, True], [False, False, False, True]], + reward=0.0, + problem_id="3", + ) + rl_utils.prepare_data_for_update( + [model], + {}, + [[mt1, mt2] for _ in range(dp)], + tokenizer, + sequence_packing=False, + is_correction=False, + ) + # 5*dp turns padded to 6*dp; 6*dp / (micro_batch_size 2 * dp) = 3 microbatches. + assert get_num_microbatches() == 3 + + r1 = make_token_rollout( + torch.tensor([[1, 2, 3, tokenizer.eod]], dtype=torch.float).cuda(), + torch.tensor([[-0.2, -0.3, -3.2]]).cuda(), + torch.tensor([[False, True, True, True]], dtype=torch.float).cuda(), reward=3.14, - generation_mask=torch.tensor([[False, True, True, True]], dtype=torch.float).cuda(), - logprobs=torch.tensor([[-0.2, -0.3, -3.2]]).cuda(), - env_id='MEGAENV', problem_id="2", - policy_epoch=[[(0, 0)]], - kv_cache_epoch=[[(0, 0)]], - num_evictions=[0], ) - r2 = TokenRollout( - trajectory=torch.tensor([[1, 2, 234, tokenizer.eod]], dtype=torch.float).cuda(), + r2 = make_token_rollout( + torch.tensor([[1, 2, 234, tokenizer.eod]], dtype=torch.float).cuda(), + torch.tensor([[-0.2, -0.3, -1.2]]), + torch.tensor([[False, True, True, True]], dtype=torch.float).cuda(), reward=0.14, - generation_mask=torch.tensor([[False, True, True, True]], dtype=torch.float).cuda(), - logprobs=torch.tensor([[-0.2, -0.3, -1.2]]), - env_id='MEGAENV', problem_id="2", - policy_epoch=[[(0, 0)]], - kv_cache_epoch=[[(0, 0)]], - num_evictions=[0], ) rollouts = [[r1, r2] for _ in range(dp)] data_iter, _, _ = rl_utils.prepare_data_for_update( @@ -595,74 +590,96 @@ def test_prepare_data_for_update(self, initialize_model_parallel): # All probabilities should be uniform. torch.testing.assert_close(old_logprobs.exp(), torch.ones_like(old_logprobs) / VOCAB) - @pytest.mark.parametrize("use_sequence_packing", [True, False]) - @pytest.mark.parametrize("num_turns", [1, 2]) - def test_prepare_trajectories(self, use_sequence_packing, num_turns): - """Test that rollouts are properly prepared for training.""" - seq_length = 8 + @pytest.mark.parametrize( + "initialize_model_parallel", + [pytest.param((1, 1), id="tp1-pp1")], + indirect=["initialize_model_parallel"], + ) + def test_prepare_data_for_update_oversampling(self, initialize_model_parallel): + """Oversampling (ratio < 1) consumes a fraction of the (padded) turn count per step: + the microbatch calculator is sized by ceil(ratio * total turns), not the full batch.""" + world_size, dp, tp, pp = initialize_model_parallel + tokenizer = MockTokenizer() + model = MockModel() + + # ratio = global_batch_size/(prompts*group) = 2*dp/(dp*4) = 0.5. + # 4*dp single-turn turns (already a multiple of 2*dp); ceil(0.5 * 4*dp) = 2*dp; + # 2*dp / (2 * dp) = 1 microbatch. self.create_test_args( - rl_use_sequence_packing=use_sequence_packing, - rl_sequence_packing_bin_size=20, - rl_skip_bos_token=False, - micro_batch_size=1, - seq_length=seq_length, + micro_batch_size=2, + seq_length=4, + curr_iteration=1, + tensor_model_parallel_size=tp, + pipeline_model_parallel_size=pp, + global_batch_size=dp * 2, + grpo_prompts_per_step=dp, + grpo_group_size=4, ) + + def single(problem_id, reward): + return make_token_rollout( + [[1, 2, 3, tokenizer.eod]], + [[-0.1, -0.2, -0.3]], + [[False, True, True, True]], + reward=reward, + problem_id=problem_id, + ) + + rollouts = [[single(str(i), float(i % 2)) for i in range(4)] for _ in range(dp)] + rl_utils.prepare_data_for_update( + [model], {}, rollouts, tokenizer, sequence_packing=False, is_correction=False + ) + assert get_num_microbatches() == 1 + + @pytest.mark.parametrize("num_turns", [1, 2]) + def test_prepare_trajectories(self, num_turns): + """Each (rollout, turn_idx) unit becomes one padded training row (a rollout with T + turns contributes T rows); PAD_TURN_UNIT entries become inert all-pad rows with no + generated tokens and no inference logprobs (DP count equalization).""" + seq_length = 8 + self.create_test_args(rl_skip_bos_token=False, micro_batch_size=1, seq_length=seq_length) tokenizer = MockTokenizer() + eod, pad = tokenizer.eod, tokenizer.pad - # Create rollouts of varying lengths - r1 = TokenRollout( - trajectory=[[1, 2, 3, tokenizer.eod]] * num_turns, + r1 = make_token_rollout( + [[1, 2, 3, eod]] * num_turns, + [[0.1, 0.2, 0.3, 0.35]] * num_turns, + [[False, True, True, True]] * num_turns, reward=3.14, - generation_mask=[[False, True, True, True]] * num_turns, - logprobs=[[0.1, 0.2, 0.3, 0.35]] * num_turns, - env_id='MEGAENV', problem_id="1", - policy_epoch=[[(0, 0)]] * num_turns, - kv_cache_epoch=[[(0, 0)]] * num_turns, - num_evictions=[0] * num_turns, ) - r2 = TokenRollout( - trajectory=[[4, 5, 6, 7, tokenizer.eod]] * num_turns, + r2 = make_token_rollout( + [[4, 5, 6, 7, eod]] * num_turns, + [[0.4, 0.5, 0.6, 0.7, 0.75]] * num_turns, + [[False, True, True, True, True]] * num_turns, reward=0.14, - generation_mask=[[False, True, True, True, True]] * num_turns, - logprobs=[[0.4, 0.5, 0.6, 0.7, 0.75]] * num_turns, - env_id='MEGAENV', problem_id="2", - policy_epoch=[[(0, 0)]] * num_turns, - kv_cache_epoch=[[(0, 0)]] * num_turns, - num_evictions=[0] * num_turns, ) - r3 = TokenRollout( - trajectory=[[8, 9, tokenizer.eod]] * num_turns, + r3 = make_token_rollout( + [[8, 9, eod]] * num_turns, + [[0.8, 0.9, 0.95]] * num_turns, + [[False, True, True]] * num_turns, reward=2.71, - generation_mask=[[False, True, True]] * num_turns, - logprobs=[[0.8, 0.9, 0.95]] * num_turns, - env_id='MEGAENV', problem_id="3", - policy_epoch=[[(0, 0)]] * num_turns, - kv_cache_epoch=[[(0, 0)]] * num_turns, - num_evictions=[0] * num_turns, ) - rollouts = [r1, r2, r3] - + turn_units = [ + (rollout, turn_idx) + for rollout in [r1, r2, r3] + for turn_idx in range(len(rollout.trajectory)) + ] + [rl_utils.PAD_TURN_UNIT] trajs, genmask, inference_logprobs = rl_utils.prepare_trajectories( - rollouts, - tokenizer, - seq_length, - sequence_packing=use_sequence_packing, - skip_bos_token=False, + turn_units, tokenizer, seq_length, skip_bos_token=False ) expected_trajs = torch.tensor( - [ - [1, 2, 3, tokenizer.eod] + [tokenizer.pad] * 4, - [4, 5, 6, 7, tokenizer.eod] + [tokenizer.pad] * 3, - [8, 9, tokenizer.eod] + [tokenizer.pad] * 5, - ], + [[1, 2, 3, eod] + [pad] * 4, [4, 5, 6, 7, eod] + [pad] * 3, [8, 9, eod] + [pad] * 5], dtype=torch.long, device=trajs.device, ).repeat_interleave(num_turns, dim=0) + expected_trajs = torch.cat( + [expected_trajs, torch.full((1, seq_length), pad, dtype=expected_trajs.dtype)] + ) assert torch.equal(trajs, expected_trajs) expected_genmask = torch.tensor( @@ -674,75 +691,98 @@ def test_prepare_trajectories(self, use_sequence_packing, num_turns): dtype=torch.bool, device=genmask.device, ).repeat_interleave(num_turns, dim=0) + expected_genmask = torch.cat( + [expected_genmask, torch.zeros((1, seq_length), dtype=torch.bool)] + ) assert torch.equal(genmask, expected_genmask) - if use_sequence_packing: - expected_logprobs = torch.tensor( - [ - [0.1, 0.2, 0.3, 0.35] + [0.0] * 4, - [0.4, 0.5, 0.6, 0.7, 0.75] + [0.0] * 3, - [0.8, 0.9, 0.95] + [0.0] * 5, - ], - dtype=torch.float32, - device=inference_logprobs.device, - ).repeat_interleave(num_turns, dim=0) - torch.testing.assert_close(inference_logprobs, expected_logprobs, rtol=0, atol=0) - else: - expected_logprobs = [ - [0.1, 0.2, 0.3, 0.35], - [0.4, 0.5, 0.6, 0.7, 0.75], - [0.8, 0.9, 0.95], - ] - expected_logprobs = [el for el in expected_logprobs for _ in range(num_turns)] - assert len(inference_logprobs) == len(expected_logprobs) - for got, exp in zip(inference_logprobs, expected_logprobs): - got_t = got if torch.is_tensor(got) else torch.tensor(got, dtype=torch.float32) - exp_t = torch.tensor(exp, dtype=torch.float32, device=got_t.device) - torch.testing.assert_close(got_t, exp_t, rtol=0, atol=0) - - def test_single_turn_advantage_calculation(self): - rewards = [[-1, 1], [4, 4]] - num_turns = [[1, 1], [1, 1]] - advs = rl_utils.calculate_grpo_advantages(rewards, num_turns) - torch.testing.assert_close( - torch.tensor(advs), torch.tensor([-1, 1.0, 0.0, 0.0]), atol=1e-4, rtol=1e-5 - ) + # Per-row list: unpadded tensor per real row, None for the pad unit. (Packing-mode + # densification happens at the call site via _pad_nonnull_with_zeros, tested separately.) + expected_logprobs = [[0.1, 0.2, 0.3, 0.35], [0.4, 0.5, 0.6, 0.7, 0.75], [0.8, 0.9, 0.95]] + expected_logprobs = [el for el in expected_logprobs for _ in range(num_turns)] + [None] + assert len(inference_logprobs) == len(expected_logprobs) + for got, exp in zip(inference_logprobs, expected_logprobs): + if exp is None: + assert got is None + else: + exp_t = torch.tensor(exp, dtype=torch.float32, device=got.device) + torch.testing.assert_close(got, exp_t, rtol=0, atol=0) - def test_multi_turn_advantage_calculation(self): - rewards = [[-1, 1], [4, 4]] - num_turns = [[2, 1], [1, 3]] - advs = rl_utils.calculate_grpo_advantages(rewards, num_turns) - torch.testing.assert_close( - torch.tensor(advs), - torch.tensor([-1, -1, 1.0, 0.0, 0.0, 0.0, 0.0]), - atol=1e-4, - rtol=1e-5, - ) + @pytest.mark.parametrize( + "num_turns, expected", + [ + pytest.param([[1, 1], [1, 1]], [-1.0, 1.0, 0.0, 0.0], id="single_turn"), + # A rollout's group advantage is repeated once per turn. + pytest.param([[2, 1], [1, 3]], [-1.0, -1.0, 1.0, 0.0, 0.0, 0.0, 0.0], id="multi_turn"), + ], + ) + def test_advantage_calculation(self, num_turns, expected): + advs = rl_utils.calculate_grpo_advantages([[-1, 1], [4, 4]], num_turns) + torch.testing.assert_close(torch.tensor(advs), torch.tensor(expected), atol=1e-4, rtol=1e-5) - def test_pad_list_of_nones(self): - with pytest.raises(ValueError) as e_info: - rl_utils._pad_nonnull_with_zeros([None] * 3, 42) - assert "At least one" in str(e_info) + @pytest.mark.parametrize( + "scenario, expected_turn_lens, expected_traj_lens, expected_num_turns", + [ + pytest.param("single_turn_only", [[4, 3]], [[4, 3]], [[1, 1]], id="single_turn_only"), + pytest.param( + "multi_and_single", [[4, 3, 4]], [[7, 4]], [[2, 1]], id="multi_and_single" + ), + ], + ) + def test_compute_group_stats( + self, scenario, expected_turn_lens, expected_traj_lens, expected_num_turns + ): + """Length metrics: single-turn rollouts use the plain per-turn length, while a multi-turn + TokenRollout re-encodes the prior conversation, so its per-turn lengths are reported + incrementally and its trajectory length is the final conversation length (not the inflated + overlap sum).""" + tokenizer = MockTokenizer() + eod = tokenizer.eod - def test_pad_with_wrong_params(self): - with pytest.raises(ValueError) as e_info: - rl_utils._pad_nonnull_with_zeros([torch.zeros(5)], 4) - assert "larger length" in str(e_info) + def single(traj, reward): + return make_token_rollout( + [traj], [[0.0]], [[False] * len(traj)], reward=reward, problem_id="s" + ) - def test_pad_full_size(self): - padded = rl_utils._pad_nonnull_with_zeros([torch.zeros(5), torch.zeros(5)], 5) - assert padded.shape == (2, 5) + if scenario == "single_turn_only": + group = [single([1, 2, 3, eod], 1.0), single([1, 2, eod], 0.0)] + else: + # Cumulative per-turn lengths 4 then 7 -> turn 1 adds 3 tokens; trajectory length is + # the full conversation (7), not 4 + 7 = 11. + multi = make_token_rollout( + [[1, 2, 3, eod], [1, 2, 3, eod, 9, 8, eod]], + [[0.1, 0.2], [0.3, 0.4]], + [[False, False, True, True], [False, False, False, False, False, True, True]], + problem_id="m", + ) + group = [multi, single([1, 2, 3, eod], 0.0)] - def test_pad_some_nones(self): - padded = rl_utils._pad_nonnull_with_zeros([None, torch.zeros(5)], 5) - assert padded.shape == (2, 5) - assert (padded[0] == 0).all() + stats = rl_utils.compute_group_stats([group], tokenizer, seq_len=8) + assert stats.turn_lens == expected_turn_lens + assert stats.traj_lens == expected_traj_lens + assert stats.num_turns == expected_num_turns - def test_pad_normal(self): - padded = rl_utils._pad_nonnull_with_zeros( - [torch.zeros(2), torch.zeros(3), torch.zeros(4)], 5 - ) - assert padded.shape == (3, 5) + @pytest.mark.parametrize( + "lengths, max_len, expected_shape", + [ + pytest.param([2, 3, 4], 5, (3, 5), id="normal"), + pytest.param([5, 5], 5, (2, 5), id="full_size"), + pytest.param([None, 5], 5, (2, 5), id="some_nones"), + # All-None (all-PAD rank): still a zero [num_rows, max_len] tensor, so every DP rank + # produces the same shape and joins the sequence-packing all_gather. + pytest.param([None, None, None], 42, (3, 42), id="all_nones"), + pytest.param([5], 4, "larger length", id="too_long_raises"), + ], + ) + def test_pad_nonnull_with_zeros(self, lengths, max_len, expected_shape): + data = [None if l is None else torch.zeros(l) for l in lengths] + if isinstance(expected_shape, str): + with pytest.raises(ValueError, match=expected_shape): + rl_utils._pad_nonnull_with_zeros(data, max_len) + return + padded = rl_utils._pad_nonnull_with_zeros(data, max_len) + assert padded.shape == expected_shape + assert (padded == 0).all() # zero inputs and zero-filled padding/None rows @pytest.mark.parametrize( "initialize_model_parallel", diff --git a/tests/unit_tests/rl/test_grouped_rollouts.py b/tests/unit_tests/rl/test_rollout_generation.py similarity index 65% rename from tests/unit_tests/rl/test_grouped_rollouts.py rename to tests/unit_tests/rl/test_rollout_generation.py index 52aafb270be..b9c26c7600e 100644 --- a/tests/unit_tests/rl/test_grouped_rollouts.py +++ b/tests/unit_tests/rl/test_rollout_generation.py @@ -1,24 +1,26 @@ -# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import asyncio from unittest.mock import MagicMock import numpy as np import pytest -from pydantic import ValidationError +from pydantic import Field, ValidationError from megatron.rl.agent.api import ( + EpisodeResult, GroupedRolloutGenerator, GroupedRolloutRequest, GroupRolloutParams, Rollout, RolloutGenerator, RolloutRequest, + TokenRollout, _SubmissionGate, ) from megatron.rl.agent.reward_only_agent import RewardOnlyAgent from megatron.rl.agent.weighted_multi_task import AgentConfig, WeightedMultiTask -from megatron.rl.inference import InferenceResponse, LLMChatMessage, ReturnsRaw +from megatron.rl.inference import InferenceResponse, LLMChatMessage, ReturnsRaw, ReturnsTokens class MockInferenceInterface(ReturnsRaw): @@ -69,22 +71,30 @@ async def prepare_group_rollout(self, request): idx = self._call_count self._call_count += 1 self.prepare_group_rollout_calls += 1 - inference_request = request.inference_interface.prepare_request( - f"t{idx}", request.generation_args - ) - async def build_rollout(response): - response_idx = int(response.response.content.removeprefix("t")) + async def run_episode(): + # Single-turn agent: the episode is one inference on the group's prompt. + turn_request = request.inference_interface.prepare_request( + f"t{idx}", request.generation_args + ) + response = await self.get_rollout_response(request, turn_request) + return EpisodeResult( + responses=[response], conversation=[*turn_request.prompt, response.response] + ) + + async def build_rollout(episode): + responses = episode.responses + reward = float(responses[-1].response.content.removeprefix("t")) return Rollout( - trajectory=[response.raw_text], - reward=float(response_idx), + trajectory=[r.raw_text for r in responses], + reward=reward, env_id=self.env_id, - policy_epoch=[response.policy_epoch], - kv_cache_epoch=[response.kv_cache_epoch], - num_evictions=[response.num_evictions], + policy_epoch=[r.policy_epoch for r in responses], + kv_cache_epoch=[r.kv_cache_epoch for r in responses], + num_evictions=[r.num_evictions for r in responses], ) - return GroupRolloutParams(inference_request=inference_request, build_rollout=build_rollout) + return GroupRolloutParams(run_episode=run_episode, build_rollout=build_rollout) class CountingRewardAgent(RewardOnlyAgent): @@ -444,3 +454,172 @@ def test_multi_env_distribution_requires_num_groups_above_one( assert min(agent_groups) == 0 assert all(slots == 0 for slots in agent_slots) assert np.gcd.reduce(agent_slots) == 0 + + +def make_response(epochs, prompt_length, total_len, content="resp", finish_reason="stop"): + return InferenceResponse( + response=LLMChatMessage(role="assistant", content=content), + raw_text=content, + token_ids=list(range(total_len)), + prompt_length=prompt_length, + logprobs=[0.0] * (total_len - prompt_length), + finish_reason=finish_reason, + policy_epoch=epochs, + kv_cache_epoch=epochs, + num_evictions=0, + ) + + +# Conversation length -> response spec: length 1 is the first turn (the bare prompt), length 3 +# the second (assistant reply + observation appended). +TWO_TURN_SCRIPT = { + 1: dict(epochs=[(0, 5)], prompt_length=3, total_len=7, content="a0"), + 3: dict(epochs=[(0, 5)], prompt_length=6, total_len=11, content="a1"), +} + +# Both two-turn termination modes (env-signaled done, max_turns exhausted) must produce this +# identical episode; only the env-consultation trace (observation_turns) differs per case. +TWO_TURN_EXPECTED = dict( + seen_roles=[["user"], ["user", "assistant", "user"]], + reward_conv=[("user", "hello"), ("assistant", "a0"), ("user", "obs0"), ("assistant", "a1")], + rewarded=[("a1", "stop")], + genmask_sums=[4, 5], + policy_epoch=[[(0, 5)], [(0, 5)]], +) + + +class ScriptedInterface(ReturnsTokens, ReturnsRaw): + """Inference stub whose reply is a pure function of the request: the conversation length + maps to a response spec, so it stays deterministic under pipeline concurrency.""" + + by_prompt_length: dict = Field(default_factory=dict) + seen_conversations: list = Field(default_factory=list) + + async def agenerate(self, request): + self.seen_conversations.append(list(request.prompt)) + return make_response(**self.by_prompt_length[len(request.prompt)]) + + +class EpisodeAgent(RewardOnlyAgent): + """Configurable multi-turn agent. + + `done_at_turn` controls when get_observation signals done: at every turn >= done_at_turn + it returns (None, True); None means it never signals done, so the episode ends only by + exhausting max_turns. Records get_reward calls and the conversation get_trajectory_reward saw. + """ + + env_id: str = "test" + max_turns: int = 1 + done_at_turn: int | None = None + rewarded: list = Field(default_factory=list) + reward_conversation: list = Field(default_factory=list) + observation_turns: list = Field(default_factory=list) + + async def get_prompt(self, validation): + return "hello", {"problem_id": "p0"} + + async def get_observation(self, turn_idx, response, conversation, golden): + self.observation_turns.append(turn_idx) + if self.done_at_turn is not None and turn_idx >= self.done_at_turn: + return None, True + return f"obs{turn_idx}", False + + async def get_reward(self, response, golden, finish_reason): + self.rewarded.append((response, finish_reason)) + return 1.5 + + async def get_trajectory_reward(self, responses, conversation, golden): + self.reward_conversation.extend(conversation) + return await super().get_trajectory_reward(responses, conversation, golden) + + +class TestMultiTurnEpisode: + + @pytest.mark.parametrize("driver", ["reward_rollouts", "pipeline"]) + @pytest.mark.parametrize( + "max_turns, done_at_turn, scripted, expected", + [ + # Single turn: get_observation is never consulted (no continuation is possible). + pytest.param( + 1, + None, + {1: dict(epochs=[(0, 7)], prompt_length=2, total_len=6, content="only")}, + dict( + seen_roles=[["user"]], + reward_conv=[("user", "hello"), ("assistant", "only")], + rewarded=[("only", "stop")], + genmask_sums=[4], + policy_epoch=[[(0, 7)]], + observation_turns=[], + ), + id="single_turn", + ), + # Multi-turn ended by the environment: turn 0 yields an observation, turn 1 is done. + pytest.param( + 3, + 1, + TWO_TURN_SCRIPT, + dict(TWO_TURN_EXPECTED, observation_turns=[0, 1]), + id="multi_turn_env_done", + ), + # Ended by exhausting max_turns instead (env never signals done): the same episode, + # except get_observation must not run for the final allowed turn. + pytest.param( + 2, + None, + TWO_TURN_SCRIPT, + dict(TWO_TURN_EXPECTED, observation_turns=[0]), + id="multi_turn_max_turns_exhausted", + ), + ], + ) + @pytest.mark.asyncio + async def test_run_episode(self, driver, max_turns, done_at_turn, scripted, expected): + """Episodes grow the conversation each turn and collapse into one per-turn rollout, + identically through get_reward_rollouts and through the real _RolloutPipeline + (get_grouped_rollouts) -- the latter proving run_episode runs in the infer stage.""" + iface = ScriptedInterface(by_prompt_length=scripted) + agent = EpisodeAgent(max_turns=max_turns, done_at_turn=done_at_turn) + + if driver == "reward_rollouts": + rollouts = await agent.get_reward_rollouts( + RolloutRequest(num_rollouts=1, inference_interface=iface) + ) + else: + groups = [] + + async def _drain(): + async for group in agent.get_grouped_rollouts( + GroupedRolloutRequest( + num_groups=1, rollouts_per_group=1, inference_interface=iface + ) + ): + groups.append(group) + + # Bounded so a wedged pipeline fails fast instead of hanging. + await asyncio.wait_for(_drain(), timeout=5.0) + (group,) = groups + rollouts = group.rollouts + (rollout,) = rollouts + + assert isinstance(rollout, TokenRollout) + assert rollout.reward == 1.5 + assert rollout.problem_id == "p0" + # One trajectory entry per generated turn. + assert len(rollout.trajectory) == len(expected["genmask_sums"]) + # Each turn's inference request = prior conversation (reply + observation appended). + assert [[m.role for m in conv] for conv in iface.seen_conversations] == expected[ + "seen_roles" + ] + # Default trajectory reward scores only the final response. + assert agent.rewarded == expected["rewarded"] + # Per-turn generation masks cover exactly each turn's generated tokens. + assert [sum(mask) for mask in rollout.generation_mask] == expected["genmask_sums"] + # Per-turn (engine-frame) staleness nesting is preserved. + assert rollout.policy_epoch == expected["policy_epoch"] + assert rollout.kv_cache_epoch == expected["policy_epoch"] + # get_observation is consulted only when another generation is still possible -- never on + # the final allowed turn. + assert agent.observation_turns == expected["observation_turns"] + # get_trajectory_reward sees the full dialogue, ending on the final reply exactly once. + assert [(m.role, m.content) for m in agent.reward_conversation] == expected["reward_conv"] From 2576c159d4dddf177853129ad4f03cb2e339bc0a Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene <34819528+tdene@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:29:20 -0500 Subject: [PATCH 131/290] Correct prefix-caching ref-count accounting (#6047) Signed-off-by: Teodor-Dumitru Ene --- .../inference/contexts/kv_block_allocator.py | 16 +++++- .../contexts/test_kv_block_allocator.py | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/megatron/core/inference/contexts/kv_block_allocator.py b/megatron/core/inference/contexts/kv_block_allocator.py index 6711cb2e606..3feb8a0a11d 100644 --- a/megatron/core/inference/contexts/kv_block_allocator.py +++ b/megatron/core/inference/contexts/kv_block_allocator.py @@ -231,11 +231,20 @@ def release_memory_blocks(self, blocks: Tensor) -> None: return if self.enable_prefix_caching: - self.block_ref_counts[blocks] -= 1 + # When multiple requests that share the same prefix finish on the same step, + # their block IDs appear multiple times in the blocks tensor. + # Writing `self.block_ref_counts[blocks] -= 1` would only decrement reference counts + # once per unique block. This is wrong. The reference counts must be decremented + # once per occurrence of the block in the `blocks` tensor. We need `scatter`. + blocks_i64 = blocks.to(torch.int64) + self.block_ref_counts.scatter_add_( + 0, blocks_i64, torch.full_like(blocks_i64, -1, dtype=torch.int32) + ) if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.REF_ZERO: zero_mask = self.block_ref_counts[blocks] == 0 if zero_mask.any(): - self._deregister_blocks(blocks[zero_mask]) + # Deduplicate so a shared block is deregistered/returned once. + self._deregister_blocks(torch.unique(blocks[zero_mask])) elif self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: # Unregistered blocks (hash == -1, ref_count == 0) have no hash # entry to preserve for reuse (e.g., partial blocks at the end of @@ -245,7 +254,8 @@ def release_memory_blocks(self, blocks: Tensor) -> None: self.block_hashes[blocks] == -1 ) if unreg_mask.any(): - unreg_blocks = blocks[unreg_mask] + # Deduplicate so a shared block returns to the pool once. + unreg_blocks = torch.unique(blocks[unreg_mask]) num_unreg = unreg_blocks.numel() self.block_bag[self.total_avail : self.total_avail + num_unreg] = unreg_blocks self.total_avail += num_unreg diff --git a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py index da068087579..2cb552ee14f 100644 --- a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py +++ b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py @@ -210,6 +210,61 @@ def test_block_usage_counts_with_prefix_caching( assert a.get_paused_used() == expected_paused +def test_release_shared_block_decrements_once_per_owner(): + """A shared prefix block appears once per finishing owner in a batched + release: each occurrence must decrement (scatter-accumulate), and a block + reaching ref 0 with a duplicated ID is freed/deregistered exactly once.""" + # REF_ZERO: three owners of a shared block finish in stages, with a private + # block mixed into the final batch. + a = KVBlockAllocator( + _make_context(), + total_count=8, + paused_count=2, + enable_prefix_caching=True, + prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO, + ) + ids = a.allocate_memory_blocks(2) # ref_count == 1 each + shared, private = int(ids[0]), int(ids[1]) + a.register_kv_block_hashes(block_ids=[shared], block_hashes=[111]) + a.block_ref_counts[shared] += 2 # two more owners pin the shared block -> ref 3 + avail0 = a.total_avail + + # One owner finishes alone: ref 3 -> 2, nothing freed yet. + a.release_memory_blocks(torch.tensor([shared], dtype=torch.int32)) + assert a.block_ref_counts[shared].item() == 2 + assert a.total_avail == avail0 + assert 111 in a.kv_hash_to_block_id + + # The final two owners and the private request finish in one batch: the + # shared block appears twice and both decrements must land (ref 2 -> 0). + a.release_memory_blocks(torch.tensor([shared, private, shared], dtype=torch.int32)) + assert a.block_ref_counts[shared].item() == 0 + assert a.block_ref_counts[private].item() == 0 + # Two distinct blocks return to the pool; the shared one only once (not twice). + assert a.total_avail == avail0 + 2 + assert 111 not in a.kv_hash_to_block_id # deregistered exactly once + free_region = a.block_bag[: a.total_avail].tolist() + assert len(set(free_region)) == len(free_region) # no double-returned id + + # LRU: a hashed shared block released by both owners in one batch must hit + # ref 0 (becoming evictable), not stall at 1 with a leaked reference. A hashed + # block stays cached for reuse rather than returning to the pool. + lru = KVBlockAllocator( + _make_context(), + total_count=8, + paused_count=2, + enable_prefix_caching=True, + prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, + ) + lshared = int(lru.allocate_memory_blocks(1)[0]) + lru.register_kv_block_hashes(block_ids=[lshared], block_hashes=[333], parent_hashes=[0]) + lru.block_ref_counts[lshared] += 1 # second owner -> ref 2 + lru.release_memory_blocks(torch.tensor([lshared, lshared], dtype=torch.int32)) + assert lru.block_ref_counts[lshared].item() == 0 + assert int(lru.get_evictable_block_count()) == 1 + assert lru.block_hashes[lshared].item() == 333 # kept cached, not pool-returned + + # --------------------------------------------------------------------------- # LRU eviction: parent-chain safety # --------------------------------------------------------------------------- From 818b2580da69431a24cc261c969f5e4b0722fac7 Mon Sep 17 00:00:00 2001 From: wdykas <73254672+wdykas@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:43:10 -0400 Subject: [PATCH 132/290] send pg group for distributed checkpoint validation (#6092) Signed-off-by: William Dykas --- .../core/dist_checkpointing/serialization.py | 7 ++++- .../core/dist_checkpointing/validation.py | 7 +++-- .../dist_checkpointing/test_validation.py | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/dist_checkpointing/test_validation.py diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index 0453f7ada8a..cc08aa26dbf 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -73,6 +73,7 @@ def load( validate_access_integrity: bool = True, strict: Union[str, StrictHandling] = StrictHandling.ASSUME_OK_UNEXPECTED, verify_integrity: bool = False, + process_group: Optional[torch.distributed.ProcessGroup] = None, ) -> Union[StateDict, Tuple[StateDict, Set[str], Set[str]]]: """Loading entrypoint. @@ -108,6 +109,8 @@ def load( and compares against the SHA-256 manifest. Raises `CheckpointingException` on any mismatch. Requires that the checkpoint was previously saved with `verify_integrity=True`. + process_group (ProcessGroup, optional): ranks that collectively describe + one complete sharded state dict. Defaults to the global process group. Returns: StateDict or Tuple[StateDict, Set[str], Set[str]]: in most cases only @@ -163,7 +166,9 @@ def load( k: v for k, v in ckpt_sharded_metadata.items() if v.key != 'common_state' } if validate_access_integrity or StrictHandling.requires_global_app_metadata(strict): - local_metadata, global_metadata = determine_global_metadata(sharded_state_dict) + local_metadata, global_metadata = determine_global_metadata( + sharded_state_dict, process_group=process_group + ) sharded_state_dict, missing_keys, unexpected_keys = validate_integrity_and_strict_load( sharded_state_dict, diff --git a/megatron/core/dist_checkpointing/validation.py b/megatron/core/dist_checkpointing/validation.py index 17b773ebb81..6095ea95d8f 100644 --- a/megatron/core/dist_checkpointing/validation.py +++ b/megatron/core/dist_checkpointing/validation.py @@ -477,18 +477,21 @@ def _validate_objects_for_key(sharded_objects: List[ShardedObject]) -> List[Chec def determine_global_metadata( sharded_state_dict: ShardedStateDict, + process_group: Optional[torch.distributed.ProcessGroup] = None, ) -> Tuple[_LocalMetadata, _GlobalMetadata]: """Exchanges local metadata with `all_gather_object` to determine global metadata. Args: sharded_state_dict (ShardedStateDict): local sharded state dict + process_group (ProcessGroup, optional): ranks whose metadata forms one + complete checkpoint view. Defaults to the global process group. Returns: Tuple[_LocalMetadata, _GlobalMetadata]: local and global ShardedBase objects with stripped data """ local_metadata = [ten.without_data() for ten in nested_values(sharded_state_dict)] - global_metadata = [None] * torch.distributed.get_world_size() - torch.distributed.all_gather_object(global_metadata, local_metadata) + global_metadata = [None] * torch.distributed.get_world_size(group=process_group) + torch.distributed.all_gather_object(global_metadata, local_metadata, group=process_group) return local_metadata, global_metadata # type: ignore[return-value] diff --git a/tests/unit_tests/dist_checkpointing/test_validation.py b/tests/unit_tests/dist_checkpointing/test_validation.py new file mode 100644 index 00000000000..80313ee8353 --- /dev/null +++ b/tests/unit_tests/dist_checkpointing/test_validation.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from unittest.mock import Mock + +import torch + +from megatron.core.dist_checkpointing.validation import determine_global_metadata + + +def test_determine_global_metadata_uses_explicit_process_group(monkeypatch): + process_group = Mock() + metadata = Mock() + shard = Mock() + shard.without_data.return_value = metadata + get_world_size = Mock(return_value=2) + all_gather_object = Mock() + monkeypatch.setattr(torch.distributed, "get_world_size", get_world_size) + monkeypatch.setattr(torch.distributed, "all_gather_object", all_gather_object) + + local_metadata, global_metadata = determine_global_metadata( + {"model": shard}, process_group=process_group + ) + + assert local_metadata == [metadata] + assert global_metadata == [None, None] + get_world_size.assert_called_once_with(group=process_group) + all_gather_object.assert_called_once_with(global_metadata, local_metadata, group=process_group) From 2658704e27b569a3a04d621af4ff93ac99456d4c Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Tue, 28 Jul 2026 21:10:44 +0200 Subject: [PATCH 133/290] fix(ci): AUT-1080 retry live NCCL watchdog timeouts (#6054) Signed-off-by: svcnemo-autobot --- pyproject.toml | 2 +- .../launch_nemo_run_workload.py | 71 ++++++++++++++++++- .../test_launch_nemo_run_workload.py | 68 ++++++++++++++++++ uv.lock | 13 ++-- 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 tests/test_utils/test_launch_nemo_run_workload.py diff --git a/pyproject.toml b/pyproject.toml index 653321556da..e19fe870334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,7 +230,7 @@ flash_mla = [ { git = "https://github.com/deepseek-ai/FlashMLA", rev = "nv_dev" }, ] transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" } -nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "17ae86b64d7f75653351664f5d8c9e466faede00" } +nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "ddd40a8f24847f5c919f911d0240bd622653612f" } emerging_optimizers = { git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git", rev = "v0.2.0" } fast-hadamard-transform = { git = "https://github.com/Dao-AILab/fast-hadamard-transform.git", rev = "f134af63deb2df17e1171a9ec1ea4a7d8604d5ca" } mamba-ssm = { git = "https://github.com/state-spaces/mamba.git", rev = "0048fbf2e7b2f214dcbe703ea3dec2b9647595e1" } diff --git a/tests/test_utils/python_scripts/launch_nemo_run_workload.py b/tests/test_utils/python_scripts/launch_nemo_run_workload.py index 45b8086ea0a..b0d2f0a14fb 100644 --- a/tests/test_utils/python_scripts/launch_nemo_run_workload.py +++ b/tests/test_utils/python_scripts/launch_nemo_run_workload.py @@ -5,6 +5,7 @@ import os import pathlib import sys +import threading from typing import Optional import click @@ -23,6 +24,7 @@ def is_flaky_failure(concat_allranks_logs: str) -> bool: "The server socket has failed to listen on any local network address." in concat_allranks_logs or "Some NCCL operations have failed or timed out." in concat_allranks_logs + or "Watchdog caught collective operation timeout" in concat_allranks_logs or "uncorrectable ECC error encountered" in concat_allranks_logs or "illegal memory access" in concat_allranks_logs or "illegal instruction" in concat_allranks_logs @@ -53,6 +55,51 @@ def is_flaky_failure(concat_allranks_logs: str) -> bool: ) +def _is_hang_prone_flaky_failure(concat_allranks_logs: str) -> bool: + """Return whether a streamed failure may prevent the attempt from exiting.""" + return "Watchdog caught collective operation timeout" in concat_allranks_logs + + +class _ThreadSafeBuffer: + """Collect output shared between the log tailer and flaky-failure monitor.""" + + def __init__(self): + self._buffer = io.StringIO() + self._lock = threading.Lock() + + def write(self, data: str) -> None: + """Append log output to the buffer.""" + with self._lock: + self._buffer.write(data) + + def flush(self) -> None: + """Provide the stream interface expected by the tee wrapper.""" + + def getvalue(self) -> str: + """Return a consistent snapshot of the buffered output.""" + with self._lock: + return self._buffer.getvalue() + + +def _cancel_on_flaky_failure( + experiment: run.Experiment, + job_id: str, + log_buffer: _ThreadSafeBuffer, + stop_event: threading.Event, + failure_detected_event: threading.Event, + poll_interval: float = 1.0, +) -> None: + """Cancel an active attempt as soon as its streamed logs show a flaky failure.""" + while not stop_event.wait(poll_interval): + if _is_hang_prone_flaky_failure(log_buffer.getvalue()): + logger.warning( + "Detected flaky failure while job is running; cancelling current attempt." + ) + failure_detected_event.set() + experiment.cancel(job_id) + return + + def _collect_failure_logs(workdir: pathlib.Path) -> list[str]: """Reads every log file that may carry a flaky-failure signature. @@ -193,7 +240,7 @@ def main( n_attempts = 0 while n_attempts < 3: - tee_buffer = io.StringIO() + tee_buffer = _ThreadSafeBuffer() original_stdout = sys.stdout original_stderr = sys.stderr @@ -213,15 +260,33 @@ def flush(self): def __getattr__(self, name): return getattr(self._real, name) + monitor_stop_event = threading.Event() + flaky_failure_detected_event = threading.Event() + monitor_thread = None sys.stdout = _TeeStream(original_stdout, tee_buffer) sys.stderr = _TeeStream(original_stderr, tee_buffer) try: with run.Experiment("mcore-ci-test", executor=executor, log_level="INFO") as exp: - _ = exp.add([inline_script], tail_logs=False, name="task-1") + job_id = exp.add([inline_script], tail_logs=False, name="task-1") exp.dryrun(log=True) + monitor_thread = threading.Thread( + target=_cancel_on_flaky_failure, + args=( + exp, + job_id, + tee_buffer, + monitor_stop_event, + flaky_failure_detected_event, + ), + daemon=True, + ) + monitor_thread.start() exp.run(detach=False, tail_logs=True, sequential=False) finally: + monitor_stop_event.set() + if monitor_thread is not None: + monitor_thread.join() sys.stdout = original_stdout sys.stderr = original_stderr @@ -237,7 +302,7 @@ def __getattr__(self, name): all_ranks_all_logs = [tee_buffer.getvalue()] all_ranks_all_logs.extend(_collect_failure_logs(pathlib.Path(os.getcwd()))) all_ranks_all_logs_string = "\n".join(all_ranks_all_logs) - if is_flaky_failure(all_ranks_all_logs_string): + if flaky_failure_detected_event.is_set() or is_flaky_failure(all_ranks_all_logs_string): logger.warning("Detected flaky failure, attempt restart.") n_attempts += 1 continue diff --git a/tests/test_utils/test_launch_nemo_run_workload.py b/tests/test_utils/test_launch_nemo_run_workload.py new file mode 100644 index 00000000000..ea9930b1fa8 --- /dev/null +++ b/tests/test_utils/test_launch_nemo_run_workload.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import threading +from unittest.mock import Mock + +from tests.test_utils.python_scripts import launch_nemo_run_workload + + +def test_nccl_watchdog_timeout_is_flaky(): + log = "Watchdog caught collective operation timeout: WorkNCCL(SeqNum=281)" + + assert launch_nemo_run_workload.is_flaky_failure(log) + + +def test_hang_prone_flaky_failure_cancels_active_attempt(): + experiment = Mock() + log_buffer = launch_nemo_run_workload._ThreadSafeBuffer() + stop_event = threading.Event() + failure_detected_event = threading.Event() + monitor = threading.Thread( + target=launch_nemo_run_workload._cancel_on_flaky_failure, + args=(experiment, "task-1", log_buffer, stop_event, failure_detected_event, 0.01), + ) + + monitor.start() + log_buffer.write("Watchdog caught collective operation timeout") + monitor.join(timeout=1) + + assert not monitor.is_alive() + assert failure_detected_event.is_set() + experiment.cancel.assert_called_once_with("task-1") + + +def test_non_hanging_flaky_failure_does_not_cancel_active_attempt(): + experiment = Mock() + log_buffer = launch_nemo_run_workload._ThreadSafeBuffer() + log_buffer.write("found NaN in local forward loss calculation") + stop_event = threading.Event() + failure_detected_event = threading.Event() + monitor = threading.Thread( + target=launch_nemo_run_workload._cancel_on_flaky_failure, + args=(experiment, "task-1", log_buffer, stop_event, failure_detected_event, 0.01), + ) + + monitor.start() + assert not failure_detected_event.wait(timeout=0.05) + stop_event.set() + monitor.join(timeout=1) + + assert not monitor.is_alive() + assert launch_nemo_run_workload.is_flaky_failure(log_buffer.getvalue()) + experiment.cancel.assert_not_called() + + +def test_stopped_monitor_does_not_cancel_attempt(): + experiment = Mock() + log_buffer = launch_nemo_run_workload._ThreadSafeBuffer() + log_buffer.write("Watchdog caught collective operation timeout") + stop_event = threading.Event() + stop_event.set() + failure_detected_event = threading.Event() + + launch_nemo_run_workload._cancel_on_flaky_failure( + experiment, "task-1", log_buffer, stop_event, failure_detected_event, poll_interval=0.01 + ) + + assert not failure_detected_event.is_set() + experiment.cancel.assert_not_called() diff --git a/uv.lock b/uv.lock index e66b7c9f996..15036ceb4ce 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -2357,7 +2357,7 @@ no-pypi-wheels = [ test = [ { name = "coverage" }, { name = "mock" }, - { name = "nemo-run", git = "https://github.com/NVIDIA-NeMo/Run.git?rev=17ae86b64d7f75653351664f5d8c9e466faede00" }, + { name = "nemo-run", git = "https://github.com/NVIDIA-NeMo/Run.git?rev=ddd40a8f24847f5c919f911d0240bd622653612f" }, { name = "nltk" }, { name = "pydantic" }, { name = "pygithub" }, @@ -2709,8 +2709,8 @@ wheels = [ [[package]] name = "nemo-run" -version = "0.9.0rc0.dev0" -source = { git = "https://github.com/NVIDIA-NeMo/Run.git?rev=17ae86b64d7f75653351664f5d8c9e466faede00#17ae86b64d7f75653351664f5d8c9e466faede00" } +version = "0.11.0+ddd40a8" +source = { git = "https://github.com/NVIDIA-NeMo/Run.git?rev=ddd40a8f24847f5c919f911d0240bd622653612f#ddd40a8f24847f5c919f911d0240bd622653612f" } dependencies = [ { name = "catalogue" }, { name = "fabric" }, @@ -2720,7 +2720,6 @@ dependencies = [ { name = "leptonai" }, { name = "networkx" }, { name = "omegaconf" }, - { name = "packaging" }, { name = "rich" }, { name = "toml" }, { name = "torchx" }, @@ -5100,14 +5099,14 @@ name = "torch" version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "cuda-bindings", marker = "python_version < '0'" }, { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, { name = "networkx" }, { name = "setuptools" }, { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'never'" }, + { name = "triton" }, { name = "typing-extensions" }, ] From 157c023f2fea81e94a4990401723c80a67441bf0 Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene <34819528+tdene@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:28:33 -0500 Subject: [PATCH 134/290] Prevent coordinator crash if an engine disconnects (#6025) Signed-off-by: Teodor-Dumitru Ene Co-authored-by: Jorge Albericio --- .../coordinator.py | 2 ++ .../handlers.py | 8 ++++- .../inference/coordinator_test_utils.py | 1 + ...est_data_parallel_inference_coordinator.py | 33 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py b/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py index 4e1026206c6..6900887bcf0 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py +++ b/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py @@ -188,6 +188,7 @@ def __init__( self.request_id_to_client_id = {} self.request_id_to_client_request_id = {} self.request_id_to_rank = {} # Maps request_id → rank identity for pending count tracking + self.removed_engine_identities = set() self.next_request_id = 0 self.tokenizer = tokenizer @@ -270,6 +271,7 @@ def _remove_engine(self, identity): only if dynamic registration/deregistration at high engine counts becomes a use case. """ self.identities_of_data_parallel_ranks.remove(identity) + self.removed_engine_identities.add(identity) idx = self.identity_to_rank_index.pop(identity, None) if idx is None: return diff --git a/megatron/core/inference/data_parallel_inference_coordinator/handlers.py b/megatron/core/inference/data_parallel_inference_coordinator/handlers.py index 34932825b34..b2d9a36fffb 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator/handlers.py +++ b/megatron/core/inference/data_parallel_inference_coordinator/handlers.py @@ -183,7 +183,13 @@ def handle_cuda_profiler_signal(coordinator, sender_identity, payload): def handle_engine_reply(coordinator, sender_identity, payload): """Route completed requests from an engine back to their originating clients.""" # This is the output of a single engine step on some data parallel rank. - assert sender_identity in coordinator.identities_of_data_parallel_ranks + if sender_identity not in coordinator.identities_of_data_parallel_ranks: + # A removed engine's final replies may still be queued up. + # Only exit with an assert if the sender was never connected to the coordinator. + assert ( + sender_identity in coordinator.removed_engine_identities + ), f"ENGINE_REPLY from never-connected sender {sender_identity!r}" + logging.warning("Coordinator: ENGINE_REPLY from removed engine %r", sender_identity) finished_requests = payload[1] for finished_request in finished_requests: diff --git a/tests/unit_tests/inference/coordinator_test_utils.py b/tests/unit_tests/inference/coordinator_test_utils.py index 93bf97dbc7b..0586231abc7 100644 --- a/tests/unit_tests/inference/coordinator_test_utils.py +++ b/tests/unit_tests/inference/coordinator_test_utils.py @@ -53,6 +53,7 @@ def make_coordinator_direct( coordinator.identities_of_data_parallel_ranks = deque( [rank_name_template.format(i).encode() for i in range(data_parallel_size)] ) + coordinator.removed_engine_identities = set() if deterministic_mode: coordinator.identities_of_data_parallel_ranks = deque( sorted(coordinator.identities_of_data_parallel_ranks) diff --git a/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py b/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py index 584b616a544..e29d27a8cf0 100644 --- a/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py +++ b/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py @@ -2,6 +2,7 @@ import asyncio import itertools +import logging import multiprocessing import os import time @@ -18,6 +19,7 @@ from megatron.core.inference.data_parallel_inference_coordinator import ( DataParallelInferenceCoordinator, ) +from megatron.core.inference.data_parallel_inference_coordinator.handlers import handle_engine_reply from megatron.core.inference.engines.async_zmq_communicator import AsyncZMQCommunicator from megatron.core.inference.engines.dynamic_engine import ( DynamicInferenceEngine, @@ -862,3 +864,34 @@ def test_load_balanced_policy_ignores_prefix(self): _set_hash_rank(coord, 99, b"rank-0", 1) assert coord.get_best_data_parallel_rank([99]) == b"rank-2" + + def test_reply_routing_survives_engine_removal(self, caplog): + """A removed engine's queued replies still deliver; never-connected senders assert.""" + + def reply(fid): + return [ + Headers.ENGINE_REPLY.value, + [{"request_id": fid, "generated_tokens": [1], "sampling_params": {}}], + ] + + coord = _make_routing_coordinator(num_ranks=2) + coord.tokenizer = DummyTokenizer() + coord.request_id_to_client_id = {11: b"client-A"} + coord.request_id_to_client_request_id = {11: 7} + coord.request_id_to_rank = {} + coord.router_socket = unittest.mock.MagicMock() + + # A sender that never registered is a protocol violation. + with pytest.raises(AssertionError, match="never-connected"): + handle_engine_reply(coord, b"impostor", reply(11)) + assert coord.router_socket.send_multipart.call_count == 0 + assert 11 in coord.request_id_to_client_id + + # Removal happens on failed *sends*, so the removed engine's in-flight + # reply can still arrive - and must reach its client. + coord._remove_engine(b"rank-0") + with caplog.at_level(logging.WARNING): + handle_engine_reply(coord, b"rank-0", reply(11)) + assert "removed engine" in caplog.text + assert coord.router_socket.send_multipart.call_args[0][0][0] == b"client-A" + assert 11 not in coord.request_id_to_client_id From bc67abde107ca0a7751208ae142d09ce602e45be Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 29 Jul 2026 00:23:40 +0000 Subject: [PATCH 135/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index 1d358a70b42..d5be6ae7b65 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From 8a63dd503b7fb84a3199a1c5c6fff7b535f85acb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 09:54:07 +0000 Subject: [PATCH 136/290] chore: rotate oncall schedule --- .github/oncall_schedule.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/oncall_schedule.json b/.github/oncall_schedule.json index 4320b7b407d..ba5e92c5980 100644 --- a/.github/oncall_schedule.json +++ b/.github/oncall_schedule.json @@ -1,8 +1,4 @@ [ - { - "user": "guihong-nv", - "date": "2026-07-22" - }, { "user": "ilml", "date": "2026-07-29" @@ -46,5 +42,9 @@ { "user": "guihong-nv", "date": "2026-10-07" + }, + { + "user": "ilml", + "date": "2026-10-14" } ] From 0a447f53c1a54dcafb15bf6de6302cb25c7738b5 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Wed, 29 Jul 2026 06:29:21 -0400 Subject: [PATCH 137/290] Make nightly sync checks advisory (#5713) Signed-off-by: Philip Petrakian --- .../workflows/nightly-sync-main-to-dev.yml | 48 ++++++++++------- skills/nightly-sync/SKILL.md | 51 +++++++++++++------ 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/.github/workflows/nightly-sync-main-to-dev.yml b/.github/workflows/nightly-sync-main-to-dev.yml index bda7146d4be..8f86a7a09fe 100644 --- a/.github/workflows/nightly-sync-main-to-dev.yml +++ b/.github/workflows/nightly-sync-main-to-dev.yml @@ -108,14 +108,19 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi - - name: Install pre-push merge guard + - name: Install pre-push merge guidance if: steps.check-sync.outputs.skip != 'true' run: | cat > .git/hooks/pre-push <<'HOOK' #!/usr/bin/env bash + + # This hook is advisory. Run the checks in a strict subshell so an + # audit error can be reported without blocking the push. + set +e + ( set -euo pipefail - echo "=== nightly-sync pre-push guard ===" + echo "=== nightly-sync pre-push guidance ===" merge_commit=$(git rev-list --min-parents=2 --max-count=1 HEAD || true) if [ -n "$merge_commit" ]; then @@ -127,8 +132,7 @@ jobs: fi if ! git diff --quiet "$dev_ref" HEAD -- .github/CODEOWNERS; then - echo "ABORT: .github/CODEOWNERS differs from dev. Restore it before pushing." - exit 1 + echo "WARNING: .github/CODEOWNERS differs from dev. Restore it before finalizing the sync." fi for f in pyproject.toml uv.lock docker/Dockerfile.ci.dev; do @@ -145,7 +149,7 @@ jobs: intentional_override_regex='^(megatron/training/training\.py|megatron/training/initialize\.py|megatron/training/utils\.py|megatron/training/datasets/data_samplers\.py|megatron/core/optimizer/layer_wise_optimizer\.py)$' skip_regex='^(pyproject\.toml|uv\.lock|docker/Dockerfile\.ci\.dev|\.github/CODEOWNERS)$' - violations=0 + findings=0 while IFS= read -r f; do [[ "$f" =~ $skip_regex ]] && continue [[ "$f" =~ $intentional_override_regex ]] && continue @@ -161,20 +165,28 @@ jobs: if [ -n "$missing" ]; then echo "=== $f ===" printf '%s\n' "$missing" - violations=$((violations + $(printf '%s\n' "$missing" | grep -c .))) + findings=$((findings + $(printf '%s\n' "$missing" | grep -c .))) fi done < <(git diff --name-only "$dev_ref"..HEAD \ -- '*.py' '*.md' '*.yaml' '*.yml' '*.toml' \ '*.sh' '*.cpp' '*.cu' '*.h' \ | sort -u) - if [ "$violations" -gt 0 ]; then - echo "ABORT: $violations dev-only line(s) were dropped by the merge." - echo "Restore the dev-only code, or document the exact main commit that intentionally removed it." - exit 1 + if [ "$findings" -gt 0 ]; then + echo "WARNING: $findings potential dev-only line removal(s) were detected." + echo "Review each finding: restore merge accidents and document intentional main removals in the PR body." + echo "This audit is advisory; the push will continue." + else + echo "No potential dev-only line removals detected." fi - echo "nightly-sync pre-push guard passed" + echo "nightly-sync pre-push guidance complete" + ) + guidance_status=$? + if [ "$guidance_status" -ne 0 ]; then + echo "WARNING: nightly-sync pre-push guidance failed with status $guidance_status; allowing the push to continue." + fi + exit 0 HOOK chmod +x .git/hooks/pre-push @@ -233,12 +245,14 @@ jobs: conversation in between — that wastes `--max-turns` and creates windows where the agent could forget the loop. - **Pre-push guard:** The workflow installs a local git pre-push - hook that enforces CODEOWNERS, dependency-triple, and dev-feature - preservation checks. You MUST NOT bypass it with `--no-verify`. - If a push fails, read the hook output, restore the dropped dev - code unless main explicitly removed it, and push again only after - the hook passes. + **Pre-push guidance:** The workflow installs a local git pre-push + hook that reports CODEOWNERS, dependency-triple, and dev-feature + preservation findings. It is advisory and MUST NOT block a push. + Do not use `--no-verify`; let the hook run and review its output. + Restore genuine merge accidents and CODEOWNERS changes. For + intentional main removals or formatting/reordering false positives, + document the evidence in the PR body and continue. Do not stop or + ask for authorization solely because advisory findings remain. **Merge strategy:** Start from `origin/dev` and run `git merge origin/main --no-edit`. Do NOT use global diff --git a/skills/nightly-sync/SKILL.md b/skills/nightly-sync/SKILL.md index d350d4a7a6f..cd3b85f2e0a 100644 --- a/skills/nightly-sync/SKILL.md +++ b/skills/nightly-sync/SKILL.md @@ -232,13 +232,19 @@ Run on ALL changed Python files (relative to `origin/dev`), in this order: 4. `pylint` on changed `megatron/core/` files — fix missing-docstring and line-too-long violations before pushing -### Pre-push invariant checks +### Pre-push advisory checks Before every `git push` in this workflow (the initial push in Phase 1 -AND every fix-push in Phase 3), run these bash checks. If any fails, -fix the condition and re-check before pushing: +AND every fix-push in Phase 3), run these bash checks as guidance. They +must never block the push. Review every finding: fix genuine merge accidents +and document intentional main removals or formatting/reordering false positives +in the PR body. ```bash +set +e +( +set -euo pipefail + MERGE_COMMIT=$(git rev-list --min-parents=2 --max-count=1 HEAD || true) if [ -n "$MERGE_COMMIT" ]; then DEV_REF="${MERGE_COMMIT}^1" @@ -250,9 +256,8 @@ fi # 1. CODEOWNERS must be identical to dev's. if ! git diff --quiet "$DEV_REF" HEAD -- .github/CODEOWNERS; then - echo "ABORT: .github/CODEOWNERS differs from dev. Restore with:" + echo "WARNING: .github/CODEOWNERS differs from dev. Restore with:" echo " git checkout $DEV_REF -- .github/CODEOWNERS" - exit 1 fi # 2. Dependency-management triple must be identical to dev's. @@ -260,7 +265,7 @@ for f in pyproject.toml uv.lock docker/Dockerfile.ci.dev; do if ! git diff --quiet "$DEV_REF" HEAD -- "$f"; then # pyproject.toml is allowed to differ ONLY for git source reconciliation # (new [tool.uv.sources] entries from main). If you intentionally edited - # it for that reason, bypass this check by re-running with $f skipped. + # it for that reason, document the reconciliation in the PR body. echo "WARNING: $f differs from dev" fi done @@ -291,7 +296,7 @@ done INTENTIONAL_OVERRIDE_REGEX='^(megatron/training/training\.py|megatron/training/initialize\.py|megatron/training/utils\.py|megatron/training/datasets/data_samplers\.py|megatron/core/optimizer/layer_wise_optimizer\.py)$' SKIP_REGEX='^(pyproject\.toml|uv\.lock|docker/Dockerfile\.ci\.dev|\.github/CODEOWNERS)$' -VIOLATIONS=0 +FINDINGS=0 for f in $(git diff --name-only "$DEV_REF"..HEAD \ -- '*.py' '*.md' '*.yaml' '*.yml' '*.toml' \ '*.sh' '*.cpp' '*.cu' '*.h' \ @@ -310,25 +315,36 @@ for f in $(git diff --name-only "$DEV_REF"..HEAD \ if [ -n "$missing" ]; then echo "=== $f ===" printf '%s\n' "$missing" - VIOLATIONS=$((VIOLATIONS + $(printf '%s\n' "$missing" | grep -c .))) + FINDINGS=$((FINDINGS + $(printf '%s\n' "$missing" | grep -c .))) fi done -if [ "$VIOLATIONS" -gt 0 ]; then - echo "ABORT: $VIOLATIONS dev-only line(s) dropped by the merge. For each:" +if [ "$FINDINGS" -gt 0 ]; then + echo "WARNING: $FINDINGS potential dev-only line removal(s) detected. For each:" echo " (a) MAIN INTENTIONALLY REMOVED — find the specific commit in" echo " 'git log origin/main -- ' that removed it; document the" echo " SHA in the PR body, then the drop is acceptable." echo " (b) MERGE ACCIDENT — main never explicitly touched that line." echo " RESTORE the dev line (Edit/Write to put it back)." echo "Default to (b); only declare (a) with a specific main commit as evidence." - exit 1 + echo "This audit is advisory; continue the push after reviewing the findings." +fi + +echo "nightly-sync pre-push guidance complete" +) +GUIDANCE_STATUS=$? +if [ "$GUIDANCE_STATUS" -ne 0 ]; then + echo "WARNING: nightly-sync pre-push guidance failed with status $GUIDANCE_STATUS; allowing the push to continue." fi +exit 0 ``` -The CODEOWNERS check and the dev-feature preservation audit are HARD -aborts — never push if either fails. The dep-triple check is a warning -because git-source reconciliation can produce legitimate diffs there. +All pre-push findings are advisory. The hook must return success even when it +finds a CODEOWNERS difference, potential dev-feature removal, dependency-triple +difference, or an internal audit error. The underlying policies still apply: +restore accidental changes, preserve dev-only features, and document exact main +commits for intentional removals. A warning by itself is never a reason to stop +the workflow or request authorization to continue. Recent regressions the dev-feature audit would have flagged (all "merge accident" type from #4659 and #4716): @@ -393,7 +409,12 @@ Phase 3 step 4 and the two-commit policy in Rules). in the PR body so reviewers see it at a glance. 3. List of files where main's version was taken over the merge 4. List of files that were deleted in dev but restored (and why) - 5. The remerge-diff output (`git show --remerge-diff HEAD` on the merge + 5. Disposition of every pre-push advisory finding, including CODEOWNERS or + dependency-triple differences and potential dev-feature removals. Record + whether each was corrected, intentional (with the exact commit or reason), + or a formatting/reordering false positive. State explicitly if there were + no findings. + 6. The remerge-diff output (`git show --remerge-diff HEAD` on the merge commit) so reviewers can inspect ONLY the conflict resolutions. If the output is very long, summarize conflicts by file and put the full diff in a collapsed `

` block. If git is too old for `--remerge-diff`, From f43ff6b7655a423bae11522f9d141cbc7a59d20d Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 29 Jul 2026 15:01:46 +0200 Subject: [PATCH 138/290] test(optimizer): MCORE-560 cover MoE gradient zero counts (#6050) Signed-off-by: svcnemo-autobot --- tests/unit_tests/training/test_param_norm.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/training/test_param_norm.py b/tests/unit_tests/training/test_param_norm.py index d58eef225d3..c1c14bd0134 100644 --- a/tests/unit_tests/training/test_param_norm.py +++ b/tests/unit_tests/training/test_param_norm.py @@ -122,13 +122,13 @@ def test_moe_param_norm_counts_each_logical_parameter_once( ((2, 2, 1), (2, 1, 2), (4, 1, 2), (2, 1, 4)), ids=("expert-parallel", "expert-tensor-parallel", "tp-larger-than-etp", "etp-larger-than-tp"), ) -def test_moe_grad_norm_and_clipping_count_each_logical_gradient_once( +def test_moe_gradient_stats_and_clipping_count_each_logical_gradient_once( tensor_parallel_size: int, expert_parallel_size: int, expert_tensor_parallel_size: int, use_distributed_optimizer: bool, ): - """Gradient clipping should use each logical parameter's gradient exactly once.""" + """Gradient norm, clipping, and zero count should include each logical gradient once.""" if Utils.world_size < 4 or Utils.world_size % 4 != 0: pytest.skip("test requires a world size divisible by four") @@ -168,6 +168,7 @@ def test_moe_grad_norm_and_clipping_count_each_logical_gradient_once( lr=0.0, bf16=True, clip_grad=max_norm, + log_num_zeros_in_grad=True, use_distributed_optimizer=use_distributed_optimizer, ), [model], @@ -175,11 +176,19 @@ def test_moe_grad_norm_and_clipping_count_each_logical_gradient_once( for param in model.parameters(): assert hasattr(param, "main_grad") + param.main_grad.zero_() + + found_inf = optimizer.prepare_grads() + assert not found_inf + assert optimizer.count_zeros() == expected_numel + + for param in model.parameters(): param.main_grad.fill_(1.0) - update_successful, actual_norm, _ = optimizer.step() + update_successful, actual_norm, actual_num_zeros = optimizer.step() assert update_successful + assert actual_num_zeros == 0 actual_norm_value = ( actual_norm.item() if isinstance(actual_norm, torch.Tensor) else actual_norm ) From b78cfd5279be41ced082d344e9380a09a146c458 Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene Date: Wed, 29 Jul 2026 07:30:58 -0500 Subject: [PATCH 139/290] RL: fix logging issues from failed rollouts (#6061) Signed-off-by: Teodor-Dumitru Ene --- megatron/rl/rl_utils.py | 87 ++++++++++++++------ tests/unit_tests/rl/test_rl_utils.py | 114 ++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 25 deletions(-) diff --git a/megatron/rl/rl_utils.py b/megatron/rl/rl_utils.py index d4424d17f74..c99f30b5c43 100644 --- a/megatron/rl/rl_utils.py +++ b/megatron/rl/rl_utils.py @@ -932,7 +932,11 @@ def compute_group_stats( assert rollout.kv_cache_epoch, "Rollout has no kv_cache_epoch data" group_policy_epoch.append([epoch for turn in rollout.policy_epoch for _, epoch in turn]) group_kv_epoch.append([epoch for turn in rollout.kv_cache_epoch for _, epoch in turn]) - group_completed_epochs.extend(turn[-1][1] for turn in rollout.policy_epoch) + # completed_epochs is per-turn, so it cannot be masked per-rollout downstream + if rollout.trajectory: + group_completed_epochs.extend( + turn[-1][1] for turn in rollout.policy_epoch + ) group_num_evictions.append(sum(rollout.num_evictions)) all_policy_epoch.append(group_policy_epoch) all_kv_cache_epoch.append(group_kv_epoch) @@ -992,12 +996,17 @@ def prep_wandb_metrics( """Make a wandb-parseable dictionary of metrics for logging. + Zero-turn rollouts are a mark of placeholders (empty-trajectory pads for failed episodes). + Their 0.0 reward deliberately stays in the reward and group-mean/std aggregates: + it does affect training dynamics. + All other per-rollout field of theirs is masked out of stats. + Args: wandb_writer: Wandb run to log to. traj_lens: Grouped list of trajectory lengths. turn_lens: Grouped list of turn lengths. rewards: Grouped list of rewards. - num_turns: Grouped list of number of turns in the trajectories. + num_turns: Grouped list of number of turns in the trajectories. Zero means failure. advantages: Flattened list of advantages. policy_epoch: Grouped list of per-token policy epoch stamps. kv_cache_epoch: Grouped list of per-token KV cache epoch stamps. @@ -1007,6 +1016,29 @@ def prep_wandb_metrics( example_group: A list of rollouts of one group to log examples of trajectories. tokenizer: Tokenizer to untokenize trajectories for logging. """ + # Zero-turn rollouts are failure placeholders. + real_mask = [[nt > 0 for nt in g] for g in num_turns] + total_rollouts = sum(len(g) for g in num_turns) + failed_rollouts = sum(not keep for g in real_mask for keep in g) + failure_metrics = { + 'failed_rollouts/count': failed_rollouts, + 'failed_rollouts/ratio': ( + failed_rollouts / total_rollouts if total_rollouts else 0.0 + ), + } + + def _real(grouped): + """Grouped per-rollout entries with placeholder (zero-turn) rollouts removed.""" + return [ + [x for x, keep in zip(g, m) if keep] for g, m in zip(grouped, real_mask) + ] + + # Reward metrics include failures. All other metrics do not. + table_rewards = [r for g in _real(rewards) for r in g] + traj_lens_real = _real(traj_lens) + num_turns_real = _real(num_turns) + policy_epoch_real = _real(policy_epoch) + kv_cache_epoch_real = _real(kv_cache_epoch) group_table = wandb_writer.Table( columns=['group_means', 'group_stds'], @@ -1014,14 +1046,22 @@ def prep_wandb_metrics( ) # Per-rollout staleness (oldest token) - rollout_policy_staleness = [current_iteration - r[0] for g in policy_epoch for r in g] - rollout_kv_staleness = [current_iteration - r[0] for g in kv_cache_epoch for r in g] + rollout_policy_staleness = [current_iteration - r[0] for g in policy_epoch_real for r in g] + rollout_kv_staleness = [current_iteration - r[0] for g in kv_cache_epoch_real for r in g] # Per-rollout staleness (newest token) - rollout_policy_last_token_staleness = [current_iteration - r[-1] for g in policy_epoch for r in g] - rollout_kv_last_token_staleness = [current_iteration - r[-1] for g in kv_cache_epoch for r in g] + rollout_policy_last_token_staleness = [ + current_iteration - r[-1] for g in policy_epoch_real for r in g + ] + rollout_kv_last_token_staleness = [ + current_iteration - r[-1] for g in kv_cache_epoch_real for r in g + ] # Per-token staleness - per_token_policy_staleness = [current_iteration - e for g in policy_epoch for r in g for e in r] - per_token_kv_staleness = [current_iteration - e for g in kv_cache_epoch for r in g for e in r] + per_token_policy_staleness = [ + current_iteration - e for g in policy_epoch_real for r in g for e in r + ] + per_token_kv_staleness = [ + current_iteration - e for g in kv_cache_epoch_real for r in g for e in r + ] metrics = { 'group_means_hist': wandb_writer.plot.histogram( @@ -1042,6 +1082,7 @@ def prep_wandb_metrics( ), 'advantages', 'Advantages' ), + # One row per real rollout. 'rollout_table': wandb_writer.Table( columns=[ 'reward', 'traj_length', 'num_evictions', @@ -1049,9 +1090,9 @@ def prep_wandb_metrics( 'policy_last_token_staleness', 'kv_last_token_staleness', ], data=list(zip( - [r for g in rewards for r in g], - [l for g in traj_lens for l in g], - [e for g in num_evictions for e in g], + table_rewards, + [l for g in traj_lens_real for l in g], + [e for g in _real(num_evictions) for e in g], rollout_policy_staleness, rollout_kv_staleness, rollout_policy_last_token_staleness, @@ -1063,17 +1104,18 @@ def prep_wandb_metrics( columns=['policy_staleness', 'kv_staleness'], data=list(zip(per_token_policy_staleness, per_token_kv_staleness)), ), - 'mean_turn_length': np.mean([np.mean(g) for g in turn_lens]), - 'mean_turn_length_std': np.mean([np.std(g) for g in turn_lens]), - 'max_turn_length': max([max(g) for g in turn_lens]), - 'min_turn_length': min([min(g) for g in turn_lens]), - 'mean_traj_length': np.mean([np.mean(g) for g in traj_lens]), - 'mean_traj_length_std': np.mean([np.std(g) for g in traj_lens]), - 'max_traj_length': max([max(g) for g in traj_lens]), - 'min_traj_length': min([min(g) for g in traj_lens]), - 'mean_num_turns': np.mean([np.mean(g) for g in num_turns]), - 'max_num_turns': max([max(g) for g in num_turns]), - 'min_num_turns': min([min(g) for g in num_turns]), + # Group-level length/turn stats skip groups with all failed rollouts. + 'mean_turn_length': np.mean([np.mean(g) for g in turn_lens if g]), + 'mean_turn_length_std': np.mean([np.std(g) for g in turn_lens if g]), + 'max_turn_length': max(max(g) for g in turn_lens if g), + 'min_turn_length': min(min(g) for g in turn_lens if g), + 'mean_traj_length': np.mean([np.mean(g) for g in traj_lens_real if g]), + 'mean_traj_length_std': np.mean([np.std(g) for g in traj_lens_real if g]), + 'max_traj_length': max(max(g) for g in traj_lens_real if g), + 'min_traj_length': min(min(g) for g in traj_lens_real if g), + 'mean_num_turns': np.mean([np.mean(g) for g in num_turns_real if g]), + 'max_num_turns': max(max(g) for g in num_turns_real if g), + 'min_num_turns': min(min(g) for g in num_turns_real if g), 'mean_reward': np.mean([np.mean(g) for g in rewards]), 'mean_advantage': np.mean(advantages), 'nonzero_groups_ratio': np.count_nonzero(advantages) @@ -1101,6 +1143,7 @@ def prep_wandb_metrics( wandb_writer.Table(columns=['staleness'], data=[[s] for s in per_token_kv_staleness]), 'staleness', 'Per-Token KV Cache Staleness' ), + **failure_metrics, } if example_group: if tokenizer is None: diff --git a/tests/unit_tests/rl/test_rl_utils.py b/tests/unit_tests/rl/test_rl_utils.py index 1ce7e045468..c37d5ec00e1 100644 --- a/tests/unit_tests/rl/test_rl_utils.py +++ b/tests/unit_tests/rl/test_rl_utils.py @@ -1159,7 +1159,10 @@ def test_get_logprobs_cuda_graphs(self, initialize_model_parallel): [pytest.param((1, 1), id="tp1-pp1")], indirect=["initialize_model_parallel"], ) - def test_prep_wandb_metrics(self, initialize_model_parallel): + @pytest.mark.parametrize( + "inject_placeholders", [False, True], ids=["clean", "with_placeholders"] + ) + def test_prep_wandb_metrics(self, initialize_model_parallel, inject_placeholders): # This tests the computation and makes us fail noisily if # inputs assumptions are changed, e.g. we expect rewards to come in groups (list[list[int]]). traj_lens = [[3, 3], [1, 2]] @@ -1174,8 +1177,29 @@ def test_prep_wandb_metrics(self, initialize_model_parallel): completed_epochs = [[5, 3], [5, 1]] num_evictions = [[0, 1], [0, 0]] current_iteration = 6 + if inject_placeholders: + for lst, sentinel in ( + (traj_lens, 0), + (rewards, 0.0), + (num_turns, 0), + (num_evictions, 0), + ): + for group in lst: + group.append(sentinel) + lst.append([sentinel, sentinel]) # the fully failed extra group + for lst in (policy_epoch, kv_cache_epoch): + for group in lst: + group.append([0]) # sentinel epoch stamp of a placeholder + lst.append([[0], [0]]) + # Placeholders contribute no turns, and compute_group_stats already + # excludes them from completed_epochs; the failed group adds empty + # inner lists, which the group-level stats must skip, not crash on. + turn_lens.append([]) + completed_epochs.append([]) + # advantages stay [0, 1]: zero-turn rollouts emit no advantage entries. + writer = MagicMock() metrics = rl_utils.prep_wandb_metrics( - MagicMock(), + writer, traj_lens, turn_lens, rewards, @@ -1187,8 +1211,21 @@ def test_prep_wandb_metrics(self, initialize_model_parallel): num_evictions=num_evictions, current_iteration=current_iteration, ) - assert metrics["mean_reward"] == 0.75 + assert metrics["failed_rollouts/count"] == (4 if inject_placeholders else 0) + assert metrics["failed_rollouts/ratio"] == (0.5 if inject_placeholders else 0.0) + # Reward aggregates keep the placeholder zeros by design: group means + # become [2/3, 1/3, 0] instead of [1, 0.5]. + assert np.isclose(metrics["mean_reward"], 1 / 3 if inject_placeholders else 0.75) assert metrics["mean_advantage"] == 0.5 + # The rollout table lists real rollouts only, in either case. + rollout_table_calls = [ + c + for c in writer.Table.call_args_list + if c.kwargs.get("columns", [None])[:2] == ["reward", "traj_length"] + ] + assert len(rollout_table_calls) == 1 + rows = rollout_table_calls[0].kwargs["data"] + assert [r[3] for r in rows] == [2, 4, 1, 6] # policy_staleness column assert metrics["nonzero_groups_ratio"] == 0.5 assert metrics["max_traj_length"] == 3 assert metrics["min_traj_length"] == 1 @@ -1221,3 +1258,74 @@ def test_prep_wandb_metrics(self, initialize_model_parallel): assert metrics["max_num_evictions"] == 1 # mean_completion_gap = mean([6-5, 6-3, 6-5, 6-1]) = mean([1, 3, 1, 5]) = 2.5 assert metrics["mean_completion_gap"] == 2.5 + + def test_compute_group_stats_excludes_placeholders_from_metric_fields(self): + def real_rollout(tokens, epoch, problem_id): + return TokenRollout( + trajectory=[tokens], + generation_mask=[[True] * len(tokens)], + reward=1.0, + logprobs=[[0.0] * len(tokens)], + env_id="swe", + problem_id=problem_id, + policy_epoch=[[(0, epoch)]], + kv_cache_epoch=[[(0, epoch)]], + num_evictions=[0], + ) + + def placeholder(): + return TokenRollout( + trajectory=[], + generation_mask=[], + reward=0.0, + logprobs=[], + env_id="swe", + problem_id="placeholder", + policy_epoch=[[(0, 0)]], + kv_cache_epoch=[[(0, 0)]], + num_evictions=[0], + ) + + eod = MockTokenizer().eod + rollouts = [ + [ + real_rollout([1, 2, eod], epoch=5, problem_id="p0"), + real_rollout([1, 2, 3, eod], epoch=6, problem_id="p0"), + placeholder(), + ], + [placeholder(), placeholder(), placeholder()], + ] + stats = rl_utils.compute_group_stats(rollouts, MockTokenizer(), seq_len=16) + + # Per-rollout lists keep the placeholder entries: alignment with rewards + # and num_turns is what lets prep_wandb_metrics mask them downstream. + assert stats.num_turns == [[1, 1, 0], [0, 0, 0]] + assert stats.policy_epoch == [[[5], [6], [0]], [[0], [0], [0]]] + assert stats.traj_lens == [[3, 4, 0], [0, 0, 0]] + # Per-turn lists exclude placeholders entirely: no sentinel epoch-0 stamp + # in completed_epochs, no fake 0-length turn for all-placeholder groups. + assert stats.completed_epochs == [[5, 6], []] + assert stats.turn_lens == [[3, 4], []] + # Rewards keep the placeholder zeros (they shape the GRPO baseline). + assert stats.rewards == [[1.0, 1.0, 0.0], [0.0, 0.0, 0.0]] + + # End to end: the sentinel epochs never reach the staleness metrics. + metrics = rl_utils.prep_wandb_metrics( + MagicMock(), + stats.traj_lens, + stats.turn_lens, + stats.rewards, + stats.num_turns, + stats.advantages, + policy_epoch=stats.policy_epoch, + kv_cache_epoch=stats.kv_cache_epoch, + completed_epochs=stats.completed_epochs, + num_evictions=stats.num_evictions, + current_iteration=7, + ) + assert metrics["max_policy_staleness"] == 2 # 7 - 5, not 7 - 0 + assert metrics["min_traj_length"] == 3 + assert metrics["min_num_turns"] == 1 + assert metrics["mean_completion_gap"] == np.mean([2, 1]) + assert metrics["failed_rollouts/count"] == 4 + assert np.isclose(metrics["failed_rollouts/ratio"], 4 / 6) From 3ff70c0064ee62b3385f26895266b3178eb8d1fa Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 29 Jul 2026 20:17:38 +0200 Subject: [PATCH 140/290] chore(ci): AUT-1135 pin GitHub Actions to commit SHAs (#6100) Signed-off-by: svcnemo-autobot --- .github/actions/action.yml | 10 +-- .../workflows/_build_test_publish_wheel.yml | 6 +- .github/workflows/_update_dependencies.yml | 12 ++-- .github/workflows/auto-assign-milestone.yml | 2 +- .github/workflows/auto-reminder-bot.yml | 4 +- .github/workflows/auto-swap-labels.yml | 6 +- .github/workflows/auto-update-copy-pr-bot.yml | 2 +- .../workflows/cherry-pick-release-commit.yml | 2 +- .github/workflows/cicd-approve-test-queue.yml | 4 +- .github/workflows/cicd-main.yml | 66 +++++++++---------- .github/workflows/claude-complexity-label.yml | 4 +- .github/workflows/claude-copy-to-main.yml | 10 +-- .github/workflows/claude_review.yml | 8 +-- .github/workflows/close-inactive-issue-pr.yml | 2 +- .github/workflows/community-bot.yml | 2 +- .../workflows/community-request-assignee.yml | 6 +- .github/workflows/copyright-check.yml | 6 +- .github/workflows/install-test.yml | 12 ++-- .github/workflows/multi-approval-bot.yml | 8 +-- .../workflows/nightly-sync-main-to-dev.yml | 6 +- .github/workflows/oncall-assign.yml | 4 +- .github/workflows/oncall-rotation.yml | 4 +- .github/workflows/release-docs.yml | 4 +- .github/workflows/release-freeze.yml | 2 +- .github/workflows/release.yaml | 6 +- .github/workflows/request-nvskills-ci.yml | 2 +- .github/workflows/review-trigger.yml | 2 +- .github/workflows/sync-team-usergroups.yml | 4 +- .github/workflows/trigger-mbridge-tests.yml | 2 +- 29 files changed, 104 insertions(+), 104 deletions(-) diff --git a/.github/actions/action.yml b/.github/actions/action.yml index 069ac4395df..97f6c17c8fd 100644 --- a/.github/actions/action.yml +++ b/.github/actions/action.yml @@ -77,7 +77,7 @@ runs: run: echo "node_name=$NODE_NAME" | tee -a "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ inputs.sha }} @@ -94,7 +94,7 @@ runs: sudo chown -R $(whoami) /home/runner/ 2>/dev/null || true - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' @@ -296,7 +296,7 @@ runs: fi - name: Upload coverage - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: ${{ always() && steps.check.outputs.coverage_report != 'none' }} with: name: ${{ steps.check.outputs.coverage_report }} @@ -307,7 +307,7 @@ runs: - name: Upload logs id: upload-logs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() continue-on-error: true with: @@ -321,7 +321,7 @@ runs: run: sleep 10 - name: Retry log upload - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: ${{ always() && steps.upload-logs.outcome == 'failure' }} with: name: ${{ steps.check.outputs.logs_report }}-retry diff --git a/.github/workflows/_build_test_publish_wheel.yml b/.github/workflows/_build_test_publish_wheel.yml index 9e37e068b6d..b6849318a55 100644 --- a/.github/workflows/_build_test_publish_wheel.yml +++ b/.github/workflows/_build_test_publish_wheel.yml @@ -43,7 +43,7 @@ jobs: PUBLISH_DRYRUN: ${{ inputs.dry-run }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ inputs.ref }} @@ -141,7 +141,7 @@ jobs: " - name: Upload wheels - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: wheels-${{ matrix.PACKAGE }}-${{ matrix.PLATFORM }}-${{ inputs.dry-run && 'dry-run' || 'release' }} path: dist/ @@ -165,7 +165,7 @@ jobs: PACKAGE: ${{ matrix.PACKAGE }} steps: - name: Download wheels - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: wheels-${{ matrix.PACKAGE }}-${{ matrix.PLATFORM }}-${{ inputs.dry-run && 'dry-run' || 'release' }} path: dist/ diff --git a/.github/workflows/_update_dependencies.yml b/.github/workflows/_update_dependencies.yml index b8410f8fc00..d899855a2df 100644 --- a/.github/workflows/_update_dependencies.yml +++ b/.github/workflows/_update_dependencies.yml @@ -33,7 +33,7 @@ jobs: TARGET_BRANCH: ${{ inputs.target-branch }} steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ env.TARGET_BRANCH }} @@ -60,7 +60,7 @@ jobs: fi - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ env.SOURCE_BRANCH }} @@ -77,7 +77,7 @@ jobs: bash -c 'uv lock --upgrade' - name: Upload lock file - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: lock-file-${{ env.SOURCE_BRANCH }} path: uv.lock @@ -90,7 +90,7 @@ jobs: TARGET_BRANCH: ${{ inputs.target-branch }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: token: ${{ secrets.PAT }} ref: ${{ env.TARGET_BRANCH }} @@ -103,12 +103,12 @@ jobs: fi - name: Download lock file - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: lock-file-${{ env.SOURCE_BRANCH }} - name: Create Bump PR - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 id: create-pull-request env: title: "chore(beep boop 🤖): Bump `uv.lock` (${{ inputs.target-branch}}) (${{ needs.pre-flight.outputs.date }})" diff --git a/.github/workflows/auto-assign-milestone.yml b/.github/workflows/auto-assign-milestone.yml index b972329bac1..f3ee6709a29 100644 --- a/.github/workflows/auto-assign-milestone.yml +++ b/.github/workflows/auto-assign-milestone.yml @@ -18,7 +18,7 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Check if PR has milestone id: check_milestone diff --git a/.github/workflows/auto-reminder-bot.yml b/.github/workflows/auto-reminder-bot.yml index 72a48e9539e..23460c4e7dc 100644 --- a/.github/workflows/auto-reminder-bot.yml +++ b/.github/workflows/auto-reminder-bot.yml @@ -14,10 +14,10 @@ jobs: if: github.repository == 'NVIDIA/Megatron-LM' steps: - name: Check out repository code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.10" diff --git a/.github/workflows/auto-swap-labels.yml b/.github/workflows/auto-swap-labels.yml index d38fb65d210..9bc7c701fc7 100644 --- a/.github/workflows/auto-swap-labels.yml +++ b/.github/workflows/auto-swap-labels.yml @@ -32,7 +32,7 @@ jobs: id: get-pr if: github.event_name == 'workflow_run' continue-on-error: true - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: pr-number path: pr-number @@ -54,11 +54,11 @@ jobs: - name: Check out repository code if: steps.pr.outputs.number - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Python if: steps.pr.outputs.number - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.10" diff --git a/.github/workflows/auto-update-copy-pr-bot.yml b/.github/workflows/auto-update-copy-pr-bot.yml index 07fdcfbfbb8..d05a844dc0b 100644 --- a/.github/workflows/auto-update-copy-pr-bot.yml +++ b/.github/workflows/auto-update-copy-pr-bot.yml @@ -11,7 +11,7 @@ jobs: if: github.repository == 'NVIDIA/Megatron-LM' steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: token: ${{ secrets.PAT }} ref: main diff --git a/.github/workflows/cherry-pick-release-commit.yml b/.github/workflows/cherry-pick-release-commit.yml index 9da305f07e6..2dcef2a06cd 100644 --- a/.github/workflows/cherry-pick-release-commit.yml +++ b/.github/workflows/cherry-pick-release-commit.yml @@ -20,7 +20,7 @@ on: jobs: cherry-pick: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cherry_pick.yml@v0.65.9 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cherry_pick.yml@0cb71cd98aa47ba338d8e38514387d5ceecfedff # v0.65.9 if: github.repository == 'NVIDIA/Megatron-LM' with: target-branches-pattern: 'core_(*dev_)?r[0-9]+\.[0-9]+\.[0-9]+' diff --git a/.github/workflows/cicd-approve-test-queue.yml b/.github/workflows/cicd-approve-test-queue.yml index 32b82a66e19..120b40f4fbb 100644 --- a/.github/workflows/cicd-approve-test-queue.yml +++ b/.github/workflows/cicd-approve-test-queue.yml @@ -30,10 +30,10 @@ jobs: contributor_type: [internal, external] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index fcfe98c8edb..da392004a4a 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -54,14 +54,14 @@ jobs: DISABLE_EXTERNAL_CONTRIBUTOR: ${{ vars.DISABLE_EXTERNAL_CONTRIBUTOR }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: token: ${{ env.GITHUB_TOKEN }} - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Check NVIDIA SSO membership id: check-sso @@ -133,7 +133,7 @@ jobs: pre-flight: needs: [is-not-external-contributor] if: github.repository == 'NVIDIA/Megatron-LM' - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 configure: runs-on: ubuntu-latest @@ -154,7 +154,7 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main # Resolve a single SHA used by the build, every test job, and every # downstream checkout so that the container image, golden values, and @@ -341,12 +341,12 @@ jobs: ) steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: 0.7.2 @@ -357,7 +357,7 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Validate updated golden values if: github.event_name == 'merge_group' || (startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push') @@ -426,7 +426,7 @@ jobs: mbridge-test-suite: ${{ needs.configure.outputs.mbridge_suite }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: How-To run: bash .github/scripts/readme.sh @@ -461,10 +461,10 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Checkout MBridge and create testing branch - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: main repository: NVIDIA-NeMo/Megatron-Bridge @@ -481,7 +481,7 @@ jobs: git push origin ${{ env.MBRIDGE_BRANCH_NAME }} --force - name: Trigger MBridge tests - uses: convictional/trigger-workflow-and-wait@v1.6.5 + uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5 env: MBRIDGE_BRANCH_NAME: mcore-testing-${{ fromJSON(steps.get-pr-info.outputs.pr-info || '{}').number || github.run_id }} with: @@ -517,7 +517,7 @@ jobs: && (needs.cicd-mbridge-testing.result == 'success' || needs.cicd-mbridge-testing.result == 'failure') steps: - name: Send Slack alert - uses: NVIDIA-NeMo/FW-CI-templates/.github/actions/send-slack-alert@main + uses: NVIDIA-NeMo/FW-CI-templates/.github/actions/send-slack-alert@209ac7913b0419a5ccbac47b02d00fbea4939243 # main with: webhook: ${{ secrets.SLACK_WH_MLM_MB_ALERTS }} message: | @@ -576,15 +576,15 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.12 @@ -647,10 +647,10 @@ jobs: fi - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.0.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push - uses: docker/build-push-action@v7.1.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: file: ${{ steps.base-image.outputs.dockerfile }} push: true @@ -691,7 +691,7 @@ jobs: && !cancelled() steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: Parse unit tests @@ -737,7 +737,7 @@ jobs: PIP_RETRIES: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: main @@ -777,7 +777,7 @@ jobs: && !cancelled() steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: Parse unit tests @@ -825,7 +825,7 @@ jobs: PIP_RETRIES: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: main @@ -916,7 +916,7 @@ jobs: integration-tests-h100: ${{ steps.main.outputs.integration-tests-h100 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} @@ -981,7 +981,7 @@ jobs: && needs.cicd-parse-integration-tests-h100.result == 'success' steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: main @@ -1015,7 +1015,7 @@ jobs: integration-tests-gb200: ${{ steps.main.outputs.integration-tests-gb200 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} @@ -1082,7 +1082,7 @@ jobs: && vars.ENABLE_GB200_TESTING == 'true' steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.configure.outputs.sha }} - name: main @@ -1124,7 +1124,7 @@ jobs: permissions: write-all steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Get workflow result id: result @@ -1234,7 +1234,7 @@ jobs: && github.repository == 'NVIDIA/Megatron-LM' steps: - name: Generate fake coverage report - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ secrets.PAT }} script: | @@ -1265,13 +1265,13 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download coverage reports of current branch - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: pattern: coverage-${{ matrix.flag }}-* @@ -1292,7 +1292,7 @@ jobs: ls -al - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 with: token: ${{ secrets.CODECOV_TOKEN }} verbose: true @@ -1300,7 +1300,7 @@ jobs: base_sha: ${{ fromJSON(steps.get-pr-info.outputs.pr-info || '{}').base.sha }} - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: coverage-${{ matrix.flag }}-aggregated path: | @@ -1321,7 +1321,7 @@ jobs: echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT - name: Comment on PR with action run URL - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ secrets.PAT }} script: | diff --git a/.github/workflows/claude-complexity-label.yml b/.github/workflows/claude-complexity-label.yml index f44e958ad2d..d9b765fb3dd 100644 --- a/.github/workflows/claude-complexity-label.yml +++ b/.github/workflows/claude-complexity-label.yml @@ -19,13 +19,13 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Run Claude Complexity Analysis id: analyze - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/.github/workflows/claude-copy-to-main.yml b/.github/workflows/claude-copy-to-main.yml index 14d18e2c08a..dc7b56e1529 100644 --- a/.github/workflows/claude-copy-to-main.yml +++ b/.github/workflows/claude-copy-to-main.yml @@ -66,7 +66,7 @@ jobs: COPY_BRANCH: copy-pr-${{ github.event.issue.number }}-to-main steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: git config user.email "svcnvidia-nemo-ci@nvidia.com" - name: Run Claude Copy to Main - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" @@ -148,7 +148,7 @@ jobs: test -s "$RUNNER_TEMP/copy-pr.patch" - name: Upload copy patch - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: copy-pr-${{ github.event.issue.number }}-patch path: ${{ runner.temp }}/copy-pr.patch @@ -170,13 +170,13 @@ jobs: COPY_BRANCH: copy-pr-${{ github.event.issue.number }}-to-main steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 token: ${{ secrets.PAT }} - name: Download copy patch - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: copy-pr-${{ github.event.issue.number }}-patch path: ${{ runner.temp }} diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml index 7b1387f29df..c29bbc2df6c 100644 --- a/.github/workflows/claude_review.yml +++ b/.github/workflows/claude_review.yml @@ -32,7 +32,7 @@ jobs: echo "sha=$(gh pr view $PR_NUMBER --repo $REPO --json headRefOid -q .headRefOid)" | tee -a $GITHUB_OUTPUT - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 1 ref: ${{ steps.get-pr-head-commit.outputs.sha }} @@ -44,7 +44,7 @@ jobs: -f content='eyes' - name: Run Claude Light Review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" @@ -131,7 +131,7 @@ jobs: echo "base_ref=$(echo $PR_DATA | jq -r .baseRefName)" >> $GITHUB_OUTPUT - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 1 ref: ${{ steps.pr-info.outputs.sha }} @@ -146,7 +146,7 @@ jobs: -f content='eyes' - name: Run Claude Strict Review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/.github/workflows/close-inactive-issue-pr.yml b/.github/workflows/close-inactive-issue-pr.yml index 7dcac837ba9..9f9377b259f 100644 --- a/.github/workflows/close-inactive-issue-pr.yml +++ b/.github/workflows/close-inactive-issue-pr.yml @@ -19,4 +19,4 @@ on: jobs: close-issues: if: github.repository == 'NVIDIA/Megatron-LM' - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_close_inactive_issue_pr.yml@v0.44.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_close_inactive_issue_pr.yml@9e07489b8a6bc533c8792099b012c588f4430298 # v0.44.0 diff --git a/.github/workflows/community-bot.yml b/.github/workflows/community-bot.yml index 1a98ece0f85..47a54ec9264 100644 --- a/.github/workflows/community-bot.yml +++ b/.github/workflows/community-bot.yml @@ -21,7 +21,7 @@ on: jobs: community-bot: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@v0.65.10 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@f0dadfd1b2d5c3f48a24ded127abd50afbf8ce11 # v0.65.10 with: community_project_id: ${{ vars.COMMUNITY_PROJECT_ID }} if: github.repository == 'NVIDIA/Megatron-LM' diff --git a/.github/workflows/community-request-assignee.yml b/.github/workflows/community-request-assignee.yml index a344690c0f2..a0581ecfd5e 100644 --- a/.github/workflows/community-request-assignee.yml +++ b/.github/workflows/community-request-assignee.yml @@ -126,13 +126,13 @@ jobs: ISSUE_AUTHOR: ${{ github.event.issue.user.login }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Analyze issue owner with Claude id: claude-analysis - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" @@ -242,7 +242,7 @@ jobs: - name: Checkout repository if: steps.still-unassigned.outputs.skip != 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Install assignment dependencies if: steps.still-unassigned.outputs.skip != 'true' diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml index 484a66fb0e0..c5a20f9c066 100644 --- a/.github/workflows/copyright-check.yml +++ b/.github/workflows/copyright-check.yml @@ -24,7 +24,7 @@ on: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 if: github.repository == 'NVIDIA/Megatron-LM' copyright-check: @@ -34,7 +34,7 @@ jobs: || needs.pre-flight.outputs.is_merge_group == 'true' || needs.pre-flight.outputs.is_deployment_workflow == 'true') && github.repository == 'NVIDIA/Megatron-LM' - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_copyright_check.yml@v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_copyright_check.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 copyright-check-summary: needs: [pre-flight, copyright-check] @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Result env: diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 3505937cd92..1a1ae490bc9 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -29,7 +29,7 @@ on: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 if: github.repository == 'NVIDIA/Megatron-LM' pip-test-pytorch: @@ -49,7 +49,7 @@ jobs: python-version: ["3.12"] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set PATH run: | @@ -65,7 +65,7 @@ jobs: run: bash docker/common/install.sh --environment dev --base-image pytorch --python-version ${{ matrix.python-version }} - name: Checkout check-imports - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: NVIDIA-NeMo/FW-CI-templates ref: v0.63.2 @@ -100,7 +100,7 @@ jobs: python-version: ["3.12"] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set PATH run: | @@ -119,7 +119,7 @@ jobs: # NGC PyTorch 25.05 has a version of triton that is broken on CPU only machines. # - name: Checkout check-imports - # uses: actions/checkout@v6 + # uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 # with: # repository: NVIDIA-NeMo/FW-CI-templates # ref: v0.63.2 @@ -145,7 +145,7 @@ jobs: && github.repository == 'NVIDIA/Megatron-LM' steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Get workflow result id: result diff --git a/.github/workflows/multi-approval-bot.yml b/.github/workflows/multi-approval-bot.yml index 63776ada338..f55b60842da 100644 --- a/.github/workflows/multi-approval-bot.yml +++ b/.github/workflows/multi-approval-bot.yml @@ -9,7 +9,7 @@ on: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 if: github.repository == 'NVIDIA/Megatron-LM' codeowners-approval: @@ -23,10 +23,10 @@ jobs: - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') - uses: nv-gha-runners/get-pr-info@main + uses: nv-gha-runners/get-pr-info@090577647b8ddc4e06e809e264f7881650ecdccf # main - name: Checkout action - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: noamelf/codeowner-multi-approval-action ref: v0.1 @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Result env: diff --git a/.github/workflows/nightly-sync-main-to-dev.yml b/.github/workflows/nightly-sync-main-to-dev.yml index 8f86a7a09fe..8b34eb1de0d 100644 --- a/.github/workflows/nightly-sync-main-to-dev.yml +++ b/.github/workflows/nightly-sync-main-to-dev.yml @@ -33,7 +33,7 @@ jobs: # Re-dispatch scheduled runs as workflow_dispatch via a PAT so the heavy # job runs with a real User-type actor. On `schedule` events GitHub sets # `github.actor` to `github-merge-queue` (no Users-API entry), which - # crashes anthropics/claude-code-action@v1 in `checkHumanActor` with a + # crashes anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 in `checkHumanActor` with a # 404 before `allowed_bots` is ever consulted. Upstream fix PR # https://github.com/anthropics/claude-code-action/pull/1212 is closed # and unmerged; see issue @@ -61,7 +61,7 @@ jobs: GH_TOKEN: ${{ secrets.PAT }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 token: ${{ secrets.PAT }} @@ -192,7 +192,7 @@ jobs: - name: Run Claude Code to merge, fix, and iterate if: steps.check-sync.outputs.skip != 'true' - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 env: ANTHROPIC_BASE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }} CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/.github/workflows/oncall-assign.yml b/.github/workflows/oncall-assign.yml index 6da0776ffc2..dc96f51b350 100644 --- a/.github/workflows/oncall-assign.yml +++ b/.github/workflows/oncall-assign.yml @@ -30,10 +30,10 @@ jobs: if: ${{ !github.event.pull_request.draft }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10' diff --git a/.github/workflows/oncall-rotation.yml b/.github/workflows/oncall-rotation.yml index 0d5f774e441..66b9fd8ddce 100644 --- a/.github/workflows/oncall-rotation.yml +++ b/.github/workflows/oncall-rotation.yml @@ -28,12 +28,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: token: ${{ secrets.PAT }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.10" diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index 6d619a8a1bc..7207f767522 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -73,7 +73,7 @@ on: jobs: build-docs: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_build_docs.yml@v0.67.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_build_docs.yml@3ab507cd035df3ae37cce8808ed3210ff6e7062b # v0.67.0 with: ref: ${{ inputs.build-docs-ref }} @@ -81,7 +81,7 @@ jobs: runs-on: ubuntu-latest needs: [build-docs] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: NVIDIA-NeMo/FW-CI-templates ref: v0.74.0 diff --git a/.github/workflows/release-freeze.yml b/.github/workflows/release-freeze.yml index 8037a8cb4bc..8eccf2caac9 100644 --- a/.github/workflows/release-freeze.yml +++ b/.github/workflows/release-freeze.yml @@ -34,7 +34,7 @@ on: default: true jobs: code-freeze: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_code_freeze.yml@v1.4.2 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_code_freeze.yml@bfdb5e35067fd8cd91ce21fca4eb1072ffd7ab8c # v1.4.2 with: library-name: Megatron-Core python-package: megatron.core diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cd193d819eb..1b3d2af292a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -72,7 +72,7 @@ concurrency: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@v0.94.1 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@211c302d648552cecccec610fe796cc22a091f37 # v0.94.1 if: github.repository == 'NVIDIA/Megatron-LM' && github.event_name != 'workflow_dispatch' bump: @@ -83,7 +83,7 @@ jobs: && !(needs.pre-flight.outputs.docs_only == 'true' || needs.pre-flight.outputs.is_merge_group == 'true' || needs.pre-flight.outputs.is_deployment_workflow == 'true') - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_release_bump.yml@v1.4.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_release_bump.yml@6dfd1b435cca9e3c2640f7b31c4f37e42c6bf796 # v1.4.0 with: release-branch-pattern: "core_[rv][0-9]*.[0-9]*.[0-9]*" release-ref: ${{ inputs.release-ref || github.sha }} @@ -123,7 +123,7 @@ jobs: github.repository == 'NVIDIA/Megatron-LM' && (success() || !failure()) && !cancelled() - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_release_finalize.yml@v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_release_finalize.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 with: release-ref: ${{ inputs.release-ref || github.sha }} release-version: ${{ needs.bump.outputs.release-version }} diff --git a/.github/workflows/request-nvskills-ci.yml b/.github/workflows/request-nvskills-ci.yml index 01c9b5c7569..07c0a846c0e 100644 --- a/.github/workflows/request-nvskills-ci.yml +++ b/.github/workflows/request-nvskills-ci.yml @@ -17,6 +17,6 @@ jobs: permissions: contents: read pull-requests: read - uses: NVIDIA/skills/.github/workflows/team-request.yml@main + uses: NVIDIA/skills/.github/workflows/team-request.yml@2528d5b9d3f125c8bc8cf644ea2134adb4322a51 # main secrets: NVSKILLS_CI_DISPATCH_TOKEN: ${{ secrets.NVSKILLS_CI_DISPATCH_TOKEN }} diff --git a/.github/workflows/review-trigger.yml b/.github/workflows/review-trigger.yml index 7375e605aff..e7aabde4113 100644 --- a/.github/workflows/review-trigger.yml +++ b/.github/workflows/review-trigger.yml @@ -22,7 +22,7 @@ jobs: mkdir -p pr echo "${{ github.event.pull_request.number }}" > pr/number - name: Upload PR number - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: pr-number path: pr/ diff --git a/.github/workflows/sync-team-usergroups.yml b/.github/workflows/sync-team-usergroups.yml index 7f32ac55c57..71e1752077e 100644 --- a/.github/workflows/sync-team-usergroups.yml +++ b/.github/workflows/sync-team-usergroups.yml @@ -24,10 +24,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.10" diff --git a/.github/workflows/trigger-mbridge-tests.yml b/.github/workflows/trigger-mbridge-tests.yml index 023851e966a..e828183b322 100644 --- a/.github/workflows/trigger-mbridge-tests.yml +++ b/.github/workflows/trigger-mbridge-tests.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Trigger MBridge tests - uses: convictional/trigger-workflow-and-wait@v1.6.5 + uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5 with: owner: NVIDIA-NeMo repo: Megatron-Bridge From 54045a510b80a6ff2f9019c5d58e029248afd91b Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 29 Jul 2026 21:19:41 +0200 Subject: [PATCH 141/290] chore(deps): AUT-1080 pin merged NeMo Run cancellation fix (#6110) Signed-off-by: svcnemo-autobot --- pyproject.toml | 2 +- uv.lock | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e19fe870334..8aa583b7464 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,7 +230,7 @@ flash_mla = [ { git = "https://github.com/deepseek-ai/FlashMLA", rev = "nv_dev" }, ] transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" } -nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "ddd40a8f24847f5c919f911d0240bd622653612f" } +nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "e3935393a290aed1822af52139b4b8ee270fed1f" } emerging_optimizers = { git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git", rev = "v0.2.0" } fast-hadamard-transform = { git = "https://github.com/Dao-AILab/fast-hadamard-transform.git", rev = "f134af63deb2df17e1171a9ec1ea4a7d8604d5ca" } mamba-ssm = { git = "https://github.com/state-spaces/mamba.git", rev = "0048fbf2e7b2f214dcbe703ea3dec2b9647595e1" } diff --git a/uv.lock b/uv.lock index 15036ceb4ce..1ea2c0b1eb0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -2357,7 +2357,7 @@ no-pypi-wheels = [ test = [ { name = "coverage" }, { name = "mock" }, - { name = "nemo-run", git = "https://github.com/NVIDIA-NeMo/Run.git?rev=ddd40a8f24847f5c919f911d0240bd622653612f" }, + { name = "nemo-run", git = "https://github.com/NVIDIA-NeMo/Run.git?rev=e3935393a290aed1822af52139b4b8ee270fed1f" }, { name = "nltk" }, { name = "pydantic" }, { name = "pygithub" }, @@ -2709,8 +2709,8 @@ wheels = [ [[package]] name = "nemo-run" -version = "0.11.0+ddd40a8" -source = { git = "https://github.com/NVIDIA-NeMo/Run.git?rev=ddd40a8f24847f5c919f911d0240bd622653612f#ddd40a8f24847f5c919f911d0240bd622653612f" } +version = "0.11.0+e393539" +source = { git = "https://github.com/NVIDIA-NeMo/Run.git?rev=e3935393a290aed1822af52139b4b8ee270fed1f#e3935393a290aed1822af52139b4b8ee270fed1f" } dependencies = [ { name = "catalogue" }, { name = "fabric" }, @@ -5099,15 +5099,15 @@ name = "torch" version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_version < '0'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton" }, - { name = "typing-extensions" }, + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "filelock", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "fsspec", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "jinja2", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "networkx", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "setuptools", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "sympy", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "triton", marker = "sys_platform == 'never'" }, + { name = "typing-extensions", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] [[package]] From efb3963f3cda2c31e8911d0321c41540d4babb6a Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 29 Jul 2026 21:20:10 +0200 Subject: [PATCH 142/290] chore: AUT-1142 bump package versions (#6120) Signed-off-by: svcnemo-autobot --- .../core/distributed/fsdp/src/megatron_fsdp/package_info.py | 2 +- megatron/core/package_info.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/package_info.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/package_info.py index c6f2355eddb..d5f083f665f 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/package_info.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/package_info.py @@ -2,7 +2,7 @@ MAJOR = 0 -MINOR = 6 +MINOR = 7 PATCH = 0 PRE_RELEASE = 'rc0' diff --git a/megatron/core/package_info.py b/megatron/core/package_info.py index 3881c5d4052..7ba1aa93c5f 100644 --- a/megatron/core/package_info.py +++ b/megatron/core/package_info.py @@ -3,7 +3,7 @@ MAJOR = 0 -MINOR = 19 +MINOR = 20 PATCH = 0 PRE_RELEASE = '' From c7d186578c510f00f39571345fd9f7d8a9e18f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Wed, 29 Jul 2026 21:29:41 +0200 Subject: [PATCH 143/290] test(inference): mark prefix caching CUDA graph test flaky (#6131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../inference/engines/test_prefix_caching_cuda_graphs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py index 52e231c1c68..a3dc42f1c71 100644 --- a/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py +++ b/tests/unit_tests/inference/engines/test_prefix_caching_cuda_graphs.py @@ -263,6 +263,7 @@ def _step_and_log(): return finished, step_log + @pytest.mark.flaky_in_dev # Issue #6130 @pytest.mark.parametrize("model_type", ["transformer", "hybrid"]) @pytest.mark.parametrize("batch_structure", ["prefill", "decode", "mixed"]) @torch.inference_mode() From e5988999c810b98df75cbd5a143e895cc82f915b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Buschk=C3=A4mper?= Date: Wed, 29 Jul 2026 18:07:55 +0200 Subject: [PATCH 144/290] Make BertModel lm_head optional. (#5690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Björn Buschkämper Signed-off-by: svcnvidia-nemo-ci Co-authored-by: svcnvidia-nemo-ci Co-authored-by: Dmytro Pykhtar <37850217+dimapihtar@users.noreply.github.com> Co-authored-by: Guihong Li --- megatron/core/models/bert/bert_model.py | 19 ++++++-- tests/unit_tests/models/test_bert_model.py | 56 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/megatron/core/models/bert/bert_model.py b/megatron/core/models/bert/bert_model.py index 3fd1e01f4a1..9260697b600 100644 --- a/megatron/core/models/bert/bert_model.py +++ b/megatron/core/models/bert/bert_model.py @@ -49,6 +49,13 @@ class BertModel(LanguageModule): rotary_percent (float): Percent of rotary dimension to use for rotary position embeddings. Defaults to 1.0 (100%). Ignored unless position_embedding_type is 'rope'. vp_stage (int): Virtual pipeline stage. + apply_lm_head (bool): Whether to transform the encoder's final hidden states with + ``BertLMHead`` (dense + GeLU + LayerNorm) before the vocabulary projection. + Defaults to True. Set to False for architectures whose output projection is + applied directly to the encoder output (e.g. models with their own final norm), + bypassing BERT's dense+GeLU+LayerNorm transform. + output_layer_bias (bool): Whether to include a bias in the vocabulary projection. + Defaults to True for backward compatibility. """ def __init__( @@ -70,6 +77,8 @@ def __init__( return_embeddings=False, vp_stage: Optional[int] = None, pg_collection: Optional[ProcessGroupCollection] = None, + apply_lm_head: bool = True, + output_layer_bias: bool = True, ): super(BertModel, self).__init__(config=config, pg_collection=pg_collection) @@ -92,6 +101,8 @@ def __init__( self.add_binary_head = add_binary_head self.return_embeddings = return_embeddings self.vp_stage = vp_stage + self.apply_lm_head = apply_lm_head + self.output_layer_bias = output_layer_bias # megatron core pipelining currently depends on model type self.model_type = ModelType.encoder_or_decoder @@ -129,7 +140,7 @@ def __init__( # Output if post_process: # TODO: Make sure you are passing in the mpu_vocab_size properly - self.lm_head = BertLMHead(config.hidden_size, config) + self.lm_head = BertLMHead(config.hidden_size, config) if self.apply_lm_head else None self.output_layer = tensor_parallel.ColumnParallelLinear( config.hidden_size, @@ -140,7 +151,7 @@ def __init__( if config.use_mup and not self.share_embeddings_and_output_weights else config.init_method ), - bias=True, + bias=self.output_layer_bias, skip_bias_add=False, gather_output=not self.parallel_output, skip_weight_param_allocation=pre_process and share_embeddings_and_output_weights, @@ -375,7 +386,9 @@ def forward( if self.share_embeddings_and_output_weights: output_weight = self.shared_embedding_or_output_weight() - hidden_states_after_lm_head = self.lm_head(hidden_states=hidden_states) + hidden_states_after_lm_head = ( + self.lm_head(hidden_states=hidden_states) if self.lm_head is not None else hidden_states + ) logits, _ = self.output_layer(hidden_states_after_lm_head, weight=output_weight) binary_logits = None diff --git a/tests/unit_tests/models/test_bert_model.py b/tests/unit_tests/models/test_bert_model.py index fb3385b8723..e878978f64e 100644 --- a/tests/unit_tests/models/test_bert_model.py +++ b/tests/unit_tests/models/test_bert_model.py @@ -11,6 +11,7 @@ get_bert_layer_with_transformer_engine_spec, get_bert_layer_with_transformer_engine_submodules, ) +from megatron.core.models.bert.bert_lm_head import BertLMHead from megatron.core.models.bert.bert_model import BertModel from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer.enums import AttnBackend, AttnMaskType @@ -92,6 +93,61 @@ def test_post_process_forward(self): assert logits[0].shape[1] == sequence_length assert logits[0].shape[2] == self.bert_model.vocab_size + @pytest.mark.internal + def test_apply_lm_head_default_creates_bert_lm_head(self): + assert isinstance(self.bert_model.lm_head, BertLMHead) + + @pytest.mark.internal + def test_output_layer_bias_false_disables_bias(self): + bert_model = BertModel( + config=self.bert_model.config, + num_tokentypes=0, + transformer_layer_spec=get_bert_layer_with_transformer_engine_spec(), + vocab_size=100, + max_sequence_length=self.bert_model.max_sequence_length, + apply_lm_head=False, + output_layer_bias=False, + ) + + assert bert_model.output_layer.bias is None + + @pytest.mark.internal + def test_apply_lm_head_false_bypasses_head(self): + config: TransformerConfig = self.bert_model.config + sequence_length = self.bert_model.max_sequence_length + micro_batch_size = 2 + + bert_model = BertModel( + config=config, + num_tokentypes=0, + transformer_layer_spec=get_bert_layer_with_transformer_engine_spec(), + vocab_size=100, + max_sequence_length=sequence_length, + apply_lm_head=False, + ) + assert bert_model.lm_head is None + bert_model.cuda() + + encoder_output = {} + bert_model.encoder.register_forward_hook( + lambda module, args, output: encoder_output.setdefault('hidden_states', output) + ) + + data = list(range(sequence_length)) + input_ids = torch.tensor(data, dtype=torch.int64).repeat((micro_batch_size, 1)).cuda() + attention_mask = torch.ones((micro_batch_size, sequence_length), dtype=bool).cuda() + + logits = bert_model.forward(input_ids=input_ids, attention_mask=attention_mask) + + assert logits[0].shape[0] == micro_batch_size + assert logits[0].shape[1] == sequence_length + assert logits[0].shape[2] == bert_model.vocab_size + + # With apply_lm_head=False, the output_layer must be applied directly to the + # encoder's hidden states, without BertLMHead's dense+GeLU+LayerNorm transform. + expected_logits, _ = bert_model.output_layer(encoder_output['hidden_states']) + torch.testing.assert_close(logits[0], expected_logits.transpose(0, 1).contiguous()) + @pytest.mark.internal def test_qk_layernorm_submodules_are_none(self): # The TE BERT spec leaves q_layernorm/k_layernorm unset (None) instead of hardcoding From e1b2a2f73052a133d474b4d735303b325fe01fae Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 29 Jul 2026 20:03:39 +0200 Subject: [PATCH 145/290] ci: AUT-1141 skip MBridge dispatch for docs-only changes (#6116) Signed-off-by: svcnemo-autobot --- .github/workflows/cicd-main.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index da392004a4a..0f0b8b900ea 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -438,9 +438,9 @@ jobs: - configure - cicd-wait-in-queue - cicd-parse-downstream-testing - # skip downstream mbridge testing on PR pushes by - # default. They still run for merge_group and nightly (schedule / - # workflow_dispatch) triggers, and PR authors can opt in by adding the + # Skip downstream MBridge testing for docs-only changes and PR pushes by + # default. Non-docs merge_group and nightly (schedule / workflow_dispatch) + # triggers still run it, and PR authors can opt in by adding the # "Run MBridge tests" label — all three cases set # configure.outputs.run_mbridge == 'true'. if: | @@ -450,6 +450,7 @@ jobs: && needs.cicd-parse-downstream-testing.result != 'cancelled' && vars.ENABLE_CICD_MBRIDGE_TESTING == 'true' && needs.configure.outputs.run_mbridge == 'true' + && needs.pre-flight.outputs.docs_only == 'false' && ( success() || needs.pre-flight.outputs.is_ci_workload == 'true' From 542ff5368611682f3f8d5487595647c13e02801b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 30 Jul 2026 00:25:39 +0000 Subject: [PATCH 146/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index d5be6ae7b65..f31faa7c806 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From 6183f9dbec2509fe00c5f0c27cb930e76520edb6 Mon Sep 17 00:00:00 2001 From: Philip Monk Date: Wed, 29 Jul 2026 12:19:39 -0700 Subject: [PATCH 147/290] Fix gradient counting for muon+expert biases (#6099) Signed-off-by: Philip Monk --- .../core/optimizer/layer_wise_optimizer.py | 9 ++ tests/unit_tests/training/test_param_norm.py | 92 ++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/megatron/core/optimizer/layer_wise_optimizer.py b/megatron/core/optimizer/layer_wise_optimizer.py index a132e1be3a1..376f9a1f1c0 100644 --- a/megatron/core/optimizer/layer_wise_optimizer.py +++ b/megatron/core/optimizer/layer_wise_optimizer.py @@ -514,6 +514,13 @@ def __init__( opt, config, None, init_state_fn_list[i] if init_state_fn_list else None ) + self.tp_group = self.pg_collection.tp + self.expert_tp_group = getattr(self.pg_collection, 'expt_tp', self.tp_group) + for optimizer in optimizers: + # Child optimizers perform TP duplicate filtering when collecting gradients. + optimizer.tp_group = self.tp_group + optimizer.expert_tp_group = self.expert_tp_group + super().__init__(optimizers) # Assign self.model_chunks AFTER super().__init__: ChainedOptimizer.__init__ @@ -857,6 +864,8 @@ def count_zeros(self): params, grad_stats_parallel_group=None, use_decoupled_grad=self.config.use_precision_aware_optimizer_no_fp8_or_ds_fp8, + tp_group=self.tp_group, + expert_tp_group=self.expert_tp_group, ) def start_param_sync_for_bucket_group_subset(self) -> None: diff --git a/tests/unit_tests/training/test_param_norm.py b/tests/unit_tests/training/test_param_norm.py index c1c14bd0134..27193ebf827 100644 --- a/tests/unit_tests/training/test_param_norm.py +++ b/tests/unit_tests/training/test_param_norm.py @@ -20,6 +20,7 @@ def _build_tiny_moe_gpt( expert_parallel_size: int, expert_tensor_parallel_size: int, bf16: bool = False, + add_bias_linear: bool = False, ) -> GPTModel: config = TransformerConfig( num_layers=1, @@ -28,7 +29,8 @@ def _build_tiny_moe_gpt( ffn_hidden_size=16, num_moe_experts=2, moe_ffn_hidden_size=16, - moe_shared_expert_intermediate_size=16, + # Shared experts do not support linear biases. + moe_shared_expert_intermediate_size=None if add_bias_linear else 16, moe_router_topk=1, moe_router_pre_softmax=True, tensor_model_parallel_size=tensor_parallel_size, @@ -36,7 +38,7 @@ def _build_tiny_moe_gpt( expert_tensor_parallel_size=expert_tensor_parallel_size, sequence_parallel=tensor_parallel_size > 1, use_cpu_initialization=True, - add_bias_linear=False, + add_bias_linear=add_bias_linear, normalization="RMSNorm", moe_grouped_gemm=True, bf16=bf16, @@ -51,7 +53,8 @@ def _build_tiny_moe_gpt( max_sequence_length=8, position_embedding_type="rope", ) - assert any(".shared_experts." in name for name, _ in model.named_parameters()) + if not add_bias_linear: + assert any(".shared_experts." in name for name, _ in model.named_parameters()) return model.cuda() @@ -209,3 +212,86 @@ def test_moe_gradient_stats_and_clipping_count_each_logical_gradient_once( assert grads_checked > 0 finally: Utils.destroy_model_parallel() + + +def test_layer_wise_muon_grad_norm_uses_expert_tp_group_for_row_parallel_bias(): + """LayerWise Muon must deduplicate replicated expert FC2 bias grads over ETP. + + With TP=2, EP=2, and ETP=1, every rank is ETP rank zero. The two EP ranks own + distinct row-parallel expert biases, so both gradients must contribute to the global + norm. Falling back to the regular TP rank drops the expert on TP rank one and + undercounts the squared norm by a factor of two. + """ + from megatron.core.optimizer.layer_wise_optimizer import LayerWiseDistributedOptimizer + from megatron.core.process_groups_config import ProcessGroupCollection + + if Utils.world_size < 4 or Utils.world_size % 4 != 0: + pytest.skip("test requires a world size divisible by four") + + tensor_parallel_size = 2 + expert_parallel_size = 2 + expert_tensor_parallel_size = 1 + + try: + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_parallel_size, + expert_model_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + ) + model = _build_tiny_moe_gpt( + tensor_parallel_size=tensor_parallel_size, + expert_parallel_size=expert_parallel_size, + expert_tensor_parallel_size=expert_tensor_parallel_size, + bf16=True, + add_bias_linear=True, + ) + + expert_fc2_biases = [ + param + for name, param in model.named_parameters() + if ".experts." in name and ".linear_fc2.bias" in name + ] + assert len(expert_fc2_biases) == model.config.num_moe_experts // expert_parallel_size + for parameter in expert_fc2_biases: + assert parameter.ndim == 1 + assert parameter.allreduce is False + assert parameter.tensor_model_parallel is False + + model = DistributedDataParallel( + model.config, DistributedDataParallelConfig(use_distributed_optimizer=False), model + ) + pg_collection = ProcessGroupCollection.use_mpu_process_groups() + optimizer = get_megatron_optimizer( + OptimizerConfig( + optimizer="muon", + lr=0.0, + weight_decay=0.0, + bf16=True, + use_distributed_optimizer=False, + use_layer_wise_distributed_optimizer=True, + muon_tp_mode="duplicated", + ), + [model], + use_gloo_process_groups=False, + pg_collection=pg_collection, + ) + + assert isinstance(optimizer, LayerWiseDistributedOptimizer) + assert pg_collection.tp.size() == tensor_parallel_size + assert pg_collection.expt_tp.size() == expert_tensor_parallel_size + + for parameter in model.parameters(): + parameter.main_grad.zero_() + for parameter in expert_fc2_biases: + parameter.main_grad.fill_(1.0) + assert optimizer.prepare_grads() is False + + actual_norm = optimizer.get_grad_norm() + actual_norm_value = ( + actual_norm.item() if isinstance(actual_norm, torch.Tensor) else actual_norm + ) + expected_norm = math.sqrt(model.config.num_moe_experts * model.config.hidden_size) + + assert actual_norm_value == pytest.approx(expected_norm) + finally: + Utils.destroy_model_parallel() From eab6551beebce75776f6b90cfb5ab58434985547 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 29 Jul 2026 21:37:08 +0200 Subject: [PATCH 148/290] test(inference): MCORE-561 trust FP8 metadata in DeepSeek checkpoints (#6049) Signed-off-by: svcnemo-autobot --- .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml index 3bd326a56e1..2b9b3e392e4 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml index 80e2a37c250..83315aca7a8 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 diff --git a/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml index 03895d97ee9..9662fe840d9 100644 --- a/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml @@ -1,5 +1,6 @@ ENV_VARS: CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_UNSAFE_PICKLE_EXTRA_STATE: 1 NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 NCCL_ALGO: Ring CUBLAS_WORKSPACE_CONFIG: :4096:8 From 3f885092ebff43e90e12ceb43e8c04f296e11ed4 Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 29 Jul 2026 12:53:52 -0700 Subject: [PATCH 149/290] ci: fix test notifications for weekly/release tests (#6055) Signed-off-by: Ajay Balasa --- .gitlab/stages/04.functional-tests.yml | 3 + .../python_scripts/launch_jet_workload.py | 58 ---------------- tests/test_utils/test_ci_triage.py | 69 ++++--------------- 3 files changed, 16 insertions(+), 114 deletions(-) diff --git a/.gitlab/stages/04.functional-tests.yml b/.gitlab/stages/04.functional-tests.yml index 7a7cc5363bd..d650e1d677d 100644 --- a/.gitlab/stages/04.functional-tests.yml +++ b/.gitlab/stages/04.functional-tests.yml @@ -428,6 +428,9 @@ functional:run_nemo: allow_failure: true - when: never +# Sole root Slack notification for functional MR, nightly, weekly, and release +# pipelines. Detailed triage and Linear updates reply in the thread recorded by +# slack_notification.json; individual workload runners must not notify directly. functional:x_notify: extends: [.functional_tests_rules] image: ${UTILITY_IMAGE}:${CI_PIPELINE_ID} diff --git a/tests/test_utils/python_scripts/launch_jet_workload.py b/tests/test_utils/python_scripts/launch_jet_workload.py index a6e5f330259..f09eac7ba10 100644 --- a/tests/test_utils/python_scripts/launch_jet_workload.py +++ b/tests/test_utils/python_scripts/launch_jet_workload.py @@ -6,7 +6,6 @@ import pathlib import re import signal -import subprocess import sys import time import uuid @@ -33,44 +32,6 @@ logger = logging.getLogger(__name__) -def send_slack_alert(test_case: str, context: str, n_iteration: int, n_attempts: int) -> None: - """Send a Slack alert via notify.py for the current release pipeline state. - - Args: - test_case: Name of the release test case being run. - context: Human-readable context string appended to the pipeline context label. - n_iteration: Current training iteration (pipeline relaunch count). - n_attempts: Current attempt count within this iteration. - """ - pipeline_id = os.getenv("PARENT_PIPELINE_ID") - pipeline_created_at = os.getenv("CI_PIPELINE_CREATED_AT", "") - - if not pipeline_id or not pipeline_created_at: - logger.info("Missing PARENT_PIPELINE_ID or CI_PIPELINE_CREATED_AT, skipping Slack alert.") - return - - pipeline_context = f"{test_case} | iteration={n_iteration} | attempt={n_attempts} | {context}" - - try: - subprocess.run( - [ - sys.executable, - str(BASE_PATH / "notify.py"), - "--pipeline-id", - pipeline_id, - "--check-for", - "functional-tests", - "--pipeline-context", - pipeline_context, - "--pipeline-created-at", - pipeline_created_at, - ], - check=False, - ) - except Exception as e: - logger.warning("Failed to send Slack alert: %s", e) - - def register_pipeline_terminator(pipeline: jetclient.JETPipeline): def sigterm_handler(_signo, _stack_frame): print(f"Trying to terminate pipeline {pipeline.jet_id}") @@ -646,34 +607,15 @@ def main( or "exiting program at iteration" in concat_allranks_logs ): logger.info("Release training finished") - send_slack_alert( - test_case=test_case, - context="training finished", - n_iteration=n_iteration, - n_attempts=n_attempts, - ) sys.exit(int(not success)) # invert for exit 0 if not success or parse_failed_job(logs=mainrank_log): logger.error("Release pipeline finished with status %s, retrying.", status.name) - send_slack_alert( - test_case=test_case, - context=f"pipeline finished with status {status.name}, retrying", - n_iteration=n_iteration, - n_attempts=n_attempts, - ) n_attempts += 1 continue n_iteration += 1 - if test_type == "release": - send_slack_alert( - test_case=test_case, - context="max attempts exhausted", - n_iteration=n_iteration, - n_attempts=n_attempts, - ) telemetrics_and_exit( success=False, test_case=test_case, diff --git a/tests/test_utils/test_ci_triage.py b/tests/test_utils/test_ci_triage.py index 95469b3ccd9..b6494c66ea0 100644 --- a/tests/test_utils/test_ci_triage.py +++ b/tests/test_utils/test_ci_triage.py @@ -157,60 +157,16 @@ def test_all_generated_test_types_enable_error_extraction(): assert functional.count('"--enable-error-extraction"') >= 2 -@pytest.mark.parametrize(("test_type", "expected_alerts"), [("regular", 0), ("release", 1)]) -def test_retry_exhaustion_per_test_alert_is_release_only( - monkeypatch, tmp_path, test_type, expected_alerts -): - pytest.importorskip("jetclient") - from tests.test_utils.python_scripts import launch_jet_workload - - base_path = tmp_path / "tests" / "test_utils" / "python_scripts" - model_config = ( - tmp_path - / "tests" - / "functional_tests" - / "test_cases" - / "model" - / "case" - / "model_config.yaml" - ) - base_path.mkdir(parents=True) - model_config.parent.mkdir(parents=True) - model_config.write_text(f"TEST_TYPE: {test_type}\n") - - job = Mock() - job.name = "basic-job" - pipeline = Mock() - pipeline.get_jobs.return_value = [job] - launch = Mock(return_value=pipeline) - alert = Mock() - telemetry = Mock() - monkeypatch.setattr(launch_jet_workload, "BASE_PATH", base_path) - monkeypatch.setattr(launch_jet_workload, "launch_and_wait_for_completion", launch) - monkeypatch.setattr(launch_jet_workload, "download_job_assets", Mock(return_value=None)) - monkeypatch.setattr(launch_jet_workload, "send_slack_alert", alert) - monkeypatch.setattr(launch_jet_workload, "telemetrics_and_exit", telemetry) - - launch_jet_workload.main.callback( - model="model", - test_case="case", - environment="dev", - n_repeat=1, - time_limit=1, - scope="mr", - account="mcore", - partition=None, - cluster="cluster", - platform="platform", - container_tag="tag", - record_checkpoints="false", - run_name="run", - wandb_experiment="experiment", - ) +def test_functional_notifications_are_parent_aggregate_only(): + launcher = Path("tests/test_utils/python_scripts/launch_jet_workload.py").read_text() + functional = yaml.safe_load(Path(".gitlab/stages/04.functional-tests.yml").read_text()) + notify_script = "\n".join(functional["functional:x_notify"]["script"]) - assert launch.call_count == 9 - assert alert.call_count == expected_alerts - telemetry.assert_called_once() + assert "send_slack_alert" not in launcher + assert "notify.py" not in launcher + assert notify_script.count("python tests/test_utils/python_scripts/notify.py") == 1 + assert "--check-for functional-tests" in notify_script + assert "--pipeline-context $CONTEXT" in notify_script def test_get_pipeline_jobs_uses_triage_collector(monkeypatch, notify_module): @@ -452,7 +408,8 @@ def test_slack_followup_uses_upstream_detailed_and_execution_summaries(): assert 'if [[ -z "${THREAD_TIMESTAMP}" ]]' in script -def test_notification_delegates_to_triage_package(monkeypatch, notify_module): +@pytest.mark.parametrize("pipeline_context", ["mr", "nightly", "weekly", "release"]) +def test_notification_delegates_to_triage_package(monkeypatch, notify_module, pipeline_context): notify = notify_module project = Mock() pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [{"status": "failed"}])] @@ -475,7 +432,7 @@ def test_notification_delegates_to_triage_package(monkeypatch, notify_module): "--check-for", "functional-tests", "--pipeline-context", - "mr", + pipeline_context, "--pipeline-created-at", "2026-07-12T00:00:00Z", ], @@ -484,7 +441,7 @@ def test_notification_delegates_to_triage_package(monkeypatch, notify_module): assert result.exit_code == 0, result.output sender.assert_called_once_with( "megatron-lm", - "mr", + pipeline_context, pipeline_jobs, None, webhook_url="https://slack.invalid/webhook", From 8f1c99c877b6b4b010a7f49ecd6c5c29b43bf92b Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene Date: Wed, 29 Jul 2026 16:27:23 -0500 Subject: [PATCH 150/290] Allow the MInf sync API to run serve() (#6123) Signed-off-by: Teodor-Dumitru Ene --- megatron/core/inference/apis/_llm_base.py | 57 ++++++++++++++++++- megatron/core/inference/apis/async_llm.py | 14 +---- megatron/core/inference/apis/llm.py | 56 ++++++++++++++++-- .../inference/high_level_api/test_apis.py | 49 +++++++++++++++- .../high_level_api/test_event_loop_manager.py | 21 +++++++ 5 files changed, 177 insertions(+), 20 deletions(-) diff --git a/megatron/core/inference/apis/_llm_base.py b/megatron/core/inference/apis/_llm_base.py index 93b1bda30c8..acc2ca336e3 100644 --- a/megatron/core/inference/apis/_llm_base.py +++ b/megatron/core/inference/apis/_llm_base.py @@ -6,7 +6,8 @@ ``MegatronAsyncLLM``: ``_EventLoopManager``, ``_CoordinatorRuntime``, and ``_MegatronLLMBase``. The public sync/async wrappers live on the subclasses; this base only exposes shared engine state, runtime spawn, validation -helpers, and the private ``__impl`` coroutines. +helpers, the public sync bridge (``submit``/``run_sync``), and the private +``__impl`` coroutines. """ import asyncio @@ -296,6 +297,7 @@ def __init__( self._loop_manager: "Optional[_EventLoopManager]" = None self._coord_runtime: "Optional[_CoordinatorRuntime]" = None self._shutdown_called: bool = False + self._serve_started: bool = False if use_coordinator: loop_manager = _EventLoopManager() @@ -342,8 +344,61 @@ def controller(self) -> "TextGenerationController": """The underlying :class:`TextGenerationController`.""" return self._controller + # ---- sync bridge (public) ---- + + def submit(self, coro: Coroutine) -> "concurrent.futures.Future": + """Schedule ``coro`` on the background runtime loop; return its future. + + The returned :class:`concurrent.futures.Future` can be consumed from + any context: block with ``.result()`` from sync code, or wrap with + ``asyncio.wrap_future(...)`` and ``await`` it from a coroutine. + Callable from any thread, including threads whose own event loop is + running (e.g. an embedder's dispatch loop) -- the coroutine executes + on the runtime loop either way. + + Raises: + RuntimeError: in direct mode (``use_coordinator=False``), which + has no background runtime loop. + """ + self._assert_coordinator() + assert self._loop_manager is not None + return self._loop_manager.submit(coro) + + def run_sync(self, coro: Coroutine): + """Schedule ``coro`` on the background runtime loop and block on it. + + Safe to call from any thread except the runtime loop itself (that + would deadlock and raises instead). Calling from a thread whose own + event loop is running is allowed: the caller's loop stalls until the + result returns, while ``coro`` runs on the runtime loop. + + Raises: + RuntimeError: in direct mode (``use_coordinator=False``), or when + called from a coroutine running on the runtime loop itself. + """ + self._assert_coordinator() + assert self._loop_manager is not None + return self._loop_manager.run_sync(coro) + # ---- internal helpers ---- + def _stop_frontend_if_started(self) -> None: + """Stop the HTTP frontend if ``serve()`` started one on this rank. + + Called first by both facades' ``shutdown()`` so no new requests + arrive while the coordinator is torn down. Invariant: + ``_serve_started`` can only be True when ``use_coordinator=True`` + because ``serve()`` raises otherwise. + """ + if not self._serve_started: + return + from megatron.core.inference.text_generation_server.dynamic_text_gen_server.text_generation_server import ( # pylint: disable=line-too-long + stop_text_gen_server, + ) + + stop_text_gen_server() + self._serve_started = False + def _assert_primary(self) -> None: if not self._is_primary_rank: raise RuntimeError( diff --git a/megatron/core/inference/apis/async_llm.py b/megatron/core/inference/apis/async_llm.py index a64fd07a78a..2c6b20693f3 100644 --- a/megatron/core/inference/apis/async_llm.py +++ b/megatron/core/inference/apis/async_llm.py @@ -61,8 +61,6 @@ def __init__( coordinator_host=coordinator_host, coordinator_port=coordinator_port, ) - # Set in serve() when this rank starts the HTTP frontend; consulted by shutdown(). - self._serve_started: bool = False async def generate( self, @@ -147,17 +145,7 @@ async def shutdown(self) -> None: return self._shutdown_called = True - # If we started an HTTP frontend, stop it first so no new requests - # arrive while we tear down the coordinator. Invariant: - # ``_serve_started`` can only be True when ``use_coordinator=True`` - # because ``serve()`` raises otherwise. - if self._serve_started: - from megatron.core.inference.text_generation_server.dynamic_text_gen_server.text_generation_server import ( # pylint: disable=line-too-long - stop_text_gen_server, - ) - - stop_text_gen_server() - self._serve_started = False + self._stop_frontend_if_started() if not self._use_coordinator: return diff --git a/megatron/core/inference/apis/llm.py b/megatron/core/inference/apis/llm.py index 40222948987..6bbdce570d8 100644 --- a/megatron/core/inference/apis/llm.py +++ b/megatron/core/inference/apis/llm.py @@ -5,6 +5,7 @@ from typing import List, Optional, Union from megatron.core.inference.apis._llm_base import _MegatronLLMBase +from megatron.core.inference.apis.serve_config import ServeConfig from megatron.core.inference.config import InferenceConfig from megatron.core.inference.inference_request import DynamicInferenceRequest from megatron.core.inference.sampling_params import SamplingParams @@ -24,12 +25,9 @@ class MegatronLLM(_MegatronLLMBase): - Sync lifecycle controls: :meth:`pause` / :meth:`unpause` / :meth:`suspend` / :meth:`resume` / :meth:`shutdown` / :meth:`wait_for_shutdown`. + - :meth:`serve` for OpenAI-compatible HTTP serving on the primary rank. - Context-manager protocol: ``with MegatronLLM(...) as llm:``; exit calls :meth:`shutdown`. - - Note: - ``serve()`` (online HTTP serving) is async-only by design; use - :class:`MegatronAsyncLLM` for serving. """ def __init__( @@ -132,6 +130,7 @@ def shutdown(self) -> None: if self._shutdown_called: return self._shutdown_called = True + self._stop_frontend_if_started() if not self._use_coordinator: return # direct mode: nothing to tear down assert self._loop_manager is not None @@ -139,6 +138,55 @@ def shutdown(self) -> None: # Sync caller already on its own thread; no need for to_thread. self._loop_manager.stop() + def serve(self, serve_config: ServeConfig, *, blocking: bool = True) -> None: + """Start the OpenAI-compatible HTTP frontend. + + Coordinator mode only. The HTTP frontend runs only on the primary + rank (global rank 0); other ranks no-op the HTTP setup but still + respect ``blocking`` (so all ranks return together). + + With ``blocking=True`` (default), this blocks the calling thread until + the engine loop terminates via :meth:`shutdown` -- suitable for + standalone serving scripts. With ``blocking=False``, this returns once + the HTTP frontend is up (primary) or immediately (workers); the engine + loop continues in the background runtime, and the user can call + :meth:`generate` / :meth:`shutdown` afterward. + + Raises: + ValueError: if ``use_coordinator=False`` (HTTP serving requires + the coordinator path). + """ + if not self._use_coordinator: + raise ValueError("MegatronLLM.serve() requires use_coordinator=True") + + if self._is_primary_rank: + # Lazy import: keep the module importable in environments where + # the HTTP server backend (Quart/Hypercorn) isn't installed. + import torch.distributed as dist + + from megatron.core.inference.text_generation_server.dynamic_text_gen_server.text_generation_server import ( # pylint: disable=line-too-long + start_text_gen_server, + ) + + assert self._coord_runtime is not None + start_text_gen_server( + coordinator_addr=self._coord_runtime.coord_addr, + tokenizer=self._controller.tokenizer, + rank=dist.get_rank(), + server_port=serve_config.port, + parsers=serve_config.parsers, + verbose=serve_config.verbose, + num_replicas=serve_config.frontend_replicas, + hostname=serve_config.host, + ) + self._serve_started = True + + if blocking: + # Block until the engine loop terminates (shutdown was invoked + # somewhere in this process; for serve(blocking=True) typically by + # SIGINT or out-of-band orchestration). + self.wait_for_shutdown() + def wait_for_shutdown(self) -> None: """Block until the engine loop terminates. Direct mode no-op.""" if not self._use_coordinator: diff --git a/tests/unit_tests/inference/high_level_api/test_apis.py b/tests/unit_tests/inference/high_level_api/test_apis.py index c9fbad3de2c..ff6721a4f0d 100644 --- a/tests/unit_tests/inference/high_level_api/test_apis.py +++ b/tests/unit_tests/inference/high_level_api/test_apis.py @@ -13,6 +13,7 @@ from megatron.core.inference.apis._llm_base import _MegatronLLMBase from megatron.core.inference.apis.async_llm import MegatronAsyncLLM from megatron.core.inference.apis.llm import MegatronLLM +from megatron.core.inference.apis.serve_config import ServeConfig @pytest.fixture @@ -50,8 +51,7 @@ def _make_worker_instance(cls): obj._loop_manager = None obj._coord_runtime = None obj._shutdown_called = False - if cls is MegatronAsyncLLM: - obj._serve_started = False + obj._serve_started = False return obj @@ -130,6 +130,51 @@ async def test_async_generate_raises_on_worker_rank(self): with pytest.raises(RuntimeError, match="primary rank"): await llm.generate("hello") + def test_bridge_and_serve_raise_in_direct_mode(self, mock_pipeline, fake_model_and_tokenizer): + model, tok = fake_model_and_tokenizer + llm = MegatronLLM(model=model, tokenizer=tok, use_coordinator=False) + with pytest.raises(ValueError, match="use_coordinator=True"): + llm.serve(ServeConfig()) + + async def coro(): + return 1 # pragma: no cover + + for method in (llm.run_sync, llm.submit): + c = coro() + with pytest.raises(RuntimeError, match="use_coordinator=True"): + method(c) + c.close() + + def test_sync_serve_nonblocking_worker_rank_noops(self): + """Worker ranks skip the HTTP setup; ``blocking=False`` returns + immediately without touching the runtime.""" + llm = _make_worker_instance(MegatronLLM) + llm.serve(ServeConfig(), blocking=False) + assert llm._serve_started is False + + def test_sync_serve_primary_rank_starts_frontend(self, monkeypatch): + """Primary rank starts the HTTP frontend against the coordinator + address and records ``_serve_started`` for shutdown teardown.""" + tgs = pytest.importorskip( + "megatron.core.inference.text_generation_server.dynamic_text_gen_server" + ".text_generation_server" + ) + import torch.distributed as dist + + llm = _make_worker_instance(MegatronLLM) + llm._is_primary_rank = True + llm._coord_runtime = MagicMock() + llm._coord_runtime.coord_addr = "tcp://coord:5555" + + started = {} + monkeypatch.setattr(dist, "get_rank", lambda: 0) + monkeypatch.setattr(tgs, "start_text_gen_server", lambda **kw: started.update(kw)) + + llm.serve(ServeConfig(port=1234), blocking=False) + assert llm._serve_started is True + assert started["coordinator_addr"] == "tcp://coord:5555" + assert started["server_port"] == 1234 + class TestNormalizePrompts: """Input-shape normalization (str / list[int] / list[str] / list[list[int]]).""" diff --git a/tests/unit_tests/inference/high_level_api/test_event_loop_manager.py b/tests/unit_tests/inference/high_level_api/test_event_loop_manager.py index 9d647dddad0..2b903f5c082 100644 --- a/tests/unit_tests/inference/high_level_api/test_event_loop_manager.py +++ b/tests/unit_tests/inference/high_level_api/test_event_loop_manager.py @@ -1,5 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import asyncio import threading import pytest @@ -76,3 +77,23 @@ async def deadlock_attempt(): mgr.submit(deadlock_attempt()).result() finally: mgr.stop() + + def test_run_sync_from_foreign_running_loop_returns_result(self): + """A thread whose own event loop is running (e.g. an embedder's + dispatch loop) may call ``run_sync``: the caller's loop stalls while + the coroutine executes on the background loop and the result is + handed back.""" + mgr = _EventLoopManager() + mgr.start() + try: + + async def inner(): + return 42 + + async def foreign_caller(): + # Runs on a fresh caller-owned loop, not mgr._loop. + return mgr.run_sync(inner()) + + assert asyncio.run(foreign_caller()) == 42 + finally: + mgr.stop() From 77c87725e71c9f5022ac289071b16941876ca858 Mon Sep 17 00:00:00 2001 From: nvcsathe Date: Wed, 29 Jul 2026 15:29:01 -0700 Subject: [PATCH 151/290] Add disaggregated KV transfer backends (#5861) Signed-off-by: Chaitra Sathe Signed-off-by: Will Dykas Co-authored-by: Will Dykas --- .../inference/disaggregation/mamba_reshard.py | 222 ------- .../inference/disaggregation/ssm_reshard.py | 205 ++++++ .../transfer_backends/__init__.py | 7 + .../disaggregation/transfer_backends/base.py | 212 ++++++ .../disaggregation/transfer_backends/nccl.py | 293 ++++++++ .../disaggregation/transfer_backends/nixl.py | 625 ++++++++++++++++++ .../core/inference/disaggregation/utils.py | 37 +- .../inference/test_kv_transfer_backends.py | 197 ++++++ .../inference/test_nccl_transfer_backend.py | 138 ++++ ...t_mamba_reshard.py => test_ssm_reshard.py} | 88 +-- 10 files changed, 1757 insertions(+), 267 deletions(-) delete mode 100644 megatron/core/inference/disaggregation/mamba_reshard.py create mode 100644 megatron/core/inference/disaggregation/ssm_reshard.py create mode 100644 megatron/core/inference/disaggregation/transfer_backends/__init__.py create mode 100644 megatron/core/inference/disaggregation/transfer_backends/base.py create mode 100644 megatron/core/inference/disaggregation/transfer_backends/nccl.py create mode 100644 megatron/core/inference/disaggregation/transfer_backends/nixl.py create mode 100644 tests/unit_tests/inference/test_kv_transfer_backends.py create mode 100644 tests/unit_tests/inference/test_nccl_transfer_backend.py rename tests/unit_tests/inference/{test_mamba_reshard.py => test_ssm_reshard.py} (66%) diff --git a/megatron/core/inference/disaggregation/mamba_reshard.py b/megatron/core/inference/disaggregation/mamba_reshard.py deleted file mode 100644 index 8a23735154a..00000000000 --- a/megatron/core/inference/disaggregation/mamba_reshard.py +++ /dev/null @@ -1,222 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. - -"""Heterogeneous TP/PP reshard of Mamba conv/ssm state between prefill and -decode shard layouts (the Mamba analog of the attention KV reshard).""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import List, Tuple - -from megatron.core.inference.disaggregation.utils import intersect - -# Channel bands of a Mamba layer's state, in the order the conv state -# concatenates them on its channel axis (x, B, C); ssm is the head axis. -# (name, lives_in_conv). conv bands share one tensor; ssm is its own tensor. -_CONV_BANDS = ("x", "B", "C") - - -@dataclass(frozen=True) -class MambaStateDims: - """The model's (global, unsharded) Mamba structural dims. - - These belong to the MambaMixer / model config -- carried as one unit (rather - than loose constants spread across the layout) so there's a single source - and they can't drift apart. The producer should read them straight from the - model config (e.g. ``ngroups = config.mamba_num_groups``) rather than - reverse-deriving from tensor shapes. TP shards ``nheads``/``ngroups``; the - rest are unsharded. - """ - - nheads: int - headdim: int - d_state: int - ngroups: int - d_conv: int - - -@dataclass(frozen=True) -class MambaShardLayout: - """One rank's Mamba-state ownership: which global layers + TP rank, plus the - model's structural dims (:class:`MambaStateDims`). Per-rank locals follow by - dividing by ``tp_size``.""" - - global_rank: int - tp_size: int - tp_rank: int - layer_start: int # global Mamba-layer index of this rank's first layer - num_layers: int # Mamba layers held locally (this PP stage) - dims: MambaStateDims - - def __post_init__(self) -> None: - # Wire reconstruction (MambaShardLayout(**dict)) hands ``dims`` as a - # plain dict; coerce it back to MambaStateDims. - if isinstance(self.dims, dict): - object.__setattr__(self, "dims", MambaStateDims(**self.dims)) - # TP shards heads and groups; both must divide evenly or the local - # conv/ssm band sizes truncate to the wrong (or zero) width silently. - if self.dims.nheads % self.tp_size != 0: - raise ValueError(f"nheads={self.dims.nheads} not divisible by tp_size={self.tp_size}") - if self.dims.ngroups % self.tp_size != 0: - raise ValueError(f"ngroups={self.dims.ngroups} not divisible by tp_size={self.tp_size}") - - # Convenience proxies onto the dims so callers read ``layout.headdim`` etc. - @property - def nheads(self) -> int: - """Global (unsharded) number of Mamba heads.""" - return self.dims.nheads - - @property - def headdim(self) -> int: - """Dimension of each Mamba head.""" - return self.dims.headdim - - @property - def d_state(self) -> int: - """SSM state size per head.""" - return self.dims.d_state - - @property - def ngroups(self) -> int: - """Global (unsharded) number of B/C groups.""" - return self.dims.ngroups - - @property - def d_conv(self) -> int: - """Convolution kernel width.""" - return self.dims.d_conv - - def mamba_shard_key(self) -> Tuple[int, int]: - """The Mamba shard this rank holds: ``(tp_rank, layer_start)``. Ranks - sharing a key hold identical state (e.g. EP/DP replicas of it).""" - return (self.tp_rank, self.layer_start) - - @property - def d_inner(self) -> int: - """Global inner dimension (nheads * headdim).""" - return self.dims.nheads * self.dims.headdim - - @property - def nheads_local(self) -> int: - """Number of Mamba heads held by this TP rank.""" - return self.dims.nheads // self.tp_size - - @property - def d_inner_local(self) -> int: - """Local inner dimension for this TP rank.""" - return self.d_inner // self.tp_size - - @property - def ngroups_local(self) -> int: - """Number of B/C groups held by this TP rank.""" - return self.dims.ngroups // self.tp_size - - @property - def conv_dim_local(self) -> int: - """Total local conv channel width (x + B + C bands).""" - return self.d_inner_local + 2 * self.ngroups_local * self.dims.d_state - - def layer_range(self) -> Tuple[int, int]: - """Global Mamba-layer range ``[lo, hi)`` owned by this rank.""" - return (self.layer_start, self.layer_start + self.num_layers) - - def _band(self, name: str) -> Tuple[int, int, int]: - """``(global_total, local_size, conv_local_offset)`` for a band. - - ``conv_local_offset`` is the band's start on the local conv channel - axis; for the ``ssm`` (head) band it is the start on the local head - axis (always 0, heads are the whole tensor).""" - if name == "x": - g = self.d_inner - return g, self.d_inner_local, 0 - if name == "B": - g = self.dims.ngroups * self.dims.d_state - return g, self.ngroups_local * self.dims.d_state, self.d_inner_local - if name == "C": - g = self.dims.ngroups * self.dims.d_state - return ( - g, - self.ngroups_local * self.dims.d_state, - self.d_inner_local + self.ngroups_local * self.dims.d_state, - ) - if name == "ssm": - return self.dims.nheads, self.nheads_local, 0 - raise KeyError(name) - - -@dataclass(frozen=True) -class MambaReshardTransfer: - """One sub-block move for the reshard. - - ``band`` is ``"x"``/``"B"``/``"C"`` (conv channel axis) or ``"ssm"`` (head - axis). ``src_layer``/``dst_layer`` are local layer indices on each side; - ``*_lo``/``*_hi`` are the local channel/head slice bounds. - """ - - src_rank: int - dst_rank: int - band: str - global_layer: int - src_layer: int - dst_layer: int - src_lo: int - src_hi: int - dst_lo: int - dst_hi: int - - @property - def is_conv(self) -> bool: - """True if this transfer targets the conv state; False for ssm.""" - return self.band in _CONV_BANDS - - -def plan_mamba_reshard( - src_layouts: List[MambaShardLayout], dst_layouts: List[MambaShardLayout] -) -> List[MambaReshardTransfer]: - """Plan the conv/ssm sub-block moves from the prefill (src) layouts to the - decode (dst) layouts. One transfer per (src rank, dst rank, global layer, - band) where both the layer ranges and the channel ranges overlap.""" - # Dedupe replica sources: ranks sharing (tp_rank, layer_start) hold identical - # Mamba state (e.g. EP/DP replicas), so source each shard from exactly one of - # them -- the smallest global_rank -- to avoid duplicate sends. - rep_rank: dict = {} - for s in src_layouts: - key = s.mamba_shard_key() - if key not in rep_rank or s.global_rank < rep_rank[key]: - rep_rank[key] = s.global_rank - source_ranks = set(rep_rank.values()) - - out: List[MambaReshardTransfer] = [] - for s in src_layouts: - if s.global_rank not in source_ranks: - continue - s_lr = s.layer_range() - for d in dst_layouts: - layer_ov = intersect(s_lr, d.layer_range()) - if layer_ov is None: - continue - for band in (*_CONV_BANDS, "ssm"): - _, s_size, s_off = s._band(band) - _, d_size, d_off = d._band(band) - s_glo = (s.tp_rank * s_size, s.tp_rank * s_size + s_size) - d_glo = (d.tp_rank * d_size, d.tp_rank * d_size + d_size) - chan_ov = intersect(s_glo, d_glo) - if chan_ov is None: - continue - lo, hi = chan_ov - for g in range(layer_ov[0], layer_ov[1]): - out.append( - MambaReshardTransfer( - src_rank=s.global_rank, - dst_rank=d.global_rank, - band=band, - global_layer=g, - src_layer=g - s.layer_start, - dst_layer=g - d.layer_start, - src_lo=s_off + (lo - s_glo[0]), - src_hi=s_off + (hi - s_glo[0]), - dst_lo=d_off + (lo - d_glo[0]), - dst_hi=d_off + (hi - d_glo[0]), - ) - ) - return out diff --git a/megatron/core/inference/disaggregation/ssm_reshard.py b/megatron/core/inference/disaggregation/ssm_reshard.py new file mode 100644 index 00000000000..9c06b9f70f9 --- /dev/null +++ b/megatron/core/inference/disaggregation/ssm_reshard.py @@ -0,0 +1,205 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Heterogeneous TP/PP reshard of SSM boundary-snapshot state between +prefill and decode shard layouts (the SSM analog of attention KV resharding). + +A snapshot's conv state packs three channel bands, [x | B | C], on one axis: +x is head-sharded (d_inner) and B/C are group-sharded (ngroups * d_state). +The recurrent state is head-sharded. ``plan_ssm_reshard`` emits one transfer per +(src rank, dst rank, global layer, band) whose layer and channel ranges +overlap; both sides compute the same plan from the same layout lists, so the +send and receive orders match. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Tuple + +from megatron.core.inference.disaggregation.utils import intersect + +# Channel bands of an SSM layer's conv state, in the order the conv state +# concatenates them on its channel axis. The recurrent band is the head axis +# of its own tensor. +_CONV_BANDS = ("x", "B", "C") + + +@dataclass(frozen=True) +class SSMStateDims: + """The model's global (unsharded) SSM structural dimensions. + + Carried as one unit so there is a single source and the dims cannot drift + apart. The producer should read them from the model config rather than + deriving them from tensor shapes. TP shards nheads/ngroups; the rest are + unsharded. + """ + + nheads: int + headdim: int + d_state: int + ngroups: int + d_conv: int + + +@dataclass(frozen=True) +class SSMShardLayout: + """One rank's SSM-state ownership: which global layers and TP rank, + plus the model's structural dims. Per-rank local sizes follow by dividing + by tp_size.""" + + global_rank: int + tp_size: int + tp_rank: int + layer_start: int # global SSM-layer index of this rank's first layer + num_layers: int # SSM layers held locally (this PP stage) + dims: SSMStateDims + + def __post_init__(self) -> None: + # Wire reconstruction (SSMShardLayout(**dict)) hands dims as a plain + # dict; coerce it back to SSMStateDims. + if isinstance(self.dims, dict): + object.__setattr__(self, "dims", SSMStateDims(**self.dims)) + # TP shards heads and groups; both must divide evenly or the local + # band widths are wrong. + if self.dims.nheads % self.tp_size != 0: + raise ValueError(f"nheads={self.dims.nheads} not divisible by tp_size={self.tp_size}") + if self.dims.ngroups % self.tp_size != 0: + raise ValueError(f"ngroups={self.dims.ngroups} not divisible by tp_size={self.tp_size}") + + @property + def d_inner(self) -> int: + """Global inner dimension (nheads * headdim).""" + return self.dims.nheads * self.dims.headdim + + @property + def nheads_local(self) -> int: + """SSM heads held by this TP rank.""" + return self.dims.nheads // self.tp_size + + @property + def d_inner_local(self) -> int: + """Local inner dimension for this TP rank.""" + return self.d_inner // self.tp_size + + @property + def ngroups_local(self) -> int: + """B/C groups held by this TP rank.""" + return self.dims.ngroups // self.tp_size + + @property + def conv_dim_local(self) -> int: + """Total local conv channel width (x + B + C bands).""" + return self.d_inner_local + 2 * self.ngroups_local * self.dims.d_state + + def shard_key(self) -> Tuple[int, int]: + """The SSM shard this rank holds: (tp_rank, layer_start). Ranks + sharing a key hold identical state (e.g. EP/DP replicas).""" + return (self.tp_rank, self.layer_start) + + def layer_range(self) -> Tuple[int, int]: + """Global SSM-layer range [lo, hi) owned by this rank.""" + return (self.layer_start, self.layer_start + self.num_layers) + + def band(self, name: str) -> Tuple[int, int, int]: + """Return (global_total, local_size, local_offset) for a band. + + local_offset is the band's start on the local conv channel axis; for + the "recurrent" (head) band it is the start on the local head axis + (always 0, heads are the whole tensor). + """ + if name == "x": + return self.d_inner, self.d_inner_local, 0 + if name == "B": + g = self.dims.ngroups * self.dims.d_state + return g, self.ngroups_local * self.dims.d_state, self.d_inner_local + if name == "C": + g = self.dims.ngroups * self.dims.d_state + return ( + g, + self.ngroups_local * self.dims.d_state, + self.d_inner_local + self.ngroups_local * self.dims.d_state, + ) + if name == "recurrent": + return self.dims.nheads, self.nheads_local, 0 + raise KeyError(name) + + +@dataclass(frozen=True) +class SSMReshardTransfer: + """One sub-block move of the snapshot reshard. + + band is "x"/"B"/"C" (conv channel axis) or "recurrent" (head axis). + src_layer/dst_layer are local layer indices on each side; *_lo/*_hi are + the local channel or head slice bounds. + """ + + src_rank: int + dst_rank: int + band: str + global_layer: int + src_layer: int + dst_layer: int + src_lo: int + src_hi: int + dst_lo: int + dst_hi: int + + @property + def is_conv(self) -> bool: + """True if this transfer targets the conv state; False for recurrent.""" + return self.band in _CONV_BANDS + + +def plan_ssm_reshard( + src_layouts: List[SSMShardLayout], dst_layouts: List[SSMShardLayout] +) -> List[SSMReshardTransfer]: + """Plan the conv/recurrent sub-block moves from the prefill (src) layouts to + the decode (dst) layouts: one transfer per (src rank, dst rank, global + layer, band) where both the layer ranges and the channel ranges overlap. + + Ranks sharing (tp_rank, layer_start) hold identical SSM state (e.g. + EP/DP replicas), so each shard is sourced from exactly one of them, the + smallest global_rank. Deterministic given the layout lists, so both sides + enumerate the same transfers in the same order. + """ + rep_rank: dict = {} + for s in src_layouts: + key = s.shard_key() + if key not in rep_rank or s.global_rank < rep_rank[key]: + rep_rank[key] = s.global_rank + source_ranks = set(rep_rank.values()) + + out: List[SSMReshardTransfer] = [] + for s in src_layouts: + if s.global_rank not in source_ranks: + continue + s_lr = s.layer_range() + for d in dst_layouts: + layer_ov = intersect(s_lr, d.layer_range()) + if layer_ov is None: + continue + for band in (*_CONV_BANDS, "recurrent"): + _, s_size, s_off = s.band(band) + _, d_size, d_off = d.band(band) + s_glo = (s.tp_rank * s_size, s.tp_rank * s_size + s_size) + d_glo = (d.tp_rank * d_size, d.tp_rank * d_size + d_size) + chan_ov = intersect(s_glo, d_glo) + if chan_ov is None: + continue + lo, hi = chan_ov + for g in range(layer_ov[0], layer_ov[1]): + out.append( + SSMReshardTransfer( + src_rank=s.global_rank, + dst_rank=d.global_rank, + band=band, + global_layer=g, + src_layer=g - s.layer_start, + dst_layer=g - d.layer_start, + src_lo=s_off + (lo - s_glo[0]), + src_hi=s_off + (hi - s_glo[0]), + dst_lo=d_off + (lo - d_glo[0]), + dst_hi=d_off + (hi - d_glo[0]), + ) + ) + return out diff --git a/megatron/core/inference/disaggregation/transfer_backends/__init__.py b/megatron/core/inference/disaggregation/transfer_backends/__init__.py new file mode 100644 index 00000000000..262675e55c0 --- /dev/null +++ b/megatron/core/inference/disaggregation/transfer_backends/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""KV transfer backends for disaggregated inference.""" + +from .base import KVTransportBackend, construct_kv_transfer_backend_class + +__all__ = ["KVTransportBackend", "construct_kv_transfer_backend_class"] diff --git a/megatron/core/inference/disaggregation/transfer_backends/base.py b/megatron/core/inference/disaggregation/transfer_backends/base.py new file mode 100644 index 00000000000..fa8df492e44 --- /dev/null +++ b/megatron/core/inference/disaggregation/transfer_backends/base.py @@ -0,0 +1,212 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""KV transfer backend registry and the buffer geometry shared by backends. + +Backends are selected explicitly by the caller's launcher configuration, +never from the environment. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Optional + +import torch + +from megatron.core.inference.disaggregation.kv_reshard import KVShardLayout +from megatron.core.inference.disaggregation.ssm_reshard import SSMShardLayout + +KVTransportBackend = Any + + +def construct_kv_transfer_backend_class(name: str) -> KVTransportBackend: + """Return the backend class registered under ``name``.""" + + normalized = name.lower().replace("_", "-") + if normalized == "nixl": + from .nixl import NixlTransferBackend + + return NixlTransferBackend + if normalized == "nccl": + from .nccl import NcclTransferBackend + + return NcclTransferBackend + raise ValueError("Unsupported KV transfer backend %r; expected 'nixl' or 'nccl'." % name) + + +@dataclass +class BufferGeometry: + """Address geometry of one registered paged buffer. + + Each (outer, block) pair is one contiguous slice; the outer stride skips + over the full block pool for that outer index. ``layout`` is the canonical + KV shard layout when the buffer is a KV cache; SSM pools carry their + typed layout separately. + """ + + buf_ptr: int + element_size: int + device_id: int + blocks_axis: int + num_blocks: int + num_outer: int + bytes_per_slice: int + outer_stride_bytes: int + heads_per_partition: Optional[int] + head_dim: Optional[int] + tokens_per_block: Optional[int] + layout: Optional[KVShardLayout] + + +def compute_buffer_geometry( + memory_buffer: torch.Tensor, + expected_num_blocks: int, + *, + backend_name: str, + tp_size: Optional[int] = None, + tp_rank: Optional[int] = None, + num_kv_heads_global: Optional[int] = None, + heads_per_partition: Optional[int] = None, + head_dim: Optional[int] = None, + tokens_per_block: Optional[int] = None, + global_rank: Optional[int] = None, + pp_size: Optional[int] = None, + pp_rank: Optional[int] = None, + num_layers_global: Optional[int] = None, + layer_start: Optional[int] = None, + layer_end: Optional[int] = None, + ssm_layout: Optional[SSMShardLayout] = None, + ssm_state_kind: Optional[str] = None, +) -> BufferGeometry: + """Locate the blocks axis, derive the slice strides, and validate the + canonical KV layout when the full geometry is provided. + + Shared by every transfer backend so they agree on addressing and on the + exported metadata schema. The inference KV layout is [2, L, B, T, H, d]. + """ + if (ssm_layout is None) != (ssm_state_kind is None): + raise ValueError("ssm_layout and ssm_state_kind must be provided together") + if ssm_state_kind not in (None, "conv", "recurrent"): + raise ValueError("ssm_state_kind must be 'conv' or 'recurrent'") + + layout_capable = ( + None + not in ( + global_rank, + tp_size, + tp_rank, + pp_size, + pp_rank, + num_layers_global, + num_kv_heads_global, + heads_per_partition, + head_dim, + tokens_per_block, + layer_start, + layer_end, + ) + and heads_per_partition * tp_size == num_kv_heads_global + ) + + shape = list(memory_buffer.shape) + candidates = [i for i, dim in enumerate(shape) if dim == expected_num_blocks] + if not candidates: + raise RuntimeError( + f"{backend_name}: no axis in memory_buffer shape {shape} matches " + f"expected_num_blocks={expected_num_blocks}. Layout is unrecognized; " + "bug in caller or new Megatron tensor shape." + ) + if len(candidates) > 1: + raise RuntimeError( + f"{backend_name}: ambiguous blocks axis in shape {shape} " + f"(expected_num_blocks={expected_num_blocks} matches multiple axes " + f"{candidates}). Caller must pass a more distinctive value." + ) + blocks_axis = candidates[0] + + elements_per_slice = 1 + for dim in shape[blocks_axis + 1 :]: + elements_per_slice *= dim + element_size = memory_buffer.element_size() + bytes_per_slice = element_size * elements_per_slice + num_outer = 1 + for dim in shape[:blocks_axis]: + num_outer *= dim + + layout = None + if layout_capable: + layout = KVShardLayout( + num_layers=int(num_layers_global), + num_heads=int(num_kv_heads_global), + tp_size=int(tp_size), + tp_rank=int(tp_rank), + pp_size=int(pp_size), + pp_rank=int(pp_rank), + global_rank=int(global_rank), + layer_start=int(layer_start), + num_local_layers=int(layer_end) - int(layer_start), + ) + if blocks_axis != 2: + raise ValueError("inference KV transfers require the [2, L, B, T, H, d] layout") + if layout.local_num_heads() != heads_per_partition: + raise ValueError( + "heads_per_partition does not match the canonical KV layout: " + f"{heads_per_partition} vs {layout.local_num_heads()}" + ) + if num_outer % layout.local_num_layers() != 0: + raise ValueError( + f"num_outer={num_outer} is not divisible by local layers=" + f"{layout.local_num_layers()}" + ) + if layout is not None and ssm_layout is not None: + raise ValueError("a transfer backend cannot have both KV and SSM layouts") + + return BufferGeometry( + buf_ptr=memory_buffer.data_ptr(), + element_size=element_size, + device_id=memory_buffer.device.index if memory_buffer.is_cuda else 0, + blocks_axis=blocks_axis, + num_blocks=expected_num_blocks, + num_outer=num_outer, + bytes_per_slice=bytes_per_slice, + outer_stride_bytes=expected_num_blocks * bytes_per_slice, + heads_per_partition=heads_per_partition, + head_dim=head_dim, + tokens_per_block=tokens_per_block, + layout=layout, + ) + + +def export_geometry_meta(geometry: BufferGeometry, ssm_layout=None) -> dict: + """The wire schema shared by every backend's export_meta.""" + meta = { + "base_addr": geometry.buf_ptr, + "outer_stride_bytes": geometry.outer_stride_bytes, + "device_id": geometry.device_id, + "num_outer": geometry.num_outer, + "bytes_per_slice": geometry.bytes_per_slice, + "blocks_axis": geometry.blocks_axis, + "num_blocks": geometry.num_blocks, + "heads_per_partition": geometry.heads_per_partition, + "head_dim": geometry.head_dim, + "tokens_per_block": geometry.tokens_per_block, + "element_size": geometry.element_size, + } + if geometry.layout is not None: + layer_start, layer_end = geometry.layout.layer_range() + meta.update( + { + "global_rank": geometry.layout.global_rank, + "tp_size": geometry.layout.tp_size, + "tp_rank": geometry.layout.tp_rank, + "pp_size": geometry.layout.pp_size, + "pp_rank": geometry.layout.pp_rank, + "num_layers_global": geometry.layout.num_layers, + "num_kv_heads_global": geometry.layout.num_heads, + "layer_start": layer_start, + "layer_end": layer_end, + } + ) + if ssm_layout is not None: + meta["ssm_layout"] = asdict(ssm_layout) + return meta diff --git a/megatron/core/inference/disaggregation/transfer_backends/nccl.py b/megatron/core/inference/disaggregation/transfer_backends/nccl.py new file mode 100644 index 00000000000..96350fb54bf --- /dev/null +++ b/megatron/core/inference/disaggregation/transfer_backends/nccl.py @@ -0,0 +1,293 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Two-sided (NCCL) KV transfer backend for disaggregated prefill/decode. + +Unlike the one-sided NIXL backend, both peers participate: the decode posts +receives when the hand-off request arrives (begin_pull_blocks) and the prefill +posts the matching sends when the coordinator's SEND_KV names the decode +instance (begin_push_blocks). Both sides enumerate the same reshard plan in +the same deterministic order, so the point-to-point operations match by post +order per peer pair. Data moves straight out of the prefill's pinned blocks; +there is no staging copy on the send side. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +import torch +import torch.distributed as dist + +from megatron.core.inference.disaggregation.kv_reshard import KVShardLayout, plan_kv_reshard +from megatron.core.inference.disaggregation.ssm_reshard import SSMShardLayout, plan_ssm_reshard +from megatron.core.inference.disaggregation.transfer_backends.base import ( + compute_buffer_geometry, + export_geometry_meta, +) +from megatron.core.inference.disaggregation.utils import transfer_peer_records + +logger = logging.getLogger(__name__) + + +class NcclTransferHandle: + """Pollable handle for one batched NCCL transfer. + + Receives land in temporary contiguous buffers; on completion the handle + runs its scatter closures once to place the data into the paged buffers. + Send handles keep the gathered source slices alive until reaped. + """ + + def __init__(self, works: List[Any], keepalive: List[torch.Tensor], scatters: List[Any]): + self._works = works + self._keepalive = keepalive + self._scatters = scatters + self._done = not works and not scatters + + def poll(self) -> bool: + """Return True if the transfer has settled, scattering received data + into the paged buffers on first completion.""" + if self._done: + return True + if not all(w.is_completed() for w in self._works): + return False + self._finish() + return True + + def wait(self) -> None: + """Block until the transfer completes, then scatter.""" + for w in self._works: + w.wait() + self._finish() + + def _finish(self) -> None: + if self._done: + return + with torch.inference_mode(): + for scatter in self._scatters: + scatter() + self._keepalive.clear() + self._works = [] + self._done = True + + +def _make_copy(view: torch.Tensor, buf: torch.Tensor): + def _copy(): + view.copy_(buf.view(view.shape)) + + return _copy + + +def _kv_layout_from_meta(meta: Dict[str, Any]) -> KVShardLayout: + """Rebuild a peer's KVShardLayout from its exported metadata.""" + return KVShardLayout( + num_layers=int(meta["num_layers_global"]), + num_heads=int(meta["num_kv_heads_global"]), + tp_size=int(meta["tp_size"]), + tp_rank=int(meta["tp_rank"]), + pp_size=int(meta["pp_size"]), + pp_rank=int(meta["pp_rank"]), + global_rank=int(meta["global_rank"]), + layer_start=int(meta["layer_start"]), + num_local_layers=int(meta["layer_end"]) - int(meta["layer_start"]), + ) + + +class NcclTransferBackend: + """Per-buffer NCCL transport over the default process group. + + Mirrors the NIXL backend's construction and metadata schema so the + hand-off layer treats the two interchangeably; only the transfer calls + differ (two-sided matched send/recv instead of one-sided reads). + """ + + name = "nccl" + is_push = True + + def __init__( + self, + agent_name: str, + memory_buffer: torch.Tensor, + expected_num_blocks: int, + tp_size: Optional[int] = None, + tp_rank: Optional[int] = None, + num_kv_heads_global: Optional[int] = None, + heads_per_partition: Optional[int] = None, + head_dim: Optional[int] = None, + tokens_per_block: Optional[int] = None, + global_rank: Optional[int] = None, + pp_size: Optional[int] = None, + pp_rank: Optional[int] = None, + num_layers_global: Optional[int] = None, + layer_start: Optional[int] = None, + layer_end: Optional[int] = None, + ssm_layout: Optional[SSMShardLayout] = None, + ssm_state_kind: Optional[str] = None, + ): + if not (dist.is_available() and dist.is_initialized()): + raise RuntimeError( + "NcclTransferBackend requires torch.distributed to be initialized; " + "the prefill and decode workers must share a process group." + ) + self.agent_name = agent_name + self._memory_buffer = memory_buffer + geometry = compute_buffer_geometry( + memory_buffer, + expected_num_blocks, + backend_name="NcclTransferBackend", + tp_size=tp_size, + tp_rank=tp_rank, + num_kv_heads_global=num_kv_heads_global, + heads_per_partition=heads_per_partition, + head_dim=head_dim, + tokens_per_block=tokens_per_block, + global_rank=global_rank, + pp_size=pp_size, + pp_rank=pp_rank, + num_layers_global=num_layers_global, + layer_start=layer_start, + layer_end=layer_end, + ssm_layout=ssm_layout, + ssm_state_kind=ssm_state_kind, + ) + self._geometry = geometry + self._layout = geometry.layout + self._ssm_layout = ssm_layout + self._ssm_state_kind = ssm_state_kind + logger.info( + "NcclTransferBackend[%s] over %d-block buffer (rank=%d, shape=%s)", + agent_name, + geometry.num_blocks, + dist.get_rank(), + list(memory_buffer.shape), + ) + + def export_meta(self) -> Dict[str, Any]: + """The shared geometry schema plus this rank's NCCL address.""" + meta = export_geometry_meta(self._geometry, self._ssm_layout) + meta["transport"] = "nccl" + meta["nccl_rank"] = dist.get_rank() + return meta + + # --- shared enumeration ------------------------------------------------- + def _kv_transfers(self, peer_records, mine_is_src: bool): + """Yield (peer_meta, layers, heads) for this rank's part of the KV + reshard plan, in deterministic plan order.""" + sources: list = [] + peers_by_rank: dict = {} + for meta, blocks in peer_records: + layout = _kv_layout_from_meta(meta) + if layout.global_rank in peers_by_rank: + raise ValueError(f"duplicate peer global_rank={layout.global_rank} in KV metadata") + peers_by_rank[layout.global_rank] = meta + sources.append(layout) + if mine_is_src: + plan = plan_kv_reshard([self._layout], sources) + else: + plan = plan_kv_reshard(sources, [self._layout]) + for transfer in plan: + peer_rank = transfer.dst_rank if mine_is_src else transfer.src_rank + meta = peers_by_rank[peer_rank] + if mine_is_src: + layers = transfer.src_layer_slice(self._layout) + heads = transfer.src_head_slice(self._layout) + else: + layers = transfer.dst_layer_slice(self._layout) + heads = transfer.dst_head_slice(self._layout) + yield meta, layers, heads + + def _ssm_transfers(self, peer_records, mine_is_src: bool): + """Yield (peer_meta, lo, hi) band slices of this rank's SSM state, + in deterministic plan order.""" + for meta, _ in peer_records: + raw_layout = meta.get("ssm_layout") + if not isinstance(raw_layout, dict): + raise ValueError("peer metadata is missing ssm_layout") + peer_layout = SSMShardLayout(**raw_layout) + if mine_is_src: + plan = plan_ssm_reshard([self._ssm_layout], [peer_layout]) + else: + plan = plan_ssm_reshard([peer_layout], [self._ssm_layout]) + for t in plan: + if t.is_conv != (self._ssm_state_kind == "conv"): + continue + if mine_is_src: + yield meta, t.src_layer, t.src_lo, t.src_hi + else: + yield meta, t.dst_layer, t.dst_lo, t.dst_hi + + def _kv_block_view(self, block_id: int, layers: slice, heads: slice) -> torch.Tensor: + """One block's (kv, layer, token, head, dim) fragment in the + [2, L, B, T, H, d] paged buffer.""" + return self._memory_buffer[:, layers, block_id, :, heads, :] + + # --- decode side --------------------------------------------------------- + def begin_pull_blocks( + self, peer_meta: Any, src_block_ids: List[int], dst_block_ids: List[int] + ) -> NcclTransferHandle: + """Post the receives matching the prefill's sends; the handle scatters + into the destination blocks (or SSM slots) on completion.""" + if not dst_block_ids: + return NcclTransferHandle([], [], []) + records = transfer_peer_records(peer_meta, src_block_ids) + + ops: List[Any] = [] + buffers: List[torch.Tensor] = [] + scatters: List[Any] = [] + device = self._memory_buffer.device + dtype = self._memory_buffer.dtype + + if self._ssm_layout is not None: + for meta, layer, lo, hi in self._ssm_transfers(records, mine_is_src=False): + for slot in dst_block_ids: + view = self._memory_buffer[layer, int(slot), lo:hi] + buf = torch.empty(view.shape, dtype=dtype, device=device) + buffers.append(buf) + ops.append(dist.P2POp(dist.irecv, buf, int(meta["nccl_rank"]))) + scatters.append(_make_copy(view, buf)) + else: + geo = self._geometry + for meta, layers, heads in self._kv_transfers(records, mine_is_src=False): + n_layers = layers.stop - layers.start + n_heads = heads.stop - heads.start + for block in dst_block_ids: + buf = torch.empty( + (2, n_layers, geo.tokens_per_block, n_heads, geo.head_dim), + dtype=dtype, + device=device, + ) + buffers.append(buf) + ops.append(dist.P2POp(dist.irecv, buf, int(meta["nccl_rank"]))) + scatters.append(_make_copy(self._kv_block_view(int(block), layers, heads), buf)) + + works = dist.batch_isend_irecv(ops) if ops else [] + return NcclTransferHandle(works, buffers, scatters) + + # --- prefill side ---------------------------------------------------------- + def begin_push_blocks(self, peer_meta: Any, src_block_ids: List[int]) -> NcclTransferHandle: + """Post the sends matching the decode's receives, straight out of the + pinned source blocks (or SSM slots). `peer_meta` is the decode + instance's per-rank metadata in the same nested shape as a hand-off's + kv_meta.""" + if not src_block_ids: + return NcclTransferHandle([], [], []) + records = transfer_peer_records(peer_meta, []) + + ops: List[Any] = [] + keep: List[torch.Tensor] = [] + + if self._ssm_layout is not None: + for meta, layer, lo, hi in self._ssm_transfers(records, mine_is_src=True): + for slot in src_block_ids: + sub = self._memory_buffer[layer, int(slot), lo:hi].contiguous() + keep.append(sub) + ops.append(dist.P2POp(dist.isend, sub, int(meta["nccl_rank"]))) + else: + for meta, layers, heads in self._kv_transfers(records, mine_is_src=True): + for block in src_block_ids: + sub = self._kv_block_view(int(block), layers, heads).contiguous() + keep.append(sub) + ops.append(dist.P2POp(dist.isend, sub, int(meta["nccl_rank"]))) + + works = dist.batch_isend_irecv(ops) if ops else [] + return NcclTransferHandle(works, keep, []) diff --git a/megatron/core/inference/disaggregation/transfer_backends/nixl.py b/megatron/core/inference/disaggregation/transfer_backends/nixl.py new file mode 100644 index 00000000000..af355e25045 --- /dev/null +++ b/megatron/core/inference/disaggregation/transfer_backends/nixl.py @@ -0,0 +1,625 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Direct NIXL backend for disaggregated prefill/decode KV transfer. + +Each rank registers its paged KV buffer once, exports NIXL peer metadata, and +the decode side pulls source block ranges directly into its local KV blocks. + +Backend selection belongs in ``transfer_backends.base`` and is supplied +explicitly by the launcher. +""" + +from __future__ import annotations + +import base64 +import logging +import os +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Optional + +import torch + +from megatron.core.inference.disaggregation.kv_reshard import KVShardLayout, plan_kv_reshard +from megatron.core.inference.disaggregation.ssm_reshard import SSMShardLayout, plan_ssm_reshard +from megatron.core.inference.disaggregation.transfer_backends.base import compute_buffer_geometry +from megatron.core.inference.disaggregation.utils import transfer_peer_records + +logger = logging.getLogger(__name__) + +try: + from nixl._api import nixl_agent # type: ignore[import-not-found] + + _HAVE_NIXL = True +except ImportError: + nixl_agent = None # type: ignore[assignment] + _HAVE_NIXL = False + + +# NIXL exposes polling, not a blocking wait. A long stall usually means peer or +# fabric failure, so cap the wait. +_POLL_INTERVAL_S = 0.0005 # 0.5 ms +_POLL_TIMEOUT_S = 30.0 + + +@dataclass +class NixlPullHandle: + """Pollable handle for one logical pull made of one or more NIXL transfers.""" + + agent: Any + xfers: List[Any] + contexts: List[str] + submitted_at: float + timeout_s: float = _POLL_TIMEOUT_S + done: bool = False + error: Optional[str] = None + + def poll(self) -> bool: + """Return True if every transfer has settled, without blocking.""" + if self.done: + if self.error is not None: + raise RuntimeError(self.error) + return True + if not self.xfers: + self.done = True + return True + + errors: List[str] = [] + pending: List[str] = [] + for xfer, ctx in zip(self.xfers, self.contexts): + state = self.agent.check_xfer_state(xfer) + if state == "DONE": + continue + if state == "ERR": + errors.append(ctx) + continue + pending.append(f"{ctx}: {state}") + + if not pending: + self.done = True + if errors: + self.error = f"NIXL transfer failed ({', '.join(errors)})" + raise RuntimeError(self.error) + return True + if time.perf_counter() - self.submitted_at > self.timeout_s: + raise TimeoutError( + f"NIXL transfer timed out after {self.timeout_s}s; pending={pending}" + ) + return False + + def wait(self) -> None: + """Block until the transfer completes; NIXL has no blocking wait, so + poll with a short sleep to avoid monopolizing a CPU core.""" + while not self.poll(): + time.sleep(_POLL_INTERVAL_S) + + +class NixlTransferBackend: + """Per-rank NIXL agent owning a registration over the paged KV buffer. + + Per-block transfers are descriptor ranges over that registration. Peer + metadata is exchanged by the control plane and registered lazily on first + pull. + """ + + name = "nixl" + + def __init__( + self, + agent_name: str, + memory_buffer: torch.Tensor, + expected_num_blocks: int, + tp_size: Optional[int] = None, + tp_rank: Optional[int] = None, + num_kv_heads_global: Optional[int] = None, + heads_per_partition: Optional[int] = None, + head_dim: Optional[int] = None, + tokens_per_block: Optional[int] = None, + global_rank: Optional[int] = None, + pp_size: Optional[int] = None, + pp_rank: Optional[int] = None, + num_layers_global: Optional[int] = None, + layer_start: Optional[int] = None, + layer_end: Optional[int] = None, + ssm_layout: Optional[SSMShardLayout] = None, + ssm_state_kind: Optional[str] = None, + ): + if not _HAVE_NIXL: + raise RuntimeError( + "NixlTransferBackend requires the nixl Python package. Install the " + "NIXL runtime and `pip install nixl` before launching " + "disaggregated workers." + ) + self.agent_name = agent_name + self._memory_buffer = memory_buffer + + # Addressing geometry shared with the other backends. + geometry = compute_buffer_geometry( + memory_buffer, + expected_num_blocks, + backend_name="NixlTransferBackend", + tp_size=tp_size, + tp_rank=tp_rank, + num_kv_heads_global=num_kv_heads_global, + heads_per_partition=heads_per_partition, + head_dim=head_dim, + tokens_per_block=tokens_per_block, + global_rank=global_rank, + pp_size=pp_size, + pp_rank=pp_rank, + num_layers_global=num_layers_global, + layer_start=layer_start, + layer_end=layer_end, + ssm_layout=ssm_layout, + ssm_state_kind=ssm_state_kind, + ) + self._geometry = geometry + shape = list(memory_buffer.shape) + self._buf_ptr = geometry.buf_ptr + self._element_size = geometry.element_size + self._device_id = geometry.device_id + self._outer_stride_bytes = geometry.outer_stride_bytes + self._num_outer = geometry.num_outer + self._bytes_per_slice = geometry.bytes_per_slice + self._blocks_axis = geometry.blocks_axis + self._num_blocks = geometry.num_blocks + self._heads_per_partition = geometry.heads_per_partition + self._head_dim = geometry.head_dim + self._tokens_per_block = geometry.tokens_per_block + self._layout = geometry.layout + self._ssm_layout = ssm_layout + self._ssm_state_kind = ssm_state_kind + + # Configure UCX before agent construction. Avoid TCP for VRAM addresses; + # operators may override this by setting UCX_TLS before launch. + os.environ.setdefault("UCX_TLS", "cuda_ipc,cuda_copy,cma,shm,self") + # Explicit registration makes the UCX memtype cache unnecessary and + # avoids stale VRAM/host classifications. + os.environ.setdefault("UCX_MEMTYPE_CACHE", "n") + + self._agent = nixl_agent(agent_name) + self._reg_handle = self._agent.register_memory(memory_buffer) + + # Base64 keeps NIXL metadata safe for msgpack/json control messages. + self._agent_metadata = self._agent.get_agent_metadata() + + # Peer agent_name -> id returned by add_remote_agent. + self._known_peers: Dict[str, Any] = {} + + logger.info( + "NixlTransferBackend[%s] registered %d-block buffer " + "(blocks_axis=%d, %d outer-slices/block × %d bytes/slice = " + "%d bytes/block, device=%d, shape=%s)", + agent_name, + self._num_blocks, + self._blocks_axis, + self._num_outer, + self._bytes_per_slice, + self._num_outer * self._bytes_per_slice, + self._device_id, + shape, + ) + + def export_meta(self) -> Dict[str, Any]: + """Return JSON/msgpack-safe metadata for shipping to a decode peer. + + Layout fields describe the scatter-gather address ranges needed to pull + source blocks into decode-owned blocks. + """ + meta = { + "agent_name": self.agent_name, + "agent_metadata_b64": base64.b64encode(self._agent_metadata).decode("ascii"), + "base_addr": self._buf_ptr, + "outer_stride_bytes": self._outer_stride_bytes, + "device_id": self._device_id, + "num_outer": self._num_outer, + "bytes_per_slice": self._bytes_per_slice, + "blocks_axis": self._blocks_axis, + "num_blocks": self._num_blocks, + "heads_per_partition": self._heads_per_partition, + "head_dim": self._head_dim, + "tokens_per_block": self._tokens_per_block, + "element_size": self._element_size, + } + if self._layout is not None: + layer_start, layer_end = self._layout.layer_range() + meta.update( + { + "global_rank": self._layout.global_rank, + "tp_size": self._layout.tp_size, + "tp_rank": self._layout.tp_rank, + "pp_size": self._layout.pp_size, + "pp_rank": self._layout.pp_rank, + "num_layers_global": self._layout.num_layers, + "num_kv_heads_global": self._layout.num_heads, + "layer_start": layer_start, + "layer_end": layer_end, + } + ) + if self._ssm_layout is not None: + meta["ssm_layout"] = asdict(self._ssm_layout) + return meta + + def _ensure_peer_registered(self, peer_meta: Dict[str, Any]) -> str: + """Register the peer with NIXL on first use; return its agent id.""" + peer_name = peer_meta["agent_name"] + existing = self._known_peers.get(peer_name) + if existing is not None: + return existing + metadata_b64 = peer_meta.get("agent_metadata_b64") + if not metadata_b64: + raise ValueError(f"peer_meta for {peer_name!r} is missing agent_metadata_b64") + peer_id = self._agent.add_remote_agent(base64.b64decode(metadata_b64)) + resolved = peer_id if peer_id else peer_name + self._known_peers[peer_name] = resolved + logger.info("NixlTransferBackend[%s] registered peer %s", self.agent_name, peer_name) + return resolved + + def _validate_peer( + self, + meta: Dict[str, Any], + src_block_ids: List[int], + dst_block_ids: List[int], + *, + matched_layout: bool = False, + ) -> None: + """Validate block mappings and physical transfer compatibility.""" + + if len(src_block_ids) != len(dst_block_ids): + raise ValueError( + f"source/destination block_id length mismatch for peer " + f"{meta.get('agent_name')!r}: {len(src_block_ids)} vs {len(dst_block_ids)}" + ) + for block in src_block_ids: + if not 0 <= block < int(meta["num_blocks"]): + raise ValueError(f"source block {block} is outside pool [0, {meta['num_blocks']})") + for block in dst_block_ids: + if not 0 <= block < self._num_blocks: + raise ValueError( + f"destination block {block} is outside pool [0, {self._num_blocks})" + ) + + local = { + "head_dim": self._head_dim, + "tokens_per_block": self._tokens_per_block, + "element_size": self._element_size, + "num_outer": self._num_outer, + "bytes_per_slice": self._bytes_per_slice, + "blocks_axis": self._blocks_axis, + "heads_per_partition": self._heads_per_partition, + } + fields = ["head_dim", "tokens_per_block", "element_size"] + if matched_layout: + fields.extend(["num_outer", "bytes_per_slice", "blocks_axis", "heads_per_partition"]) + mismatches = [ + f"{field}: peer={meta.get(field)} local={local[field]}" + for field in fields + if meta.get(field) is not None + and local[field] is not None + and meta.get(field) != local[field] + ] + if mismatches: + kind = "matched-layout" if matched_layout else "transfer" + raise ValueError(f"{kind} geometry mismatch: {', '.join(mismatches)}") + + @staticmethod + def _kv_layout_from_meta(meta: Dict[str, Any]) -> KVShardLayout: + """Reconstruct a main-planner KV layout from peer wire metadata.""" + + keys = ( + "global_rank", + "tp_size", + "tp_rank", + "pp_size", + "pp_rank", + "num_layers_global", + "num_kv_heads_global", + "layer_start", + "layer_end", + ) + missing = [key for key in keys if meta.get(key) is None] + if missing: + raise ValueError(f"peer metadata missing KV layout fields: {missing}") + return KVShardLayout( + num_layers=int(meta["num_layers_global"]), + num_heads=int(meta["num_kv_heads_global"]), + tp_size=int(meta["tp_size"]), + tp_rank=int(meta["tp_rank"]), + pp_size=int(meta["pp_size"]), + pp_rank=int(meta["pp_rank"]), + global_rank=int(meta["global_rank"]), + layer_start=int(meta["layer_start"]), + num_local_layers=int(meta["layer_end"]) - int(meta["layer_start"]), + ) + + def begin_pull_blocks( + self, peer_meta: Any, src_block_ids: List[int], dst_block_ids: List[int] + ) -> NixlPullHandle: + """Submit a pull and return a handle that can be polled later.""" + if not isinstance(peer_meta, dict) or "pp_metas" not in peer_meta: + if not src_block_ids and not dst_block_ids: + return NixlPullHandle( + agent=self._agent, + xfers=[], + contexts=[], + submitted_at=time.perf_counter(), + done=True, + ) + + xfers: List[Any] = [] + contexts: List[str] = [] + submitted_at = time.perf_counter() + try: + if self._ssm_layout is not None: + state_kind = self._ssm_state_kind + assert state_kind is not None + width = ( + self._ssm_layout.conv_dim_local + if state_kind == "conv" + else self._ssm_layout.nheads_local + ) + if ( + self._heads_per_partition != width + or self._num_outer != self._ssm_layout.num_layers + or self._blocks_axis != 1 + ): + raise ValueError(f"local {state_kind} geometry does not match its SSM layout") + + sources = [] + peers_by_rank = {} + for meta, blocks in transfer_peer_records(peer_meta, src_block_ids): + raw_layout = meta.get("ssm_layout") + if not isinstance(raw_layout, dict): + raise ValueError("peer metadata is missing ssm_layout") + layout = SSMShardLayout(**raw_layout) + self._validate_peer(meta, blocks, dst_block_ids) + peer_width = ( + layout.conv_dim_local if state_kind == "conv" else layout.nheads_local + ) + if ( + meta.get("heads_per_partition") != peer_width + or int(meta["num_outer"]) != layout.num_layers + or int(meta["blocks_axis"]) != 1 + ): + raise ValueError( + f"peer {state_kind} geometry does not match its SSM layout" + ) + if layout.global_rank in peers_by_rank: + raise ValueError( + f"duplicate source global_rank={layout.global_rank} " "in SSM metadata" + ) + sources.append(layout) + peers_by_rank[layout.global_rank] = (meta, blocks) + if not sources: + raise ValueError("SSM handoff contains no source peer metadata") + + transfers = [ + transfer + for transfer in plan_ssm_reshard(sources, [self._ssm_layout]) + if transfer.is_conv == (state_kind == "conv") + ] + for layer in range(self._ssm_layout.num_layers): + intervals = sorted( + (transfer.dst_lo, transfer.dst_hi) + for transfer in transfers + if transfer.dst_layer == layer + ) + if not intervals or intervals[0][0] != 0 or intervals[-1][1] != width: + raise ValueError(f"incomplete SSM {state_kind} coverage for layer {layer}") + if any(a[1] != b[0] for a, b in zip(intervals, intervals[1:])): + raise ValueError( + f"non-contiguous SSM {state_kind} coverage for layer {layer}" + ) + + for transfer in transfers: + meta, blocks = peers_by_rank[transfer.src_rank] + xfer, ctx = self._begin_transfer( + meta, + blocks, + dst_block_ids, + transfer.src_layer, + transfer.dst_layer, + 1, + transfer.src_lo, + transfer.dst_lo, + transfer.src_hi - transfer.src_lo, + ) + xfers.append(xfer) + contexts.append(ctx) + elif self._layout is not None: + sources = [] + peers_by_rank = {} + for meta, blocks in transfer_peer_records(peer_meta, src_block_ids): + layout = self._kv_layout_from_meta(meta) + self._validate_peer(meta, blocks, dst_block_ids) + if meta.get("heads_per_partition") != layout.local_num_heads(): + raise ValueError("peer heads_per_partition does not match its KV layout") + if int(meta["num_outer"]) % layout.local_num_layers(): + raise ValueError("peer num_outer is not divisible by its local layer count") + if layout.global_rank in peers_by_rank: + raise ValueError( + f"duplicate source global_rank={layout.global_rank} in KV metadata" + ) + sources.append(layout) + peers_by_rank[layout.global_rank] = (meta, blocks, layout) + if not sources: + raise ValueError("KV handoff contains no source peer metadata") + + local_planes = self._num_outer // self._layout.local_num_layers() + for transfer in plan_kv_reshard(sources, [self._layout]): + meta, blocks, source_layout = peers_by_rank[transfer.src_rank] + source_planes = int(meta["num_outer"]) // source_layout.local_num_layers() + if source_planes != local_planes: + raise ValueError( + f"outer-plane mismatch peer={source_planes} local={local_planes}" + ) + + src_layers = transfer.src_layer_slice(source_layout) + dst_layers = transfer.dst_layer_slice(self._layout) + src_heads = transfer.src_head_slice(source_layout) + dst_heads = transfer.dst_head_slice(self._layout) + layer_count = src_layers.stop - src_layers.start + head_count = src_heads.stop - src_heads.start + full_heads = ( + src_heads.start == 0 + and src_heads.stop == source_layout.local_num_heads() + and dst_heads.start == 0 + and dst_heads.stop == self._layout.local_num_heads() + and int(meta["bytes_per_slice"]) == self._bytes_per_slice + ) + full_layers = ( + src_layers.start == 0 + and src_layers.stop == source_layout.local_num_layers() + and dst_layers.start == 0 + and dst_layers.stop == self._layout.local_num_layers() + ) + if full_heads and full_layers: + xfer, ctx = self._begin_transfer( + meta, blocks, dst_block_ids, 0, 0, self._num_outer + ) + xfers.append(xfer) + contexts.append(ctx) + continue + if not full_heads and (int(meta["blocks_axis"]) != 2 or self._blocks_axis != 2): + raise NotImplementedError( + "KV head resharding requires the [2, L, B, T, H, d] layout" + ) + + for plane in range(local_planes): + xfer, ctx = self._begin_transfer( + meta, + blocks, + dst_block_ids, + plane * source_layout.local_num_layers() + src_layers.start, + plane * self._layout.local_num_layers() + dst_layers.start, + layer_count, + 0 if full_heads else src_heads.start, + 0 if full_heads else dst_heads.start, + 0 if full_heads else head_count, + ) + xfers.append(xfer) + contexts.append(ctx) + else: + records = transfer_peer_records(peer_meta, src_block_ids) + if len(records) != 1: + raise ValueError("matched-layout transfer requires exactly one source peer") + meta, blocks = records[0] + self._validate_peer(meta, blocks, dst_block_ids, matched_layout=True) + xfer, ctx = self._begin_transfer(meta, blocks, dst_block_ids, 0, 0, self._num_outer) + xfers.append(xfer) + contexts.append(ctx) + except Exception as exc: + if xfers: + cleanup = NixlPullHandle( + agent=self._agent, xfers=xfers, contexts=contexts, submitted_at=submitted_at + ) + try: + cleanup.wait() + except TimeoutError: + # Tell the owner not to recycle the destination storage while + # an already-submitted transfer may still write to it. + setattr(exc, "transfer_destinations_safe", False) + except Exception: + # Transfer errors are reported only after every submitted + # transfer has reached a terminal state. + pass + raise + return NixlPullHandle( + agent=self._agent, xfers=xfers, contexts=contexts, submitted_at=submitted_at + ) + + def _begin_transfer( + self, + peer_meta: Dict[str, Any], + src_block_ids: List[int], + dst_block_ids: List[int], + src_o_start: int, + dst_o_start: int, + n_outer: int, + src_h0: int = 0, + dst_h0: int = 0, + n_heads: int = 0, + ) -> tuple[Any, str]: + """Submit one full-slice or head-fragment NIXL transfer.""" + pm = peer_meta + peer_base = pm["base_addr"] + peer_device_id = pm.get("device_id", 0) + peer_bps = pm["bytes_per_slice"] + peer_os = pm["outer_stride_bytes"] + peer_id = self._ensure_peer_registered(pm) + + bps = self._bytes_per_slice + local_os = self._outer_stride_bytes + + src_tuples: List[Any] = [] + dst_tuples: List[Any] = [] + + if n_heads == 0: + # One descriptor per block and outer slice. + for src_b, dst_b in zip(src_block_ids, dst_block_ids): + for i in range(n_outer): + src_o = src_o_start + i + dst_o = dst_o_start + i + src_tuples.append( + (peer_base + src_o * peer_os + src_b * peer_bps, peer_bps, peer_device_id) + ) + dst_tuples.append( + (self._buf_ptr + dst_o * local_os + dst_b * bps, bps, self._device_id) + ) + ctx = ( + f"matched peer={peer_id} outer[{src_o_start}:+{n_outer}] " + f"blocks={len(src_block_ids)}" + ) + else: + # Head sub-range copy: one descriptor per token. + assert self._head_dim is not None + assert self._heads_per_partition is not None + assert self._tokens_per_block is not None + d_bytes = self._head_dim * self._element_size + local_token_stride = self._heads_per_partition * d_bytes + peer_token_stride = pm["heads_per_partition"] * d_bytes + T = self._tokens_per_block + frag_bytes = n_heads * d_bytes + src_h_off = src_h0 * d_bytes + dst_h_off = dst_h0 * d_bytes + + for src_b, dst_b in zip(src_block_ids, dst_block_ids): + for i in range(n_outer): + src_o = src_o_start + i + dst_o = dst_o_start + i + src_slice = peer_base + src_o * peer_os + src_b * peer_bps + src_h_off + dst_slice = self._buf_ptr + dst_o * local_os + dst_b * bps + dst_h_off + for t in range(T): + src_tuples.append( + (src_slice + t * peer_token_stride, frag_bytes, peer_device_id) + ) + dst_tuples.append( + (dst_slice + t * local_token_stride, frag_bytes, self._device_id) + ) + ctx = ( + f"reshard peer={peer_id} outer[{src_o_start}:+{n_outer}] " + f"heads[{src_h0}:+{n_heads}] blocks={len(src_block_ids)}" + ) + + src_descs = self._agent.get_xfer_descs(src_tuples, mem_type="VRAM") + dst_descs = self._agent.get_xfer_descs(dst_tuples, mem_type="VRAM") + # READ pulls remote -> local. Signature is (op, local, remote, peer). + xfer = self._agent.initialize_xfer("READ", dst_descs, src_descs, peer_id) + try: + self._agent.transfer(xfer) + except Exception as exc: + # The transport may have accepted the operation before surfacing an + # error, so its destination cannot be proven safe for immediate reuse. + setattr(exc, "transfer_destinations_safe", False) + raise + return xfer, ctx + + def close(self) -> None: + """Release the registration and agent.""" + if self._agent is None: + return + try: + self._agent.deregister_memory(self._reg_handle) + except Exception: # noqa: BLE001 - shutdown path + logger.exception("NixlTransferBackend: deregister_memory failed") + self._agent = None diff --git a/megatron/core/inference/disaggregation/utils.py b/megatron/core/inference/disaggregation/utils.py index 9b5e153b443..779207e40f6 100644 --- a/megatron/core/inference/disaggregation/utils.py +++ b/megatron/core/inference/disaggregation/utils.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Optional, Tuple +from typing import Any, List, Optional, Tuple def intersect(a: Tuple[int, int], b: Tuple[int, int]) -> Optional[Tuple[int, int]]: @@ -14,7 +14,7 @@ def intersect(a: Tuple[int, int], b: Tuple[int, int]) -> Optional[Tuple[int, int def transfers_for_src(plan, src_rank): - """Transfers in ``plan`` originating from ``src_rank`` (any KV/Mamba + """Transfers in ``plan`` originating from ``src_rank`` (any KV/SSM reshard transfer -- both expose a ``src_rank`` field).""" return [t for t in plan if t.src_rank == src_rank] @@ -22,3 +22,36 @@ def transfers_for_src(plan, src_rank): def transfers_for_dst(plan, dst_rank): """Transfers in ``plan`` destined for ``dst_rank``.""" return [t for t in plan if t.dst_rank == dst_rank] + + +def transfer_peer_records(peer_meta: Any, src_block_ids: List[int]) -> List[Tuple[dict, List[int]]]: + """Normalize flat/TP/PP transfer metadata into peer/block records.""" + + def append_metas(raw_metas: Any, default_blocks: List[int]) -> None: + metas = raw_metas if isinstance(raw_metas, list) else [raw_metas] + for meta in metas: + if not isinstance(meta, dict): + raise ValueError("transfer peer metadata entries must be dictionaries") + blocks = meta.get("block_ids", default_blocks) + records.append((meta, [int(block) for block in blocks])) + + records: List[Tuple[dict, List[int]]] = [] + if isinstance(peer_meta, dict) and "pp_metas" in peer_meta: + for entry in peer_meta["pp_metas"]: + raw_metas = entry.get("tp_metas", entry) + blocks = [int(block) for block in entry.get("block_ids", [])] + append_metas(raw_metas, blocks) + return records + + if isinstance(peer_meta, dict) and "tp_metas" in peer_meta: + peer_meta = peer_meta["tp_metas"] + blocks = [int(block) for block in src_block_ids] + append_metas(peer_meta, blocks) + return records + + +def transfer_block_count(peer_meta: Any, src_block_ids: List[int]) -> int: + """Return the sequence-block count represented by transfer metadata.""" + + records = transfer_peer_records(peer_meta, src_block_ids) + return len(records[0][1]) if records else 0 diff --git a/tests/unit_tests/inference/test_kv_transfer_backends.py b/tests/unit_tests/inference/test_kv_transfer_backends.py new file mode 100644 index 00000000000..0f32bc2e5c9 --- /dev/null +++ b/tests/unit_tests/inference/test_kv_transfer_backends.py @@ -0,0 +1,197 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import pytest +import torch + +from megatron.core.inference.disaggregation.ssm_reshard import SSMShardLayout, SSMStateDims +from megatron.core.inference.disaggregation.transfer_backends import base + + +def test_backend_registry_selects_by_explicit_name(): + assert base.construct_kv_transfer_backend_class("nixl").name == "nixl" + + try: + base.construct_kv_transfer_backend_class("unsupported") + except ValueError as exc: + assert "expected 'nixl'" in str(exc) + else: + raise AssertionError("unsupported backend should raise") + + +def test_ssm_geometry_uses_conv_and_recurrent_state_names(): + layout = SSMShardLayout( + global_rank=0, + tp_size=1, + tp_rank=0, + layer_start=0, + num_layers=1, + dims=SSMStateDims(nheads=2, headdim=4, d_state=5, ngroups=1, d_conv=3), + ) + memory_buffer = torch.zeros(1, 3, 2, 4, 5) + geometry = base.compute_buffer_geometry( + memory_buffer, + expected_num_blocks=3, + backend_name="test", + heads_per_partition=2, + ssm_layout=layout, + ssm_state_kind="recurrent", + ) + + metadata = base.export_geometry_meta(geometry, layout) + assert metadata["ssm_layout"]["dims"]["nheads"] == 2 + assert "mamba_layout" not in metadata + + with pytest.raises(ValueError, match="'conv' or 'recurrent'"): + base.compute_buffer_geometry( + memory_buffer, + expected_num_blocks=3, + backend_name="test", + heads_per_partition=2, + ssm_layout=layout, + ssm_state_kind="ssm", + ) + + +def test_nixl_direct_backend_exports_metadata_with_fake_agent(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + class FakeAgent: + def __init__(self, name): + self.name = name + + def get_agent_metadata(self): + return b"agent-meta" + + def register_memory(self, tensor): + return ("reg", tuple(tensor.shape)) + + monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) + monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + + backend = nixl_mod.NixlTransferBackend( + "prefill", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 + ) + metadata = backend.export_meta() + + assert metadata["agent_name"] == "prefill" + assert metadata["bytes_per_slice"] == 20 + assert metadata["num_outer"] == 2 + assert metadata["num_blocks"] == 3 + assert metadata["blocks_axis"] == 1 + + +def test_nixl_begin_pull_blocks_uses_remote_metadata_with_fake_agent(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + class FakeAgent: + def __init__(self, name): + self.name = name + self.transferred = False + + def get_agent_metadata(self): + return b"local" + + def register_memory(self, tensor): + return ("reg", tuple(tensor.shape)) + + def add_remote_agent(self, metadata): + assert metadata == b"remote" + return "peer" + + def get_xfer_descs(self, tuples, mem_type): + assert mem_type == "VRAM" + return tuples + + def initialize_xfer(self, op, local_desc, remote_desc, peer_id): + assert op == "READ" + assert peer_id == "peer" + return (local_desc, remote_desc) + + def transfer(self, xfer): + self.transferred = True + + def check_xfer_state(self, xfer): + assert self.transferred + return "DONE" + + monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) + monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + + backend = nixl_mod.NixlTransferBackend( + "decode", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 + ) + peer_meta = { + "agent_name": "prefill", + "agent_metadata_b64": "cmVtb3Rl", + "base_addr": 1234, + "bytes_per_slice": 20, + "num_outer": 2, + "outer_stride_bytes": 60, + "num_blocks": 3, + "device_id": 0, + "blocks_axis": 1, + } + backend.begin_pull_blocks(peer_meta, [1], [2]).wait() + + assert backend._agent.transferred is True + + +def test_nixl_begin_pull_blocks_returns_pollable_handle(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + class FakeAgent: + def __init__(self, name): + self.name = name + self.transfers = 0 + self.polls = 0 + + def get_agent_metadata(self): + return b"local" + + def register_memory(self, tensor): + return ("reg", tuple(tensor.shape)) + + def add_remote_agent(self, metadata): + assert metadata == b"remote" + return "peer" + + def get_xfer_descs(self, tuples, mem_type): + assert mem_type == "VRAM" + return tuples + + def initialize_xfer(self, op, local_desc, remote_desc, peer_id): + assert op == "READ" + assert peer_id == "peer" + return {"local": local_desc, "remote": remote_desc} + + def transfer(self, xfer): + self.transfers += 1 + + def check_xfer_state(self, xfer): + self.polls += 1 + return "DONE" if self.polls >= 2 else "PENDING" + + monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) + monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + + backend = nixl_mod.NixlTransferBackend( + "decode", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 + ) + peer_meta = { + "agent_name": "prefill", + "agent_metadata_b64": "cmVtb3Rl", + "base_addr": 1234, + "bytes_per_slice": 20, + "num_outer": 2, + "outer_stride_bytes": 60, + "num_blocks": 3, + "device_id": 0, + "blocks_axis": 1, + } + + handle = backend.begin_pull_blocks(peer_meta, [1], [2]) + + assert backend._agent.transfers == 1 + assert backend._agent.polls == 0 + assert handle.poll() is False + assert handle.poll() is True diff --git a/tests/unit_tests/inference/test_nccl_transfer_backend.py b/tests/unit_tests/inference/test_nccl_transfer_backend.py new file mode 100644 index 00000000000..ecb5fb44b27 --- /dev/null +++ b/tests/unit_tests/inference/test_nccl_transfer_backend.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Distributed unit test of the two-sided NCCL transfer backend. + +Prefill TP2 {0,1} -> decode TP1 {2} on real GPUs: the decode posts +begin_pull_blocks, the prefills post the matching begin_push_blocks, and the +decode's paged buffer must end up byte-identical to a direct shard of a known +global KV. Exercises the hetero head-merge through the same reshard plan the +NIXL backend uses. The test uses the process group provided by the unit-test +runner instead of spawning a nested distributed job. +""" + +import os + +import pytest +import torch + +L, H, HD, T, NB = 4, 8, 16, 8, 6 # layers, kv heads, head dim, tokens/block, pool blocks +BLOCKS = [1, 3] # the request's blocks (same ids both sides for simplicity) + + +def _global_blocks(): + """Global KV for the request's blocks: (block, kv, layer, token, head, dim) + with a distinct value per (block, kv, layer, head).""" + g = torch.zeros(len(BLOCKS), 2, L, T, H, HD) + for b in range(len(BLOCKS)): + for kv in range(2): + for l in range(L): + for h in range(H): + g[b, kv, l, :, h, :] = ((b * 2 + kv) * L + l) * 100 + h + return g + + +def _backend(rank, tp_size, tp_rank, device): + from megatron.core.inference.disaggregation.transfer_backends.nccl import NcclTransferBackend + + heads_local = H // tp_size + buf = torch.zeros(2, L, NB, T, heads_local, HD, device=device) + backend = NcclTransferBackend( + agent_name=f"test-rank{rank}", + memory_buffer=buf, + expected_num_blocks=NB, + tp_size=tp_size, + tp_rank=tp_rank, + num_kv_heads_global=H, + heads_per_partition=heads_local, + head_dim=HD, + tokens_per_block=T, + global_rank=rank, + pp_size=1, + pp_rank=0, + num_layers_global=L, + layer_start=0, + layer_end=L, + ) + return backend, buf + + +def _meta_stub(rank, tp_size, tp_rank): + """A rank's export_meta, built without its backend; the address fields are + unused by NCCL and the geometry is deterministic.""" + heads_local = H // tp_size + return { + "transport": "nccl", + "nccl_rank": rank, + "num_blocks": NB, + "blocks_axis": 2, + "num_outer": 2 * L, + "heads_per_partition": heads_local, + "head_dim": HD, + "tokens_per_block": T, + "element_size": 4, + "bytes_per_slice": T * heads_local * HD * 4, + "outer_stride_bytes": NB * T * heads_local * HD * 4, + "base_addr": 0, + "device_id": rank, + "global_rank": rank, + "tp_size": tp_size, + "tp_rank": tp_rank, + "pp_size": 1, + "pp_rank": 0, + "num_layers_global": L, + "num_kv_heads_global": H, + "layer_start": 0, + "layer_end": L, + } + + +@pytest.mark.skipif( + not ( + torch.cuda.is_available() + and torch.cuda.device_count() >= 3 + and int(os.environ.get("WORLD_SIZE", "1")) >= 3 + ), + reason="requires torchrun with >=3 CUDA ranks (prefill TP2 {0,1} + decode TP1 {2})", +) +def test_nccl_push_pull_tp2_to_tp1(): + import torch.distributed as dist + + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + device = f"cuda:{local_rank}" + if not dist.is_initialized(): + dist.init_process_group("nccl") + + rank = dist.get_rank() + control_group = dist.new_group(backend="gloo") + + # Initialize the default NCCL communicator collectively before ranks 0–2 + # use it for point-to-point transfers. Extra CI ranks synchronize through + # the Gloo control group and do not issue conflicting NCCL collectives. + dist.barrier() + + transfer_ok = True + g = _global_blocks().to(device) + if rank in (0, 1): # prefill TP2 + backend, buf = _backend(rank, 2, rank, device) + heads = slice(rank * (H // 2), (rank + 1) * (H // 2)) + for i, block in enumerate(BLOCKS): + # buffer layout [2, L, B, T, h, d] + buf[:, :, block] = g[i, :, :, :, heads, :] + # In production the decode's metas arrive in SEND_KV. + handle = backend.begin_push_blocks({"tp_metas": [_meta_stub(2, 1, 0)]}, BLOCKS) + handle.wait() + elif rank == 2: # decode TP1 + backend, buf = _backend(rank, 1, 0, device) + # In production the prefills' metas arrive in the hand-off kv_meta. + metas = [_meta_stub(0, 2, 0), _meta_stub(1, 2, 1)] + handle = backend.begin_pull_blocks({"tp_metas": metas}, BLOCKS, BLOCKS) + handle.wait() + expected = torch.zeros_like(buf) + for i, block in enumerate(BLOCKS): + expected[:, :, block] = g[i] + transfer_ok = torch.equal(buf, expected) + + result = torch.tensor(int(transfer_ok)) + dist.all_reduce(result, op=dist.ReduceOp.MIN, group=control_group) + assert result.item() == 1 diff --git a/tests/unit_tests/inference/test_mamba_reshard.py b/tests/unit_tests/inference/test_ssm_reshard.py similarity index 66% rename from tests/unit_tests/inference/test_mamba_reshard.py rename to tests/unit_tests/inference/test_ssm_reshard.py index 4a197813ab9..584f3a0213e 100644 --- a/tests/unit_tests/inference/test_mamba_reshard.py +++ b/tests/unit_tests/inference/test_ssm_reshard.py @@ -1,10 +1,10 @@ # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -"""Hetero TP/PP reshard of Mamba conv/ssm state (pure, CPU). +"""Hetero TP/PP reshard of SSM conv/recurrent state (pure, CPU). -Builds a known global Mamba state, shards it to a source (tp,pp) the exact way -mamba_mixer does ([x|B|C] conv bands + head-sharded ssm, layers split by PP), -runs plan_mamba_reshard to a different destination (tp,pp), and asserts every +Builds a known global SSM state, shards it to a source (tp,pp) the exact way +MambaMixer does ([x|B|C] conv bands + head-sharded recurrent state, layers +split by PP), runs ``plan_ssm_reshard`` to a different destination (tp,pp), and asserts every destination rank ends up byte-identical to a direct shard of the global state. This validates the band/layer index math against the real sharding model without a hybrid checkpoint (the residual gap is a real-model functional run). @@ -13,10 +13,10 @@ import pytest import torch -from megatron.core.inference.disaggregation.mamba_reshard import ( - MambaShardLayout, - MambaStateDims, - plan_mamba_reshard, +from megatron.core.inference.disaggregation.ssm_reshard import ( + SSMShardLayout, + SSMStateDims, + plan_ssm_reshard, ) @@ -26,17 +26,17 @@ def apply_conv_transfer(t, src_conv, dst_conv): dst_conv[t.dst_layer, t.dst_lo : t.dst_hi, :] = src_conv[t.src_layer, t.src_lo : t.src_hi, :] -def apply_ssm_transfer(t, src_ssm, dst_ssm): - """Copy an ssm sub-block in-memory; ssm is +def apply_recurrent_transfer(t, src_recurrent, dst_recurrent): + """Copy a recurrent-state sub-block in-memory; recurrent state is ``(num_layers, nheads_local, headdim, d_state)`` -- the band slices heads.""" - dst_ssm[t.dst_layer, t.dst_lo : t.dst_hi, :, :] = src_ssm[ + dst_recurrent[t.dst_layer, t.dst_lo : t.dst_hi, :, :] = src_recurrent[ t.src_layer, t.src_lo : t.src_hi, :, : ] # Global model dims (chosen divisible by the tp values under test). NHEADS, HEADDIM, DSTATE, NGROUPS, DCONV = 8, 4, 2, 2, 3 -M = 4 # global Mamba layers +M = 4 # global SSM layers D_INNER = NHEADS * HEADDIM # 32 G = NGROUPS * DSTATE # 4 (B and C band global size) CONV_DIM = D_INNER + 2 * G # 40 @@ -45,38 +45,38 @@ def apply_ssm_transfer(t, src_ssm, dst_ssm): def _global_state(): """Distinct value per (layer, channel, ...) so any mis-slice is caught.""" conv = torch.arange(M * CONV_DIM * DCONV, dtype=torch.float32).reshape(M, CONV_DIM, DCONV) - ssm = ( + recurrent = ( torch.arange(M * NHEADS * HEADDIM * DSTATE, dtype=torch.float32).reshape( M, NHEADS, HEADDIM, DSTATE ) + 10_000.0 ) - return conv, ssm + return conv, recurrent def _layouts(tp, pp): - """One MambaShardLayout per rank for a (tp, pp) instance; rank = p*tp + r. + """One SSMShardLayout per rank for a (tp, pp) instance; rank = p*tp + r. PP splits the M layers evenly (contiguous per stage).""" per = M // pp out = {} for p in range(pp): for r in range(tp): rank = p * tp + r - out[rank] = MambaShardLayout( + out[rank] = SSMShardLayout( global_rank=rank, tp_size=tp, tp_rank=r, layer_start=p * per, num_layers=per, - dims=MambaStateDims( + dims=SSMStateDims( nheads=NHEADS, headdim=HEADDIM, d_state=DSTATE, ngroups=NGROUPS, d_conv=DCONV ), ) return out -def _shard(conv_g, ssm_g, lay: MambaShardLayout): - """Shard the global state to one rank exactly as mamba_mixer does.""" +def _shard(conv_g, recurrent_g, lay: SSMShardLayout): + """Shard the global state to one rank exactly as MambaMixer does.""" s, e = lay.layer_range() r, tp = lay.tp_rank, lay.tp_size di_l = D_INNER // tp @@ -86,8 +86,8 @@ def _shard(conv_g, ssm_g, lay: MambaShardLayout): c = conv_g[s:e, D_INNER + G : D_INNER + 2 * G][:, r * g_l : (r + 1) * g_l] conv_l = torch.cat([x, b, c], dim=1).contiguous() nh_l = NHEADS // tp - ssm_l = ssm_g[s:e, r * nh_l : (r + 1) * nh_l, :, :].contiguous() - return conv_l, ssm_l + recurrent_l = recurrent_g[s:e, r * nh_l : (r + 1) * nh_l, :, :].contiguous() + return conv_l, recurrent_l @pytest.mark.parametrize( @@ -101,12 +101,12 @@ def _shard(conv_g, ssm_g, lay: MambaShardLayout): ((2, 1), (2, 1)), # identity ], ) -def test_mamba_reshard_reconstructs_destination(src, dst): - conv_g, ssm_g = _global_state() +def test_ssm_reshard_reconstructs_destination(src, dst): + conv_g, recurrent_g = _global_state() src_lay, dst_lay = _layouts(*src), _layouts(*dst) # Source per-rank tensors (as a prefill instance would hold them). - src_t = {rk: _shard(conv_g, ssm_g, lay) for rk, lay in src_lay.items()} + src_t = {rk: _shard(conv_g, recurrent_g, lay) for rk, lay in src_lay.items()} # Destination buffers, zero-filled at each rank's local shape. dst_t = {} for rk, lay in dst_lay.items(): @@ -115,71 +115,73 @@ def test_mamba_reshard_reconstructs_destination(src, dst): torch.zeros(lay.num_layers, lay.nheads_local, HEADDIM, DSTATE), ) - plan = plan_mamba_reshard(list(src_lay.values()), list(dst_lay.values())) + plan = plan_ssm_reshard(list(src_lay.values()), list(dst_lay.values())) for t in plan: if t.is_conv: apply_conv_transfer(t, src_t[t.src_rank][0], dst_t[t.dst_rank][0]) else: - apply_ssm_transfer(t, src_t[t.src_rank][1], dst_t[t.dst_rank][1]) + apply_recurrent_transfer(t, src_t[t.src_rank][1], dst_t[t.dst_rank][1]) # Every destination rank must match a direct shard of the global state. for rk, lay in dst_lay.items(): - want_conv, want_ssm = _shard(conv_g, ssm_g, lay) + want_conv, want_recurrent = _shard(conv_g, recurrent_g, lay) assert torch.equal(dst_t[rk][0], want_conv), f"conv mismatch at rank {rk} ({src}->{dst})" - assert torch.equal(dst_t[rk][1], want_ssm), f"ssm mismatch at rank {rk} ({src}->{dst})" + assert torch.equal( + dst_t[rk][1], want_recurrent + ), f"recurrent mismatch at rank {rk} ({src}->{dst})" -def test_mamba_rejects_indivisible_groups(): +def test_ssm_rejects_indivisible_groups(): """ngroups < tp_size would truncate the B/C bands to zero width; reject it up front instead of silently dropping state.""" with pytest.raises(ValueError): - MambaShardLayout( + SSMShardLayout( global_rank=0, tp_size=4, tp_rank=0, layer_start=0, num_layers=1, - dims=MambaStateDims(nheads=8, headdim=HEADDIM, d_state=DSTATE, ngroups=2, d_conv=DCONV), + dims=SSMStateDims(nheads=8, headdim=HEADDIM, d_state=DSTATE, ngroups=2, d_conv=DCONV), ) -def test_mamba_dedupes_replica_sources(): - """Two source ranks holding the same Mamba shard (same tp_rank+layer_start, +def test_ssm_dedupes_replica_sources(): + """Two source ranks holding the same SSM shard (same tp_rank+layer_start, e.g. EP/DP replicas) are deduped: the shard is sourced from exactly one of them (smallest global_rank), so no duplicate sends.""" def _lay(gr): - return MambaShardLayout( + return SSMShardLayout( global_rank=gr, tp_size=1, tp_rank=0, layer_start=0, num_layers=M, - dims=MambaStateDims( + dims=SSMStateDims( nheads=NHEADS, headdim=HEADDIM, d_state=DSTATE, ngroups=NGROUPS, d_conv=DCONV ), ) - plan = plan_mamba_reshard([_lay(0), _lay(1)], [_lay(2)]) + plan = plan_ssm_reshard([_lay(0), _lay(1)], [_lay(2)]) assert {t.src_rank for t in plan} == {0} # only the smallest-rank replica sources def test_layout_wire_roundtrip(): - """Layouts cross the coordinator as plain dicts (asdict) and are rebuilt via - MambaShardLayout(**dict); the nested dims dict must coerce back to - MambaStateDims so proxies (.headdim/.d_conv/...) keep working.""" + """Layouts cross the coordinator as plain dicts (asdict) and are rebuilt + via SSMShardLayout(**dict); the nested dims dict must coerce back to + SSMStateDims.""" import dataclasses - lay = MambaShardLayout( + lay = SSMShardLayout( global_rank=1, tp_size=2, tp_rank=1, layer_start=0, num_layers=M, - dims=MambaStateDims( + dims=SSMStateDims( nheads=NHEADS, headdim=HEADDIM, d_state=DSTATE, ngroups=NGROUPS, d_conv=DCONV ), ) - rebuilt = MambaShardLayout(**dataclasses.asdict(lay)) + rebuilt = SSMShardLayout(**dataclasses.asdict(lay)) assert rebuilt == lay - assert rebuilt.headdim == HEADDIM and rebuilt.d_conv == DCONV + assert rebuilt.conv_dim_local == lay.conv_dim_local From 943665611c8a8060dbd243e34030e955f64747ee Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Wed, 29 Jul 2026 20:47:35 -0400 Subject: [PATCH 152/290] Reorganize a few docs (#6109) Signed-off-by: Philip Petrakian --- docs/api-guide/core/index.md | 1 - docs/index.md | 4 +--- docs/user-guide/features/context_parallel.md | 5 +---- docs/user-guide/features/index.md | 2 -- docs/user-guide/index.md | 1 - docs/user-guide/parallelism-guide.md | 10 +++++++++- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/api-guide/core/index.md b/docs/api-guide/core/index.md index af22af6c6e0..0d39e46e744 100644 --- a/docs/api-guide/core/index.md +++ b/docs/api-guide/core/index.md @@ -16,7 +16,6 @@ Low-level API reference for core Megatron components. transformer tensor_parallel -generalized_tensor_parallel pipeline_parallel fusions distributed diff --git a/docs/index.md b/docs/index.md index 623f1514614..3995c70217d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,6 @@ get-started/quickstart user-guide/data-preparation user-guide/training-examples user-guide/parallelism-guide -user-guide/hybrid-model-migration ``` ```{toctree} @@ -67,11 +66,9 @@ models/index :caption: Advanced Features user-guide/features/moe -user-guide/features/context_parallel user-guide/features/megatron_fsdp user-guide/features/dist_optimizer user-guide/features/optimizer_cpu_offload -user-guide/features/pipeline_parallel_layout user-guide/features/fine_grained_activation_offloading user-guide/data-loading user-guide/features/megatron_energon @@ -104,5 +101,6 @@ apidocs/index.rst :hidden: :caption: Resources +user-guide/hybrid-model-migration advanced/index ``` diff --git a/docs/user-guide/features/context_parallel.md b/docs/user-guide/features/context_parallel.md index 890609ac7de..31965a2fbdb 100644 --- a/docs/user-guide/features/context_parallel.md +++ b/docs/user-guide/features/context_parallel.md @@ -7,9 +7,7 @@ license agreement from NVIDIA CORPORATION is strictly prohibited. --> -# Context Parallel Package - -## Context Parallelism Overview +# Context Parallel Overview ```{figure} ../../images/context_parallel/CP_overview.png :alt: Diagram of a transformer layer with tensor parallelism 2 and context parallelism 2, showing CP and TP communication patterns around attention and other blocks. @@ -40,4 +38,3 @@ CP addresses these tradeoffs. With CP, each GPU computes on part of the sequence CP support is included on the GPT code path. Other models that share that path, such as LLaMA, can use CP as well. CP works with TP (tensor model parallelism), PP (pipeline model parallelism), and DP (data parallelism). The total GPU count is TP × CP × PP × DP. CP also works with different attention variants, including MHA, MQA, and GQA, with unidirectional or bidirectional masking. Enable CP by setting `context_parallel_size=` on the command line. The default `context_parallel_size` is 1, which disables CP. Running with CP requires Megatron Core (>=0.5.0) and Transformer Engine (>=1.1). - diff --git a/docs/user-guide/features/index.md b/docs/user-guide/features/index.md index cb2e895afdc..ff805d67eb2 100644 --- a/docs/user-guide/features/index.md +++ b/docs/user-guide/features/index.md @@ -17,12 +17,10 @@ Guides for Megatron Core training features. cuda_graph fine_grained_activation_offloading moe -context_parallel megatron_fsdp dist_optimizer optimizer_cpu_offload paged_stash -pipeline_parallel_layout tokenizers megatron_energon megatron_rl diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 522b29299da..2262709bec4 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -24,5 +24,4 @@ training-examples parallelism-guide deterministic-training features/index -hybrid-model-migration ``` diff --git a/docs/user-guide/parallelism-guide.md b/docs/user-guide/parallelism-guide.md index 2540ca0a827..375afde60f4 100644 --- a/docs/user-guide/parallelism-guide.md +++ b/docs/user-guide/parallelism-guide.md @@ -11,6 +11,14 @@ Megatron Core supports multiple parallelism strategies that can be combined to efficiently train models from billions to trillions of parameters across thousands of GPUs. +```{toctree} +:hidden: + +features/context_parallel +features/pipeline_parallel_layout +../api-guide/core/generalized_tensor_parallel +``` + ## Overview The following table summarizes supported parallelism strategies. @@ -116,7 +124,7 @@ Split long sequences across GPUs for efficient long-context training. - Reduces activation memory - Can combine with TP, PP, DP -Refer to [Context Parallelism Deep Dive](features/context_parallel.md) for a detailed guide with performance analysis. +Refer to the [Context Parallel Overview](features/context_parallel.md) for a detailed guide with performance analysis. ## Expert Parallelism (EP) From 8e57bb642344f60e04bec0d7a79e5fd66c5c0023 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Thu, 30 Jul 2026 10:03:50 +0800 Subject: [PATCH 153/290] Perf: skip per-param copy_ dispatch in the MXFP8 param copy-back (#6094) Signed-off-by: Shiqing Fan --- .../core/distributed/param_and_grad_buffer.py | 12 ++- megatron/core/fp8_utils.py | 40 ++++++++++ tests/unit_tests/test_fp8_utils.py | 76 +++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index 50fa566d1b6..4439f123852 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -28,7 +28,7 @@ modify_nvfp4_rowwise_storage, ) from ..fp8_utils import ( - copy_tensor_to_quantized_param, + copy_tensors_to_quantized_params, is_float8tensor, is_grouped_mxfp8tensor, is_grouped_tensor, @@ -308,6 +308,9 @@ def _post_param_sync(self): # buffer to copy back from. continue has_non_quantized_weight = False + quantized_params = [] + param_slices = [] + flat_param_data = bucket.param_data.view(-1) for param in bucket.params: # Non-quantized weights are already mapped to param.data. Skip # mixed buckets because zeroing bucket.param_data would also @@ -316,8 +319,11 @@ def _post_param_sync(self): has_non_quantized_weight = True break param_start, param_end = bucket.param_to_index[param] - param_slice = bucket.param_data.view(-1)[param_start:param_end] - copy_tensor_to_quantized_param(param, param_slice) + quantized_params.append(param) + param_slices.append(flat_param_data[param_start:param_end]) + # Cast the bucket in one call: these casts are small, so the per-param cost of + # issuing them is worth avoiding. + copy_tensors_to_quantized_params(quantized_params, param_slices) if has_non_quantized_weight: continue # All-gathered params are not needed after being copied to param.data. diff --git a/megatron/core/fp8_utils.py b/megatron/core/fp8_utils.py index 5411b676d83..895d46e9b3d 100644 --- a/megatron/core/fp8_utils.py +++ b/megatron/core/fp8_utils.py @@ -226,6 +226,46 @@ def copy_tensor_to_quantized_param(param: torch.Tensor, src: torch.Tensor) -> No dst.copy_(src.view(dst.shape)) +def copy_tensors_to_quantized_params(params: List[torch.Tensor], srcs: List[torch.Tensor]) -> None: + """List form of :func:`copy_tensor_to_quantized_param`, for a whole bucket of params. + + Same values, minus the per-param ``copy_`` and tensor-subclass dispatch: the quantizer is + resolved up front and called directly. Cast kernels are unchanged, one per param. Worth it + because those casts are small and issuing them is expensive, and under + --reuse-grad-buf-for-mxfp8-param-ag they run inside the forward pass. + + Args: + params: quantized model params to write into. + srcs: high-precision source values, one per param, in the same order. + """ + if len(params) == 0: + return + + srcs_to_cast = [] + dsts_to_cast = [] + quantizers = [] + for param, src in zip(params, srcs): + dst = _unwrap_parameter_data(param) + quantizer = ( + None + if is_grouped_tensor_with_quantized_storage(dst) + else getattr(dst, "_quantizer", None) + ) + if quantizer is None: + # Grouped storage quantizes per member; a missing quantizer has to be built. Both + # cases are handled by the single-param path. + copy_tensor_to_quantized_param(param, src) + continue + srcs_to_cast.append(src.view(dst.shape)) + dsts_to_cast.append(dst) + quantizers.append(quantizer) + + # Equivalent to dst.copy_(src), but entered directly instead of via the aten::copy_ op, + # QuantizedTensor.__torch_dispatch__ (type and usage checks) and dst.quantize_(src). + for src, quantizer, dst in zip(srcs_to_cast, quantizers, dsts_to_cast): + quantizer.update_quantized(src, dst) + + def modify_grouped_tensor_rowwise_storage(tensor: torch.Tensor, new_storage: torch.Tensor) -> None: """Replace a high-precision Transformer Engine GroupedTensor's rowwise storage.""" tensor = _unwrap_parameter_data(tensor) diff --git a/tests/unit_tests/test_fp8_utils.py b/tests/unit_tests/test_fp8_utils.py index 5be17f03c9f..dc65d541455 100644 --- a/tests/unit_tests/test_fp8_utils.py +++ b/tests/unit_tests/test_fp8_utils.py @@ -7,8 +7,21 @@ import torch.nn as nn from megatron.core import fp8_utils +from megatron.training.utils import get_device_arch_version from tests.unit_tests.test_utilities import Utils +try: + import transformer_engine_torch as tex + from transformer_engine.pytorch.tensor.mxfp8_tensor import MXFP8Quantizer + + HAVE_MXFP8_TENSOR = True +except ImportError: + HAVE_MXFP8_TENSOR = False + +# MXFP8 needs Blackwell or newer. +mxfp8_available = HAVE_MXFP8_TENSOR and get_device_arch_version() >= 10 +reason_for_no_mxfp8 = "MXFP8 requires Transformer Engine and device arch >= 10" + class MockTELinear(nn.Module): """Mock TE Linear module for testing.""" @@ -130,3 +143,66 @@ def track_forward(x): # Verify output has original shape assert output.shape == (6, 2, 4096) # Back to original seq_len + + +@pytest.mark.skipif(not mxfp8_available, reason=reason_for_no_mxfp8) +class TestCopyTensorsToQuantizedParams: + """Cover the batched MXFP8 param copy-back used by _post_param_sync. + + ``copy_tensors_to_quantized_params`` bypasses ``copy_`` and calls the destination quantizer + directly, so the contract to protect is that it still writes exactly what the per-param + ``copy_tensor_to_quantized_param`` would have written. + """ + + SHAPES = [(1024, 512), (2048, 256), (512, 1024)] + + def _make_param(self, shape): + quantizer = MXFP8Quantizer(fp8_dtype=tex.DType.kFloat8E4M3, rowwise=True, columnwise=True) + tensor = quantizer.make_empty(shape, dtype=torch.bfloat16, device="cuda") + return torch.nn.Parameter(tensor, requires_grad=False) + + def _raw_buffers(self, param): + """The four buffers MXFP8 storage is made of, i.e. everything a cast writes.""" + data = param.data + return ( + data._rowwise_data, + data._rowwise_scale_inv, + data._columnwise_data, + data._columnwise_scale_inv, + ) + + def test_matches_per_param_copy(self): + """Batched copy-back is bitwise identical to copying one param at a time.""" + torch.manual_seed(0) + reference_params = [self._make_param(shape) for shape in self.SHAPES] + batched_params = [self._make_param(shape) for shape in self.SHAPES] + # Sources are flat slices, matching how _post_param_sync views the param buffer. + srcs = [ + torch.randn(shape, dtype=torch.bfloat16, device="cuda").view(-1) + for shape in self.SHAPES + ] + + for param, src in zip(reference_params, srcs): + fp8_utils.copy_tensor_to_quantized_param(param, src) + fp8_utils.copy_tensors_to_quantized_params(batched_params, srcs) + torch.cuda.synchronize() + + for reference, batched in zip(reference_params, batched_params): + for expected, actual in zip(self._raw_buffers(reference), self._raw_buffers(batched)): + assert torch.equal(expected, actual) + + def test_falls_back_without_quantizer(self): + """A destination with no quantizer of its own still gets written.""" + param = self._make_param(self.SHAPES[0]) + param.data._quantizer = None + src = torch.randn(self.SHAPES[0], dtype=torch.bfloat16, device="cuda").view(-1) + + fp8_utils.copy_tensors_to_quantized_params([param], [src]) + torch.cuda.synchronize() + + # A quantized copy of a non-zero source cannot be all zeros. + assert param.data._rowwise_data.any() + + def test_empty_input(self): + """No params is a no-op rather than an error.""" + fp8_utils.copy_tensors_to_quantized_params([], []) From 354b5d2d06ebf2c529688db69ce5773932226381 Mon Sep 17 00:00:00 2001 From: Lawrence McAfee <85179052+lmcafee-nvidia@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:43:40 -0400 Subject: [PATCH 154/290] test: add async scheduling inference coverage (#6104) Signed-off-by: shanmugamr1992 Signed-off-by: Lawrence McAfee Co-authored-by: shanmugamr1992 Co-authored-by: Claude Opus 4.7 --- .../golden_values_dev_dgx_gb200.json | 711 ++++++++++++++++++ .../golden_values_dev_dgx_h100.json | 711 ++++++++++++++++++ .../model_config.yaml | 59 ++ .../shell_test_utils/run_perf_test.sh | 20 + .../baseline_values.json | 52 ++ .../model_config.yaml | 38 + .../baseline_values.json | 40 + .../model_config.yaml | 36 + .../recipes/gb200/gpt-dynamic-inference.yaml | 65 ++ .../recipes/gb200/gpt-perf-dp4.yaml | 2 +- .../recipes/h100/gpt-dynamic-inference.yaml | 5 + .../test_utils/recipes/h100/gpt-perf-dp8.yaml | 2 +- 12 files changed, 1739 insertions(+), 2 deletions(-) create mode 100644 tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_gb200.json create mode 100644 tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_h100.json create mode 100644 tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/model_config.yaml create mode 100644 tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/baseline_values.json create mode 100644 tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/model_config.yaml create mode 100644 tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/baseline_values.json create mode 100644 tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/model_config.yaml create mode 100644 tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_gb200.json new file mode 100644 index 00000000000..5737d10fce9 --- /dev/null +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_gb200.json @@ -0,0 +1,711 @@ +{ + "0": { + "input_prompt": "The $500 Cup of coffee?\nConsider this, most Americans spend an average of $1,500-2,000 a year on this bean water.\nI have a few question for you: \nHow has business been the past few months?\nDo you ever feel like your business is stuck?\nDon't feel like you're able to improve performance and make changes required to achieve success ?\nAre your customers spneding less and less and wanting more?\nHave the gas prices affected your business?\nDo you have employees and do they hate you or wish they could quit?\n\nNow, before you and I can decide wheter or not I will be a good fit for your business we should talk this over with coffee.\nAnd, just to warn you this isn't some casual thing. This is not a date or time to be personal or social (but by all means share what you will coz I'll gladly listen).\nTher eare two major talking points and stratagies we will focios on in our lil coffee social\nFor one, we will find your unique selling Proposition (USP).\nDo have the best price? Are you the cheapest in town? Are your customers jerks? Do you haVE REGULARS? Why do people come back?\nwe'll also look for the holes in your business bucket. I'm willing to bet there's a hole or two in your business we'll find together that'll make this 500 dollar cup of Joe pay for itse;f immedietly.\nMany find themselves to be more profitable by just finding out where the dollars are escaping in their business and I like to think of myself as a guy that comes along with some spakel or putty and patch those holes up for you.\nBeleive me, just fixing one hole can mean a lot...just think about a sinking boat that has a hole in it that's about 3\u201d in diameter... it doesn't take long to sink.\nI have no agenda, besides f=getting to know your business and seeing wher I can patch the holes and find what makes you do darn unique (I know this won't take long.)\nMany folks, I bet, will find what they need to get off their chest with a quick phone call and they just paypal me the money and make a coffee at home. Look, that's fine too.\nI just to get you ot of your comfort zone, because this is where it all starts my frind.\nSome smart GOAT entrepreneur will probably get everything they need just out of our lil mini consulatant for the more extensive business consukting I offer, and look, that's fine, too.\nMaybe this cup of coffee will be all you need to gtet back on your feet, but not only surive, but thrive!\nI'm not trying to make frineds, or make a bunch of money, or look down your shirt\nBut this is only going to be a 45 minute (max) coffee chat\nAnd, I know you (and me) both have a lot on our plates. So no messing around\nAfter our consultation I will follow up with you in a few days and see how things are going, then I will be emailing you about once or twice every two weeks, just to make sure you're staying on task and implementing what we went over.\nTghere is no obligation to go any further and will gladly give you back your money if this pre-consultation doesn't put you on the right path or you don't get any value out of it...", + "generated_text": " $ is a$ is a $ is a $ is a $ is a $ is a $$1, you\n$ $$ $\n$ $- $\n$\n$\n$ $$$\n$\n$\n$\n$\n$\n$\n$\n$???????, $\n-You\n$\n$-1\n$\n$\nHow\n$.?\n\n$\n$\n1.1\nI\n$.\nI\n\n\nHow\n$,,,0,1,0,00- $\n... is\nThe\nThe\n", + "generated_tokens": [ + 1659, + 1395, + 1261, + 1036, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1036, + 1049, + 1044, + 1636, + 1010, + 1036, + 1659, + 1036, + 1659, + 1010, + 1036, + 1659, + 1045, + 1659, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1659, + 1036, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1063, + 1063, + 1063, + 1063, + 1063, + 1063, + 1063, + 1044, + 1659, + 1010, + 1045, + 4568, + 1010, + 1036, + 1010, + 1036, + 1045, + 1049, + 1010, + 1036, + 1010, + 1036, + 1010, + 7801, + 1010, + 1036, + 1046, + 9551, + 1010, + 1036, + 1010, + 1036, + 1010, + 1049, + 1046, + 1049, + 1010, + 1073, + 1010, + 1036, + 1046, + 1010, + 1073, + 1010, + 1010, + 1010, + 7801, + 1010, + 1036, + 1044, + 1044, + 1044, + 1048, + 1044, + 1049, + 1044, + 1048, + 1044, + 1048, + 1048, + 1045, + 1659, + 1010, + 2880, + 1395, + 1010, + 1784, + 1010, + 1784, + 1010 + ], + "latency": 15.173143148422241, + "ttft": 0.27222132682800293, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "32": { + "input_prompt": "create a conversational article", + "generated_text": " about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article.", + "generated_tokens": [ + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046 + ], + "latency": 15.158410787582397, + "ttft": 0.2701425552368164, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "64": { + "input_prompt": "App Concept: \"Eggy's Interactive Adventure World\"\n\nEggy's Interactive Adventure World is a cutting-edge app designed for phones and tablets that combines immersive storytelling with engaging, interactive experiences for both parents and children. This innovative app allows users to explore the world of Eggy and participate in a series of adventures that teach essential life skills, foster personal growth, and strengthen parent-child bonds.\n\nKey Features:\n\n1. Interactive Stories: The app brings the 20 Eggy stories to life through interactive, animated storytelling. Users can read, listen, and actively participate in the stories, making choices that guide Eggy through various adventures, each with unique lessons and experiences.\n2. Augmented Reality (AR) Integration: The app utilizes AR technology, allowing users to interact with Eggy and the story characters in their real-world environment. This immersive experience creates a deeper connection with the stories and encourages engagement.\n3. Personalized Adventures: The app customizes each story based on the user's interests, needs, and personal growth areas. By collecting user input, the app tailors the storylines and learning outcomes to ensure that each adventure resonates with the child's unique experiences.\n4. Parent-Child Cooperative Play: The app encourages parents and children to collaborate and engage in the stories together. Through a split-screen mode or multi-device connectivity, parents and children can actively participate in the stories and learn essential life skills together.\n5. Skill-Building Mini-Games: Interspersed throughout the stories are educational mini-games that reinforce the lessons and themes. These games are designed to be fun, engaging, and accessible to children, encouraging them to practice and develop various skills.\n6. Progress Tracking and Rewards: The app tracks the user's progress through the stories and rewards them with badges, trophies, and other collectibles. This gamification aspect encourages continued engagement and fosters a sense of accomplishment and growth.\n7. Community Features: The app provides a safe, moderated online community where parents can share their experiences, discuss the stories and lessons, and connect with other families who are also using the app. This community fosters support, inspiration, and learning.\n8. Expert Resources: The app includes access to resources from experts in child development, psychology, and education, providing parents with additional tools and guidance on fostering the growth and well-being of their children.\n\nEggy's Interactive Adventure World is a one-of-a-kind, mind-blowing app that revolutionizes the way parents and children engage with stories and learn essential life skills. Combining the power of storytelling, cutting-edge technology, and expert resources, this app is a must-have for families looking to strengthen their bonds and support their children's personal growth.", + "generated_text": "The 1999\u20132000 season was the 10th season of the National Hockey League (NHL). The 1999\u20132000 season was the 10th season of the National Hockey League (NHL). The 1999\u20132000 season was the 10th season of the National Hockey League (NHL). The 1999\u20132000 season was the 10th season was the 10th season was the 10 season was the 10 season was the 10", + "generated_tokens": [ + 2, + 1784, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1307, + 1278, + 5805, + 46982, + 8525, + 1319, + 19644, + 1076, + 1577, + 1531, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1307, + 1278, + 5805, + 46982, + 8525, + 1319, + 19644, + 1076, + 1577, + 1531, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1307, + 1278, + 5805, + 46982, + 8525, + 1319, + 19644, + 1076, + 1577, + 1531, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048 + ], + "latency": 15.145662069320679, + "ttft": 0.26699352264404297, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "96": { + "input_prompt": "what is the concept of double materiality in sustainability?", + "generated_text": " What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double material", + "generated_tokens": [ + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318 + ], + "latency": 15.136452198028564, + "ttft": 0.24875521659851074, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "127": { + "input_prompt": "as before we talk about using , i usually do if else() to in case the property would be null before.\nif today i public a property and i want to use using to instead , will code be like this?\n\npublic Material Mat;\n\nusing(Mat){\n// Do something;\n}\n\nis that right?", + "generated_text": " i am not sure if i am using the wrong syntax or if i am using the syntax correctly but i am not sure if i am using the syntax correctly or if i am using the syntax incorrectly.\n\nI am not sure if i am using the wrong syntax or if i am using the syntax correctly but i am not sure if i am using the syntax correctly or if i am using the syntax incorrectly.\n\nI am not sure if i am using the wrong syntax or if i am using the syntax correctly but i am not sure if i am using the syntax correctly or if i am using the syntax incorrectly.\n\nI am not sure if i am using", + "generated_tokens": [ + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 8462, + 22692, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1809, + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 73751, + 1338, + 1073, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 8462, + 22692, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1809, + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 73751, + 1338, + 1073, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 8462, + 22692, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1809, + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 73751, + 1338, + 1073, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505 + ], + "latency": 15.125433683395386, + "ttft": 0.23802447319030762, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "throughput": [ + 836.2814759906043, + 1044.265060439428, + 1053.2105377765834 + ], + "mem-max-allocated-bytes": 22954507776, + "lifetime_prefill_token_count": 28887, + "async_sched_step_count": 131, + "async_sched_compaction_step_count": 4 +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_h100.json new file mode 100644 index 00000000000..af2242f77a9 --- /dev/null +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/golden_values_dev_dgx_h100.json @@ -0,0 +1,711 @@ +{ + "0": { + "input_prompt": "The $500 Cup of coffee?\nConsider this, most Americans spend an average of $1,500-2,000 a year on this bean water.\nI have a few question for you: \nHow has business been the past few months?\nDo you ever feel like your business is stuck?\nDon't feel like you're able to improve performance and make changes required to achieve success ?\nAre your customers spneding less and less and wanting more?\nHave the gas prices affected your business?\nDo you have employees and do they hate you or wish they could quit?\n\nNow, before you and I can decide wheter or not I will be a good fit for your business we should talk this over with coffee.\nAnd, just to warn you this isn't some casual thing. This is not a date or time to be personal or social (but by all means share what you will coz I'll gladly listen).\nTher eare two major talking points and stratagies we will focios on in our lil coffee social\nFor one, we will find your unique selling Proposition (USP).\nDo have the best price? Are you the cheapest in town? Are your customers jerks? Do you haVE REGULARS? Why do people come back?\nwe'll also look for the holes in your business bucket. I'm willing to bet there's a hole or two in your business we'll find together that'll make this 500 dollar cup of Joe pay for itse;f immedietly.\nMany find themselves to be more profitable by just finding out where the dollars are escaping in their business and I like to think of myself as a guy that comes along with some spakel or putty and patch those holes up for you.\nBeleive me, just fixing one hole can mean a lot...just think about a sinking boat that has a hole in it that's about 3\u201d in diameter... it doesn't take long to sink.\nI have no agenda, besides f=getting to know your business and seeing wher I can patch the holes and find what makes you do darn unique (I know this won't take long.)\nMany folks, I bet, will find what they need to get off their chest with a quick phone call and they just paypal me the money and make a coffee at home. Look, that's fine too.\nI just to get you ot of your comfort zone, because this is where it all starts my frind.\nSome smart GOAT entrepreneur will probably get everything they need just out of our lil mini consulatant for the more extensive business consukting I offer, and look, that's fine, too.\nMaybe this cup of coffee will be all you need to gtet back on your feet, but not only surive, but thrive!\nI'm not trying to make frineds, or make a bunch of money, or look down your shirt\nBut this is only going to be a 45 minute (max) coffee chat\nAnd, I know you (and me) both have a lot on our plates. So no messing around\nAfter our consultation I will follow up with you in a few days and see how things are going, then I will be emailing you about once or twice every two weeks, just to make sure you're staying on task and implementing what we went over.\nTghere is no obligation to go any further and will gladly give you back your money if this pre-consultation doesn't put you on the right path or you don't get any value out of it...", + "generated_text": " $ is a$ is a $ is a $ is a $ is a $ is a $$1, you\n$ $$ $\n$ $- $\n$\n$\n$ $$$\n$\n$\n$\n$\n$\n$\n$\n$???????, $\n-You\n$\n$-1\n$\n$\nHow\n$.?\n\n$\n$\n1.1\nI\n$.\nI\n\n\nHow\n$,,,0,1,0,00- $\n... is\nThe\nThe\n", + "generated_tokens": [ + 1659, + 1395, + 1261, + 1036, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1395, + 1261, + 1659, + 1036, + 1049, + 1044, + 1636, + 1010, + 1036, + 1659, + 1036, + 1659, + 1010, + 1036, + 1659, + 1045, + 1659, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1659, + 1036, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1010, + 1036, + 1063, + 1063, + 1063, + 1063, + 1063, + 1063, + 1063, + 1044, + 1659, + 1010, + 1045, + 4568, + 1010, + 1036, + 1010, + 1036, + 1045, + 1049, + 1010, + 1036, + 1010, + 1036, + 1010, + 7801, + 1010, + 1036, + 1046, + 9551, + 1010, + 1036, + 1010, + 1036, + 1010, + 1049, + 1046, + 1049, + 1010, + 1073, + 1010, + 1036, + 1046, + 1010, + 1073, + 1010, + 1010, + 1010, + 7801, + 1010, + 1036, + 1044, + 1044, + 1044, + 1048, + 1044, + 1049, + 1044, + 1048, + 1044, + 1048, + 1048, + 1045, + 1659, + 1010, + 2880, + 1395, + 1010, + 1784, + 1010, + 1784, + 1010 + ], + "latency": 10.892494678497314, + "ttft": 0.194596529006958, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "32": { + "input_prompt": "create a conversational article", + "generated_text": " about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article. The article should be about the topic of the article.", + "generated_tokens": [ + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046, + 1531, + 9369, + 2715, + 1402, + 2314, + 1278, + 17915, + 1307, + 1278, + 9369, + 1046 + ], + "latency": 10.882038831710815, + "ttft": 0.1947627067565918, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "64": { + "input_prompt": "App Concept: \"Eggy's Interactive Adventure World\"\n\nEggy's Interactive Adventure World is a cutting-edge app designed for phones and tablets that combines immersive storytelling with engaging, interactive experiences for both parents and children. This innovative app allows users to explore the world of Eggy and participate in a series of adventures that teach essential life skills, foster personal growth, and strengthen parent-child bonds.\n\nKey Features:\n\n1. Interactive Stories: The app brings the 20 Eggy stories to life through interactive, animated storytelling. Users can read, listen, and actively participate in the stories, making choices that guide Eggy through various adventures, each with unique lessons and experiences.\n2. Augmented Reality (AR) Integration: The app utilizes AR technology, allowing users to interact with Eggy and the story characters in their real-world environment. This immersive experience creates a deeper connection with the stories and encourages engagement.\n3. Personalized Adventures: The app customizes each story based on the user's interests, needs, and personal growth areas. By collecting user input, the app tailors the storylines and learning outcomes to ensure that each adventure resonates with the child's unique experiences.\n4. Parent-Child Cooperative Play: The app encourages parents and children to collaborate and engage in the stories together. Through a split-screen mode or multi-device connectivity, parents and children can actively participate in the stories and learn essential life skills together.\n5. Skill-Building Mini-Games: Interspersed throughout the stories are educational mini-games that reinforce the lessons and themes. These games are designed to be fun, engaging, and accessible to children, encouraging them to practice and develop various skills.\n6. Progress Tracking and Rewards: The app tracks the user's progress through the stories and rewards them with badges, trophies, and other collectibles. This gamification aspect encourages continued engagement and fosters a sense of accomplishment and growth.\n7. Community Features: The app provides a safe, moderated online community where parents can share their experiences, discuss the stories and lessons, and connect with other families who are also using the app. This community fosters support, inspiration, and learning.\n8. Expert Resources: The app includes access to resources from experts in child development, psychology, and education, providing parents with additional tools and guidance on fostering the growth and well-being of their children.\n\nEggy's Interactive Adventure World is a one-of-a-kind, mind-blowing app that revolutionizes the way parents and children engage with stories and learn essential life skills. Combining the power of storytelling, cutting-edge technology, and expert resources, this app is a must-have for families looking to strengthen their bonds and support their children's personal growth.", + "generated_text": "The 1999\u20132000 season was the 10th season of the National Hockey League (NHL). The 1999\u20132000 season was the 10th season of the National Hockey League (NHL). The 1999\u20132000 season was the 10th season of the National Hockey League (NHL). The 1999\u20132000 season was the 10th season was the 10th season was the 10 season was the 10 season was the 10", + "generated_tokens": [ + 2, + 1784, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1307, + 1278, + 5805, + 46982, + 8525, + 1319, + 19644, + 1076, + 1577, + 1531, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1307, + 1278, + 5805, + 46982, + 8525, + 1319, + 19644, + 1076, + 1577, + 1531, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1307, + 1278, + 5805, + 46982, + 8525, + 1319, + 19644, + 1076, + 1577, + 1531, + 1032, + 1049, + 1057, + 1057, + 1057, + 1882, + 1050, + 1048, + 1048, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 1411, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048, + 5526, + 1486, + 1278, + 1032, + 1049, + 1048 + ], + "latency": 10.872116804122925, + "ttft": 0.19449090957641602, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "96": { + "input_prompt": "what is the concept of double materiality in sustainability?", + "generated_text": " What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double materiality in sustainability? What is the concept of double material", + "generated_tokens": [ + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318, + 1532, + 1294, + 60187, + 1063, + 5675, + 1395, + 1278, + 7401, + 1307, + 5558, + 4318 + ], + "latency": 10.860994100570679, + "ttft": 0.18096256256103516, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "127": { + "input_prompt": "as before we talk about using , i usually do if else() to in case the property would be null before.\nif today i public a property and i want to use using to instead , will code be like this?\n\npublic Material Mat;\n\nusing(Mat){\n// Do something;\n}\n\nis that right?", + "generated_text": " i am not sure if i am using the wrong syntax or if i am using the syntax correctly but i am not sure if i am using the syntax correctly or if i am using the syntax incorrectly.\n\nI am not sure if i am using the wrong syntax or if i am using the syntax correctly but i am not sure if i am using the syntax correctly or if i am using the syntax incorrectly.\n\nI am not sure if i am using the wrong syntax or if i am using the syntax correctly but i am not sure if i am using the syntax correctly or if i am using the syntax incorrectly.\n\nI am not sure if i am using", + "generated_tokens": [ + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 8462, + 22692, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1809, + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 73751, + 1338, + 1073, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 8462, + 22692, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1809, + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 73751, + 1338, + 1073, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 8462, + 22692, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1809, + 1623, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 17047, + 1505, + 1693, + 1623, + 1855, + 2505, + 1278, + 22692, + 73751, + 1338, + 1073, + 1855, + 1605, + 5257, + 1693, + 1623, + 1855, + 2505 + ], + "latency": 10.852606058120728, + "ttft": 0.17280030250549316, + "cuda_graph_request_count_map": null, + "step_count": 132, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null + }, + "throughput": [ + 1130.0929743054994, + 1467.7527907005106, + 1467.8709552178896 + ], + "mem-max-allocated-bytes": 22954507776, + "lifetime_prefill_token_count": 28887, + "async_sched_step_count": 131, + "async_sched_compaction_step_count": 4 +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/model_config.yaml new file mode 100644 index 00000000000..e4c49d32c68 --- /dev/null +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_async_sched/model_config.yaml @@ -0,0 +1,59 @@ +ENV_VARS: + CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 + NCCL_ALGO: Ring + CUBLAS_WORKSPACE_CONFIG: :4096:8 +TEST_TYPE: frozen-start +MODE: inference +MODEL_ARGS: + --tiktoken-pattern: v2 + --use-mcore-models: true + --tokenizer-type: TikTokenizer + --tokenizer-model: ${CHECKPOINT_LOAD_PATH}/model/mcore_mistral/nemo_minitron-0.5b/v1/multiMixV8.gpt4o_nc_sd.500000.128k.vocab.json + --auto-detect-ckpt-format: true + --max-tokens-to-oom: 3600000 + --inference-max-seq-length: 4096 + --attention-backend: flash + --use-checkpoint-args: true + --micro-batch-size: 1 + --no-load-optim: true + --no-use-tokenizer-model-from-checkpoint-args: true + --timing-log-level: 0 + --load: ${CHECKPOINT_LOAD_PATH}/model/mcore_mistral/nemo_minitron-0.5b/v1 + --distributed-backend: nccl + --log-interval: 1 + --transformer-impl: transformer_engine + --tensor-model-parallel-size: 1 + --pipeline-model-parallel-size: 1 + --deterministic-mode: true + --ckpt-format: torch_dist + --bf16: true + --log-memory-to-tensorboard: true + --log-num-zeros-in-grad: true + --log-validation-ppl-to-tensorboard: true + --log-timers-to-tensorboard: true + --num-layers: 24 + --hidden-size: 1152 + --num-attention-heads: 16 + --max-position-embeddings: 1024 + --seq-length: 1024 + --temperature: 1.0 + --top_k: 1 + # Async scheduling only supports greedy sampling (top_k=1, top_p=0.0) and does + # not support log probabilities, stop words, chunked prefill, or prefix + # caching (see dynamic_engine._validate_async_sched_support_for_request). + --inference-dynamic-batching-buffer-size-gb: 20 + --inference-dynamic-batching-async-sched-mode: async + --dist-ckpt-strictness: log_unexpected + --inference-ckpt-non-strict: true # To handle the extra_state errors + --output-path: ${INFERENCE_OUTPUT_PATH} + --output-every-n-results: 32 + --prompt-file: ${DATA_PATH}/text/sharegpt-vicuna/filtered/processed.jsonl + --prompt-file-num-truncate: 128 # originally 1024 + --num-tokens-to-generate: 128 # originally 512 + --incoming-requests-per-step: 32 + --termination-id: -1 + --inference-repeat-n: 3 + --inference-logging-step-interval: 1 +METRICS: + - "generated_tokens" diff --git a/tests/performance_tests/shell_test_utils/run_perf_test.sh b/tests/performance_tests/shell_test_utils/run_perf_test.sh index f39196cda98..830af93c5e0 100755 --- a/tests/performance_tests/shell_test_utils/run_perf_test.sh +++ b/tests/performance_tests/shell_test_utils/run_perf_test.sh @@ -88,6 +88,12 @@ NUM_TIMED_ITERS=$("$YQ" '.NUM_TIMED_ITERS // 5' "$CONFIG_PATH") # hybrid models should use 'gsm8k' — synthetic input gives misleading # perf because every token is identical (uniform expert routing, hot KV). DATASET=$("$YQ" '.DATASET // "synthetic"' "$CONFIG_PATH") +# Async prefill scheduling for dynamic batching. When true, the server is +# launched with --inference-dynamic-batching-async-sched-mode async (overlaps +# the prefill scheduler with GPU compute). Requires greedy sampling / no +# logprobs / no stop words — the static benchmark client already satisfies +# these (temperature 0.0, ignore_eos, no stop tokens, no logprobs requested). +ASYNC_SCHED=$("$YQ" '.ASYNC_SCHED // false' "$CONFIG_PATH") mapfile -t BATCH_SIZES < <("$YQ" '.BATCH_SIZES[]' "$CONFIG_PATH") # For MoE configs, expert-parallelism is orthogonal to DP and reshapes the @@ -194,6 +200,20 @@ SERVER_COMMON_ARGS=( --host 0.0.0.0 ) +# Enable async prefill scheduling when the test case opts in. Async scheduling +# requires materialize_only_last_token_logits=True. run_dynamic_text_generation_server +# force-sets return_log_probs=True (for echo/loglikelihood support), which would +# flip materialize_only_last_token_logits to False; passing --skip-prompt-log-probs +# keeps it True (materialize = not(return_log_probs and not skip_prompt_log_probs)). +# The perf client never requests prompt logprobs, so this is a no-op for the metrics. +if [[ "$ASYNC_SCHED" == "true" ]]; then + echo "[run_perf_test] async scheduling enabled (--inference-dynamic-batching-async-sched-mode async --skip-prompt-log-probs)" + SERVER_COMMON_ARGS+=( + --inference-dynamic-batching-async-sched-mode async + --skip-prompt-log-probs + ) +fi + ( cd "$ROOT_DIR" uv run --no-sync python -m torch.distributed.run \ diff --git a/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/baseline_values.json b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/baseline_values.json new file mode 100644 index 00000000000..026a7354660 --- /dev/null +++ b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/baseline_values.json @@ -0,0 +1,52 @@ +{ + "h100": { + "batch_1": { + "batch_size": 1, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 42.75601962213067, + "avg_latency_ms": 2993.6806965619326, + "p50_latency_ms": 2992.9271759465337, + "p99_latency_ms": 3003.761636093259, + "tpot_ms_per_tok": 23.388519530999474 + }, + "batch_8": { + "batch_size": 8, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 344.0237043263471, + "avg_latency_ms": 2920.4657254274935, + "p50_latency_ms": 2921.021580696106, + "p99_latency_ms": 2982.457813806832, + "tpot_ms_per_tok": 23.254211554012727 + }, + "batch_32": { + "batch_size": 32, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 1372.033198644746, + "avg_latency_ms": 2927.9737344244495, + "p50_latency_ms": 2925.4276445135474, + "p99_latency_ms": 3030.2867460995913, + "tpot_ms_per_tok": 23.32305080635706 + }, + "batch_128": { + "batch_size": 128, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 5395.836458577838, + "avg_latency_ms": 2958.10645565507, + "p50_latency_ms": 2953.0187863856554, + "p99_latency_ms": 3037.2949857264757, + "tpot_ms_per_tok": 23.72199398232624 + } + } +} diff --git a/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/model_config.yaml b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/model_config.yaml new file mode 100644 index 00000000000..d95f385ebe4 --- /dev/null +++ b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched/model_config.yaml @@ -0,0 +1,38 @@ +# Inference perf test: 583M mcore-mistral checkpoint, TP=1 PP=1 DP=8 (8 GPUs), +# async prefill scheduling enabled. +# +# Same 583M checkpoint / DP=8 dynamic-batching path as gpt_583m_perf, but the +# server runs with --inference-dynamic-batching-async-sched-mode async (the +# scheduler overlaps prefill setup with GPU compute). Measures the throughput / +# latency of the async scheduling path exercised by the functional test +# gpt_dynamic_inference_tp1_pp1_583m_async_sched. +# +# Async scheduling requires greedy sampling / no logprobs / no stop words; the +# static benchmark client already satisfies these (temperature 0.0, ignore_eos). +# +# Baseline values are recorded by `RECORD_BASELINE=1 run_perf_test.sh ...` +# and compared on subsequent runs with TOLERANCE_PCT tolerance. + +MODEL: gpt_583m +TP: 1 +PP: 1 +DP: 8 +ASYNC_SCHED: true +NUM_INPUT_TOKENS: 512 +NUM_OUTPUT_TOKENS: 128 +NUM_WARMUP_ITERS: 2 +NUM_TIMED_ITERS: 5 +BATCH_SIZES: + - 1 + - 8 + - 32 + - 128 +TOLERANCE_PCT: 10 +# p99 omitted on purpose: with NUM_TIMED_ITERS=5 it is the max of 5 samples, +# not a real percentile, so it produces flaky regressions even when throughput +# / avg / p50 are stable. p99 is still recorded in results.json for visibility. +METRICS: + - throughput_tok_per_sec + - avg_latency_ms + - p50_latency_ms + - tpot_ms_per_tok diff --git a/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/baseline_values.json b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/baseline_values.json new file mode 100644 index 00000000000..122e3399183 --- /dev/null +++ b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/baseline_values.json @@ -0,0 +1,40 @@ +{ + "gb200": { + "batch_8": { + "batch_size": 8, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 306.77571598122546, + "avg_latency_ms": 3292.265848722309, + "p50_latency_ms": 3284.7761889570393, + "p99_latency_ms": 3369.791687990073, + "tpot_ms_per_tok": 26.07768341249539 + }, + "batch_32": { + "batch_size": 32, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 1217.121447486444, + "avg_latency_ms": 3322.6687765843963, + "p50_latency_ms": 3319.4968919851817, + "p99_latency_ms": 3425.9593110182323, + "tpot_ms_per_tok": 26.29154228288826 + }, + "batch_128": { + "batch_size": 128, + "dataset": "synthetic", + "num_input_tokens_avg": 512.0, + "num_output_tokens": 128, + "num_iters": 5, + "throughput_tok_per_sec": 4573.446310856698, + "avg_latency_ms": 3390.523879592547, + "p50_latency_ms": 3371.0599309997633, + "p99_latency_ms": 3771.179543051403, + "tpot_ms_per_tok": 27.987646798464993 + } + } +} diff --git a/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/model_config.yaml b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/model_config.yaml new file mode 100644 index 00000000000..16bc84ab5c5 --- /dev/null +++ b/tests/performance_tests/test_cases/gpt/gpt_583m_perf_async_sched_gb200_4gpu/model_config.yaml @@ -0,0 +1,36 @@ +# Inference perf test: 583M mcore-mistral checkpoint, TP=1 PP=1 DP=4 (4 GPUs), +# async prefill scheduling enabled. +# +# GB200 single-node variant of gpt_583m_perf_async_sched. GB200 nodes have a +# 4-GPU/node limit, so the DP=8 configuration used on H100 doesn't fit on a +# single GB200 node. This is a separate test (different world size, different +# baseline) — not a multi-node port of the DP=8 case. +# +# The server runs with --inference-dynamic-batching-async-sched-mode async. +# Async scheduling requires greedy sampling / no logprobs / no stop words; the +# static benchmark client already satisfies these (temperature 0.0, ignore_eos). + +MODEL: gpt_583m +TP: 1 +PP: 1 +DP: 4 +ASYNC_SCHED: true +NUM_INPUT_TOKENS: 512 +NUM_OUTPUT_TOKENS: 128 +# 2 warmup iters can leave the first timed iteration cold on GB200, poisoning +# the mean/tail metrics; warm up more so timing starts at steady state. +NUM_WARMUP_ITERS: 5 +NUM_TIMED_ITERS: 5 +# batch_size=1 is omitted: at single-stream the 4-GPU (DP=4) deployment is +# barely utilized, so per-iteration jitter dominates and the small-sample p50 +# latency is too noisy to gate on. Larger batches are stable perf signals. +BATCH_SIZES: + - 8 + - 32 + - 128 +TOLERANCE_PCT: 10 +METRICS: + - throughput_tok_per_sec + - avg_latency_ms + - p50_latency_ms + - tpot_ms_per_tok diff --git a/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml b/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml new file mode 100644 index 00000000000..6a81fab3b9a --- /dev/null +++ b/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml @@ -0,0 +1,65 @@ +type: basic +format_version: 1 +maintainers: [mcore] +loggers: [stdout] +spec: + name: '{test_case}_{environment}_{platforms}' + model: gpt + build: mcore-pyt-{environment} + nodes: 1 + gpus: 4 + n_repeat: 1 + platforms: dgx_gb200 + script_setup: | + set -euo pipefail + unset https_proxy + echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + + # Checkout latest + cd /opt + rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm + git init + git remote add origin $MCORE_REPO + git fetch origin '+refs/merge-requests/*:refs/remotes/merge-requests/*' + git fetch origin $MCORE_MR_COMMIT + git checkout $MCORE_MR_COMMIT + git rev-parse HEAD + # Checkout backwards-ref + cd /opt + rm -rf /opt/megatron-lm-legacy; mkdir megatron-lm-legacy; cd megatron-lm-legacy + git init + git remote add origin $MCORE_REPO + git fetch origin $MCORE_BACKWARDS_COMMIT + git checkout $MCORE_BACKWARDS_COMMIT + git rev-parse HEAD + rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + script: |- + set -euo pipefail + ls + cd /opt/megatron-lm + export GPUS_PER_NODE={gpus} + + ARGUMENTS=( + "CHECKPOINT_LOAD_PATH=/mnt/artifacts" + "CHECKPOINT_SAVE_PATH=/tmp/checkpoints" + "DATA_PATH=/mnt/artifacts" + "DATA_CACHE_PATH=/workspace/data/cache" + "TRAINING_SCRIPT_PATH=examples/inference/advanced/gpt_dynamic_inference.py" + "TRAINING_PARAMS_PATH=./tests/functional_tests/test_cases/{model}/{test_case}/model_config.yaml" + "GOLDEN_VALUES_PATH=./tests/functional_tests/test_cases/{model}/{test_case}/golden_values_{environment}_{platforms}.json" + "OUTPUT_PATH={assets_dir}" + "TENSORBOARD_PATH={assets_dir}/tensorboard" + "INFERENCE_OUTPUT_PATH={assets_dir}/golden_values_{environment}_{platforms}.json" + "N_REPEAT={n_repeat}" + "ENABLE_LIGHTWEIGHT_MODE=${{ENABLE_LIGHTWEIGHT_MODE:-}}" + "RECORD_CHECKPOINTS=${{RECORD_CHECKPOINTS:-}}" + ) + + bash ./tests/functional_tests/shell_test_utils/run_ci_test.sh ${{ARGUMENTS[@]}} + +products: + - test_case: [gpt_dynamic_inference_tp1_pp1_583m_async_sched] + products: + - environment: [dev] + scope: [mr] + platforms: [dgx_gb200] diff --git a/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml b/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml index 9f4839d28fe..71a9dccf1b2 100644 --- a/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml +++ b/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml @@ -40,7 +40,7 @@ spec: GPUS_PER_NODE=4 bash ./tests/performance_tests/shell_test_utils/run_perf_test.sh ${{ARGUMENTS[@]}} products: - - test_case: [gpt_583m_perf_gb200_4gpu] + - test_case: [gpt_583m_perf_gb200_4gpu, gpt_583m_perf_async_sched_gb200_4gpu] products: - environment: [dev] scope: [mr] diff --git a/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml b/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml index 43661c16cd3..79e97beb5f0 100644 --- a/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml +++ b/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml @@ -178,3 +178,8 @@ products: - environment: [dev] scope: [mr] platforms: [dgx_h100] + - test_case: [gpt_dynamic_inference_tp1_pp1_583m_async_sched] + products: + - environment: [dev] + scope: [mr] + platforms: [dgx_h100] diff --git a/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml b/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml index 6d9cc4e948f..7484989358f 100644 --- a/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml +++ b/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml @@ -38,7 +38,7 @@ spec: GPUS_PER_NODE=8 bash ./tests/performance_tests/shell_test_utils/run_perf_test.sh ${{ARGUMENTS[@]}} products: - - test_case: [gpt_583m_perf] + - test_case: [gpt_583m_perf, gpt_583m_perf_async_sched] products: - environment: [dev] scope: [mr] From ab16414a20a539d9530d8f0986d35423ccb95025 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Thu, 30 Jul 2026 11:48:11 +0200 Subject: [PATCH 155/290] test(ci): AUT-1173 set functional tests to ft_launcher (#6128) Signed-off-by: svcnemo-autobot --- .../test_cases/bert/bert_mcore_tp1_pp2/model_config.yaml | 1 + .../test_cases/bert/bert_mcore_tp1_pp4_vp2/model_config.yaml | 1 + .../test_cases/bert/bert_mcore_tp2_pp2/model_config.yaml | 1 + .../model_config.yaml | 1 + .../bert/bert_mcore_tp2_pp2_local_spec/model_config.yaml | 1 + .../bert_mcore_tp2_pp2_resume_torch_dist/model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/bert/bert_mcore_tp4_pp1/model_config.yaml | 1 + .../test_cases/common/ckpt_converter/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt/gpt3_7b_tp1_pp4_memory_speed/model_config.yaml | 1 + .../gpt/gpt3_7b_tp4_pp1_memory_speed/model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_reruns_disable/model_config.yaml | 3 ++- .../test_cases/gpt/gpt3_mcore_reruns_enable/model_config.yaml | 3 ++- .../gpt/gpt3_mcore_reruns_persistent_1/model_config.yaml | 1 + .../gpt/gpt3_mcore_reruns_persistent_1_1node/model_config.yaml | 1 + .../gpt/gpt3_mcore_reruns_persistent_2/model_config.yaml | 3 ++- .../test_cases/gpt/gpt3_mcore_reruns_reshard/model_config.yaml | 3 ++- .../test_cases/gpt/gpt3_mcore_reruns_resume/model_config.yaml | 3 ++- .../gpt/gpt3_mcore_reruns_resume_check_grads/model_config.yaml | 1 + .../gpt/gpt3_mcore_reruns_transient/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp1_pp1_mup/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt3_mcore_te_tp1_pp2_rope_embeddings/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt3_mcore_te_tp1_pp4_sequence_parallel/model_config.yaml | 1 + .../gpt/gpt3_mcore_te_tp1_pp4_swiglu/model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1/model_config.yaml | 1 + .../gpt/gpt3_mcore_te_tp1_pp4_vp1_1node/model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt3_mcore_te_tp1_pp4_vp1_decoupled_lr/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn/model_config.yaml | 1 + .../gpt/gpt3_mcore_te_tp2_pp1_gdn_1node/model_config.yaml | 1 + .../gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async/model_config.yaml | 3 ++- .../model_config.yaml | 3 ++- .../gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_sync/model_config.yaml | 3 ++- .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp2_pp2/model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2/model_config.yaml | 1 + .../gpt/gpt3_mcore_te_tp2_pp2_cp2_1node/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp2_pp2_dsa/model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla/model_config.yaml | 1 + .../gpt/gpt3_mcore_te_tp2_pp2_mla_1node/model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt3_mcore_te_tp2_pp2_no_mmap_bin_files/model_config.yaml | 1 + .../gpt3_mcore_te_tp2_pp2_resume_torch_dist/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_tp1_pp2/model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_tp1_pp2_fp16/model_config.yaml | 1 + .../gpt/gpt3_mcore_tp1_pp2_resume_torch_dist/model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_tp1_pp4/model_config.yaml | 1 + .../gpt/gpt3_mcore_tp1_pp4_resume_torch_dist/model_config.yaml | 1 + .../model_config.yaml | 1 + .../gpt/gpt3_mcore_tp2_pp2_uninstall_te/model_config.yaml | 1 + .../gpt3_mcore_tp2_pp2_uninstall_te_1node/model_config.yaml | 1 + .../test_cases/gpt/gpt3_mcore_tp4_pp1/model_config.yaml | 1 + .../gpt/gpt3_mcore_tp4_pp1_resume_torch/model_config.yaml | 1 + .../gpt/gpt3_mcore_tp4_pp1_resume_torch_dist/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/gpt/gpt_grpo_basic_function/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 3 ++- .../model_config.yaml | 1 + .../gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml | 1 + .../moe/deepseek_proxy_fsdp_ep2_fsdp2_1node/model_config.yaml | 1 + .../deepseek_proxy_fsdp_ep2_fsdp2_ep_overlap/model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../model_config.yaml | 1 + .../multimodal_llava_mcore_te_tp1_pp1/model_config.yaml | 1 + .../multimodal_llava_mcore_te_tp4_sp_cp2/model_config.yaml | 1 + .../nemotron/nemotron3_super_release_gb200/model_config.yaml | 1 + .../nemotron3_super_release_gb200_sm/model_config.yaml | 1 + .../test_cases/t5/t5_11b_mcore_tp4_pp1/model_config.yaml | 1 + .../t5/t5_mcore_te_tp1_pp1_vp1_resume_torch/model_config.yaml | 1 + .../test_cases/t5/t5_mcore_te_tp2_pp1_vp1/model_config.yaml | 1 + .../model_config.yaml | 1 + .../test_cases/t5/t5_mcore_te_tp4_pp1/model_config.yaml | 1 + .../t5/t5_mcore_te_tp4_pp1_resume_torch_dist/model_config.yaml | 1 + .../test_cases/t5/t5_mcore_tp1_pp1_vp1/model_config.yaml | 1 + .../t5/t5_mcore_tp1_pp1_vp1_resume_torch/model_config.yaml | 1 + .../test_cases/t5/t5_mcore_tp2_pp1_vp1/model_config.yaml | 1 + .../test_cases/t5/t5_mcore_tp4_pp1/model_config.yaml | 1 + .../t5/t5_mcore_tp4_pp1_resume_torch_dist/model_config.yaml | 1 + 301 files changed, 310 insertions(+), 9 deletions(-) diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/model_config.yaml index 0dc97066835..5f410958836 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp2/model_config.yaml @@ -42,3 +42,4 @@ MODEL_ARGS: --ckpt-format: torch --attention-backend: unfused TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/model_config.yaml index 05117c8f4a0..a406edabde6 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp1_pp4_vp2/model_config.yaml @@ -43,3 +43,4 @@ MODEL_ARGS: --ckpt-format: torch --attention-backend: unfused TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2/model_config.yaml index 01ba8adeccf..b457d9b74cc 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2/model_config.yaml @@ -42,3 +42,4 @@ MODEL_ARGS: --ckpt-format: torch --attention-backend: unfused TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_frozen_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_frozen_resume_torch_dist/model_config.yaml index 680c3c69ea7..df1be1da58a 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_frozen_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_frozen_resume_torch_dist/model_config.yaml @@ -45,3 +45,4 @@ MODEL_ARGS: --dist-ckpt-strictness: log_all # backward compatibility for TE changes --attention-backend: unfused TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_local_spec/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_local_spec/model_config.yaml index c372de7180a..6316bcff0a6 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_local_spec/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_local_spec/model_config.yaml @@ -43,3 +43,4 @@ MODEL_ARGS: --ckpt-format: torch --attention-backend: local TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist/model_config.yaml index 4afcb0c9d47..3099db79789 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist/model_config.yaml @@ -45,3 +45,4 @@ MODEL_ARGS: --dist-ckpt-strictness: log_all # backward compatibility for TE changes --attention-backend: unfused TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist_local_spec/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist_local_spec/model_config.yaml index 8a776e6bfe5..608274462b3 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist_local_spec/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp2_pp2_resume_torch_dist_local_spec/model_config.yaml @@ -46,3 +46,4 @@ MODEL_ARGS: --ckpt-format: torch --attention-backend: local TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/bert/bert_mcore_tp4_pp1/model_config.yaml b/tests/functional_tests/test_cases/bert/bert_mcore_tp4_pp1/model_config.yaml index 15ec6afdebe..10f2b2e7047 100644 --- a/tests/functional_tests/test_cases/bert/bert_mcore_tp4_pp1/model_config.yaml +++ b/tests/functional_tests/test_cases/bert/bert_mcore_tp4_pp1/model_config.yaml @@ -42,3 +42,4 @@ MODEL_ARGS: --ckpt-format: torch --attention-backend: unfused TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/common/ckpt_converter/model_config.yaml b/tests/functional_tests/test_cases/common/ckpt_converter/model_config.yaml index 2ac5db11472..fbafc91b4f8 100644 --- a/tests/functional_tests/test_cases/common/ckpt_converter/model_config.yaml +++ b/tests/functional_tests/test_cases/common/ckpt_converter/model_config.yaml @@ -5,3 +5,4 @@ ENV_VARS: CUBLAS_WORKSPACE_CONFIG: :4096:8 MODEL_ARGS: TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt-nemo/bert-nemo_340m_mr_mbs2_gbs32_mcore_te_tp2_pp2_1N8G/model_config.yaml b/tests/functional_tests/test_cases/gpt-nemo/bert-nemo_340m_mr_mbs2_gbs32_mcore_te_tp2_pp2_1N8G/model_config.yaml index b7edb433b46..612bc41ee17 100644 --- a/tests/functional_tests/test_cases/gpt-nemo/bert-nemo_340m_mr_mbs2_gbs32_mcore_te_tp2_pp2_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt-nemo/bert-nemo_340m_mr_mbs2_gbs32_mcore_te_tp2_pp2_1N8G/model_config.yaml @@ -15,3 +15,4 @@ MODEL_ARGS: data.seq_length: 512 log.log_dir: ${CHECKPOINT_SAVE_PATH} TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt-nemo/gemma2-nemo_2b_mr_mbs1_gbs8_mcore_te_tp4_pp1_cp1_1N8G/model_config.yaml b/tests/functional_tests/test_cases/gpt-nemo/gemma2-nemo_2b_mr_mbs1_gbs8_mcore_te_tp4_pp1_cp1_1N8G/model_config.yaml index 7d967e68a27..23a819d08e5 100644 --- a/tests/functional_tests/test_cases/gpt-nemo/gemma2-nemo_2b_mr_mbs1_gbs8_mcore_te_tp4_pp1_cp1_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt-nemo/gemma2-nemo_2b_mr_mbs1_gbs8_mcore_te_tp4_pp1_cp1_1N8G/model_config.yaml @@ -15,3 +15,4 @@ MODEL_ARGS: data.global_batch_size: 8 data.seq_length: 2048 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs1_gbs8_mcore_te_8experts_tp2_ep2_pp2_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs1_gbs8_mcore_te_8experts_tp2_ep2_pp2_dgx_a100_1N8G/model_config.yaml index e1fb8875b56..b6d8b8647a7 100644 --- a/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs1_gbs8_mcore_te_8experts_tp2_ep2_pp2_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs1_gbs8_mcore_te_8experts_tp2_ep2_pp2_dgx_a100_1N8G/model_config.yaml @@ -30,3 +30,4 @@ MODEL_ARGS: data.seq_length: 2048 log.log_dir: ${CHECKPOINT_SAVE_PATH} TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs4_gbs64_mcore_te_tp1_pp1_cp2_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs4_gbs64_mcore_te_tp1_pp1_cp2_dgx_a100_1N8G/model_config.yaml index 0a7d5d8079d..fd992ef066c 100644 --- a/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs4_gbs64_mcore_te_tp1_pp1_cp2_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt-nemo/llama3-nemo_8b_mr_mbs4_gbs64_mcore_te_tp1_pp1_cp2_dgx_a100_1N8G/model_config.yaml @@ -20,3 +20,4 @@ MODEL_ARGS: data.seq_length: 2048 log.log_dir: ${CHECKPOINT_SAVE_PATH} TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt-nemo/mixtral-nemo_8x7b_mr_mbs1_gbs8_mcore_te_tp2_pp1_ep2_1N8G/model_config.yaml b/tests/functional_tests/test_cases/gpt-nemo/mixtral-nemo_8x7b_mr_mbs1_gbs8_mcore_te_tp2_pp1_ep2_1N8G/model_config.yaml index c3dfa7845ed..5cdf82fbcd7 100644 --- a/tests/functional_tests/test_cases/gpt-nemo/mixtral-nemo_8x7b_mr_mbs1_gbs8_mcore_te_tp2_pp1_ep2_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt-nemo/mixtral-nemo_8x7b_mr_mbs1_gbs8_mcore_te_tp2_pp1_ep2_1N8G/model_config.yaml @@ -19,3 +19,4 @@ MODEL_ARGS: data.global_batch_size: 8 data.seq_length: 2048 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt-nemo/t5-nemo_220m_mr_mbs4_gbs64_te_tp1_pp1_1N8G/model_config.yaml b/tests/functional_tests/test_cases/gpt-nemo/t5-nemo_220m_mr_mbs4_gbs64_te_tp1_pp1_1N8G/model_config.yaml index fabc337d832..e02604fe06d 100644 --- a/tests/functional_tests/test_cases/gpt-nemo/t5-nemo_220m_mr_mbs4_gbs64_te_tp1_pp1_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt-nemo/t5-nemo_220m_mr_mbs4_gbs64_te_tp1_pp1_1N8G/model_config.yaml @@ -13,3 +13,4 @@ MODEL_ARGS: data.global_batch_size: 64 data.seq_length: 512 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/model_config.yaml index eb253b243f1..d902204b201 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp1_pp4_memory_speed/model_config.yaml @@ -67,3 +67,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/model_config.yaml index ae067d246c9..0dfe58f1070 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_7b_tp4_pp1_memory_speed/model_config.yaml @@ -65,3 +65,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_disable/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_disable/model_config.yaml index 7d946d05b0c..ba949522872 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_disable/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_disable/model_config.yaml @@ -85,4 +85,5 @@ AFTER_SCRIPT: | check_log_not -F "WARNING:megatron.core.rerun_state_machine:Result validation enabled" check_log -F "Setting rerun_state_machine.current_iteration to 0..." EXIT_CODE=0 -TEST_TYPE: regular \ No newline at end of file +TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_enable/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_enable/model_config.yaml index ee557999f8e..c21ff97bbef 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_enable/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_enable/model_config.yaml @@ -82,4 +82,5 @@ AFTER_SCRIPT: | check_log() { if [[ -z $(grep -r $1 "$2" $LOG_DIR) ]]; then exit 1; else echo OK; fi } check_log -F "WARNING:megatron.core.rerun_state_machine:Result validation enabled" EXIT_CODE=0 -TEST_TYPE: regular \ No newline at end of file +TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1/model_config.yaml index daa5093f0a4..66cc7d815b0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1/model_config.yaml @@ -88,3 +88,4 @@ AFTER_SCRIPT: | check_log -F "Saving a checkpoint and exiting now. Please resume the job from the checkpoint to rerun the last iteration and establish a diagnostic" EXIT_CODE=0 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1_1node/model_config.yaml index daa5093f0a4..66cc7d815b0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_1_1node/model_config.yaml @@ -88,3 +88,4 @@ AFTER_SCRIPT: | check_log -F "Saving a checkpoint and exiting now. Please resume the job from the checkpoint to rerun the last iteration and establish a diagnostic" EXIT_CODE=0 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_2/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_2/model_config.yaml index d9e8a56f0de..35a48e59081 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_2/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_persistent_2/model_config.yaml @@ -83,4 +83,5 @@ AFTER_SCRIPT: | check_log -F "WARNING:megatron.core.rerun_state_machine:Result validation enabled" check_log -E "ERROR:megatron\.core\.rerun_state_machine:Rank [0-9]+, node ([0-9a-z]|\-)+, device [0-9]+: Possible persistent error!!" EXIT_CODE=0 -TEST_TYPE: frozen-start \ No newline at end of file +TEST_TYPE: frozen-start +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_reshard/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_reshard/model_config.yaml index 7fbd18cfbe5..e229f5039bc 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_reshard/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_reshard/model_config.yaml @@ -84,4 +84,5 @@ AFTER_SCRIPT: | check_log -F "WARNING:megatron.core.rerun_state_machine:Result validation enabled" check_log -F "Job sharding has changed: Rerun state will be ignored" EXIT_CODE=0 -TEST_TYPE: frozen-start \ No newline at end of file +TEST_TYPE: frozen-start +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume/model_config.yaml index f2516fd2d4b..85c2f580f27 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume/model_config.yaml @@ -83,4 +83,5 @@ AFTER_SCRIPT: | check_log -F "successfully loaded checkpoint" check_log -F "WARNING:megatron.core.rerun_state_machine:Result validation enabled" EXIT_CODE=0 -TEST_TYPE: frozen-start \ No newline at end of file +TEST_TYPE: frozen-start +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume_check_grads/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume_check_grads/model_config.yaml index 1a260774210..b671f4925c1 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume_check_grads/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_resume_check_grads/model_config.yaml @@ -151,3 +151,4 @@ MODEL_ARGS_5: --tensor-model-parallel-size: 1 --context-parallel-size: 1 --pipeline-model-parallel-size: 2 +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_transient/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_transient/model_config.yaml index 47fd85b9d5c..097044ac1ec 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_transient/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_reruns_transient/model_config.yaml @@ -88,3 +88,4 @@ AFTER_SCRIPT: | check_log -E "ERROR:megatron\.core\.rerun_state_machine:Rank [0-9]+, node ([0-9a-z]|\-)+, device [0-9]+: Possible transient error!!" EXIT_CODE=0 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset/model_config.yaml index 86039c4d7f2..f93ab50e9c7 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset_1node/model_config.yaml index 86039c4d7f2..f93ab50e9c7 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_fim_dataset_1node/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files/model_config.yaml index 6a90ba0f943..bacaf68d3f7 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files_1node/model_config.yaml index 6a90ba0f943..bacaf68d3f7 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_dist_optimizer_no_mmap_bin_files_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_frozen_resume_torch_dist_dist_optimizer/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_frozen_resume_torch_dist_dist_optimizer/model_config.yaml index 8dae22b8852..cbb3a61cf83 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_frozen_resume_torch_dist_dist_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_frozen_resume_torch_dist_dist_optimizer/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_mup/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_mup/model_config.yaml index ff2da3180fc..5e85ed6473c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_mup/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_mup/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer/model_config.yaml index 5859b7461f9..7f3ee2a26cb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_1node/model_config.yaml index 5859b7461f9..7f3ee2a26cb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_no_mmap_bin_files/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_no_mmap_bin_files/model_config.yaml index 685ec4b3db7..9a22333bba6 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_no_mmap_bin_files/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_dist_optimizer_no_mmap_bin_files/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute/model_config.yaml index c3ca9477dd7..2a427358771 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute_1node/model_config.yaml index c3ca9477dd7..2a427358771 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_resume_torch_dist_uniform_full_recompute_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_uniform_full_recompute/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_uniform_full_recompute/model_config.yaml index 8d8d30fc39c..c93f4e2964d 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_uniform_full_recompute/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp1_uniform_full_recompute/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_cp4_a2a_p2p_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_cp4_a2a_p2p_nondeterministic/model_config.yaml index 99de5a5d98c..3da6173b0d3 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_cp4_a2a_p2p_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_cp4_a2a_p2p_nondeterministic/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_cp4_a2a_p2p_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_cp4_a2a_p2p_nondeterministic/model_config.yaml index a173a0a5845..adfb883898e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_cp4_a2a_p2p_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_cp4_a2a_p2p_nondeterministic/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings/model_config.yaml index 9cde3247944..ff6ca307333 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_1node/model_config.yaml index 9cde3247944..ff6ca307333 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion/model_config.yaml index b4e07b5b5e1..5c655dcfc96 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion_1node/model_config.yaml index b4e07b5b5e1..5c655dcfc96 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_resume_torch_dist_rope_embeddings_interleaved_no_fusion_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings/model_config.yaml index e80995f90a3..77cd8a9758e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings_interleaved_no_fusion/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings_interleaved_no_fusion/model_config.yaml index 98c7db9b9bd..7499d1b6d3e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings_interleaved_no_fusion/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp2_rope_embeddings_interleaved_no_fusion/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_disable_bias_linear/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_disable_bias_linear/model_config.yaml index 8d01a9132eb..dea6494695b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_disable_bias_linear/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_disable_bias_linear/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_frozen_resume_torch_dist_swiglu/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_frozen_resume_torch_dist_swiglu/model_config.yaml index 16f9ba79fe4..9de582252a4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_frozen_resume_torch_dist_swiglu/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_frozen_resume_torch_dist_swiglu/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_persistent_ckpt_disable_bias_linear/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_persistent_ckpt_disable_bias_linear/model_config.yaml index 89b883024df..d716f6c7cc0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_persistent_ckpt_disable_bias_linear/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_persistent_ckpt_disable_bias_linear/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear/model_config.yaml index 305e7eabf98..7df314462eb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear_1node/model_config.yaml index 305e7eabf98..7df314462eb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_disable_bias_linear_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear/model_config.yaml index 3cf79eaf7d2..fe0f6e9ddcb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --bf16: true --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear_1node/model_config.yaml index 3cf79eaf7d2..fe0f6e9ddcb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_persistent_disable_bias_linear_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --bf16: true --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_sequence_parallel/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_sequence_parallel/model_config.yaml index 15772459af3..292bf75f976 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_sequence_parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_sequence_parallel/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu/model_config.yaml index a39ef7f4f78..5f8177e0eca 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu_1node/model_config.yaml index a39ef7f4f78..5f8177e0eca 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_swiglu_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs/model_config.yaml index 5d1a4257402..6d0c41792b5 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs_1node/model_config.yaml index 5d1a4257402..6d0c41792b5 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_resume_torch_dist_untie_embeddings_and_outputs_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_sequence_parallel/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_sequence_parallel/model_config.yaml index c201855b87a..d995f169bc9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_sequence_parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_sequence_parallel/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_swiglu/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_swiglu/model_config.yaml index fcadf67b1c0..8b1d7be149e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_swiglu/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_swiglu/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_untie_embeddings_and_outputs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_untie_embeddings_and_outputs/model_config.yaml index 5b2bc318437..df7a618b6c9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_untie_embeddings_and_outputs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_untie_embeddings_and_outputs/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1/model_config.yaml index 8d764f5a87a..5d6feb5dde4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_1node/model_config.yaml index 8d764f5a87a..5d6feb5dde4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_calculate_per_token_loss/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_calculate_per_token_loss/model_config.yaml index 034339eef65..f96b0ea4686 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_calculate_per_token_loss/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_calculate_per_token_loss/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_decoupled_lr/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_decoupled_lr/model_config.yaml index bf6775690cb..f1c6025107b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_decoupled_lr/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_decoupled_lr/model_config.yaml @@ -50,3 +50,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce/model_config.yaml index 43d9d059569..1147d997f23 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml index 153db6838cc..39d06eff08a 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer/model_config.yaml index 28248046b44..30d8699dc52 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer_1node/model_config.yaml index 28248046b44..30d8699dc52 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_param_gather_overlap_optimizer_1node/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml index 7c6c7ff6f9f..b6d50d2b81d 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr/model_config.yaml index 6bcce43a2db..d136e7afeb9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --bf16: true --log-memory-to-tensorboard: true TEST_TYPE: regular # Usually ckpt-resume, but as a WAR to #513 set to regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr_1node/model_config.yaml index 6bcce43a2db..d136e7afeb9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_decoupled_lr_1node/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --bf16: true --log-memory-to-tensorboard: true TEST_TYPE: regular # Usually ckpt-resume, but as a WAR to #513 set to regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist/model_config.yaml index 51cbf48c21b..a5347336e67 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular # Usually ckpt-resume, but as a WAR to #513 set to regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss/model_config.yaml index 6d77c3df361..d1e0bb6b16e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss_1node/model_config.yaml index 6d77c3df361..d1e0bb6b16e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_calculate_per_token_loss_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml index 0a1f510fc8f..415acdf4717 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml index 0a1f510fc8f..415acdf4717 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml index a8194368c4e..e929bba269f 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_untied/model_config.yaml @@ -58,3 +58,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular # Usually ckpt-resume, but as a WAR to #513 set to regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_tunable_overlap/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_tunable_overlap/model_config.yaml index 4dfc41a948f..f78a4fd10bc 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_tunable_overlap/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_resume_torch_dist_tunable_overlap/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular # Usually ckpt-resume, but as a WAR to #513 set to regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap/model_config.yaml index 7dcdd335e8c..a87c869dcd1 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap_1node/model_config.yaml index 7dcdd335e8c..a87c869dcd1 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_tunable_overlap_1node/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline/model_config.yaml index e068c864d10..7ae45f52dbf 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline_1node/model_config.yaml index e068c864d10..7ae45f52dbf 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp1_uneven_pipeline_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split/model_config.yaml index b9e66c55bff..798224308d4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split_1node/model_config.yaml index b9e66c55bff..798224308d4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp1_pp4_vp2_account_for_embedding_loss_in_pipeline_split_1node/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_cp2_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_cp2_nondeterministic/model_config.yaml index 2f33dea359d..b798aa016e1 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_cp2_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_cp2_nondeterministic/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_cp2_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_cp2_nondeterministic/model_config.yaml index 0d803ff1aaf..f2affd7e792 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_cp2_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_cp2_nondeterministic/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_fsdp2_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_fsdp2_resume_torch_dist/model_config.yaml index 9876606f2f7..2a52d3c1250 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_fsdp2_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_fsdp2_resume_torch_dist/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn/model_config.yaml index cc7211c9967..4127f72642b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn/model_config.yaml @@ -80,3 +80,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_1node/model_config.yaml index cc7211c9967..4127f72642b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_1node/model_config.yaml @@ -80,3 +80,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async/model_config.yaml index 620b4c2b734..48ba564b1f8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async/model_config.yaml @@ -85,4 +85,5 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular -TEST_EVALUATION: xpass \ No newline at end of file +TEST_EVALUATION: xpass +LAUNCHER: torchrun diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async_mcore/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async_mcore/model_config.yaml index 731a2927b22..4d361a20ce6 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async_mcore/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_async_mcore/model_config.yaml @@ -85,4 +85,5 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --attention-backend: unfused --log-memory-to-tensorboard: true -TEST_TYPE: regular \ No newline at end of file +TEST_TYPE: regular +LAUNCHER: torchrun diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_sync/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_sync/model_config.yaml index 6dfd675c343..b354332b027 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_sync/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_gdn_no_nvrx_sync/model_config.yaml @@ -82,4 +82,5 @@ MODEL_ARGS: --bf16: true --attention-backend: unfused --log-memory-to-tensorboard: true -TEST_TYPE: regular \ No newline at end of file +TEST_TYPE: regular +LAUNCHER: torchrun diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml index 2ba8cb329d0..9576723f116 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume/model_config.yaml @@ -72,3 +72,4 @@ TEST_TYPE: ckpt-resume METRICS: - lm loss - total loss +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume_1node/model_config.yaml index c75a5a81414..494b685d13a 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_modelopt_distill_resume_1node/model_config.yaml @@ -69,3 +69,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_multi_dist_optimizer_instances/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_multi_dist_optimizer_instances/model_config.yaml index e541610ae72..9a7cd250f08 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_multi_dist_optimizer_instances/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_multi_dist_optimizer_instances/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic/model_config.yaml index bd303906bd6..d3a2debce80 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml index bd303906bd6..d3a2debce80 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp1_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2/model_config.yaml index 11b6f1c57a1..ecdeafbc27c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2/model_config.yaml index fc6d56fab55..45ea1cd9483 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_1node/model_config.yaml index 7be86a80447..ffd6294c4bd 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss/model_config.yaml index 4aa6deabd64..841ffb86163 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_1node/model_config.yaml index 27fd45b5701..ede6949b68d 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic/model_config.yaml index f1d41d4a22a..cf00d693fe8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic_1node/model_config.yaml index 27ff315894a..2044b3b9011 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_calculate_per_token_loss_nondeterministic_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last/model_config.yaml index eb6ae776b50..9a0f0a12912 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last_1node/model_config.yaml index caf364efb28..e88fecd2f87 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_dp_last_1node/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last/model_config.yaml index 98c4aefea36..a6268baefc2 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last_1node/model_config.yaml index c1087788693..951ea39e0e3 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_calculate_per_token_loss_nondeterministic_dp_last_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last/model_config.yaml index f798a99d703..93148b90ef5 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last_1node/model_config.yaml index 67831941bf5..a9f579fc051 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_dp_last_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last/model_config.yaml index 4b8aad6f105..8279ca3ca04 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last_1node/model_config.yaml index 72faa6ac2da..b671f0ef8ba 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_etp4_nondeterministic_dp_last_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic/model_config.yaml index 0216d5283d7..777aebe3f57 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic_1node/model_config.yaml index 2f33dea359d..b798aa016e1 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cp2_nondeterministic_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion/model_config.yaml index 2f73bf24f01..c0633f3359e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion/model_config.yaml @@ -49,3 +49,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion_1node/model_config.yaml index 2f73bf24f01..c0633f3359e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_cross_entropy_loss_fusion_1node/model_config.yaml @@ -49,3 +49,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_ddp_average_in_collective/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_ddp_average_in_collective/model_config.yaml index 4eb20fa0683..dddef126f20 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_ddp_average_in_collective/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_ddp_average_in_collective/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_defer_embedding_wgrad_compute/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_defer_embedding_wgrad_compute/model_config.yaml index bc37d8cd771..193c8e6e144 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_defer_embedding_wgrad_compute/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_defer_embedding_wgrad_compute/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_dsa/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_dsa/model_config.yaml index 507e8de9df7..6dcd6b02eb3 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_dsa/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_dsa/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --bf16: true --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla/model_config.yaml index d8afdbf0756..110bcf8bae8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla_1node/model_config.yaml index d8afdbf0756..110bcf8bae8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_mla_1node/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_create_attention_mask_in_dataloader/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_create_attention_mask_in_dataloader/model_config.yaml index 023fc7b6e5b..95bf40ac648 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_create_attention_mask_in_dataloader/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_create_attention_mask_in_dataloader/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_mmap_bin_files/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_mmap_bin_files/model_config.yaml index 9e0518f9ffd..1368abfc7b6 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_mmap_bin_files/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_no_mmap_bin_files/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist/model_config.yaml index 14e40a430d5..5ef5ede58a5 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --verify-integrity: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_1node/model_config.yaml index 9436fa2a5e6..c0d19c140b2 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_1node/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic/model_config.yaml index 29a9bbef0c1..f81a6a59243 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --verify-integrity: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml index c682b78eedc..b1a5757076a 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cp2_nondeterministic_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion/model_config.yaml index 77789c90192..40d83402a4c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion_1node/model_config.yaml index 77789c90192..40d83402a4c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_cross_entropy_loss_fusion_1node/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective/model_config.yaml index aacc077e937..0f4d863c12c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective_1node/model_config.yaml index aacc077e937..0f4d863c12c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_ddp_average_in_collective_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute/model_config.yaml index f600f587ae9..340f6125883 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute_1node/model_config.yaml index f600f587ae9..340f6125883 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_defer_embedding_wgrad_compute_1node/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_create_attention_mask_in_dataloader/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_create_attention_mask_in_dataloader/model_config.yaml index 58d9e2efbd2..2513ca33435 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_create_attention_mask_in_dataloader/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_create_attention_mask_in_dataloader/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_mmap_bin_files/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_mmap_bin_files/model_config.yaml index f1f26115d6b..9f563090212 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_mmap_bin_files/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_no_mmap_bin_files/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone/model_config.yaml index ec29ea58ca6..bfb0c0f614d 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --verify-integrity: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone_1node/model_config.yaml index a413b7ebb0c..346ef0e46dc 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_pp2_resume_torch_dist_reshard_1x4xNone_1node/model_config.yaml @@ -50,3 +50,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor/model_config.yaml index c07e943cbd8..3ef25d3f3af 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor_1node/model_config.yaml index c07e943cbd8..3ef25d3f3af 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp2_zp_z3_resume_fsdp_dtensor_1node/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce/model_config.yaml index d9cb8494444..a0c79e33ebb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml index 11a72d75951..fe66ff34a7c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather_1node/model_config.yaml index 11a72d75951..fe66ff34a7c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_dist_optimizer_overlap_grad_reduce_param_gather_1node/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_qk_layernorm_test_mode/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_qk_layernorm_test_mode/model_config.yaml index 9790a7f74ce..6b298d17c9e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_qk_layernorm_test_mode/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_qk_layernorm_test_mode/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml index bd6f526c209..f18736b62dc 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml index bd6f526c209..f18736b62dc 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode/model_config.yaml index addb335475f..bca8cea595b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode_1node/model_config.yaml index addb335475f..bca8cea595b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp1_resume_torch_dist_qk_layernorm_test_mode_1node/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_frozen_resume_torch_dist_reshard_8x1xNone/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_frozen_resume_torch_dist_reshard_8x1xNone/model_config.yaml index 2a4ce95cd6d..d76829ded35 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_frozen_resume_torch_dist_reshard_8x1xNone/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_frozen_resume_torch_dist_reshard_8x1xNone/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone/model_config.yaml index 881fe7ebe2d..0a7e68912eb 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone_1node/model_config.yaml index 630da63ed62..d1cc6d40025 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_te_tp4_pp2_resume_torch_dist_reshard_8x1xNone_1node/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml index f28a2d05a5c..c2da6641c92 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_fsdp2_resume_torch_dist_te/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_fsdp2_resume_torch_dist_te/model_config.yaml index c9eea9f5b0c..f0818de4b4e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_fsdp2_resume_torch_dist_te/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_fsdp2_resume_torch_dist_te/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml index 6588160cc67..7a103b83328 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp1_resume_torch_dist_dist_optimizer_overlap_grad_reduce_param_gather/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --verify-integrity: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2/model_config.yaml index 7ee44b85c81..07dc4d8e44b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_fp16/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_fp16/model_config.yaml index 8e68343c17e..0763212e0e5 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_fp16/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_fp16/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_resume_torch_dist/model_config.yaml index abe46384819..85ac5ccf314 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp2_resume_torch_dist/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4/model_config.yaml index 57db98fd87e..57afdb9c140 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4_resume_torch_dist/model_config.yaml index 9bd0e50311d..feb482e71c8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp1_pp4_resume_torch_dist/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_resume_torch_dist_uninstall_te/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_resume_torch_dist_uninstall_te/model_config.yaml index a13ee609ace..661f2f722f5 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_resume_torch_dist_uninstall_te/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_resume_torch_dist_uninstall_te/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te/model_config.yaml index d44645ba5e3..735b0bf3a18 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te_1node/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te_1node/model_config.yaml index d44645ba5e3..735b0bf3a18 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp2_pp2_uninstall_te_1node/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1/model_config.yaml index abc889ac89e..3fc4281a27e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch/model_config.yaml index e61ad0b9ea9..0c2a0d64480 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch/model_config.yaml @@ -48,3 +48,4 @@ MODEL_ARGS: --apply-query-key-layer-scaling: true --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch_dist/model_config.yaml index a46065b1121..aff7990d847 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_mcore_tp4_pp1_resume_torch_dist/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp2_pp2_current_scaling_native_fp8_tp_pp_sp_tp_overlap/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp2_pp2_current_scaling_native_fp8_tp_pp_sp_tp_overlap/model_config.yaml index e8673fbae20..7f073c4d995 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp2_pp2_current_scaling_native_fp8_tp_pp_sp_tp_overlap/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp2_pp2_current_scaling_native_fp8_tp_pp_sp_tp_overlap/model_config.yaml @@ -63,3 +63,4 @@ METRICS: - iteration-time - lm loss - "mem-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp4_cp2_current_scaling_native_fp8_tp_sp_cp_tp_overlap/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp4_cp2_current_scaling_native_fp8_tp_sp_cp_tp_overlap/model_config.yaml index 2dccf144291..2772714757c 100644 --- a/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp4_cp2_current_scaling_native_fp8_tp_sp_cp_tp_overlap/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt3_weekly_mcore_tp4_cp2_current_scaling_native_fp8_tp_sp_cp_tp_overlap/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill/model_config.yaml index c304e8bf5df..98215b5c3c4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill_cuda_graphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill_cuda_graphs/model_config.yaml index 4b9e265c022..5046b7088e8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill_cuda_graphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_chunked_prefill_cuda_graphs/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml index 71701790bc0..3c00126bb32 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_fp8_logitsmatch/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml index d9298d649c6..d44ea0486e9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_logitsmatch_decode_graphs_only/model_config.yaml @@ -58,3 +58,4 @@ METRICS: - "generated_tokens" - "logprobs" - "throughput" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/model_config.yaml index 2ac5db11472..fbafc91b4f8 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_cuda_graphs_validation/model_config.yaml @@ -5,3 +5,4 @@ ENV_VARS: CUBLAS_WORKSPACE_CONFIG: :4096:8 MODEL_ARGS: TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_flashinfer/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_flashinfer/model_config.yaml index 90e1cf11107..0e8bfa319ad 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_flashinfer/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_flashinfer/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_logitsmatch/model_config.yaml index 4e250b956ef..e69e3e93703 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_logitsmatch/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching/model_config.yaml index 5cf5f3c9902..7a4a8902984 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill/model_config.yaml index cc4f4cbdd7c..30d9b109c09 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_cuda_graphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_cuda_graphs/model_config.yaml index 0a041e16316..3d3f3e5d372 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_cuda_graphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_cuda_graphs/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_flashinfer/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_flashinfer/model_config.yaml index c6d8a77c9be..d0fb2e0c1a2 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_flashinfer/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_chunked_prefill_flashinfer/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_cuda_graphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_cuda_graphs/model_config.yaml index bd46560d562..cafcf760c76 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_cuda_graphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_cuda_graphs/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_lru/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_lru/model_config.yaml index efc71f68f70..a64b50f4d48 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_lru/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_lru/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_stop_words/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_stop_words/model_config.yaml index 8259814da8b..1f117df5d63 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_stop_words/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_stop_words/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_n_logprobs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_n_logprobs/model_config.yaml index 487ddc157f1..64989c12adf 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_n_logprobs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_n_logprobs/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_p_sampling/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_p_sampling/model_config.yaml index 3e92838c537..b1204cfcca0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_p_sampling/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_top_p_sampling/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_uvm_level1/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_uvm_level1/model_config.yaml index a5d304eff91..4b64cface52 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_uvm_level1/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_583m_uvm_level1/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml index d84dd24487f..dea5eec83dc 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml index c915728da33..452dce08eae 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_load_balanced_zmq/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_longest_prefix_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_longest_prefix_zmq/model_config.yaml index fcfc6c716f0..f6e3112b8dd 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_longest_prefix_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp1_dp8_583m_prefix_caching_longest_prefix_zmq/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_583m_prefix_caching/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_583m_prefix_caching/model_config.yaml index 72483d72ccb..23969d560c4 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_583m_prefix_caching/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_583m_prefix_caching/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_dp1_583m_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_dp1_583m_logitsmatch_zmq/model_config.yaml index 345fc250694..3fe997dd46b 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_dp1_583m_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp1_pp8_dp1_583m_logitsmatch_zmq/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_chunked_prefill/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_chunked_prefill/model_config.yaml index 51c1a4ad703..98be4420000 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_chunked_prefill/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_chunked_prefill/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_cuda_graphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_cuda_graphs/model_config.yaml index 83f68909f47..6101d34c87a 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_cuda_graphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_cuda_graphs/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching/model_config.yaml index a157e899c2f..8df34df686e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching_cuda_graphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching_cuda_graphs/model_config.yaml index e1a1f680c0f..507e8abc0bf 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching_cuda_graphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_583m_prefix_caching_cuda_graphs/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_dp2_583m_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_dp2_583m_logitsmatch_zmq/model_config.yaml index 3b55b09e82e..a3976951850 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_dp2_583m_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp2_pp2_dp2_583m_logitsmatch_zmq/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp4_pp1_583m_flashinfer/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp4_pp1_583m_flashinfer/model_config.yaml index ea1d201f339..3791f475136 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp4_pp1_583m_flashinfer/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp4_pp1_583m_flashinfer/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_logitsmatch/model_config.yaml index 4458edf5772..451b5070661 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_logitsmatch/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_prefix_caching/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_prefix_caching/model_config.yaml index 8f8b9d55925..b1f473124ab 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_prefix_caching/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_583m_prefix_caching/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_dp1_583m_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_dp1_583m_logitsmatch_zmq/model_config.yaml index 88a3e40a193..e34e63ec4c0 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_dp1_583m_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_dynamic_inference_tp8_pp1_dp1_583m_logitsmatch_zmq/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/model_config.yaml index 0143a39f017..f052a0bca75 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_basic_function/model_config.yaml @@ -97,3 +97,4 @@ MODEL_ARGS: --finetune: true --inference-logging-step-interval: 1 METRICS: [] +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/model_config.yaml index 4f9be214289..7ef22862c00 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest/model_config.yaml @@ -80,3 +80,4 @@ MODEL_ARGS: METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/model_config.yaml index c8fa19d0500..e12eea86625 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp1tp2_pp1_dp8_583m_throughputtest_github/model_config.yaml @@ -86,4 +86,5 @@ METRICS: - "mem-max-allocated-bytes" THROUGHPUT_TEST_PARAMS: - --start_step: 1 \ No newline at end of file + --start_step: 1 +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/model_config.yaml index 654df68947f..d1e0a562625 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_cudagraphs_throughput/model_config.yaml @@ -113,3 +113,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "iteration-time" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/model_config.yaml index b7fb41046f3..35b56bbe8f9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput/model_config.yaml @@ -112,3 +112,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "iteration-time" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/model_config.yaml index cc25f3ab90e..8b4a14b9836 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_grpo_tp4_pp1_dp2_8b_throughput_github/model_config.yaml @@ -98,3 +98,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "iteration-time" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_offline_inference_async_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_offline_inference_async_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml index 076659eb455..2a91ac2a559 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_offline_inference_async_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_offline_inference_async_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_583m_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_583m_logitsmatch/model_config.yaml index 6a5fc63aab3..bf82a3b32d9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_583m_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_583m_logitsmatch/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml index ba3622a9417..5f18ceddcf9 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_offline_inference_sync_tp1_pp1_dp8_583m_logitsmatch_zmq/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml index e4cd2240764..01680ec5e2e 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_16b_multiprompt_tokensmatch/model_config.yaml @@ -84,3 +84,4 @@ MODEL_ARGS: --inference-dynamic-batching-buffer-size-gb: 20 METRICS: - "generated_text" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml index b79909b1d74..28635a7fec7 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_cudagraphs/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml index 381e694cbe6..6eaff84d57f 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_fp8_cudagraphs/model_config.yaml @@ -61,3 +61,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml index 0d6834ee2d6..bac8adc5051 100644 --- a/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/gpt/gpt_static_inference_tp1_pp1_583m_logitsmatch/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_ep8_nanov3_chunked_prefill/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_ep8_nanov3_chunked_prefill/model_config.yaml index ba07a85c024..9cce670aa6b 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_ep8_nanov3_chunked_prefill/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_ep8_nanov3_chunked_prefill/model_config.yaml @@ -66,3 +66,4 @@ MODEL_ARGS: --inference-moe-token-dispatcher-type: nvls --inference-logging-step-interval: 1 METRICS: +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml index 2a02eaf9bae..3cacaf01867 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_2b_async_sched_async/model_config.yaml @@ -72,3 +72,4 @@ MODEL_ARGS: --inference-dynamic-batching-async-sched-mode: async METRICS: - "generated_tokens" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m/model_config.yaml index 4b258afe0d6..5bf8fc30d2a 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m/model_config.yaml @@ -73,3 +73,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_chunked_prefill/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_chunked_prefill/model_config.yaml index bd86d2faa44..5f4cd9cef83 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_chunked_prefill/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_chunked_prefill/model_config.yaml @@ -76,3 +76,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/model_config.yaml index e989be22f7e..2491bd21433 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_flashinfer/model_config.yaml @@ -74,3 +74,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_mamba_bf16_states/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_mamba_bf16_states/model_config.yaml index 9affc9878f9..2a3de19fab6 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_mamba_bf16_states/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_dynamic_inference_tp1_pp1_dp8_583m_mamba_bf16_states/model_config.yaml @@ -73,3 +73,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_flextron_nightly_tp2_pp1_ep2_dgx_h100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_flextron_nightly_tp2_pp1_ep2_dgx_h100_1N8G/model_config.yaml index 493aaa31b16..34e4c07817b 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_flextron_nightly_tp2_pp1_ep2_dgx_h100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_flextron_nightly_tp2_pp1_ep2_dgx_h100_1N8G/model_config.yaml @@ -125,3 +125,4 @@ MODEL_ARGS: --slice: true --router-std: 0.1 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp1_cp1_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp1_cp1_dgx_a100_1N8G/model_config.yaml index 9add53f8a49..b451a9ad5a5 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp1_cp1_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp1_cp1_dgx_a100_1N8G/model_config.yaml @@ -58,3 +58,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp2_vpp2_cp1_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp2_vpp2_cp1_dgx_a100_1N8G/model_config.yaml index 25df6aa0359..a5d76369b4b 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp2_vpp2_cp1_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp2_vpp2_cp1_dgx_a100_1N8G/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp4_cp1_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp4_cp1_dgx_a100_1N8G/model_config.yaml index fe4f9e63714..08707cb1d2c 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp4_cp1_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp1_pp4_cp1_dgx_a100_1N8G/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --async-strategy: mcore --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp1_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp1_dgx_a100_1N8G/model_config.yaml index 2339f7a7ce9..b4593e6102d 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp1_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp1_dgx_a100_1N8G/model_config.yaml @@ -56,3 +56,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp4_dgx_a100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp4_dgx_a100_1N8G/model_config.yaml index 3efc155949f..b203b225784 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp4_dgx_a100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_mr_mcore_te_tp2_pp1_cp4_dgx_a100_1N8G/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml index e2116268b4c..8e9946adcca 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_nemotron_v3_pico_7b_a1b_tp1_ep8_QAD_dgx_h100_1N8G/model_config.yaml @@ -188,3 +188,4 @@ TEST_TYPE: regular METRICS: - lm loss - total loss +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_cudagraphs/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_cudagraphs/model_config.yaml index 02c5cc3055c..9db4d9e247d 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_cudagraphs/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_cudagraphs/model_config.yaml @@ -72,3 +72,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_logitsmatch/model_config.yaml index 2543f59e668..75f341931a0 100644 --- a/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/hybrid/hybrid_static_inference_tp1_pp1_2B_logitsmatch/model_config.yaml @@ -68,3 +68,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8/model_config.yaml b/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8/model_config.yaml index e95856e7308..4aecf7bc60a 100644 --- a/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8/model_config.yaml +++ b/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8/model_config.yaml @@ -58,3 +58,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8_seq_packing/model_config.yaml b/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8_seq_packing/model_config.yaml index 2e86278fa67..89a71c25d9b 100644 --- a/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8_seq_packing/model_config.yaml +++ b/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp1_dp8_seq_packing/model_config.yaml @@ -63,3 +63,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp2_dp8/model_config.yaml b/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp2_dp8/model_config.yaml index 37c55e4cd93..43acdf5cce9 100644 --- a/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp2_dp8/model_config.yaml +++ b/tests/functional_tests/test_cases/mimo/mimo_vlm_pretrain_convergence_tp1_pp1_cp2_dp8/model_config.yaml @@ -63,3 +63,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml index 1e7f15ce500..8a5fe85b09a 100644 --- a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2/model_config.yaml @@ -143,3 +143,4 @@ METRICS: - "lm loss" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_1node/model_config.yaml index a243bcf6b84..9cf511795a9 100644 --- a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_1node/model_config.yaml @@ -139,3 +139,4 @@ METRICS: - "lm loss" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_ep_overlap/model_config.yaml b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_ep_overlap/model_config.yaml index 0d1e04af73d..bac8829e669 100644 --- a/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_ep_overlap/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/deepseek_proxy_fsdp_ep2_fsdp2_ep_overlap/model_config.yaml @@ -133,3 +133,4 @@ METRICS: - "lm loss" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic/model_config.yaml index b3e8a82de72..b8ec491215c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic_dp_last/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic_dp_last/model_config.yaml index 59887d4eec9..70f522d1d4c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_cp2_pp2_ep2_te_4experts2parallel_nondeterministic_dp_last/model_config.yaml @@ -61,3 +61,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp1_pp1_te_4experts_groupedGEMM_op_fuser/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp1_pp1_te_4experts_groupedGEMM_op_fuser/model_config.yaml index b092774efd3..82fd2582380 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp1_pp1_te_4experts_groupedGEMM_op_fuser/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp1_pp1_te_4experts_groupedGEMM_op_fuser/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --no-bias-gelu-fusion: true --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml index c4bc4528090..4038a4e55e8 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml @@ -63,3 +63,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml index bdef7c88323..763a92f7b64 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_frozen_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: frozen-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml index 2a8a2a5d72b..51b9d2e103a 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --verify-integrity: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer_1node/model_config.yaml index 764c576645e..f8f7222f004 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_dist_optimizer_1node/model_config.yaml @@ -63,3 +63,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml index 381039b4905..d141f68132d 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_groupedGEMM/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml index 86a775807b8..2c11ccb1acb 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml @@ -61,3 +61,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/model_config.yaml index 8ced6e37a52..9353bea2933 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/model_config.yaml index 8ced6e37a52..9353bea2933 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_ddp_average_in_collective_1node/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_dist_optimizer/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_dist_optimizer/model_config.yaml index ec59433dc1b..3a9d1ec9a78 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_dist_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_dist_optimizer/model_config.yaml @@ -61,3 +61,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/model_config.yaml index 8ced6e37a52..9353bea2933 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/model_config.yaml index 8ced6e37a52..9353bea2933 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_overlap_grad_reduce_param_gather_groupedGEMM_1node/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_top2router/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_top2router/model_config.yaml index 097beac2085..bfaea1e6d5c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_top2router/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts2parallel_top2router/model_config.yaml @@ -61,3 +61,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts_etp1_ep4/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts_etp1_ep4/model_config.yaml index 8ae6dc79fe2..665792146e6 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts_etp1_ep4/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_8experts_etp1_ep4/model_config.yaml @@ -64,3 +64,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/model_config.yaml index 5423ee39527..d7711454e7f 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4/model_config.yaml @@ -66,3 +66,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4_1node/model_config.yaml index 5423ee39527..d7711454e7f 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp1_te_a2a_ovlp_8experts_etp1_ep4_1node/model_config.yaml @@ -66,3 +66,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_memory_speed/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_memory_speed/model_config.yaml index 8f108eabdac..9f046fa10ff 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_memory_speed/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_memory_speed/model_config.yaml @@ -133,3 +133,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "mtp_1 loss" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_mtp_resume_torch_dist_fp8/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_mtp_resume_torch_dist_fp8/model_config.yaml index 3f1ce0e8f16..7976caff8ec 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_mtp_resume_torch_dist_fp8/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_mtp_resume_torch_dist_fp8/model_config.yaml @@ -135,3 +135,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "mtp_1 loss" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_resume_torch_dist_attn_cudagraph/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_resume_torch_dist_attn_cudagraph/model_config.yaml index 64cdacd6076..c534cf14ec3 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_resume_torch_dist_attn_cudagraph/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_resume_torch_dist_attn_cudagraph/model_config.yaml @@ -137,3 +137,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_selective_recompute_experimental/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_selective_recompute_experimental/model_config.yaml index 172fac96f6f..b107e806902 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_selective_recompute_experimental/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_pp2_ep4_etp1_selective_recompute_experimental/model_config.yaml @@ -136,3 +136,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "mtp_1 loss" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_zp_z3_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_zp_z3_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml index 15f971e9ff3..4e060b589c2 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_zp_z3_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_te_tp2_zp_z3_resume_torch_dist_te_8experts2parallel_top2router/model_config.yaml @@ -63,3 +63,4 @@ MODEL_ARGS: --bf16: true --no-bias-gelu-fusion: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel/model_config.yaml index 901cb22f005..a2cff5cdab5 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel_dp_last/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel_dp_last/model_config.yaml index 61d25aeb356..f6a8b3f365c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_cp2_pp2_ep2_te_4experts2parallel_dp_last/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel/model_config.yaml index b03dd7fe023..0a3087e9bcb 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel_dp_last/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel_dp_last/model_config.yaml index c48348735b8..261074e80a1 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel_dp_last/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_etp2_te_4experts2parallel_dp_last/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_resume_torch_dist_te_4experts2parallel/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_resume_torch_dist_te_4experts2parallel/model_config.yaml index 192f7d09101..b32a30d946c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_resume_torch_dist_te_4experts2parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_resume_torch_dist_te_4experts2parallel/model_config.yaml @@ -58,3 +58,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_te_4experts2parallel/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_te_4experts2parallel/model_config.yaml index f4b020370ff..2ec13052640 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_te_4experts2parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_ep2_te_4experts2parallel/model_config.yaml @@ -59,3 +59,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_resume_torch_dist_te_2experts/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_resume_torch_dist_te_2experts/model_config.yaml index cf0f282e5b1..efe1fa34a8f 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_resume_torch_dist_te_2experts/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_mcore_tp2_pp2_resume_torch_dist_te_2experts/model_config.yaml @@ -58,3 +58,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon/model_config.yaml index fffad86a016..316b155d685 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon/model_config.yaml @@ -68,3 +68,4 @@ MODEL_ARGS: --use-distributed-optimizer: true --no-use-layer-wise-param-layout: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_1node/model_config.yaml index 874e7dccf8d..da53a3e7e5f 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_1node/model_config.yaml @@ -68,3 +68,4 @@ MODEL_ARGS: --use-distributed-optimizer: true --no-use-layer-wise-param-layout: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout/model_config.yaml index 028074bd34f..bc3bd5484f1 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout/model_config.yaml @@ -67,3 +67,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --use-distributed-optimizer: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout_1node/model_config.yaml index 40ac94eed9b..d184ee46673 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon_param_layout_1node/model_config.yaml @@ -67,3 +67,4 @@ MODEL_ARGS: --use-persistent-ckpt-worker: true --use-distributed-optimizer: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer/model_config.yaml index f69a44638d6..317bd508caf 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer/model_config.yaml @@ -65,3 +65,4 @@ MODEL_ARGS: --async-strategy: mcore --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer_1node/model_config.yaml index 85b4ea629df..1aabaee0efa 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_optimizer_1node/model_config.yaml @@ -65,3 +65,4 @@ MODEL_ARGS: --async-strategy: mcore --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon/model_config.yaml index 6b14d162d9f..d5ee0d0fe8c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon/model_config.yaml @@ -68,3 +68,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon_1node/model_config.yaml index e1653ce7ed4..736b3c52f9f 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_ep8_resume_torch_dist_muon_1node/model_config.yaml @@ -68,3 +68,4 @@ MODEL_ARGS: --async-save: true --use-persistent-ckpt-worker: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_fine_grained_offloading/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_fine_grained_offloading/model_config.yaml index 845f0990460..fcab06c39fc 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_fine_grained_offloading/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_fine_grained_offloading/model_config.yaml @@ -140,3 +140,4 @@ METRICS: - "mem-allocated-bytes" - "mem-max-allocated-bytes" - "mtp_1 loss" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_no_mtp_no_a2a_ovlp_fine_grained_offloading/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_no_mtp_no_a2a_ovlp_fine_grained_offloading/model_config.yaml index d16c56b2264..a639727bc2c 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_no_mtp_no_a2a_ovlp_fine_grained_offloading/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp2_pp2_ep4_etp1_no_mtp_no_a2a_ovlp_fine_grained_offloading/model_config.yaml @@ -134,3 +134,4 @@ METRICS: - "lm loss" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/model_config.yaml index efdac2478fd..58540a9d6e7 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph/model_config.yaml @@ -96,3 +96,4 @@ METRICS: # - "mem-allocated-bytes" # - "mem-max-allocated-bytes" - "mtp_1 loss" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph_1node/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph_1node/model_config.yaml index 26af6497637..90011c10c76 100644 --- a/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph_1node/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt3_moe_mcore_te_tp4_ep2_etp2_pp2_scoped_cudagraph_1node/model_config.yaml @@ -95,3 +95,4 @@ METRICS: # - "mem-allocated-bytes" # - "mem-max-allocated-bytes" - "mtp_1 loss" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml index 2b9b3e392e4..2355f67a22f 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml @@ -87,3 +87,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp2_pp2_ep2_gptoss_20b_swa/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp2_pp2_ep2_gptoss_20b_swa/model_config.yaml index 7d87f0a9998..facc8c97da3 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp2_pp2_ep2_gptoss_20b_swa/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp2_pp2_ep2_gptoss_20b_swa/model_config.yaml @@ -102,3 +102,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml index 83315aca7a8..a4131cb245b 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_cudagraph_zmq/model_config.yaml @@ -88,3 +88,4 @@ METRICS: - "generated_tokens" - "logprobs" - "routing_indices" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq/model_config.yaml index 479cb7a4751..b32eb7e76dc 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq/model_config.yaml @@ -84,3 +84,4 @@ METRICS: - "generated_tokens" - "logprobs" - "routing_indices" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq_suspend_resume/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq_suspend_resume/model_config.yaml index 1f302455440..5e63740403a 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq_suspend_resume/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_etp1_pp1_ep8_16B_logitsmatch_zmq_suspend_resume/model_config.yaml @@ -87,3 +87,4 @@ MODEL_ARGS: --no-rl-persist-cuda-graphs: true METRICS: +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_chunked_prefill/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_chunked_prefill/model_config.yaml index db20ea13cf1..35f0b7951e7 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_chunked_prefill/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_chunked_prefill/model_config.yaml @@ -83,3 +83,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml index 5ed1f1205f6..ea8eb4c2f03 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml @@ -80,3 +80,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_prefix_caching/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_prefix_caching/model_config.yaml index d293646fa2b..69fba5c46cc 100644 --- a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_prefix_caching/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_prefix_caching/model_config.yaml @@ -81,3 +81,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/model_config.yaml index 22cc8d5e4d2..2e9157cbcc2 100644 --- a/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_grpo_tp8tp4_pp1_ep8ep2_dp8_throughputtest/model_config.yaml @@ -136,3 +136,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_static_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_static_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml index 049c9090099..013eee2e49f 100644 --- a/tests/functional_tests/test_cases/moe/gpt_static_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_static_inference_cuda_graphs_pad_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml @@ -85,3 +85,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml index 9662fe840d9..9be551d93ea 100644 --- a/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_static_inference_tp1_pp1_ep1_16B_logitsmatch/model_config.yaml @@ -80,3 +80,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/moe/gpt_static_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_static_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml index 9259d63c9d1..93532c83707 100644 --- a/tests/functional_tests/test_cases/moe/gpt_static_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml +++ b/tests/functional_tests/test_cases/moe/gpt_static_inference_tp4_pp1_ep4_16B_logitsmatch/model_config.yaml @@ -80,3 +80,4 @@ MODEL_ARGS: METRICS: - "generated_tokens" - "logprobs" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp1_pp1/model_config.yaml b/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp1_pp1/model_config.yaml index 377371b2370..2fdd47c8def 100644 --- a/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp1_pp1/model_config.yaml +++ b/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp1_pp1/model_config.yaml @@ -51,3 +51,4 @@ MODEL_ARGS: --mock-data: true --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp4_sp_cp2/model_config.yaml b/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp4_sp_cp2/model_config.yaml index 9332c934613..ac4f20b2781 100644 --- a/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp4_sp_cp2/model_config.yaml +++ b/tests/functional_tests/test_cases/multimodal-llava/multimodal_llava_mcore_te_tp4_sp_cp2/model_config.yaml @@ -57,3 +57,4 @@ MODEL_ARGS: --log-memory-to-tensorboard: true --calculate-per-token-loss: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml index 63d6eb050fc..c7b9da95622 100644 --- a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml @@ -160,3 +160,4 @@ METRICS: - "lm loss" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml index 87d9f587ee1..75dbca82700 100644 --- a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml @@ -160,3 +160,4 @@ METRICS: - "lm loss" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_11b_mcore_tp4_pp1/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_11b_mcore_tp4_pp1/model_config.yaml index 8dc5cf5a713..687ee924f5b 100644 --- a/tests/functional_tests/test_cases/t5/t5_11b_mcore_tp4_pp1/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_11b_mcore_tp4_pp1/model_config.yaml @@ -57,3 +57,4 @@ METRICS: - "num-zeros" - "mem-allocated-bytes" - "mem-max-allocated-bytes" +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp1_pp1_vp1_resume_torch/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp1_pp1_vp1_resume_torch/model_config.yaml index 00a11b9a439..6adfb92fb77 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp1_pp1_vp1_resume_torch/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp1_pp1_vp1_resume_torch/model_config.yaml @@ -60,3 +60,4 @@ MODEL_ARGS: # the worker-queue hang surface; test data is tiny so perf impact is nil. --num-workers: 0 TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1/model_config.yaml index 277d637cb4d..c872de4b25b 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1_sequence_parallel/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1_sequence_parallel/model_config.yaml index 8463c0ebfa4..b58764cf414 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1_sequence_parallel/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp2_pp1_vp1_sequence_parallel/model_config.yaml @@ -55,3 +55,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1/model_config.yaml index f4a4e7c3b1c..bc2837c7fa3 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1_resume_torch_dist/model_config.yaml index 5f170ec8f43..dea7ae55995 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_te_tp4_pp1_resume_torch_dist/model_config.yaml @@ -54,3 +54,4 @@ MODEL_ARGS: --attention-backend: unfused --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1/model_config.yaml index 805cc3e9858..b5e2398a84e 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1/model_config.yaml @@ -58,3 +58,4 @@ MODEL_ARGS: # the worker-queue hang surface; test data is tiny so perf impact is nil. --num-workers: 0 TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1_resume_torch/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1_resume_torch/model_config.yaml index 5b1e192a3a9..5e4f1aa8019 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1_resume_torch/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_tp1_pp1_vp1_resume_torch/model_config.yaml @@ -58,3 +58,4 @@ MODEL_ARGS: # the worker-queue hang surface; test data is tiny so perf impact is nil. --num-workers: 0 TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_tp2_pp1_vp1/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_tp2_pp1_vp1/model_config.yaml index df1bb9d1833..b7391532152 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_tp2_pp1_vp1/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_tp2_pp1_vp1/model_config.yaml @@ -52,3 +52,4 @@ MODEL_ARGS: --ckpt-format: torch --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1/model_config.yaml index 33d79798194..a0d1d0a40d8 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --dist-ckpt-strictness: log_all # backward compatibility for TE changes --log-memory-to-tensorboard: true TEST_TYPE: regular +LAUNCHER: ft_launcher diff --git a/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1_resume_torch_dist/model_config.yaml b/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1_resume_torch_dist/model_config.yaml index 15096f11362..44aa3101027 100644 --- a/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1_resume_torch_dist/model_config.yaml +++ b/tests/functional_tests/test_cases/t5/t5_mcore_tp4_pp1_resume_torch_dist/model_config.yaml @@ -53,3 +53,4 @@ MODEL_ARGS: --dist-ckpt-strictness: log_all # backward compatibility for TE changes --log-memory-to-tensorboard: true TEST_TYPE: ckpt-resume +LAUNCHER: ft_launcher From ff6b92a7c3711c41f8169663614cd865233468ca Mon Sep 17 00:00:00 2001 From: Asha Anoosheh Date: Thu, 30 Jul 2026 14:57:49 +0200 Subject: [PATCH 156/290] Fix --freeze-all-layers for new model builder path + unit tests (#5926) Signed-off-by: Asha Anoosheh --- megatron/training/arguments.py | 7 + megatron/training/checkpointing.py | 132 ++++++++--- megatron/training/datasets/data_samplers.py | 6 + megatron/training/training.py | 72 ++++-- tests/unit_tests/test_frozen_ckpt_resume.py | 56 +++++ .../training/test_freeze_all_layers.py | 205 ++++++++++++++++++ 6 files changed, 424 insertions(+), 54 deletions(-) create mode 100644 tests/unit_tests/test_frozen_ckpt_resume.py create mode 100644 tests/unit_tests/training/test_freeze_all_layers.py diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index fd30984b38a..294229102b3 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1754,6 +1754,13 @@ def validate_args(args, defaults={}): '--logits-save-dir requires --async-save (and --use-persistent-ckpt-worker). ' 'Logits are flushed as an async request in the checkpoint queue.' ) + if not args.freeze_all_layers: + warn_rank_0( + '--logits-save-dir without --freeze-all-layers: the LM loss is still computed and ' + 'gradients will update the model while logits are dumped. This is intended only ' + 'when dumping logits during active training; for a frozen-teacher dump pass ' + '--freeze-all-layers.' + ) if args.freeze_all_layers: if args.use_distributed_optimizer: diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index eee57040dda..26650a7d559 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -47,7 +47,7 @@ from .async_utils import get_save_and_finalize_callbacks, is_empty_async_queue, schedule_async_save from .global_vars import get_args from .one_logger_utils import on_save_checkpoint_start, on_save_checkpoint_success -from .utils import append_to_progress_log, is_last_rank, print_rank_0 +from .utils import append_to_progress_log, is_last_rank, print_rank_0, print_rank_last, warn_rank_0 try: from megatron.core.distributed.fsdp.src.megatron_fsdp.uneven_dtensor import ( @@ -395,6 +395,24 @@ def read_metadata(tracker_filename): return max_iter, release +def read_frozen_resume_iteration(save_dir): + """Resume iteration for a ``--freeze-all-layers`` run. + + Returns the integer recorded in ``save_dir``'s progress tracker + (``latest_checkpointed_iteration.txt``), or ``0`` when there is none -- i.e. a + fresh run, equivalent to ``--finetune`` on the first launch. Unlike + :func:`read_metadata` (which returns ``-1`` / errors on a missing or malformed + file), a missing tracker here simply means "start from the beginning". + """ + if save_dir is None: + return 0 + tracker_filename = get_checkpoint_tracker_filename(save_dir) + if not maybe_msc.os.path.isfile(tracker_filename): + return 0 + iteration, _release = read_metadata(tracker_filename) + return iteration + + def get_rng_state( ckpt_format: str, tp_group: torch.distributed.ProcessGroup, @@ -608,6 +626,10 @@ def save_checkpoint( # Only rank zero of the data parallel writes to the disk. model = unwrap_model(model) + # --freeze-all-layers: weights are frozen, so skip the weight write and its finalizers; dump + # progress is recorded separately via the post-logits finalize (see the async block below). + skip_weight_ckpt = getattr(args, 'freeze_all_layers', False) + # Handle non_persistent_ckpt flag. Besides overwriting `args.save` and # `args.use_dist_ckpt`, non-persistent global ckpt requires no additional logic ckpt_type = CheckpointType.GLOBAL if args.use_dist_ckpt else CheckpointType.LEGACY @@ -734,7 +756,7 @@ def save_checkpoint( # exactly one rank. Neither dp_rank==0 nor edp_rank==0 alone covers all shards when # the dense and expert parallelism layouts disagree (e.g. TP > EP*ETP); the union # does, with at most one rank per (tp_rank, ep_rank) inside any DP group. - if ( + if not skip_weight_ckpt and ( not torch.distributed.is_initialized() or ckpt_type != CheckpointType.LEGACY or dp_rank == 0 @@ -956,7 +978,10 @@ def save_checkpoint( torch.distributed.barrier() # And update the latest iteration - if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: + if not skip_weight_ckpt and ( + not torch.distributed.is_initialized() + or torch.distributed.get_rank() == 0 + ): tracker_filename = get_checkpoint_tracker_filename(save_dir) if ckpt_type == CheckpointType.LOCAL: @@ -1075,7 +1100,9 @@ def iter_finalize_fn(): iter_finalize_fn() # Additional callback for one_logger (last rank) - if not torch.distributed.is_initialized() or is_last_rank(): + if not skip_weight_ckpt and ( + not torch.distributed.is_initialized() or is_last_rank() + ): def onelogger_finalize_fn(): on_save_checkpoint_success(productive_metrics, args.async_save) @@ -1087,7 +1114,9 @@ def onelogger_finalize_fn(): onelogger_finalize_fn() # Additional callback for wandb (last rank) - if not torch.distributed.is_initialized() or is_last_rank(): + if not skip_weight_ckpt and ( + not torch.distributed.is_initialized() or is_last_rank() + ): def wandb_finalize_fn(): wandb_utils.on_save_checkpoint_success( @@ -1110,15 +1139,38 @@ def wandb_finalize_fn(): logits_saver = get_logits_saver() if logits_saver is not None: + # In frozen-dump mode there is no checkpoint request (async_save_request is None); the + # logits request then owns its own finalize_fns. + if async_save_request is not None: + logits_finalize_fns = async_save_request.finalize_fns.copy() + async_save_request.finalize_fns.clear() + else: + logits_finalize_fns = [] + # Record run progress AFTER the logits tar is confirmed written, so a resumed job + # never skips or replays a window. Written by a single rank -- the last rank, which + # lives on the last pipeline stage where the logits saver is attached (get_logits_saver + # is None on earlier stages, including global rank 0 when PP > 1). + if skip_weight_ckpt and ( + not torch.distributed.is_initialized() or is_last_rank() + ): + + def progress_finalize_fn(): + tracker_filename = get_checkpoint_tracker_filename(args.save) + with maybe_msc.open(tracker_filename, 'w') as f: + f.write(str(iteration)) + print_rank_last(f" recorded logits-dump progress: iteration " + f"{iteration} to {tracker_filename}") + + logits_finalize_fns.append(progress_finalize_fn) async_request_cls = get_async_strategy(args.async_strategy)[1]['AsyncRequest'] async_logits_request = async_request_cls( async_fn=logits_saver._write_batched_tar, async_fn_args=logits_saver.take_pending_data(), - finalize_fns=async_save_request.finalize_fns.copy(), + finalize_fns=logits_finalize_fns, ) - async_save_request.finalize_fns.clear() - schedule_async_save(async_save_request) + if async_save_request is not None: + schedule_async_save(async_save_request) if logits_saver is not None: schedule_async_save(async_logits_request) print_rank_0( @@ -2092,6 +2144,22 @@ def load_checkpoint( args = get_args() load_dir = getattr(args, load_arg) + # --freeze-all-layers: nothing trains, so load the model in --load weights-only (finetune-style) + # and auto-resume the data position by feeding this run's own progress tracker -- written to + # --save on the previous launch -- into the standard --override-ckpt-iteration path. Reading + # progress from --save (this job's output) rather than --load lets --load stay pinned to a + # fixed checkpoint across resubmits. An explicit --override-ckpt-iteration wins. (The main use + # today is offline-KD teacher-logit dumps.) + if getattr(args, 'freeze_all_layers', False): + # Weights only: don't adopt the loaded checkpoint's optimizer / LR-scheduler / rng, or run + # check_checkpoint_args against a checkpoint from a different run (finetune gates all of + # those; --freeze-all-layers alone would still load the scheduler and assert on arg drift). + args.finetune = True + if args.override_ckpt_iteration is None: + progress_iteration = read_frozen_resume_iteration(args.save) + if progress_iteration > 0: + args.override_ckpt_iteration = progress_iteration + # Finetuning directories pretrained_dir = getattr(args, 'pretrained_checkpoint', None) if pretrained_dir is not None and not checkpoint_exists(load_dir): @@ -2437,30 +2505,6 @@ def load_checkpoint( # Iteration and num_floating_point_operations_so_far default to 0. return 0, 0 - # Override iteration/consumed_samples if requested (e.g. to rewind the data loader). - if getattr(args, 'override_ckpt_iteration', None) is not None: - target_iter = args.override_ckpt_iteration - state_dict['iteration'] = target_iter - if 'args' in state_dict: - checkpoint_global_batch_size = getattr(state_dict['args'], 'global_batch_size', None) - if ( - checkpoint_global_batch_size is not None - and checkpoint_global_batch_size != args.global_batch_size - ): - raise RuntimeError( - '--override-ckpt-iteration recomputes consumed_train_samples from the target ' - f'iteration and current global_batch_size, but checkpoint global_batch_size ' - f'({checkpoint_global_batch_size}) != current global_batch_size ' - f'({args.global_batch_size}). This would replay the data loader from the ' - 'wrong sample offset.' - ) - state_dict['args'].consumed_train_samples = target_iter * args.global_batch_size - state_dict['args'].skipped_train_samples = 0 - print_rank_0( - f'Overriding checkpoint iteration to {target_iter} ' - f'(consumed_train_samples = {target_iter * args.global_batch_size})' - ) - # Set checkpoint version. set_checkpoint_version(state_dict.get('checkpoint_version', 0)) @@ -2503,6 +2547,30 @@ def load_checkpoint( else: print_rank_0('could not find arguments in the checkpoint ...') + # --override-ckpt-iteration: rewind the data loader to this iteration, operating on `args` + # (not state_dict) so it also works on checkpoints with no saved `args` (release / HF). The + # GBS-match check applies only when adopting this checkpoint's own args (not a finetune load). + if getattr(args, 'override_ckpt_iteration', None) is not None: + if 'args' in state_dict and not args.finetune: + ckpt_global_batch_size = getattr(state_dict['args'], 'global_batch_size', None) + if ( + ckpt_global_batch_size is not None + and ckpt_global_batch_size != args.global_batch_size + ): + warn_rank_0( + '--override-ckpt-iteration recomputes consumed_train_samples = target_iter * ' + f'current global_batch_size ({args.global_batch_size}), but this checkpoint ' + f'was saved at global_batch_size {ckpt_global_batch_size}. If the target ' + "iteration is in the checkpoint run's units, the data loader resumes from a " + 'shifted sample offset.' + ) + iteration = args.override_ckpt_iteration + args.consumed_train_samples = iteration * args.global_batch_size + args.skipped_train_samples = 0 + update_num_microbatches(consumed_samples=args.consumed_train_samples, verbose=True) + print_rank_0(f'--override-ckpt-iteration: start at iteration {iteration} ' + f'(consumed_train_samples {args.consumed_train_samples})') + def load_model_state_dict(module, state_dict, strict: bool): """Helper function to load state dict with fallback for missing extra states.""" # GTP native-FP8 weights: load_state_dict's copy_ re-quantizes into the FP8 param, which diff --git a/megatron/training/datasets/data_samplers.py b/megatron/training/datasets/data_samplers.py index 296acc97941..d51d9c6c8a2 100644 --- a/megatron/training/datasets/data_samplers.py +++ b/megatron/training/datasets/data_samplers.py @@ -21,6 +21,12 @@ def build_pretraining_data_loader(dataset, consumed_samples): if dataset is None: return None + # Empty split (e.g. valid/test when --eval-iters 0): return null loader + try: + if len(dataset) == 0: + return None + except TypeError: + pass args = get_args() if hasattr(dataset, 'split'): diff --git a/megatron/training/training.py b/megatron/training/training.py index 57b8efe9390..823c0271926 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -1703,6 +1703,31 @@ def wrap_model_chunks_with_ddp( return wrapped +def _freeze_all_model_chunks(model_list): + """Freeze all parameters in a list of model chunks (for logits-saving runs).""" + for model_module in model_list: + model_module.requires_grad_(False) + # Additionally freeze expert biases of routers + for module in model_module.modules(): + if hasattr(module, "frozen_expert_bias"): + module.frozen_expert_bias = True + return model_list + + +def _forward_backward_grad_context(args): + """Grad context for a train step's forward/backward pass. + + Returns a tuple of (grad_context, forward_only). + grad_context is ``torch.no_grad()`` when all layers are frozen (e.g. teacher logits + dumps), no parameter needs gradients, so there is no reason to build the + autograd graph. Otherwise returns a no-op context. + forward_only is True when all layers are frozen, False otherwise. + """ + grad_context = torch.no_grad() if getattr(args, "freeze_all_layers", False) else nullcontext() + forward_only = getattr(args, "freeze_all_layers", False) + return grad_context, forward_only + + def get_model(model_provider_func, model_type=ModelType.encoder_or_decoder, wrap_with_ddp=True, config=None, pg_collection=None): """Build the model.""" args = get_args() @@ -1776,12 +1801,7 @@ def build_model(): # For rare operations like post-training logits saving if args.freeze_all_layers: - for model_module in model: - model_module.requires_grad_(False) - # Additionally freeze expert biases of routers - for module in model_module.modules(): - if hasattr(module, "frozen_expert_bias"): - module.frozen_expert_bias = True + _freeze_all_model_chunks(model) # Set tensor model parallel attributes if not set. # Only parameters that are already tensor model parallel have these @@ -2047,6 +2067,12 @@ def _build_model_wrapper(wrap_with_ddp: bool): model_config = cfg.model builder_cls = model_config.get_builder_cls() builder = builder_cls(model_config) + + # Inject freeze_all_layers as a pre-wrap hook so DDP sees requires_grad=False + # and skips grad-buffer allocation for all params (matching get_model behavior). + if args.freeze_all_layers: + model_config.pre_wrap_hooks.append(_freeze_all_model_chunks) + return builder.build_distributed_models( pg_collection=pg_collection, ddp_config=cfg.ddp, @@ -2089,7 +2115,7 @@ def _build_model_wrapper(wrap_with_ddp: bool): cuda_graph_impl=getattr(args, 'cuda_graph_impl', 'none'), ) - if args.logits_save_dir is not None: + if args.logits_save_dir is not None and mpu.is_pipeline_last_stage(): from megatron.training.distillation import LogitsSaverHooks logits_saver = LogitsSaverHooks( @@ -2101,7 +2127,7 @@ def _build_model_wrapper(wrap_with_ddp: bool): ) logits_saver.attach_hooks(unwrapped_model[-1]) - if args.logits_load_dir is not None: + if args.logits_load_dir is not None and mpu.is_pipeline_last_stage(): from megatron.training.distillation import StudentLogitsCapture student_logits_capture = StudentLogitsCapture() @@ -2404,20 +2430,22 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch enable_tokens_per_expert_logging(model, args.save) if save_dgrads_in_this_iteration: enable_dgrad_logging(model, args.save) - losses_reduced = forward_backward_func( - forward_step_func=forward_step_func, - data_iterator=data_iterator, - model=model, - num_microbatches=get_num_microbatches(), - seq_length=args.seq_length, - micro_batch_size=args.micro_batch_size, - decoder_seq_length=args.decoder_seq_length, - forward_only=False, - adjust_tensor_shapes_fn=adjust_tensor_shapes_fn, - force_all_reduce=save_wgrads_in_this_iteration, - p2p_communicator=p2p_communicator, - pg_collection=pg_collection, - ) + grad_context, forward_only = _forward_backward_grad_context(args) + with grad_context: + losses_reduced = forward_backward_func( + forward_step_func=forward_step_func, + data_iterator=data_iterator, + model=model, + num_microbatches=get_num_microbatches(), + seq_length=args.seq_length, + micro_batch_size=args.micro_batch_size, + decoder_seq_length=args.decoder_seq_length, + forward_only=forward_only, + adjust_tensor_shapes_fn=adjust_tensor_shapes_fn, + force_all_reduce=save_wgrads_in_this_iteration, + p2p_communicator=p2p_communicator, + pg_collection=pg_collection, + ) if save_activations_in_this_iteration: save_activations(iteration + 1) disable_activation_logging() diff --git a/tests/unit_tests/test_frozen_ckpt_resume.py b/tests/unit_tests/test_frozen_ckpt_resume.py new file mode 100644 index 00000000000..3b9b5c44d63 --- /dev/null +++ b/tests/unit_tests/test_frozen_ckpt_resume.py @@ -0,0 +1,56 @@ +# Copyright (c) 2024-2026, NVIDIA CORPORATION. All rights reserved. + +"""Unit tests for --freeze-all-layers auto-resume iteration reading. + +``read_frozen_resume_iteration`` reads the progress tracker +(``latest_checkpointed_iteration.txt``) that a frozen (--freeze-all-layers) run writes +to its --save dir, so an identical resubmitted job continues where it stopped instead +of restarting. Its main use today is offline-KD teacher-logit dumps. Pure file I/O +(no CUDA, no distributed init), so it runs on CPU. +""" + +from megatron.training.checkpointing import ( + get_checkpoint_tracker_filename, + read_frozen_resume_iteration, +) + + +def _write_tracker(dir_path, content): + with open(get_checkpoint_tracker_filename(str(dir_path)), "w") as f: + f.write(content) + + +def test_missing_tracker_is_fresh_dump(tmp_path): + """No tracker in the load dir -> start at iteration 0 (first run, --finetune-like).""" + assert read_frozen_resume_iteration(str(tmp_path)) == 0 + + +def test_none_load_dir_is_zero(): + """A None load dir (nothing to resume from) -> 0.""" + assert read_frozen_resume_iteration(None) == 0 + + +def test_reads_recorded_iteration(tmp_path): + """A tracker written by a prior dump is read back as the resume iteration.""" + _write_tracker(tmp_path, "1500") + assert read_frozen_resume_iteration(str(tmp_path)) == 1500 + + +def test_tolerates_trailing_whitespace(tmp_path): + """A trailing newline in the tracker is stripped (read_metadata semantics).""" + _write_tracker(tmp_path, "42\n") + assert read_frozen_resume_iteration(str(tmp_path)) == 42 + + +def test_release_tracker_is_zero(tmp_path): + """A 'release' tracker maps to iteration 0 (start from the beginning).""" + _write_tracker(tmp_path, "release") + assert read_frozen_resume_iteration(str(tmp_path)) == 0 + + +def test_advancing_progress_reads_latest(tmp_path): + """Overwriting the tracker (progress advancing across runs) reads the newest value.""" + _write_tracker(tmp_path, "100") + assert read_frozen_resume_iteration(str(tmp_path)) == 100 + _write_tracker(tmp_path, "250") + assert read_frozen_resume_iteration(str(tmp_path)) == 250 diff --git a/tests/unit_tests/training/test_freeze_all_layers.py b/tests/unit_tests/training/test_freeze_all_layers.py new file mode 100644 index 00000000000..fef00b0254c --- /dev/null +++ b/tests/unit_tests/training/test_freeze_all_layers.py @@ -0,0 +1,205 @@ +# Copyright (c) 2024-2026, NVIDIA CORPORATION. All rights reserved. + +"""Unit tests for the --freeze-all-layers helpers in megatron.training.training. + +These exercise ``_freeze_all_model_chunks`` and ``_forward_backward_grad_context`` +in isolation. Both operate on plain python objects (``requires_grad_``, an +attribute flip, and a grad context), so they run on CPU and need neither CUDA nor +a real Megatron model. The grad-context tests reproduce the PP>1 case that +motivates the fix: a recv_prev input activation with ``requires_grad=True``. +""" + +from contextlib import nullcontext +from types import SimpleNamespace + +import torch + +from megatron.training.training import _forward_backward_grad_context, _freeze_all_model_chunks + + +class _FakeRouter(torch.nn.Module): + """Stand-in for an MoE router that carries ``frozen_expert_bias`` (see + ``megatron/core/transformer/moe/router.py``).""" + + def __init__(self): + super().__init__() + self.gate = torch.nn.Linear(4, 2) + self.frozen_expert_bias = False + + +class _FakeModelChunk(torch.nn.Module): + """Minimal module tree: some trainable params plus a router submodule.""" + + def __init__(self): + super().__init__() + self.embedding = torch.nn.Linear(4, 8) + self.router = _FakeRouter() + self.output_layer = torch.nn.Linear(8, 4) + + +def _all_require_grad(module, value): + return all(p.requires_grad is value for p in module.parameters()) + + +def test_freezes_every_parameter(): + """All parameters across all chunks end up with requires_grad=False.""" + chunks = [_FakeModelChunk(), _FakeModelChunk()] + assert all(_all_require_grad(c, True) for c in chunks), "params start trainable" + + _freeze_all_model_chunks(chunks) + + assert all(_all_require_grad(c, False) for c in chunks) + + +def test_sets_frozen_expert_bias(): + """Modules exposing ``frozen_expert_bias`` are flipped to True; others are + left alone.""" + chunk = _FakeModelChunk() + assert chunk.router.frozen_expert_bias is False + + _freeze_all_model_chunks([chunk]) + + assert chunk.router.frozen_expert_bias is True + # A module without the attribute must not gain one. + assert not hasattr(chunk.embedding, "frozen_expert_bias") + + +def test_returns_same_list_object(): + """The helper freezes in place and returns the list it was given.""" + chunks = [_FakeModelChunk()] + + returned = _freeze_all_model_chunks(chunks) + + assert returned is chunks + + +def test_handles_multiple_routers_and_pp_style_chunks(): + """A VPP/PP-style list with several chunks, each with its own router, is + fully handled.""" + chunks = [_FakeModelChunk() for _ in range(3)] + + _freeze_all_model_chunks(chunks) + + for chunk in chunks: + assert _all_require_grad(chunk, False) + assert chunk.router.frozen_expert_bias is True + + +def test_empty_list_is_noop(): + """An empty chunk list is accepted and returned unchanged.""" + assert _freeze_all_model_chunks([]) == [] + + +def test_idempotent(): + """Applying the freeze twice keeps everything frozen.""" + chunk = _FakeModelChunk() + + _freeze_all_model_chunks([chunk]) + _freeze_all_model_chunks([chunk]) + + assert _all_require_grad(chunk, False) + assert chunk.router.frozen_expert_bias is True + + +# --------------------------------------------------------------------------- +# _forward_backward_grad_context +# +# The helper returns a ``(grad_context, forward_only)`` tuple that a frozen train +# step uses to mirror the eval forward pass: +# * grad_context is ``torch.no_grad()`` when frozen, else a no-op context; +# * forward_only is True when frozen, so the schedule skips the backward and +# finalize-grads collectives entirely. +# +# Why the grad_context matters for PP>1: on a non-first pipeline stage the input +# activation is received via ``create_tensor_recv_prev()`` +# (``megatron/core/pipeline_parallel/p2p_communication.py``), which allocates it +# with ``requires_grad=True`` so gradients can flow back to the prior stage. +# That means a fully *frozen* model still builds an autograd graph during forward +# -- purely because its input requires grad -- retaining activations for a +# backward that is never useful. ``forward_only`` alone does not prevent that +# graph (it only skips the backward call); ``torch.no_grad()`` is what suppresses +# it on frozen (e.g. teacher logits dump) runs. +# --------------------------------------------------------------------------- + + +def _recv_prev_activation(): + """A stand-in for the PP>1 stage input from ``create_tensor_recv_prev()``: + an activation tensor allocated with ``requires_grad=True``.""" + return torch.ones(2, 4, requires_grad=True) + + +def _forward_through_frozen_stage(chunk, recv_prev): + """Run a recv_prev activation through a frozen model chunk (Linear stack).""" + return chunk.output_layer(chunk.embedding(recv_prev)) + + +def test_frozen_returns_no_grad_and_forward_only(): + """With --freeze-all-layers: no_grad context and forward_only=True.""" + args = SimpleNamespace(freeze_all_layers=True) + + grad_context, forward_only = _forward_backward_grad_context(args) + + assert isinstance(grad_context, torch.no_grad) + assert forward_only is True + + +def test_unfrozen_returns_nullcontext_and_not_forward_only(): + """Without --freeze-all-layers: no-op context and forward_only=False.""" + args = SimpleNamespace(freeze_all_layers=False) + + grad_context, forward_only = _forward_backward_grad_context(args) + + assert isinstance(grad_context, nullcontext) + assert forward_only is False + + +def test_missing_flag_defaults_to_not_frozen(): + """A minimal args mock (no freeze_all_layers attr) is treated as unfrozen.""" + grad_context, forward_only = _forward_backward_grad_context(SimpleNamespace()) + + assert isinstance(grad_context, nullcontext) + assert forward_only is False + + +def test_pp_gt1_frozen_forward_without_context_still_builds_graph(): + """Regression: a frozen PP>1 stage builds a graph anyway, because the + recv_prev input requires grad. This is the situation the fix addresses.""" + chunk = _FakeModelChunk() + _freeze_all_model_chunks([chunk]) + assert _all_require_grad(chunk, False) # every parameter is frozen + + out = _forward_through_frozen_stage(chunk, _recv_prev_activation()) + + # Graph built despite all params frozen -- solely due to the recv_prev input. + assert out.requires_grad + assert out.grad_fn is not None + + +def test_pp_gt1_frozen_forward_under_context_skips_graph(): + """The fix: running the same frozen PP>1 forward under the freeze context + suppresses the autograd graph even though recv_prev requires grad.""" + chunk = _FakeModelChunk() + _freeze_all_model_chunks([chunk]) + args = SimpleNamespace(freeze_all_layers=True) + + grad_context, _ = _forward_backward_grad_context(args) + with grad_context: + assert not torch.is_grad_enabled() + out = _forward_through_frozen_stage(chunk, _recv_prev_activation()) + + assert out.grad_fn is None # no graph, no retained activations + assert torch.is_grad_enabled() # grad state restored on exit + + +def test_unfrozen_forward_builds_graph_normally(): + """The no-op context leaves normal (trainable) training untouched: a forward + over a recv_prev input still builds its graph.""" + chunk = _FakeModelChunk() # params trainable + args = SimpleNamespace(freeze_all_layers=False) + + grad_context, _ = _forward_backward_grad_context(args) + with grad_context: + assert torch.is_grad_enabled() + out = _forward_through_frozen_stage(chunk, _recv_prev_activation()) + + assert out.grad_fn is not None From 3fd8f330760456fb5ff5869e220194e33e30bc83 Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene Date: Thu, 30 Jul 2026 13:00:36 -0500 Subject: [PATCH 157/290] Fix inference overflow into dummy blocks (#5950) Signed-off-by: Teodor-Dumitru Ene Co-authored-by: Jorge Albericio --- .../inference/contexts/dynamic_context.py | 4 +- .../core/inference/engines/dynamic_engine.py | 19 ++-- .../inference/engines/test_dynamic_engine.py | 87 +++++++++++++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 102a5e5e55b..97459d62ece 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -2367,10 +2367,10 @@ def initialize_attention_state( self._cpu_mha_cu_kv_seq_lengths[real_bs] ) - # Block table: [0:real_bs] real, [real_bs:padded_bs] = -1 sentinel. + # Block table: [0:real_bs] real, [real_bs:padded_bs] = dummy block. self._cpu_mha_block_table[:real_bs] = request_to_kv_block_ids_view[:real_bs] if real_bs < padded_bs: - self._cpu_mha_block_table[real_bs:padded_bs] = -1 + self._cpu_mha_block_table[real_bs:padded_bs] = self.kv_block_allocator.dummy_block_idx # Max sequence lengths (Python scalars; consumed as kernel launch args). if not self.using_cuda_graph_this_step() and real_bs > 0: diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 833b65dd15f..a7eb5c5ff6c 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -1138,13 +1138,18 @@ def _add_request( request.status = Status.FAILED request.add_event_error_nontransient(TokenOverflowError(request_id)) - # Check that the KV cache has enough blocks for this request's max sequence length. - max_request_tokens = ( - len(request.prompt_tokens) + request.sampling_params.num_tokens_to_generate - ) - request_block_count = math.ceil(max_request_tokens / self.context.block_size_tokens) - total_blocks = self.context.kv_block_allocator.total_count - 1 # -1 for dummy block - if request_block_count > total_blocks: + # Check that the KV cache has enough blocks for this request's stored tokens: + # the prompt, all generated tokens but the last, and the final decode step's drafts. + # Blocks are granted to a running request only from the active pool. + max_stored_tokens = len(request.prompt_tokens) + if request.sampling_params.num_tokens_to_generate > 1: + max_stored_tokens += ( + request.sampling_params.num_tokens_to_generate + - 1 + + self.context.num_speculative_tokens + ) + request_block_count = math.ceil(max_stored_tokens / self.context.block_size_tokens) + if request_block_count > self.context.kv_block_allocator.active_count: request.status = Status.FAILED request.add_event_error_nontransient(BlockOverflowError(request_id)) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 7bffb5dadf0..0947181b844 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -904,6 +904,93 @@ def test_max_sequence_length_clamp(self) -> None: assert request.status != Status.FAILED assert request.sampling_params.num_tokens_to_generate == remaining_tokens + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @torch.inference_mode() + @pytest.mark.parametrize("num_speculative_tokens,exact_fit_tokens", [(0, 249), (2, 247)]) + def test_generation_within_tight_kv_pool( + self, num_speculative_tokens: int, exact_fit_tokens: int + ) -> None: + """Admission is bounded by what the active pool can ever grant a running request: + paused-pool blocks are not grantable (a request admitted against them pauses forever) + and only stored tokens need slots; + the final sampled token is never stored, the last decode step stores its speculative drafts. + Exact fit: 8 prompt + (exact_fit_tokens - 1) outputs + drafts = 256.""" + env = self._build_test_env(DynamicEngineTestConfig()) + block_size_bytes = env.engine.context.block_size_bytes + + # 3-block pool: 1 active + 1 paused + 1 dummy. + test_config = DynamicEngineTestConfig( + num_requests=3, + min_prompt_length=8, + max_prompt_length=8, + num_tokens_to_generate=None, + max_sequence_length=512, + num_speculative_tokens=num_speculative_tokens, + context_buffer_size_gb=3 * block_size_bytes / 1024**3, + context_paused_buffer_size_gb=block_size_bytes / 1024**3, + context_max_requests=4, + ) + env = self._build_test_env(test_config) + + # The msl-derived default budget (8 + 504) fits the old total-blocks + # bound but needs more than the 1 grantable block; fails at admission. + doomed_request = env.requests[0] + env.engine._add_request(doomed_request) + assert doomed_request.status == Status.FAILED + + # One more stored token than the active block holds; fails at admission. + overflow_request = env.requests[2] + overflow_request.sampling_params.num_tokens_to_generate = exact_fit_tokens + 1 + env.engine._add_request(overflow_request) + assert overflow_request.status == Status.FAILED + + # An exact-fit request runs to completion. + request = env.requests[1] + request.sampling_params.num_tokens_to_generate = exact_fit_tokens + request.sampling_params.termination_id = -1 # never terminate early + env.engine._add_request(request) + assert request.status != Status.FAILED + + # Bound the loop so a scheduling regression fails instead of hanging. + for _ in range(400): + self._run_step(env) + if not env.engine.has_unfinished_requests(): + break + assert not env.engine.has_unfinished_requests() + assert request.status == Status.COMPLETED + assert len(request.output) == exact_fit_tokens + + @pytest.mark.internal + @pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" + ) + @torch.inference_mode() + def test_cuda_graph_padding_uses_dummy_block(self) -> None: + """One real request in a four-request graph bucket: the padded block-table + rows must hold the dummy block index, not the old -1 sentinel (OOB reads).""" + test_config = DynamicEngineTestConfig( + num_requests=1, + min_prompt_length=8, + max_prompt_length=8, + num_cuda_graphs=1, + context_max_requests=4, + ) + env = self._build_test_env(test_config) + context = env.engine.context + + env.engine._add_request(env.requests[0]) + self._run_step(env) # prefill + self._run_step(env) # decode: 1 real request in the 4-request graph bucket + + assert context.using_cuda_graph_this_step() + assert context.padded_batch_dimensions.req_count == 4 + padded_rows = context._cpu_mha_block_table[1:4] + assert (padded_rows != -1).all() + assert (padded_rows == context.kv_block_allocator.dummy_block_idx).all() + @pytest.mark.internal @pytest.mark.skipif( not is_fa_min_version("2.7.3"), reason="need latest flash attn for dynamic batching" From 175b4ccdfe386ff2fe497c5496ca5e999f47d9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Thu, 30 Jul 2026 20:07:06 +0200 Subject: [PATCH 158/290] fix(tests): prefer staged release assets (#6149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/cicd-main.yml | 2 + .../download_unit_tests_dataset.py | 130 +++++++++++------- .../test_download_unit_tests_dataset.py | 64 +++++++++ 3 files changed, 147 insertions(+), 49 deletions(-) create mode 100644 tests/test_utils/python_scripts/test_download_unit_tests_dataset.py diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index 0f0b8b900ea..738e91baab3 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -591,6 +591,8 @@ jobs: - name: Download test data shell: bash + env: + NEMO_TEST_DATA_ROOT: /mnt/datadrive/TestData/nemo-fw/TestData run: | echo "::group::Download test data" for attempt in 1 2 3; do diff --git a/tests/test_utils/python_scripts/download_unit_tests_dataset.py b/tests/test_utils/python_scripts/download_unit_tests_dataset.py index a29394c29de..32fcc040225 100644 --- a/tests/test_utils/python_scripts/download_unit_tests_dataset.py +++ b/tests/test_utils/python_scripts/download_unit_tests_dataset.py @@ -2,11 +2,11 @@ #!/usr/bin/env python3 """ -Script to fetch the oldest release of NVIDIA/Megatron-LM on GitHub and list its assets. -Uses the PyGithub SDK to interact with the GitHub API. +Populate unit-test data from staged or public NVIDIA/Megatron-LM v2.5 release assets. """ import logging +import os import tarfile import zipfile from pathlib import Path @@ -17,6 +17,9 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +DEFAULT_TEST_DATA_ROOT = Path("/home/TestData") +TEST_DATA_ROOT_ENV = "NEMO_TEST_DATA_ROOT" +STAGED_RELEASE_ASSET_DIR = Path("megatron-lm/release-assets/v2.5") ASSETS = [ { "name": "datasets.zip", @@ -29,55 +32,85 @@ ] -def download_and_extract_asset(assets_dir: Path) -> bool: - """ - Download and extract an asset to the assets directory. +def get_test_data_root() -> Path: + """Return the configured shared TestData root.""" + return Path(os.environ.get(TEST_DATA_ROOT_ENV) or DEFAULT_TEST_DATA_ROOT) + + +def extract_asset(asset_path: Path, assets_dir: Path) -> bool: + """Extract a release asset into the writable test data directory. Args: - asset_url: URL to download the asset from - asset_name: Name of the asset file - assets_dir: Directory to extract the asset to + asset_path: Release archive to extract. + assets_dir: Directory to extract the asset into. Returns: - bool: True if successful, False otherwise + True when extraction succeeds. """ - for asset in ASSETS: - asset_name, asset_url = asset.values() - try: - # Download the asset - logger.info(f" Downloading {asset_name}...") - response = requests.get(asset_url, stream=True) - response.raise_for_status() - - # Save to temporary file - temp_file = assets_dir / asset_name - with open(temp_file, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - - logger.info(f" Extracting {asset_name} to {assets_dir}...") - - # Extract based on file type - if asset_name.endswith('.zip'): - with zipfile.ZipFile(temp_file, 'r') as zip_ref: - zip_ref.extractall(assets_dir) - elif asset_name.endswith(('.tar.gz', '.tgz')): - with tarfile.open(temp_file, 'r:gz') as tar_ref: - tar_ref.extractall(assets_dir) - elif asset_name.endswith('.tar'): - with tarfile.open(temp_file, 'r') as tar_ref: - tar_ref.extractall(assets_dir) - else: - logger.warning( - f" Warning: Unknown file type for {asset_name}, skipping extraction" - ) - - # Clean up temporary file + try: + logger.info(f" Extracting {asset_path.name} to {assets_dir}...") + + if asset_path.name.endswith('.zip'): + with zipfile.ZipFile(asset_path, 'r') as zip_ref: + zip_ref.extractall(assets_dir) + elif asset_path.name.endswith(('.tar.gz', '.tgz')): + with tarfile.open(asset_path, 'r:gz') as tar_ref: + tar_ref.extractall(assets_dir) + elif asset_path.name.endswith('.tar'): + with tarfile.open(asset_path, 'r') as tar_ref: + tar_ref.extractall(assets_dir) + else: + logger.warning( + f" Warning: Unknown file type for {asset_path.name}, skipping extraction" + ) + return False + + logger.info(f" Successfully extracted to {assets_dir}") + return True + except Exception as e: + logger.error(f" Error extracting {asset_path.name}: {e}") + return False + + +def extract_staged_release_assets(assets_dir: Path) -> bool: + """Extract staged Megatron-LM v2.5 assets when all of them are available.""" + staged_dir = get_test_data_root() / STAGED_RELEASE_ASSET_DIR + staged_assets = tuple(staged_dir / asset["name"] for asset in ASSETS) + if not all(asset_path.is_file() for asset_path in staged_assets): + return False + + logger.info(f"Using staged release assets from {staged_dir}") + return all(extract_asset(asset_path, assets_dir) for asset_path in staged_assets) + + +def download_release_asset(asset_url: str, asset_name: str, assets_dir: Path) -> bool: + """Download and extract one public GitHub release asset.""" + temp_file = assets_dir / asset_name + try: + logger.info(f" Downloading {asset_name}...") + response = requests.get(asset_url, stream=True, timeout=60) + response.raise_for_status() + + with open(temp_file, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return extract_asset(temp_file, assets_dir) + except Exception as e: + logger.error(f" Error downloading/extracting {asset_name}: {e}") + return False + finally: + if temp_file.is_file(): temp_file.unlink() - logger.info(f" Successfully extracted to {assets_dir}") - except Exception as e: - logger.error(f" Error downloading/extracting {asset_name}: {e}") + +def download_and_extract_asset(assets_dir: Path) -> bool: + """Use staged v2.5 assets first, then fall back to public GitHub downloads.""" + assets_dir.mkdir(parents=True, exist_ok=True) + if extract_staged_release_assets(assets_dir): + return True + + return all(download_release_asset(asset["url"], asset["name"], assets_dir) for asset in ASSETS) @click.command() @@ -86,13 +119,12 @@ def download_and_extract_asset(assets_dir: Path) -> bool: ) @click.option('--assets-dir', default='assets', help='Directory to extract assets to') def main(repo, assets_dir): - """Fetch the oldest release of a GitHub repository and download its assets.""" - logger.info(f"Fetching oldest release of {repo}...") + """Populate unit-test data from staged or public release assets.""" + logger.info(f"Preparing v2.5 release assets for {repo}...") logger.info("=" * 80) - Path(assets_dir).mkdir(parents=True, exist_ok=True) - - download_and_extract_asset(Path(assets_dir)) + if not download_and_extract_asset(Path(assets_dir)): + raise click.ClickException("Failed to download and extract release assets") if __name__ == "__main__": diff --git a/tests/test_utils/python_scripts/test_download_unit_tests_dataset.py b/tests/test_utils/python_scripts/test_download_unit_tests_dataset.py new file mode 100644 index 00000000000..8ebf812cc41 --- /dev/null +++ b/tests/test_utils/python_scripts/test_download_unit_tests_dataset.py @@ -0,0 +1,64 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from io import BytesIO +from pathlib import Path +from unittest.mock import MagicMock, call +from zipfile import ZipFile + +from tests.test_utils.python_scripts import download_unit_tests_dataset + + +def _archive_bytes(directory: str, content: str) -> bytes: + buffer = BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr(f"{directory}/fixture.txt", content) + return buffer.getvalue() + + +def test_download_and_extract_asset_prefers_staged_assets(monkeypatch, tmp_path): + staged_root = tmp_path / "staged" + staged_dir = staged_root / download_unit_tests_dataset.STAGED_RELEASE_ASSET_DIR + staged_dir.mkdir(parents=True) + for asset in download_unit_tests_dataset.ASSETS: + asset_path = staged_dir / asset["name"] + asset_path.write_bytes(_archive_bytes(asset_path.stem, asset_path.name)) + + monkeypatch.setenv(download_unit_tests_dataset.TEST_DATA_ROOT_ENV, str(staged_root)) + get = MagicMock(side_effect=AssertionError("GitHub fallback should not be used")) + monkeypatch.setattr(download_unit_tests_dataset.requests, "get", get) + + output_dir = tmp_path / "output" + assert download_unit_tests_dataset.download_and_extract_asset(output_dir) + assert (output_dir / "datasets" / "fixture.txt").read_text() == "datasets.zip" + assert (output_dir / "tokenizers" / "fixture.txt").read_text() == "tokenizers.zip" + get.assert_not_called() + + +def test_download_and_extract_asset_falls_back_without_github_token(monkeypatch, tmp_path): + archives = { + asset["url"]: _archive_bytes(Path(asset["name"]).stem, asset["name"]) + for asset in download_unit_tests_dataset.ASSETS + } + + class Response: + def __init__(self, content: bytes): + self.content = content + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size: int): + yield self.content + + get = MagicMock(side_effect=lambda url, **_: Response(archives[url])) + monkeypatch.setenv(download_unit_tests_dataset.TEST_DATA_ROOT_ENV, str(tmp_path / "missing")) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setattr(download_unit_tests_dataset.requests, "get", get) + + output_dir = tmp_path / "output" + assert download_unit_tests_dataset.download_and_extract_asset(output_dir) + assert (output_dir / "datasets" / "fixture.txt").read_text() == "datasets.zip" + assert (output_dir / "tokenizers" / "fixture.txt").read_text() == "tokenizers.zip" + assert get.call_args_list == [ + call(asset["url"], stream=True, timeout=60) for asset in download_unit_tests_dataset.ASSETS + ] From ce8a8a736ffae0a0d254d03042eec57cd1b4fc3c Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 30 Jul 2026 12:50:36 -0700 Subject: [PATCH 159/290] Fix Megatron FSDP optimizer under-update (#5976) Signed-off-by: Jingyue Wu Signed-off-by: svcnvidia-nemo-ci Co-authored-by: svcnvidia-nemo-ci --- .../fsdp/src/megatron_fsdp/fully_shard.py | 48 ++++++++++++++++- .../mfsdp_v1/test_mfsdp_fully_shard.py | 54 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/fully_shard.py index 45e9f83452d..d5bb310845b 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/fully_shard.py @@ -499,7 +499,13 @@ def fully_shard_optimizer( optimizer_step_base_func = type(optimizer).step optimizer_zero_grad_base_func = type(optimizer).zero_grad - # Pre-initialize the optimizer state for checkpoint loading via DCP. + # Materialize lazy optimizer state so DCP has state tensors to load into. This + # follows PyTorch DCP's `_init_optim_state`, which takes a synthetic zero-gradient + # step before loading optimizer state. + # TODO: Consider moving this initialization to the checkpoint-loading path, as in + # the MFSDP v2 checkpoint API (#6024), where checkpoint state immediately + # overwrites the synthetic state. Keeping it in fully_shard_optimizer() means that + # fresh training may follow, so this synthetic step must be numerically inert. for group in optimizer.param_groups: for param in group["params"]: if param.numel() == 0 or ( @@ -509,8 +515,46 @@ def fully_shard_optimizer( continue # Optimizer state is built from wgrad. param.grad = torch.zeros_like(param) - # Non-lazy optimizer state initialization. + + # A zero gradient alone does not make optimizer.step() inert. Set lr to zero + # to prevent parameter updates. Also disable weight decay because optimizers + # with coupled decay add it to the gradient before updating their persistent + # moment buffers, independently of lr. + optimizer_group_settings = [] + for group in optimizer.param_groups: + optimizer_group_settings.append( + (group, {key: group[key] for key in ("lr", "weight_decay") if key in group}) + ) + if "lr" in group: + # Capturable optimizers may require lr to remain a device tensor. + group["lr"] = ( + torch.zeros_like(group["lr"]) if isinstance(group["lr"], torch.Tensor) else 0.0 + ) + if "weight_decay" in group: + group["weight_decay"] = 0.0 + # Allocate the state, then restore the caller's optimizer settings. optimizer.step() + for group, settings in optimizer_group_settings: + group.update(settings) + + # Optimizers advance their step counters even when lr is zero. Reset them so + # the first real update uses step 1 for bias correction. + for group in optimizer.param_groups: + if "step" not in group: + continue + if isinstance(group["step"], torch.Tensor): + group["step"].zero_() + else: + group["step"] = 0 + for state in optimizer.state.values(): + if "step" not in state: + continue + if isinstance(state["step"], torch.Tensor): + state["step"].zero_() + else: + state["step"] = 0 + + # Remove the synthetic gradients installed above. optimizer.zero_grad() # Define a new optimizer.step() method that distributes optimizer state and gradients, diff --git a/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py index 7b3a8fd9c9a..28e95ed4a56 100644 --- a/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v1/test_mfsdp_fully_shard.py @@ -748,6 +748,60 @@ def test_fully_shard_ez(self, shard_strategy): optimizer.step() optimizer.zero_grad() + @pytest.mark.parametrize("shard_strategy", [OPTIM_GRADS, OPTIM_GRADS_PARAMS]) + @pytest.mark.parametrize("optimizer_type", ["adam", "adamw", "fused_adam"]) + def test_optimizer_loss_curve_matches_reference(self, shard_strategy, optimizer_type): + """Fully sharding an optimizer must not alter its loss curve.""" + if optimizer_type == "fused_adam" and not HAVE_TE_FUSED_ADAM: + pytest.skip("Transformer Engine FusedAdam is not available") + + torch.manual_seed(1234) + reference_model = RootParamModel().cuda() + model = RootParamModel().cuda() + model.load_state_dict(reference_model.state_dict()) + + model = fully_shard_model( + module=model, fsdp_unit_modules=[RootParamModel], zero_dp_strategy=shard_strategy + ) + if optimizer_type == "adam": + optimizer_cls = torch.optim.Adam + elif optimizer_type == "adamw": + optimizer_cls = torch.optim.AdamW + else: + optimizer_cls = FusedAdam + reference_optimizer = optimizer_cls(reference_model.parameters(), lr=0.01, weight_decay=0.1) + optimizer = fully_shard_optimizer( + optimizer_cls(model.parameters(), lr=0.01, weight_decay=0.1) + ) + + data_generator = torch.Generator(device="cuda").manual_seed( + 91011 + torch.distributed.get_rank() + ) + model_input = torch.randn(DIM_SIZE, DIM_SIZE, device="cuda", generator=data_generator) + target = torch.randn(DIM_SIZE, DIM_SIZE, device="cuda", generator=data_generator) + + reference_losses = [] + losses = [] + for _ in range(NUM_STEPS): + reference_optimizer.zero_grad() + optimizer.zero_grad() + reference_loss = mse_loss(reference_model(model_input), target) + loss = mse_loss(model(model_input), target) + reference_losses.append(reference_loss.detach()) + losses.append(loss.detach()) + + reference_loss.backward() + loss.backward() + # The reference model is not wrapped in DDP, so explicitly average its + # rank-local gradients to match MFSDP's automatic DP synchronization. + for param in reference_model.parameters(): + torch.distributed.all_reduce(param.grad, op=torch.distributed.ReduceOp.AVG) + + reference_optimizer.step() + optimizer.step() + + torch.testing.assert_close(torch.stack(losses), torch.stack(reference_losses)) + def test_root_module_forward_uses_gathered_parameters(self): """ Test that root-owned parameters are gathered before the root forward. From 9d416eed4b118974487ee48f60b3e79b07ac0900 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 30 Jul 2026 12:50:37 -0700 Subject: [PATCH 160/290] Rebind MFSDP v2 sharded grads once per gradient reduction (#6041) Signed-off-by: Jingyue Wu Co-authored-by: Claude Opus 4.8 (1M context) --- .../experimental/parameter_group.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index fd5eb5d2033..9627f2a7112 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -250,12 +250,6 @@ def release_unsharded_storage(self) -> None: # so keep the shared storage-release path. self._unsharded_model_weight.release_storage() - def _install_sharded_grads(self) -> None: - """Point each sharded parameter's grad at main_grad's current DTensor view.""" - assert self.main_grad is not None - for index, sharded_parameter in enumerate(self.sharded_parameters): - sharded_parameter.grad = self.main_grad.get_dtensor(index) - def allocate_partial_grad_buffer(self) -> DBuffer: """Allocate the unreduced reduce-scatter input buffer.""" assert self.main_grad is not None @@ -321,8 +315,6 @@ def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: # and a fresh reduce-scattered buffer for HFSDP in the future. if self.main_grad.placements != self._accumulation_placements: self.main_grad = self.main_grad.redistribute(self._accumulation_placements) - if has_sharded_grads: - self._install_sharded_grads() can_reduce_into_main_grad = ( not has_sharded_grads and partial_grad.dtype == self.main_grad.dtype @@ -346,14 +338,15 @@ def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: self.main_grad.local_buffer.add_(reduced_grad.local_buffer) else: self.main_grad.local_buffer.copy_(reduced_grad.local_buffer) - if not has_sharded_grads: - self._install_sharded_grads() if is_last_microbatch: # Finalize the deferred DP-outer reduction (all-reduce for HSDP, - # reduce-scatter for HFSDP) and install the sharded parameter gradients. + # reduce-scatter for HFSDP) before binding the sharded parameter grads. self.main_grad = self.main_grad.redistribute(self.main_weight.placements) - self._install_sharded_grads() + + # Make each sharded parameter's .grad consistent with the final main_grad. + for index, sharded_parameter in enumerate(self.sharded_parameters): + sharded_parameter.grad = self.main_grad.get_dtensor(index) def _get_parameter_owner(module: nn.Module, name: str) -> tuple[nn.Module, str]: From 0081c849f9e7f32a99fe2e37efa0b14017fc0c51 Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Thu, 30 Jul 2026 12:54:21 -0700 Subject: [PATCH 161/290] Fix MLA dynamic inference decode flag (#4902) Signed-off-by: Chen Cui --- .../transformer/multi_latent_attention.py | 1 + .../test_multi_latent_attention.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/megatron/core/transformer/multi_latent_attention.py b/megatron/core/transformer/multi_latent_attention.py index 50e11151dcd..aa21e78ce86 100644 --- a/megatron/core/transformer/multi_latent_attention.py +++ b/megatron/core/transformer/multi_latent_attention.py @@ -421,6 +421,7 @@ def forward( cu_kv_lengths, kv_lengths, block_table, + inference_context.is_decode_only(), ) # Only rearrange if not in absorption mode (Flash MLA handles format correctly) if not inference_context.is_decode_only(): diff --git a/tests/unit_tests/transformer/test_multi_latent_attention.py b/tests/unit_tests/transformer/test_multi_latent_attention.py index 8462490c727..95517f7176d 100644 --- a/tests/unit_tests/transformer/test_multi_latent_attention.py +++ b/tests/unit_tests/transformer/test_multi_latent_attention.py @@ -166,6 +166,56 @@ def test_input_params_forward(self): missing_params = attn_params - mla_params assert not missing_params, f"Missing parameters in MultiLatentAttention: {missing_params}" + @pytest.mark.parametrize("is_decode_only", [False, True]) + def test_dynamic_inference_forwards_decode_only_to_flash_attention(self, is_decode_only): + """Test that MLA forwards the dynamic context's decode-only state.""" + attention = self.parallel_attention + attention.eval() + attention.config.cache_mla_latents = True + attention.cache_mla_latents = True + + hidden_states = torch.empty((1, 1, attention.config.hidden_size)) + query = torch.empty((1, 1, 1, 1)) + key = torch.empty_like(query) + value = torch.empty_like(query) + block_table = torch.zeros((1, 1), dtype=torch.int32) + cu_seqlens = torch.tensor([0, 1], dtype=torch.int32) + sequence_lengths = torch.ones(1, dtype=torch.int32) + + inference_context = mock.Mock() + inference_context.is_static_batching.return_value = False + inference_context.is_decode_only.return_value = is_decode_only + inference_context.cu_query_lengths.return_value = (cu_seqlens, 1) + inference_context.cu_kv_lengths.return_value = (cu_seqlens, sequence_lengths, 1) + + with ( + mock.patch.object(attention, "prepare_for_absorption"), + mock.patch.object( + attention, + "get_query_key_value_tensors", + return_value=(query, key, value, None, None), + ), + mock.patch.object( + attention, + "_adjust_key_value_for_inference", + return_value=(query, key, value, None, AttnMaskType.causal, block_table), + ), + mock.patch.object( + attention, + "flash_decode_and_prefill", + side_effect=RuntimeError("flash attention call reached"), + ) as flash_decode_and_prefill, + pytest.raises(RuntimeError, match="flash attention call reached"), + ): + attention(hidden_states, attention_mask=None, inference_context=inference_context) + + flash_call = signature(Attention.flash_decode_and_prefill).bind( + attention, + *flash_decode_and_prefill.call_args.args, + **flash_decode_and_prefill.call_args.kwargs, + ) + assert flash_call.arguments["is_decode_only"] is is_decode_only + def test_constructor(self): assert isinstance(self.parallel_attention, MLASelfAttention) assert self.parallel_attention.layer_number == 1 From dc3c9ab35997604b64d646e5fb2e2261dc9ef0a7 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 30 Jul 2026 12:56:06 -0700 Subject: [PATCH 162/290] Normalize MFSDP data-parallel axes at fully_shard (#6067) Signed-off-by: Jingyue Wu --- .../megatron_fsdp/experimental/fully_shard.py | 25 ++++++++++++++++++- .../src/megatron_fsdp/experimental/module.py | 20 ++------------- .../megatron_fsdp/experimental/placement.py | 4 +-- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py index 0f53c34f359..076a5ee227e 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py @@ -14,6 +14,7 @@ """Minimal Megatron-FSDP fully_shard entrypoint.""" +import dataclasses from collections.abc import Iterator from contextlib import contextmanager @@ -22,7 +23,7 @@ from ..mixed_precision import MixedPrecisionPolicy from .module import FsdpContext, FsdpModule -from .placement import Placements +from .placement import MeshAxis, Placements def fully_shard( @@ -49,6 +50,7 @@ def fully_shard( if isinstance(module, FsdpModule): raise ValueError("This module is already managed by FSDP.") + placements = _normalize_placements(mesh, placements) mixed_precision_policy = mixed_precision_policy or MixedPrecisionPolicy() original_cls = module.__class__ _attach_mixin(module) @@ -66,6 +68,27 @@ def fully_shard( raise +def _normalize_placements(mesh: DeviceMesh, placements: Placements) -> Placements: + """Return a copy with data-parallel mesh axes normalized to integer indices.""" + dp_axes = tuple(_axis_index(mesh, axis) for axis in placements.dp_axes) + return dataclasses.replace(placements, dp_axes=dp_axes) + + +def _axis_index(mesh: DeviceMesh, axis: MeshAxis) -> int: + if isinstance(axis, int): + axis_index = axis + if axis_index < 0: + axis_index += mesh.ndim + if axis_index < 0 or axis_index >= mesh.ndim: + raise ValueError(f"Mesh axis {axis} is out of bounds for mesh ndim {mesh.ndim}.") + return axis_index + + dim_names = mesh.mesh_dim_names + if dim_names is None or axis not in dim_names: + raise ValueError(f"Mesh axis {axis!r} is not present in mesh dim names {dim_names}.") + return dim_names.index(axis) + + @contextmanager def microbatch(module: nn.Module, is_last: bool) -> Iterator[None]: """Mark an FSDP microbatch as the last accumulation microbatch. diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 33e6985d8ed..f4d30286e14 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -24,7 +24,7 @@ from ..mixed_precision import MixedPrecisionPolicy from .indexed_order import IndexedOrder from .parameter_group import FsdpParameterGroup, get_containing_parameter_group -from .placement import MeshAxis, Placements +from .placement import Placements class FsdpContext: @@ -104,8 +104,7 @@ def __init__( self._name = None self._unshard_event = None owned_parameters = _collect_owned_parameters(self) - axis_indices = tuple(_axis_index(mesh, axis) for axis in placements.dp_axes) - assert axis_indices == tuple( + assert tuple(placements.dp_axes) == tuple( range(mesh.ndim) ), "FSDP requires dp_axes to match every mesh axis in mesh order for now." parameter_groups = [ @@ -358,21 +357,6 @@ def _collect_backward_order(module: nn.Module, order: IndexedOrder["FsdpModule"] _collect_backward_order(child, order) -def _axis_index(mesh: DeviceMesh, axis: MeshAxis) -> int: - if isinstance(axis, int): - axis_index = axis - if axis_index < 0: - axis_index += mesh.ndim - if axis_index < 0 or axis_index >= mesh.ndim: - raise ValueError(f"Mesh axis {axis} is out of bounds for mesh ndim {mesh.ndim}.") - return axis_index - - dim_names = mesh.mesh_dim_names - if dim_names is None or axis not in dim_names: - raise ValueError(f"Mesh axis {axis!r} is not present in mesh dim names {dim_names}.") - return dim_names.index(axis) - - def _collect_owned_parameters(root_module: nn.Module) -> dict[str, nn.Parameter]: parameters: dict[str, nn.Parameter] = {} diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py index 6f99ff1a06c..563d992d11a 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/placement.py @@ -30,7 +30,7 @@ """ import dataclasses -from collections.abc import Iterable +from collections.abc import Iterable, Sequence import torch.distributed as dist @@ -82,7 +82,7 @@ def changed_mesh_axis( class Placements: """Per-mesh-axis placements for parameter, gradient, and optimizer buffers.""" - dp_axes: list[MeshAxis] + dp_axes: Sequence[MeshAxis] parameter: list[Placement] gradient: list[Placement] optimizer: list[Placement] From ab20a601247647ab8aab56af1dc0f3adb9e9193a Mon Sep 17 00:00:00 2001 From: Yan Bai Date: Fri, 31 Jul 2026 04:13:52 +0800 Subject: [PATCH 163/290] Add Agent Compose package and runtime interface skeleton (#5937) Signed-off-by: Yan Bai --- experimental/agent_compose/README.md | 141 ++++++++++++++---- .../agent_compose/docs/architecture.md | 77 ++++++++++ experimental/agent_compose/docs/model.md | 44 ++++++ experimental/agent_compose/docs/runtime.md | 54 +++++++ .../experimental/agent_compose/__init__.py | 6 + .../agent_compose/model/__init__.py | 6 + .../agent_compose/primitive/__init__.py | 6 + .../agent_compose/runtime/__init__.py | 83 +++++++++++ .../runtime/backends/__init__.py | 110 ++++++++++++++ .../runtime/contracts/__init__.py | 31 ++++ .../agent_compose/runtime/contracts/config.py | 60 ++++++++ .../agent_compose/runtime/contracts/data.py | 103 +++++++++++++ .../agent_compose/runtime/contracts/handle.py | 50 +++++++ .../agent_compose/runtime/contracts/loss.py | 50 +++++++ experimental/agent_compose/skills/README.md | 29 ++++ .../skills/basic/constitution.md | 37 +++++ .../agent_compose/skills/basic/lint-skill.md | 42 ++++++ .../agent_compose/skills/model/compose.md | 35 +++++ .../skills/primitive/contract.md | 41 +++++ .../agent_compose/skills/runtime/validate.md | 48 ++++++ .../agent_compose/tests/unit/test_import.py | 56 +++++++ .../tests/unit/test_layering_contracts.py | 54 +++++++ .../tests/unit/test_runtime_interface.py | 80 ++++++++++ .../agent_compose/tests/unit/test_skills.py | 38 +++++ 24 files changed, 1250 insertions(+), 31 deletions(-) create mode 100644 experimental/agent_compose/docs/architecture.md create mode 100644 experimental/agent_compose/docs/model.md create mode 100644 experimental/agent_compose/docs/runtime.md create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/__init__.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py create mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py create mode 100644 experimental/agent_compose/skills/README.md create mode 100644 experimental/agent_compose/skills/basic/constitution.md create mode 100644 experimental/agent_compose/skills/basic/lint-skill.md create mode 100644 experimental/agent_compose/skills/model/compose.md create mode 100644 experimental/agent_compose/skills/primitive/contract.md create mode 100644 experimental/agent_compose/skills/runtime/validate.md create mode 100644 experimental/agent_compose/tests/unit/test_import.py create mode 100644 experimental/agent_compose/tests/unit/test_layering_contracts.py create mode 100644 experimental/agent_compose/tests/unit/test_runtime_interface.py create mode 100644 experimental/agent_compose/tests/unit/test_skills.py diff --git a/experimental/agent_compose/README.md b/experimental/agent_compose/README.md index ca2c8e43e38..0300687e913 100644 --- a/experimental/agent_compose/README.md +++ b/experimental/agent_compose/README.md @@ -1,45 +1,124 @@ # Agent Compose (experimental) -Agent Compose is an experimental effort to make Megatron-LM development -agentic-native: composing Megatron Core primitives with coding agents, rather -than introducing a new standalone product or training stack. +Agent Compose is an experimental incubation surface for incrementally reviewed +Megatron capabilities. It makes Megatron-LM development agentic-native by +combining Megatron Core primitives with coding agents, rather than maintaining +a fork or introducing a standalone training stack. -This directory is a placeholder that establishes the location and naming for -the upstreamed work. Content will land here incrementally as a series of small, -reviewable PRs. +`experimental/agent_compose` is the project and review location. The Python +package uses the public namespace `megatron.experimental.agent_compose`; +`experimental.agent_compose` is not an import path. -## Preview +## Main And Dev -The full work-in-progress implementation lives on the `dev` branch under -`experimental/lite/`: +The complete work-in-progress implementation remains in `experimental/lite` on +the `dev` branch, while `experimental/agent_compose` on `main` is its +independently reviewed upstream incubation surface. Code is promoted from the +development preview one vertically complete and independently validated slice +at a time. -- https://github.com/NVIDIA/Megatron-LM/tree/dev/experimental/lite +| Surface | `main` | `dev` | +| --- | --- | --- | +| Project tree | `experimental/agent_compose` | Development preview | +| Role | Reviewed upstream subset | Work-in-progress superset | +| Python namespace | `megatron.experimental.agent_compose` | Preview-local | -The preview currently includes: +The upstream package has no runtime dependency on the preview tree. Do not add +both source roots to the same `PYTHONPATH`; select the tree from the branch being +tested. -- A lightweight runtime API built from small composable primitives. -- Native model implementations with explicit model/runtime protocols. -- Hugging Face safetensors load/export helpers. -- Validation recipes and benchmark examples against Megatron-Core reference - paths (bitwise loss/grad-norm parity on the distributed-optimizer path). -- Skills playbooks that let coding agents extend models and primitives in a - reviewable way. +## Incubation + +Incomplete prototypes remain on `dev`. Changes promoted to `main` must be +functionally complete and validated for their declared scope. + +Successful, reusable capabilities should graduate to their long-term owners: + +- reusable primitives and backend-neutral interfaces to Megatron Core; +- training orchestration to Megatron Bridge or Automodel; +- integration-specific behavior to the owning integration project. + +Agent Compose first switches to the graduated implementation and validates +parity. Duplicate incubating code is removed only after that transition, so the +Agent Compose path remains coherent and runnable. + +## Architecture + +The initial package establishes three layers: + +- `primitive`: replaceable lower-level components built from Megatron Core. +- `model`: model declarations and composition from validated primitives. +- `runtime`: lifecycle and training orchestration through model protocols. + +Dependencies flow from runtime to model to primitive. Model and primitive code +may use an explicitly stable runtime contract, but they must not import runtime +backends. Primitive code must remain model-agnostic. + +```text +experimental/agent_compose/ + README.md + docs/ + architecture.md + model.md + megatron/ + experimental/ + agent_compose/ + primitive/ + model/ + runtime/ + skills/ + basic/ + primitive/ + model/ + runtime/ + tests/ + unit/ +``` + +For local source-tree use: + +```bash +export PYTHONPATH=/path/to/Megatron-LM/experimental/agent_compose:$PYTHONPATH +``` + +The skeleton exposes the initial runtime interface and shared runtime contracts, +but contains no built-in runtime backend, model, or primitive implementation. +Those implementations will be added in separate reviewable PRs. + +```python +from megatron.experimental.agent_compose.runtime import Runtime, RuntimeConfig, create_runtime, register_runtime +``` + +Backends subclass `Runtime` and register a module-level factory before +`create_runtime` is called. The skeleton intentionally registers no built-in +backend. + +## Documentation + +- [Three-layer architecture](docs/architecture.md) +- [Runtime interface](docs/runtime.md) +- [Model layer and protocol](docs/model.md) + +## Skills + +`skills/` contains agent-agnostic operational contracts. The initial skills set +the global constraints and the minimum contract for each architecture layer. +Each primitive PR should add or update the corresponding leaf skill together +with its reference and validation path. ## Principles -- **Compose, don't fork.** Primitives reuse and build from existing Megatron - Core modules wherever appropriate. When a primitive cannot reuse an existing - module and needs a separate implementation, the reason is documented in the - docstring, making gaps explicit and providing input for future Megatron Core - improvements. -- **Reviewable by construction.** Runtime, model, and primitive code are split - into small contracts so agents and humans can make targeted changes without - touching unrelated Megatron subsystems. -- **Core performance.** Changes are validated against Megatron-Core reference - paths for both correctness and speed. +- **Compose, don't fork.** Reuse Megatron Core wherever appropriate. Document + why a separate implementation is necessary when reuse is not possible. +- **Reviewable by construction.** Keep runtime, model, and primitive contracts + small enough to review independently. +- **Reference before implementation.** Every implementation needs a checkable + Megatron, Hugging Face, Torch, or first-principles reference. +- **Core performance.** Validate accepted code against Megatron Core for both + correctness and speed where applicable. ## Status -Upstreaming is being scoped: the current work is being evaluated for splitting -into small PRs, after which a timeline will be shared. Until then, please use -the preview branch above. +The package and skill boundaries are established here. Implementations will +land incrementally; use the `dev` preview for surfaces not yet present on +`main`. diff --git a/experimental/agent_compose/docs/architecture.md b/experimental/agent_compose/docs/architecture.md new file mode 100644 index 00000000000..fd9685dd2ac --- /dev/null +++ b/experimental/agent_compose/docs/architecture.md @@ -0,0 +1,77 @@ +# Three-Layer Architecture + +Agent Compose provides three reviewable layers under the public +`megatron.experimental.agent_compose` namespace. + +## Layers + +### Primitive + +`megatron.experimental.agent_compose.primitive` owns reusable lower-level +components: parallel operations and state, modules, checkpoint conversion, +optimizer integration, and focused math or kernel shims. A primitive must be +independently selectable and validated. It may build on Megatron Core, but it +must not know model family names or runtime backend implementations. + +### Model + +`megatron.experimental.agent_compose.model` owns model-family configuration and +the protocol that composes validated primitives into model chunks. It also owns +model-specific checkpoint mappings and forward adaptation. It does not own the +training loop or distributed runtime lifecycle. + +### Runtime + +`megatron.experimental.agent_compose.runtime` owns the backend-neutral +lifecycle: model construction, mode changes, forward/backward microbatch +orchestration, checkpoint dispatch, optimizer and scheduler steps, weight +export, and optional device offload. Concrete backends implement the public +`Runtime` interface. + +## Dependency Direction + +```text +runtime orchestration -> model protocol -> primitive -> Megatron Core + | | + +---- runtime contracts <----+ +``` + +`runtime.contracts` is the shared boundary surface, not a runtime backend. +Model and primitive code may import these stable data types. They must not +import `runtime.backends` or backend implementation modules. + +The static layering test enforces import direction: + +- primitive does not import model; +- primitive and model do not import runtime implementation code; +- reviewed code never imports development-preview source at runtime. + +The runtime skill and human review additionally require runtime code to remain +model-family agnostic; that semantic rule cannot be fully expressed as an +import-prefix check before model families exist in this tree. + +## Composition Flow + +1. A runtime resolves a model protocol without importing model-family details + into the runtime layer. +2. The model protocol selects validated primitives and constructs model chunks. +3. The protocol returns backend-consumable model state through shared contracts. +4. The runtime drives training without reaching into model internals. + +## Incubation Lifecycle + +1. Prototype and iterate on `dev`. +2. Promote a vertically complete, referenced, and validated slice into Agent + Compose. +3. Validate correctness, composition, end-to-end behavior, and performance for + the declared scope. +4. Select the long-term owner: Megatron Core for reusable primitives and + backend-neutral interfaces, Megatron Bridge or Automodel for training + orchestration, or the owning project for integration-specific behavior. +5. Switch Agent Compose to consume the graduated implementation and demonstrate + parity. +6. Remove the duplicate incubating implementation only after the Agent Compose + path is complete and runnable with the graduated capability. + +The current skeleton exposes the runtime interface and shared contracts. Model, +primitive, and backend implementations will land in separate PRs. diff --git a/experimental/agent_compose/docs/model.md b/experimental/agent_compose/docs/model.md new file mode 100644 index 00000000000..390f4940916 --- /dev/null +++ b/experimental/agent_compose/docs/model.md @@ -0,0 +1,44 @@ +# Model Layer + +Model code lives under `megatron.experimental.agent_compose.model`. The layer +turns a model-family declaration into backend-consumable model state by +composing validated primitives; it is not a second runtime. + +## Responsibilities + +The model layer owns: + +- typed architecture configuration; +- construction of model chunks from primitives; +- model-specific forward input/output adaptation; +- recompute and offload placement choices; +- Hugging Face load and export mappings; +- model-specific optimizer wiring when it cannot be expressed generically. + +It does not own distributed initialization, the training-step lifecycle, +checkpoint scheduling, or runtime backend selection. + +## Protocol Direction + +The development preview currently uses module-level model protocols. A model +protocol provides these required operations: + +```text +ImplConfig +build_model_config(source, **overrides) +build_model(model_cfg, *, impl_cfg) +``` + +Optional operations include Hugging Face load/export helpers and vocabulary +metadata. This document records the intended boundary; no concrete model or +registry is included in the skeleton PR. The first model PR should upstream the +smallest protocol surface justified by its selected primitives rather than +freezing every preview-only hook here. + +## Rules For Adding A Model + +1. Declare required features before choosing primitives. +2. Use only primitives with a checkable reference and validation path. +3. Keep model-family imports out of runtime code. +4. Keep heavyweight optional imports inside protocol operations where possible. +5. Add a composition test and an end-to-end runtime validation before delivery. diff --git a/experimental/agent_compose/docs/runtime.md b/experimental/agent_compose/docs/runtime.md new file mode 100644 index 00000000000..b50ac76b8cc --- /dev/null +++ b/experimental/agent_compose/docs/runtime.md @@ -0,0 +1,54 @@ +# Runtime Interface + +The public runtime entrypoint is +`megatron.experimental.agent_compose.runtime`. It defines a backend-neutral +interface so training applications do not depend on a concrete backend. + +## API Tiers + +Every backend implements the pretraining tier: + +- `build_model` +- `save_checkpoint` and `load_checkpoint` +- `train_mode` and `eval_mode` +- `forward_backward` +- `zero_grad` +- `optimizer_step` +- `lr_scheduler_step` + +Implementing `export_weights` adds the `rl_ready` tier. Implementing both +`export_weights` and `to` adds the `rl_best` tier, including model and optimizer +offload between training and rollout phases. `Runtime.tier` reports the highest +implemented tier. + +## Shared Contracts + +The runtime package exposes: + +- `RuntimeConfig`, `ParallelConfig`, and `OptimizerConfig`; +- `Batch`, `PackedBatch`, and legacy `TrainBatch` inputs; +- `ModelOutputs` and `ForwardResult` outputs; +- the opaque `ModelHandle` returned by `build_model`; +- `LossContext` for per-microbatch output and loss policy. + +Imports are lazy where Torch-backed data contracts are involved. Importing +`megatron.experimental.agent_compose.runtime` alone does not load Torch. + +## Backend Registration + +A backend module provides `create(hf_path, backend_cfg)` and registers its +dotted module path: + +```python +from megatron.experimental.agent_compose.runtime import ( + RuntimeConfig, + create_runtime, + register_runtime, +) + +register_runtime("my_backend", "my_package.runtime") +runtime = create_runtime(RuntimeConfig(backend="my_backend")) +``` + +No built-in backend is registered in the skeleton. Each backend will be added +with its own reference, implementation skill, and lifecycle validation. diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/__init__.py new file mode 100644 index 00000000000..9c33c07f264 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Agent Compose components reviewed through Agent Compose.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py new file mode 100644 index 00000000000..f6c60b7209a --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Agent Compose model composition layer.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py new file mode 100644 index 00000000000..47d000d627d --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Composable Agent Compose primitives.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py new file mode 100644 index 00000000000..e0246c8c9d6 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py @@ -0,0 +1,83 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Public runtime interface for Agent Compose.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +from megatron.experimental.agent_compose.runtime.contracts.config import RuntimeConfig + +if TYPE_CHECKING: + from megatron.experimental.agent_compose.runtime.backends import Runtime + from megatron.experimental.agent_compose.runtime.contracts.config import ( + OptimizerConfig, + ParallelConfig, + ) + from megatron.experimental.agent_compose.runtime.contracts.data import ( + Batch, + ForwardResult, + ModelOutputs, + PackedBatch, + TrainBatch, + ) + from megatron.experimental.agent_compose.runtime.contracts.handle import ModelHandle + from megatron.experimental.agent_compose.runtime.contracts.loss import LossContext + +_RUNTIME_REGISTRY: dict[str, str] = {} + + +def register_runtime(name: str, module_path: str) -> None: + """Register a module that provides ``create(hf_path, backend_cfg)``.""" + if not name or not module_path: + raise ValueError("runtime name and module path must be non-empty") + _RUNTIME_REGISTRY[name] = module_path + + +def create_runtime(cfg: RuntimeConfig) -> Runtime: + """Create a registered runtime backend for ``cfg``.""" + if cfg.backend not in _RUNTIME_REGISTRY: + raise ValueError( + f"No runtime backend registered for {cfg.backend!r}. " + f"Available: {sorted(_RUNTIME_REGISTRY)}" + ) + module = importlib.import_module(_RUNTIME_REGISTRY[cfg.backend]) + return module.create(cfg.hf_path, cfg.backend_cfg) + + +def __getattr__(name: str): + lazy = { + "Batch": "megatron.experimental.agent_compose.runtime.contracts.data", + "ForwardResult": "megatron.experimental.agent_compose.runtime.contracts.data", + "LossContext": "megatron.experimental.agent_compose.runtime.contracts.loss", + "ModelHandle": "megatron.experimental.agent_compose.runtime.contracts.handle", + "ModelOutputs": "megatron.experimental.agent_compose.runtime.contracts.data", + "OptimizerConfig": "megatron.experimental.agent_compose.runtime.contracts.config", + "PackedBatch": "megatron.experimental.agent_compose.runtime.contracts.data", + "ParallelConfig": "megatron.experimental.agent_compose.runtime.contracts.config", + "Runtime": "megatron.experimental.agent_compose.runtime.backends", + "TrainBatch": "megatron.experimental.agent_compose.runtime.contracts.data", + } + if name in lazy: + module = importlib.import_module(lazy[name]) + value = getattr(module, name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "Batch", + "ForwardResult", + "LossContext", + "ModelHandle", + "ModelOutputs", + "OptimizerConfig", + "PackedBatch", + "ParallelConfig", + "Runtime", + "RuntimeConfig", + "TrainBatch", + "create_runtime", + "register_runtime", +] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py new file mode 100644 index 00000000000..abb027ad973 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Runtime interface implemented by Agent Compose backends.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + import torch + + from megatron.experimental.agent_compose.runtime.contracts.data import ForwardResult + from megatron.experimental.agent_compose.runtime.contracts.handle import ModelHandle + + +class Runtime(ABC): + """Backend-neutral lifecycle and training interface. + + The required methods form the pretraining tier. Implementing + :meth:`export_weights` adds the RL-ready tier; implementing both + :meth:`export_weights` and :meth:`to` adds the RL-best tier. + """ + + @abstractmethod + def build_model( + self, hf_path: str | None = None, cfg: Any = None, **kwargs + ) -> ModelHandle: + """Build model state and return an opaque handle.""" + ... + + @abstractmethod + def save_checkpoint(self, handle: ModelHandle, path: str, **kwargs) -> None: ... + + @abstractmethod + def load_checkpoint(self, handle: ModelHandle, path: str, **kwargs) -> int: ... + + @abstractmethod + def train_mode(self, handle: ModelHandle) -> Any: ... + + @abstractmethod + def eval_mode(self, handle: ModelHandle) -> Any: ... + + @abstractmethod + def forward_backward( + self, + handle: ModelHandle, + data: Any, + loss_fn: Callable | None, + *, + num_microbatches: int = 1, + forward_only: bool = False, + router_replay: Any = None, + ) -> ForwardResult: + """Run a logical forward/backward step over one or more microbatches.""" + ... + + @abstractmethod + def zero_grad(self, handle: ModelHandle) -> None: ... + + @abstractmethod + def optimizer_step(self, handle: ModelHandle) -> tuple[bool, float, int | None]: + """Return ``(update_successful, grad_norm, num_zeros_in_grad)``.""" + ... + + @abstractmethod + def lr_scheduler_step(self, handle: ModelHandle) -> float | list[float]: ... + + def is_mp_src_rank_with_outputs(self, handle: ModelHandle) -> bool: + """Return whether this rank owns complete model outputs.""" + return True + + def export_weights( + self, handle: ModelHandle, **kwargs + ) -> Iterator[tuple[str, torch.Tensor]]: + """Iterate over inference-compatible ``(name, tensor)`` pairs.""" + raise NotImplementedError( + f"{type(self).__name__} does not implement export_weights. " + "Implement it to unlock the RL Ready tier." + ) + + def to( + self, + handle: ModelHandle, + device: str, + *, + model: bool = True, + optimizer: bool = True, + grad: bool = True, + ) -> None: + """Move selected model state between devices.""" + raise NotImplementedError( + f"{type(self).__name__} does not implement to(). " + "Implement it to unlock the RL Best tier." + ) + + @property + def tier(self) -> Literal["pretrain", "rl_ready", "rl_best"]: + """Report the highest runtime API tier implemented by this backend.""" + cls = type(self) + has_export = cls.export_weights is not Runtime.export_weights + has_to = cls.to is not Runtime.to + if has_export and has_to: + return "rl_best" + if has_export: + return "rl_ready" + return "pretrain" + + +__all__ = ["Runtime"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py new file mode 100644 index 00000000000..11e6d4d47c5 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Lazy public exports for shared runtime contracts.""" + +from __future__ import annotations + +import importlib + +_EXPORTS = { + "Batch": "megatron.experimental.agent_compose.runtime.contracts.data", + "ForwardResult": "megatron.experimental.agent_compose.runtime.contracts.data", + "LossContext": "megatron.experimental.agent_compose.runtime.contracts.loss", + "ModelHandle": "megatron.experimental.agent_compose.runtime.contracts.handle", + "ModelOutputs": "megatron.experimental.agent_compose.runtime.contracts.data", + "OptimizerConfig": "megatron.experimental.agent_compose.runtime.contracts.config", + "PackedBatch": "megatron.experimental.agent_compose.runtime.contracts.data", + "ParallelConfig": "megatron.experimental.agent_compose.runtime.contracts.config", + "RuntimeConfig": "megatron.experimental.agent_compose.runtime.contracts.config", + "TrainBatch": "megatron.experimental.agent_compose.runtime.contracts.data", +} + + +def __getattr__(name: str): + if name not in _EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module = importlib.import_module(_EXPORTS[name]) + value = getattr(module, name) + globals()[name] = value + return value + + +__all__ = list(_EXPORTS) diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py new file mode 100644 index 00000000000..a379133d99e --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Backend-neutral runtime configuration contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ParallelConfig: + """Parallel dimensions shared by runtime backends.""" + + tp: int = 1 + etp: int | None = None + ep: int = 1 + pp: int = 1 + vpp: int = 1 + cp: int = 1 + pp_layout: str | list | None = None + + +@dataclass +class OptimizerConfig: + """Optimizer and learning-rate scheduler settings.""" + + optimizer: str = "adam" + lr: float = 1e-3 + min_lr: float = 0.0 + clip_grad: float = 1.0 + weight_decay: float = 0.01 + lr_warmup_steps_ratio: float = 0.0 + total_training_steps: int = -1 + lr_warmup_steps: int = -1 + lr_warmup_init: float = 0.0 + lr_decay_steps: int | None = None + lr_decay_style: str = "linear" + weight_decay_incr_style: str = "constant" + lr_wsd_decay_style: str = "exponential" + lr_wsd_decay_steps: int | None = None + use_checkpoint_opt_param_scheduler: bool = False + + adam_beta1: float | None = None + adam_beta2: float | None = None + adam_eps: float | None = None + offload_fraction: float | None = None + use_precision_aware_optimizer: bool | None = None + decoupled_weight_decay: bool | None = None + + +@dataclass +class RuntimeConfig: + """Select a runtime backend and provide its configuration.""" + + backend: str = "agent_compose" + hf_path: str = "" + backend_cfg: Any = field(default_factory=dict) + + +__all__ = ["OptimizerConfig", "ParallelConfig", "RuntimeConfig"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py new file mode 100644 index 00000000000..536b02d4615 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Input and output contracts for ``Runtime.forward_backward``.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import torch + + +class Batch: + """Base contract for a model-agnostic runtime batch.""" + + def __len__(self) -> int: + """Return the number of sequences in the batch.""" + raise NotImplementedError + + def sizes(self) -> torch.Tensor: + """Return per-sequence token counts.""" + raise NotImplementedError + + +@dataclass(slots=True) +class PackedBatch(Batch): + """Variable-length sequences packed without padding.""" + + input_ids: torch.Tensor + labels: torch.Tensor + seq_lens: torch.Tensor + loss_mask: torch.Tensor | None = None + position_ids: torch.Tensor | None = None + routed_experts: torch.Tensor | None = None + extras: dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.seq_lens) + + def sizes(self) -> torch.Tensor: + return self.seq_lens + + @property + def cu_seqlens(self) -> torch.Tensor: + """Return cumulative sequence lengths in int32.""" + return torch.cat( + [ + torch.zeros(1, dtype=torch.int32, device=self.seq_lens.device), + self.seq_lens.cumsum(0).to(torch.int32), + ] + ) + + @property + def total_tokens(self) -> int: + return int(self.seq_lens.sum()) + + def make_position_ids(self) -> torch.Tensor: + """Return explicit or generated per-token position IDs.""" + if self.position_ids is not None: + return self.position_ids + return torch.cat( + [ + torch.arange(length, device=self.seq_lens.device) + for length in self.seq_lens.tolist() + ] + ) + + +@dataclass(slots=True) +class TrainBatch: + """Legacy fixed-shape batch contract.""" + + input_ids: torch.Tensor + labels: torch.Tensor + loss_mask: torch.Tensor | None = None + position_ids: torch.Tensor | None = None + routed_experts: torch.Tensor | None = None + cp_size: int | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class ModelOutputs: + """Model outputs understood by runtime integrations.""" + + loss: torch.Tensor | None = None + vocab_parallel_logits: torch.Tensor | None = None + log_probs: torch.Tensor | None = None + hidden_states: torch.Tensor | None = None + values: torch.Tensor | None = None + mtp_logits: torch.Tensor | None = None + mtp_loss: torch.Tensor | None = None + routed_experts: torch.Tensor | None = None + + +@dataclass(slots=True) +class ForwardResult: + """Result of one logical runtime forward/backward call.""" + + model_output: ModelOutputs = field(default_factory=ModelOutputs) + metrics: dict[str, Any] = field(default_factory=dict) + + +__all__ = ["Batch", "ForwardResult", "ModelOutputs", "PackedBatch", "TrainBatch"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py new file mode 100644 index 00000000000..b7f6ab7d4a3 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Opaque model handle exchanged through the runtime interface.""" + +from __future__ import annotations + +from typing import Any + + +class ModelHandle: + """Hold backend state while exposing only stable distributed metadata.""" + + def __init__( + self, + *, + model: Any, + optimizer: Any = None, + lr_scheduler: Any = None, + parallel_state: Any = None, + config: Any = None, + _extras: dict[str, Any] | None = None, + ): + self._model = model + self._optimizer = optimizer + self._lr_scheduler = lr_scheduler + self._parallel_state = parallel_state + self._config = config + self._extras = _extras or {} + + @property + def dp_rank(self) -> int: + return getattr(self._parallel_state, "dp_rank", 0) + + @property + def dp_size(self) -> int: + return getattr(self._parallel_state, "dp_size", 1) + + @property + def dp_group(self): + return getattr(self._parallel_state, "dp_group", None) + + @property + def cp_range(self) -> tuple[int, int]: + return self._extras.get("cp_range", (1, 1)) + + @property + def config(self) -> Any: + return self._config + + +__all__ = ["ModelHandle"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py new file mode 100644 index 00000000000..59d37dc2be8 --- /dev/null +++ b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Per-microbatch loss and output policy.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class LossContext: + temperature: float = 1.0 + calculate_entropy: bool = False + return_log_probs: bool = True + loss_scale: float = 1.0 + source_batch: Any | None = None + + +_CURRENT_LOSS_CONTEXT: ContextVar[LossContext | None] = ContextVar( + "megatron_agent_compose_loss_context", default=None +) + + +def get_loss_context() -> LossContext | None: + return _CURRENT_LOSS_CONTEXT.get() + + +@contextmanager +def use_loss_context(loss_context: LossContext | None) -> Iterator[None]: + token = _CURRENT_LOSS_CONTEXT.set(loss_context) + try: + yield + finally: + _CURRENT_LOSS_CONTEXT.reset(token) + + +def split_loss_context(item): + if ( + isinstance(item, tuple) + and len(item) == 2 + and (item[1] is None or isinstance(item[1], LossContext)) + ): + return item + return item, None + + +__all__ = ["LossContext", "get_loss_context", "split_loss_context", "use_loss_context"] diff --git a/experimental/agent_compose/skills/README.md b/experimental/agent_compose/skills/README.md new file mode 100644 index 00000000000..80724b6fc80 --- /dev/null +++ b/experimental/agent_compose/skills/README.md @@ -0,0 +1,29 @@ +# Agent Compose Skills + +This directory defines agent-agnostic operational contracts for upstreaming +capabilities through Agent Compose. Skills describe how to make and validate a +change; they do not replace executable tests. + +## Format + +Each skill is one Markdown file with three parts: + +1. A short human-facing title. +2. A schema between `AGENT_COMPOSE_SKILL_SCHEMA_BEGIN` and + `AGENT_COMPOSE_SKILL_SCHEMA_END`. +3. A finite Python-like pseudocode body with a declared exit. + +Schema names map to paths by replacing underscores with hyphens and dots with +directories. For example, `runtime.validate` maps to +`runtime/validate.md`. + +## Initial Registry + +- `basic.constitution`: global design and validation constraints. +- `basic.lint_skill`: structural validation for skills. +- `primitive.contract`: required contract for a primitive. +- `model.compose`: compose a model only from contracted primitives. +- `runtime.validate`: validate the runtime lifecycle end to end. + +Load this file, then exactly one leaf skill for the current work type and every +skill named by that leaf's `imports`. diff --git a/experimental/agent_compose/skills/basic/constitution.md b/experimental/agent_compose/skills/basic/constitution.md new file mode 100644 index 00000000000..5d57b92fc20 --- /dev/null +++ b/experimental/agent_compose/skills/basic/constitution.md @@ -0,0 +1,37 @@ +# Agent Compose Constitution + +Global constraints for work upstreamed through Agent Compose. + + +```python +schema = Skill( + "basic.constitution", kind="constitution", purpose="set global Agent Compose constraints", + imports=[], calls=[], + inputs=["task", "layer", "reference"], + outputs=["constraints", "validation", "stop"], exits=["done", "blocked", "out_of_scope"], +) +``` + + +```python +def constitution(task, layer, reference): + if layer not in ["primitive", "model", "runtime"]: + return out_of_scope("unknown Agent Compose layer") + if reference is None: + return blocked("no checkable validation reference") + + constraints = [ + occam_razor("choose the smallest correct and reviewable design"), + modularity("keep primitives replaceable unless explicitly fused"), + layering("runtime -> model -> primitive -> Megatron Core"), + isolation("do not import the dev preview at runtime"), + ] + validation = [ + reference_order(["Megatron", "HuggingFace", "Torch", "first principles"]), + require_bitwise_when_possible(reference), + require_layer_test(layer), + require_end_to_end_before_delivery(task), + ] + stop = ["missing reference", "missing validation path", "layer boundary violation"] + return done(constraints=constraints, validation=validation, stop=stop) +``` diff --git a/experimental/agent_compose/skills/basic/lint-skill.md b/experimental/agent_compose/skills/basic/lint-skill.md new file mode 100644 index 00000000000..92a4c2ca903 --- /dev/null +++ b/experimental/agent_compose/skills/basic/lint-skill.md @@ -0,0 +1,42 @@ +# Skill Lint + +Validate an Agent Compose skill before review. + + +```python +schema = Skill( + "basic.lint_skill", kind="state_machine", purpose="validate skill structure", + imports=[], calls=[], + inputs=["skill_file", "registry", "budget"], + outputs=["lint", "risks"], exits=["done", "blocked", "out_of_scope"], +) +``` + + +```python +def lint_skill(skill_file, registry, budget): + root = "experimental/agent_compose/skills/" + if not skill_file.path.startswith(root): + return out_of_scope("not an Agent Compose skill") + + schema = extract_schema_block(skill_file) + if schema is None: + return blocked("missing schema markers") + spec = parse_skill_schema(schema) + expected_path = root + module_name_to_path(spec.name) + if skill_file.path != expected_path: + return blocked("schema name does not match file path") + + checks = [ + require_python_like_body(skill_file), + require_top_level_function(skill_file, spec.name.split(".")[-1], spec.inputs), + require_declared_exits(skill_file, spec.exits), + require_bounded_loops(skill_file, max_steps=budget.max_steps), + require_resolved_imports(spec.imports, registry), + require_resolved_calls(spec.calls, registry), + require_body_calls_declared(skill_file.body, spec.calls), + ] + if any(check.fail for check in checks): + return blocked("skill lint failed", lint=checks) + return done(lint=checks, risks=["structural lint does not replace executable tests"]) +``` diff --git a/experimental/agent_compose/skills/model/compose.md b/experimental/agent_compose/skills/model/compose.md new file mode 100644 index 00000000000..a0b76ba930e --- /dev/null +++ b/experimental/agent_compose/skills/model/compose.md @@ -0,0 +1,35 @@ +# Model Compose + +Compose a model only from primitives with explicit review contracts. + + +```python +schema = Skill( + "model.compose", kind="state_machine", purpose="compose a model from validated primitives", + imports=["basic.constitution", "primitive.contract"], + calls=["basic.constitution", "primitive.contract"], + inputs=["task", "model_spec", "primitives", "reference", "budget"], + outputs=["model", "evidence", "risks"], exits=["done", "blocked", "out_of_scope"], +) +``` + + +```python +def compose(task, model_spec, primitives, reference, budget): + base = basic.constitution(task, layer="model", reference=reference) + if not base.done: + return blocked("model constitution failed", evidence=base) + + evidence = [] + selected = primitives[:budget.max_primitives] + for candidate in selected: + checked = primitive.contract(task, primitive=candidate, reference=candidate.reference) + evidence.append(checked) + if not checked.done: + return blocked("primitive contract failed before composition", evidence=evidence) + + if not covers_required_features(selected, model_spec.required_features): + return blocked("selected primitives do not cover the model spec", evidence=evidence) + model = compose_layers(model_spec, selected, boundary=["model", "primitive"]) + return done(model=model, evidence=evidence, risks=["composition can hide boundary bugs"]) +``` diff --git a/experimental/agent_compose/skills/primitive/contract.md b/experimental/agent_compose/skills/primitive/contract.md new file mode 100644 index 00000000000..bf2ba8e0852 --- /dev/null +++ b/experimental/agent_compose/skills/primitive/contract.md @@ -0,0 +1,41 @@ +# Primitive Contract + +Define the minimum review contract for an upstreamed primitive. + + +```python +schema = Skill( + "primitive.contract", kind="constitution", purpose="define primitive review outputs", + imports=["basic.constitution"], calls=["basic.constitution"], + inputs=["task", "primitive", "reference"], + outputs=["principle", "implementation", "usage", "validation", "risks"], + exits=["done", "blocked", "out_of_scope"], +) +``` + + +```python +def contract(task, primitive, reference): + base = basic.constitution(task, layer="primitive", reference=reference) + if not base.done: + return blocked("primitive constitution failed", evidence=base) + + principle = require(["semantics", "invariants", "shape_dtype_rank_rules"]) + implementation = require([ + "owned_modules", "public_api", "state_and_config", "failure_modes", + ]) + usage = require([ + "minimal_example", "selection_rules", "valid_combinations", "unsupported_combinations", + ]) + validation = require([ + "single_gpu_or_single_node_proxy", "reference_comparison", "composition_test", + ]) + risks = ["silent mismatch", "dtype drift", "hidden coupling", "wrong selection rule"] + return done( + principle=principle, + implementation=implementation, + usage=usage, + validation=validation, + risks=risks, + ) +``` diff --git a/experimental/agent_compose/skills/runtime/validate.md b/experimental/agent_compose/skills/runtime/validate.md new file mode 100644 index 00000000000..011284b059a --- /dev/null +++ b/experimental/agent_compose/skills/runtime/validate.md @@ -0,0 +1,48 @@ +# Runtime Validate + +Validate the runtime lifecycle through a composed model. + + +```python +schema = Skill( + "runtime.validate", kind="state_machine", purpose="validate the runtime lifecycle end to end", + imports=["basic.constitution", "model.compose"], + calls=["basic.constitution", "model.compose"], + inputs=["task", "runtime_config", "model_spec", "primitives", "reference", "budget"], + outputs=["runtime", "evidence", "risks"], exits=["done", "blocked", "out_of_scope"], +) +``` + + +```python +def validate(task, runtime_config, model_spec, primitives, reference, budget): + base = basic.constitution(task, layer="runtime", reference=reference) + if not base.done: + return blocked("runtime constitution failed", evidence=base) + if not conforms_to_runtime_interface(runtime_config.backend): + return blocked("backend does not implement the public Runtime interface") + + composed = model.compose( + task, + model_spec=model_spec, + primitives=primitives, + reference=reference, + budget=budget.model, + ) + if not composed.done: + return blocked("model composition failed before runtime validation", evidence=composed) + + run = execute_runtime_steps( + ["init", "build_model", "train_step", "save", "load"], + runtime_config, + composed.model, + max_steps=budget.max_steps, + ) + if run.return_code != 0: + return blocked("runtime lifecycle failed", evidence=[composed, run]) + return done( + runtime=run, + evidence=[composed, run], + risks=["runtime success can hide model-local mismatch"], + ) +``` diff --git a/experimental/agent_compose/tests/unit/test_import.py b/experimental/agent_compose/tests/unit/test_import.py new file mode 100644 index 00000000000..043b25ee9fb --- /dev/null +++ b/experimental/agent_compose/tests/unit/test_import.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Import checks for the Agent Compose package skeleton.""" + +from __future__ import annotations + +import importlib + + +def test_three_layer_skeleton_imports() -> None: + modules = [ + "megatron.experimental.agent_compose", + "megatron.experimental.agent_compose.primitive", + "megatron.experimental.agent_compose.model", + "megatron.experimental.agent_compose.runtime", + ] + + for module in modules: + imported = importlib.import_module(module) + assert imported.__name__ == module + + +def test_runtime_public_surface_imports() -> None: + from megatron.experimental.agent_compose.runtime import ( + Batch, + ForwardResult, + LossContext, + ModelHandle, + ModelOutputs, + OptimizerConfig, + PackedBatch, + ParallelConfig, + Runtime, + RuntimeConfig, + TrainBatch, + create_runtime, + register_runtime, + ) + + assert all( + value is not None + for value in ( + Batch, + ForwardResult, + LossContext, + ModelHandle, + ModelOutputs, + OptimizerConfig, + PackedBatch, + ParallelConfig, + Runtime, + RuntimeConfig, + TrainBatch, + create_runtime, + register_runtime, + ) + ) diff --git a/experimental/agent_compose/tests/unit/test_layering_contracts.py b/experimental/agent_compose/tests/unit/test_layering_contracts.py new file mode 100644 index 00000000000..7695c599ec4 --- /dev/null +++ b/experimental/agent_compose/tests/unit/test_layering_contracts.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Static import guards for the runtime, model, and primitive layers.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +AGENT_COMPOSE_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = AGENT_COMPOSE_ROOT / "megatron" / "experimental" / "agent_compose" +LAYER_ROOTS = { + "primitive": PACKAGE_ROOT / "primitive", + "model": PACKAGE_ROOT / "model", + "runtime": PACKAGE_ROOT / "runtime", +} +DENIED_IMPORTS = { + "primitive": ( + "experimental", + "megatron.experimental.agent_compose.model", + "megatron.experimental.agent_compose.runtime", + ), + "model": ("experimental", "megatron.experimental.agent_compose.runtime"), + "runtime": ("experimental",), +} +SHARED_CONTRACTS = ("megatron.experimental.agent_compose.runtime.contracts",) + + +def _matches(module: str, prefix: str) -> bool: + return module == prefix or module.startswith(prefix + ".") + + +def _imports(path: Path) -> list[tuple[int, str]]: + tree = ast.parse(path.read_text(encoding="utf-8")) + found: list[tuple[int, str]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.extend((node.lineno, alias.name) for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + found.append((node.lineno, node.module)) + return found + + +def test_layer_import_boundaries() -> None: + violations: list[str] = [] + for layer, root in LAYER_ROOTS.items(): + for path in sorted(root.rglob("*.py")): + for lineno, module in _imports(path): + if any(_matches(module, allowed) for allowed in SHARED_CONTRACTS): + continue + for denied in DENIED_IMPORTS[layer]: + if _matches(module, denied): + rel = path.relative_to(AGENT_COMPOSE_ROOT) + violations.append(f"{rel}:{lineno}: {module} imports {denied}") + assert violations == [] diff --git a/experimental/agent_compose/tests/unit/test_runtime_interface.py b/experimental/agent_compose/tests/unit/test_runtime_interface.py new file mode 100644 index 00000000000..ec540e58c00 --- /dev/null +++ b/experimental/agent_compose/tests/unit/test_runtime_interface.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Contract tests for the public runtime interface.""" + +from __future__ import annotations + +import subprocess +import sys +import types + +import pytest + + +def test_runtime_import_stays_lightweight() -> None: + script = ( + "import sys; " + "import megatron.experimental.agent_compose.runtime as runtime; " + "assert runtime.RuntimeConfig().backend == 'agent_compose'; " + "assert 'torch' not in sys.modules" + ) + subprocess.run([sys.executable, "-c", script], check=True) + + +def test_public_runtime_contracts() -> None: + import torch + + from megatron.experimental.agent_compose.runtime import PackedBatch, ParallelConfig + + batch = PackedBatch( + input_ids=torch.tensor([1, 2, 3]), + labels=torch.tensor([2, 3, 4]), + seq_lens=torch.tensor([2, 1]), + ) + + assert ParallelConfig(tp=2).tp == 2 + assert len(batch) == 2 + assert batch.total_tokens == 3 + assert batch.cu_seqlens.tolist() == [0, 2, 3] + assert batch.make_position_ids().tolist() == [0, 1, 0] + + +def test_unregistered_backend_fails_explicitly() -> None: + from megatron.experimental.agent_compose.runtime import ( + RuntimeConfig, + create_runtime, + ) + + with pytest.raises(ValueError, match="No runtime backend registered"): + create_runtime(RuntimeConfig(backend="missing")) + + +def test_runtime_required_method_set() -> None: + from megatron.experimental.agent_compose.runtime import Runtime + + assert Runtime.__abstractmethods__ == { + "build_model", + "eval_mode", + "forward_backward", + "load_checkpoint", + "lr_scheduler_step", + "optimizer_step", + "save_checkpoint", + "train_mode", + "zero_grad", + } + + +def test_registered_runtime_factory(monkeypatch) -> None: + from megatron.experimental.agent_compose.runtime import ( + RuntimeConfig, + create_runtime, + register_runtime, + ) + + module = types.ModuleType("agent_compose_test_runtime") + module.create = lambda hf_path, cfg: (hf_path, cfg) + monkeypatch.setitem(sys.modules, module.__name__, module) + register_runtime("test", module.__name__) + + cfg = RuntimeConfig(backend="test", hf_path="model", backend_cfg={"tp": 2}) + assert create_runtime(cfg) == ("model", {"tp": 2}) diff --git a/experimental/agent_compose/tests/unit/test_skills.py b/experimental/agent_compose/tests/unit/test_skills.py new file mode 100644 index 00000000000..9ca7a8ba636 --- /dev/null +++ b/experimental/agent_compose/tests/unit/test_skills.py @@ -0,0 +1,38 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Structural checks for the initial Agent Compose skill registry.""" + +from __future__ import annotations + +import re +from pathlib import Path + +AGENT_COMPOSE_ROOT = Path(__file__).resolve().parents[2] +SKILL_ROOT = AGENT_COMPOSE_ROOT / "skills" +EXPECTED = { + "basic.constitution": "basic/constitution.md", + "basic.lint_skill": "basic/lint-skill.md", + "primitive.contract": "primitive/contract.md", + "model.compose": "model/compose.md", + "runtime.validate": "runtime/validate.md", +} +SCHEMA_NAME = re.compile(r'schema\s*=\s*Skill\(\s*"([^"]+)"', re.DOTALL) +LIST_FIELD = re.compile(r"\b(imports|calls)\s*=\s*\[([^]]*)\]", re.DOTALL) +QUOTED = re.compile(r'"([^"]+)"') + + +def test_skill_registry_is_complete_and_resolved() -> None: + for expected_name, relative_path in EXPECTED.items(): + path = SKILL_ROOT / relative_path + text = path.read_text(encoding="utf-8") + assert text.count("AGENT_COMPOSE_SKILL_SCHEMA_BEGIN") == 1 + assert text.count("AGENT_COMPOSE_SKILL_SCHEMA_END") == 1 + match = SCHEMA_NAME.search(text) + assert match is not None + assert match.group(1) == expected_name + assert f"def {expected_name.rsplit('.', 1)[-1]}(" in text + + fields = { + name: QUOTED.findall(values) for name, values in LIST_FIELD.findall(text) + } + for dependency in fields.get("imports", []) + fields.get("calls", []): + assert dependency in EXPECTED From ec53920e605b7d5cd84d182981db3c83439a4fad Mon Sep 17 00:00:00 2001 From: Prajwal Singhania Date: Thu, 30 Jul 2026 14:22:28 -0700 Subject: [PATCH 164/290] Fix GTP full-iteration CUDA-graph capture regression (#6077) Signed-off-by: Prajwal Singhania Co-authored-by: Claude Opus 5 --- .../distributed/distributed_data_parallel.py | 23 +++++++++------ .../generalized_tensor_parallelism.py | 29 ++++++++++++++----- .../test_gtp_basics.py | 10 +++++++ 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/megatron/core/distributed/distributed_data_parallel.py b/megatron/core/distributed/distributed_data_parallel.py index 68f020fb638..81cdbeb3303 100644 --- a/megatron/core/distributed/distributed_data_parallel.py +++ b/megatron/core/distributed/distributed_data_parallel.py @@ -373,20 +373,25 @@ def unmap_weight_tensor(m): self._make_backward_post_hook(param) ) break - elif getattr(param, 'is_gtp_weight_remat', False) and hasattr( - param, 'register_grad_accum_hook' - ): - # GTP_remat defers the main_grad add to a later backward node, so drive the - # post-hook from its manual call (_handle_megatron_grad_accum) rather than - # autograd's AccumulateGrad, which would fire grad-ready on stale main_grad. - param.register_grad_accum_hook(None, self._make_backward_post_hook(param)) else: # Expand so we get access to grad_fn. param_tmp = param.expand_as(param) # Get the gradient accumulator function. grad_acc = param_tmp.grad_fn.next_functions[0][0] - grad_acc.register_hook(self._make_backward_post_hook(param)) - self.grad_accs.append(grad_acc) + if getattr(param, 'is_gtp_weight_remat', False) and hasattr( + param, 'register_grad_accum_hook' + ): + # GTP_remat computes wgrad via an async reduce-scatter, so autograd's + # AccumulateGrad sees only a dummy; grad-ready is driven manually from + # _handle_megatron_grad_accum (the hook passed here). RETAINING the node + # keeps it on the capture stream for full-iteration CUDA-graph capture. + # No autograd hook or grad_accs entry: either would fire on a stale grad. + param.register_grad_accum_hook( + grad_acc, self._make_backward_post_hook(param) + ) + else: + grad_acc.register_hook(self._make_backward_post_hook(param)) + self.grad_accs.append(grad_acc) # Note: overlap_param_gather covers both the distributed optimizer and the # layer-wise optimizer cases; the latter sets overlap_param_gather=True diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index 08f17e54996..c72cf313dc2 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -31,7 +31,7 @@ from contextlib import contextmanager, nullcontext from dataclasses import dataclass, field from enum import Enum -from typing import Dict, List, Optional +from typing import Callable, Dict, List, Optional import torch from packaging.version import Version @@ -349,15 +349,21 @@ def get_rs_stream(chain_id: str = GTPChain.GRAPHED.value, group=None) -> torch.c def wait_for_gtp_grad_reduction_on_current_stream() -> None: """Fence the current stream against all GTP backward grad work before the DP gradient sync. - Drains the eager AG/RS side streams, then waits on each CG runner's replay stream - (its tail = captured Phase 2 main_grad.add_). No-op when GTP is inactive. + Drains the eager AG/RS side streams, then — outside CUDA-graph capture — waits on each CG + runner's replay stream (its tail = captured Phase 2 main_grad.add_). Under whole-step capture + there are no per-layer runners, so that second wait is skipped. No-op when GTP is inactive. """ wait_async_comms() cur = torch.cuda.current_stream() + # Join the async AG/RS side streams for both the eager and CUDA-graph capture paths. for s in _AG_STREAMS.values(): cur.wait_stream(s) for s in _RS_STREAMS.values(): cur.wait_stream(s) + # The per-layer CG runner replay streams exist only in the eager / per-layer-CG path; under + # whole-step capture there are no runners, so stop here while capturing. + if torch.cuda.is_current_stream_capturing(): + return # Local import: cuda_graphs imports this module, so a module-level import would be circular. from megatron.core.transformer.cuda_graphs import get_gtp_runner_streams @@ -788,6 +794,9 @@ def _init_gtp_runtime_attrs(obj): # DDP backward hook (set by register_grad_accum_hook); invoked after # the wgrad RS accumulation completes (Graphed.backward / chain cascade). obj._grad_accum_hook = None + # The weight's AccumulateGrad node (set by register_grad_accum_hook). Retained so the leaf + # lands on the capture stream for full-iteration CUDA-graph capture; None until DDP registers. + obj._grad_accum_node = None # Quantization. For native-FP8 GTP the reclass path overwrites _quantizer with the tensor's # own MXFP8 quantizer and points quantized at self; BF16 GTP leaves both unset. obj._quantizer = None @@ -1446,16 +1455,22 @@ def get_wgrad_tensor(self): """Pool-allocate a wgrad scratch tensor of unsharded shape for the bwd GEMM.""" return _wgrad_pool_get(self._unsharded_shape, self.main_grad.dtype, self.device) - def register_grad_accum_hook(self, grad_accum_node, hook): + def register_grad_accum_hook( + self, grad_accum_node: torch.autograd.graph.Node | None, hook: Callable[..., None] | None + ) -> None: """Register a DDP backward hook to call after the wgrad RS finalize. For GTP params autograd may receive None (async RS), so the normal grad-accumulator hook never fires; the integrator (Graphed.backward for captured chains, or the eager chain-tail cascade) calls this hook explicitly after RS wait + accumulation, so DDP's - register_grad_ready fires at the right time. grad_accum_node is accepted for API - compatibility but not retained — only the hook callable. + register_grad_ready fires at the right time. We retain grad_accum_node (the weight's + AccumulateGrad) here. Keeping a live strong reference across the warmup->capture + boundary is what places the node on the capture stream for full-iteration CG; an + un-retained node stays stranded on the default stream and trips capture. The node is + not added to DDP's grad_accs (that list is for autograd-hooked nodes) and never gets + .register_hook'd, because grad-ready is fired manually via _grad_accum_hook. """ - del grad_accum_node + self._grad_accum_node = grad_accum_node self._grad_accum_hook = hook @staticmethod diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py index f488d64ae6a..09f09d5d61a 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py @@ -1152,6 +1152,10 @@ def _worker_gtp_ddp_grad_ready_wiring(rank, world_size, port): grad_data (corrupts reduce_scatter_with_fp32_accumulation). The fix routes grad-ready through register_grad_accum_hook (fired after the add) and skips the autograd hook. This pins that wiring: every GTP weight has _grad_accum_hook set and none falls through to the autograd list. + + It also checks that the AccumulateGrad node is materialized and stored on the param. Holding + that reference is what keeps the leaf on the capture stream for full-iteration CUDA-graph + capture. """ from megatron.core import parallel_state as ps from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig @@ -1190,6 +1194,12 @@ def __init__(self): assert ( getattr(w, "_grad_accum_hook", None) is not None ), f"{name}.weight must have _grad_accum_hook set (manual grad-ready, not autograd)" + # The node must also be RETAINED: a live strong reference across the + # warmup->capture boundary is what keeps the leaf on the capture stream. + # Identity (not just non-None): a dropped node would be recreated by expand_as here. + assert ( + getattr(w, "_grad_accum_node", None) is w.expand_as(w).grad_fn.next_functions[0][0] + ), f"{name}.weight must retain its AccumulateGrad node (full-iteration CG capture)" # bias=False -> all params are GTP_remat -> none took the autograd path. assert len(ddp_model.grad_accs) == 0, ( From d04e7a0ba77d5ee14a7584d22379579fac94fb66 Mon Sep 17 00:00:00 2001 From: wdykas Date: Thu, 30 Jul 2026 18:12:19 -0400 Subject: [PATCH 165/290] Harden dynamic prefix cache allocation - mamba mostly (#6091) Signed-off-by: William Dykas --- .../inference/contexts/kv_block_allocator.py | 4 +- .../contexts/mamba_slot_allocator.py | 85 +++++++++++++++---- .../contexts/test_dynamic_prefix_caching.py | 84 ++++++++++++++++++ .../contexts/test_kv_block_allocator.py | 14 +++ 4 files changed, 170 insertions(+), 17 deletions(-) diff --git a/megatron/core/inference/contexts/kv_block_allocator.py b/megatron/core/inference/contexts/kv_block_allocator.py index 3feb8a0a11d..7c6df3419e0 100644 --- a/megatron/core/inference/contexts/kv_block_allocator.py +++ b/megatron/core/inference/contexts/kv_block_allocator.py @@ -279,7 +279,9 @@ def reset(self) -> None: # Without resetting the block bag, context request memory will clash and # requests will point to each other's memory blocks, resulting in faulty # generations. - self.block_bag = torch.arange(self.total_count, dtype=torch.int32, device='cpu') + # Refill the existing buffer so it remains mutable when reset runs under + # torch.inference_mode(), such as during CUDA graph setup. + torch.arange(self.total_count, out=self.block_bag) self.total_avail = self.total_count - 1 diff --git a/megatron/core/inference/contexts/mamba_slot_allocator.py b/megatron/core/inference/contexts/mamba_slot_allocator.py index 21116b0cb7e..2cae00e05d9 100644 --- a/megatron/core/inference/contexts/mamba_slot_allocator.py +++ b/megatron/core/inference/contexts/mamba_slot_allocator.py @@ -16,6 +16,18 @@ MAX_INTERMEDIATE_OFFSETS_PER_REQUEST = 3 +class MambaSlotCapacityError(RuntimeError): + """Raised when the durable Mamba cache cannot satisfy an allocation.""" + + def __init__(self, required: int, available: int): + self.required = required + self.available = available + super().__init__( + f"Mamba cache requires {required} new durable slots, but only " + f"{available} free or evictable slots are available" + ) + + class MambaSlotAllocator: """Manages Mamba state caching for prefix caching in hybrid models. @@ -159,6 +171,18 @@ def allocate_slots_batch(self, block_ids: list) -> list: if num_new == 0: return existing_slots + # Reserve the full batch atomically. A failed eviction must not consume + # the free portion of the request. + need_evict = max(0, num_new - self.free_count) + evictable_block_ids = ( + self._evictable_block_ids() + if need_evict > 0 + else torch.empty(0, dtype=torch.int64, device=device) + ) + available = self.free_count + evictable_block_ids.numel() + if available < num_new: + raise MambaSlotCapacityError(required=num_new, available=available) + # Phase 3: Get slots from free pool, evicting if necessary from_free = min(num_new, self.free_count) new_slots = [] @@ -169,7 +193,7 @@ def allocate_slots_batch(self, block_ids: list) -> list: need_evict = num_new - from_free if need_evict > 0: - new_slots.extend(self._evict_lru_slots_batch(need_evict)) + new_slots.extend(self._evict_lru_slots_batch(need_evict, evictable_block_ids)) # Phase 4: Batch GPU writes for new mappings new_bid_tensor = torch.tensor(new_bids, dtype=torch.int64, device=device) @@ -188,32 +212,34 @@ def allocate_slots_batch(self, block_ids: list) -> list: result.append(alloc_bid_to_slot[bid]) return result - def _evict_lru_slots_batch(self, num_needed: int) -> list: + def _evictable_block_ids(self) -> Tensor: + """Return blocks whose durable Mamba slots have no live KV owner.""" + + kv_alloc = self.context.kv_block_allocator + has_slot_mask = self.block_to_slot[: kv_alloc.total_count] >= 0 + ref_zero_mask = kv_alloc.block_ref_counts[: kv_alloc.total_count] == 0 + return torch.nonzero(has_slot_mask & ref_zero_mask, as_tuple=True)[0] + + def _evict_lru_slots_batch(self, num_needed: int, candidate_ids: Tensor) -> list: """Evict the least recently used Mamba cache slots. Does NOT return slots to the free pool — caller takes ownership. Args: num_needed: Number of slots to evict. + candidate_ids: Blocks confirmed to be evictable for this allocation. Returns: List of freed slot indices. """ kv_alloc = self.context.kv_block_allocator - # Find blocks that have mamba slots and ref_count == 0 - has_slot_mask = self.block_to_slot[: kv_alloc.total_count] >= 0 - ref_zero_mask = kv_alloc.block_ref_counts[: kv_alloc.total_count] == 0 - candidates = has_slot_mask & ref_zero_mask - candidate_ids = torch.nonzero(candidates, as_tuple=True)[0] - - if candidate_ids.numel() < num_needed: - raise RuntimeError("No evictable Mamba cache slots available") + assert candidate_ids.numel() >= num_needed # Pick oldest blocks by timestamp (LRU) or first N (REF_ZERO) if self.context.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: timestamps = kv_alloc.block_timestamps[candidate_ids] - sorted_indices = torch.argsort(timestamps)[:num_needed] - evict_ids = candidate_ids[sorted_indices] + _, oldest_indices = torch.topk(timestamps, k=num_needed, largest=False, sorted=False) + evict_ids = candidate_ids[oldest_indices] else: evict_ids = candidate_ids[:num_needed] @@ -533,12 +559,39 @@ def commit_intermediate_states(self) -> None: return intermediate_bids, src_offsets, eos_bids, eos_ctx_indices, all_hashes = collected - # Allocate all slots in one batch (intermediates + EOS) + # These snapshots only improve future cache hits; the active requests + # continue from their live Mamba state if durable capacity is exhausted. all_bids = intermediate_bids + eos_bids - all_slots = self.allocate_slots_batch(all_bids) + n_intermediate = len(intermediate_bids) + try: + all_slots = self.allocate_slots_batch(all_bids) + except MambaSlotCapacityError as error: + existing_slots = self.block_to_slot[all_bids].tolist() + kept_indices = [] + kept_new_bids = set() + for index, (block_id, slot) in enumerate(zip(all_bids, existing_slots)): + if slot >= 0 or block_id in kept_new_bids: + kept_indices.append(index) + elif len(kept_new_bids) < error.available: + kept_new_bids.add(block_id) + kept_indices.append(index) + + if not kept_indices: + self._clear_intermediate_state() + return + + all_bids = [all_bids[index] for index in kept_indices] + all_hashes = [all_hashes[index] for index in kept_indices] + src_offsets = [src_offsets[index] for index in kept_indices if index < n_intermediate] + eos_ctx_indices = [ + eos_ctx_indices[index - n_intermediate] + for index in kept_indices + if index >= n_intermediate + ] + all_slots = self.allocate_slots_batch(all_bids) + n_intermediate = len(src_offsets) # Copy intermediate states from output buffers to cache - n_intermediate = len(intermediate_bids) self._copy_intermediate_to_cache(src_offsets, all_slots[:n_intermediate]) # Copy EOS states from live buffers to cache @@ -651,7 +704,7 @@ def reset(self) -> None: """Reset all state (mappings, free pool, cache, intermediate tracking).""" self.block_to_slot.fill_(-1) self.slot_to_block.fill_(-1) - self.free_slots = torch.arange(self.max_slots, dtype=torch.int32, device='cpu') + torch.arange(self.max_slots, out=self.free_slots) self.free_count = self.max_slots self.hash_to_block_id.clear() self.intermediate_ssm_out.zero_() diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index fa166af9669..59e896d1285 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -2,6 +2,7 @@ import asyncio from collections import deque +from types import SimpleNamespace import numpy as np import pytest @@ -9,6 +10,10 @@ from megatron.core.inference.config import InferenceConfig, PrefixCachingEvictionPolicy from megatron.core.inference.contexts.dynamic_context import DynamicInferenceContext +from megatron.core.inference.contexts.mamba_slot_allocator import ( + MambaSlotAllocator, + MambaSlotCapacityError, +) from megatron.core.inference.engines.dynamic_engine import DynamicInferenceEngine from megatron.core.inference.inference_request import ( DynamicInferenceRequest, @@ -1249,6 +1254,85 @@ def test_mixed_batch(self, model_type): assert len(log_probs_list[4]) == fresh_ql +def _make_cpu_mamba_slot_allocator( + monkeypatch, *, total_blocks: int, max_slots: int +) -> MambaSlotAllocator: + monkeypatch.setattr(torch.cuda, "current_device", lambda: "cpu") + kv_allocator = SimpleNamespace( + total_count=total_blocks, + block_ref_counts=torch.ones(total_blocks, dtype=torch.int32), + block_timestamps=torch.zeros(total_blocks, dtype=torch.int64), + block_hashes=torch.full((total_blocks,), -1, dtype=torch.int64), + ) + context = SimpleNamespace( + max_requests=1, + max_mamba_intermediate_states_per_step=1, + prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, + kv_block_allocator=kv_allocator, + ) + return MambaSlotAllocator( + context=context, + max_slots=max_slots, + num_mamba_layers=1, + conv_states_shape=(1,), + ssm_states_shape=(1,), + conv_states_dtype=torch.float32, + ssm_states_dtype=torch.float32, + ) + + +def test_mamba_slot_allocation_failure_is_atomic(monkeypatch): + allocator = _make_cpu_mamba_slot_allocator(monkeypatch, total_blocks=3, max_slots=2) + allocator.allocate_slots_batch([0]) + block_to_slot_before = allocator.block_to_slot.clone() + slot_to_block_before = allocator.slot_to_block.clone() + free_count_before = allocator.free_count + + with pytest.raises(MambaSlotCapacityError, match="only 1 free or evictable"): + allocator.allocate_slots_batch([1, 2]) + + assert allocator.free_count == free_count_before + assert torch.equal(allocator.block_to_slot, block_to_slot_before) + assert torch.equal(allocator.slot_to_block, slot_to_block_before) + + +def test_mamba_lru_eviction_selects_only_requested_oldest_slots(monkeypatch): + allocator = _make_cpu_mamba_slot_allocator(monkeypatch, total_blocks=6, max_slots=4) + allocator.allocate_slots_batch([0, 1, 2, 3]) + allocator.context.kv_block_allocator.block_ref_counts[:4] = 0 + allocator.context.kv_block_allocator.block_timestamps[:4] = torch.tensor([40, 10, 30, 20]) + allocator.context.kv_block_allocator.block_hashes[:4] = torch.tensor([100, 101, 102, 103]) + allocator.register_block_hashes_batch([0, 1, 2, 3], [100, 101, 102, 103]) + + allocator.allocate_slots_batch([4, 5]) + + assert allocator.block_to_slot.tolist()[1] == -1 + assert allocator.block_to_slot.tolist()[3] == -1 + + +@pytest.mark.parametrize("max_slots", [0, 1]) +def test_optional_mamba_checkpoint_commit_uses_available_capacity(monkeypatch, max_slots): + allocator = _make_cpu_mamba_slot_allocator(monkeypatch, total_blocks=3, max_slots=max_slots) + allocator._collect_commit_data = lambda: ([1], [0], [2], [0], [101, 102]) + copy_calls = [] + store_calls = [] + register_calls = [] + clear_calls = [] + allocator._copy_intermediate_to_cache = lambda *args: copy_calls.append(args) + allocator.store_from_live_batch = lambda *args: store_calls.append(args) + allocator.register_block_hashes_batch = lambda *args: register_calls.append(args) + allocator._clear_intermediate_state = lambda: clear_calls.append(True) + + allocator.commit_intermediate_states() + + expected_slot = 0 if max_slots else -1 + assert allocator.block_to_slot.tolist() == [-1, expected_slot, -1] + assert copy_calls == ([([0], [0])] if max_slots else []) + assert store_calls == ([([], [])] if max_slots else []) + assert register_calls == ([([1], [101])] if max_slots else []) + assert clear_calls == [True] + + class TestMambaSlotAllocator(PrefixCachingTestBase): def _mctx(self, **kwargs): diff --git a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py index 2cb552ee14f..07de26e9abf 100644 --- a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py +++ b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py @@ -83,6 +83,20 @@ def test_allocate_release_reset_round_trip_no_prefix_caching(): assert a.block_routing == {} +def test_reset_under_inference_mode_preserves_mutable_block_bag(): + allocator = KVBlockAllocator(_make_context(), total_count=8, paused_count=0) + original_block_bag = allocator.block_bag + + with torch.inference_mode(): + allocator.reset() + + blocks = allocator.allocate_memory_blocks(1) + allocator.release_memory_blocks(blocks) + + assert allocator.block_bag is original_block_bag + assert allocator.total_avail == 7 + + @pytest.mark.parametrize( "scope,paused,total,counts,expected_active,expected_paused", [ From 6513e3e23d6b5eda6a1c934990b15e804237732b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 31 Jul 2026 00:27:09 +0000 Subject: [PATCH 166/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index f31faa7c806..fdd98fbd507 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnemo-autobot", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From 8543ae40feb98e58aea80b332f393f65433b8100 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Fri, 31 Jul 2026 01:41:29 +0200 Subject: [PATCH 167/290] ci(cache): AUT-1188 harden container cache selection (#6160) Signed-off-by: svcnemo-autobot --- .github/scripts/test_cache_keys.sh | 56 +++++++++++++++++++++++ .github/workflows/cicd-main.yml | 73 +++++++++++++++++++++++------- 2 files changed, 113 insertions(+), 16 deletions(-) create mode 100755 .github/scripts/test_cache_keys.sh diff --git a/.github/scripts/test_cache_keys.sh b/.github/scripts/test_cache_keys.sh new file mode 100755 index 00000000000..abb73bdc13b --- /dev/null +++ b/.github/scripts/test_cache_keys.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +compute_keys() { + local base_ref=$1 + local cache_variant=$2 + local pr_number=$3 + local merge_group_head_ref=$4 + local github_ref=$5 + local event_name=$6 + local ref_name=$7 + + BASE_REF="${base_ref#refs/heads/}" + CACHE_VARIANT=$cache_variant + CACHE_NAMESPACE=$(printf '%s' "$BASE_REF" | tr '/:@' '-' | tr -cd '[:alnum:]_.-') + if [ -z "$CACHE_NAMESPACE" ]; then + return 1 + fi + + PR_NUMBER=$pr_number + if [ "$PR_NUMBER" = "0" ] && [ -n "$merge_group_head_ref" ]; then + PR_NUMBER=$(printf '%s' "$merge_group_head_ref" | sed -nE 's#.*pr-([0-9]+)-.*#\1#p') + fi + + BASELINE_KEY="${CACHE_NAMESPACE}-${CACHE_VARIANT}-baseline" + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "0" ]; then + KEY="${CACHE_NAMESPACE}-${CACHE_VARIANT}-${PR_NUMBER}" + elif [ "$github_ref" = "refs/heads/$BASE_REF" ] || [ "$event_name" = "schedule" ]; then + KEY="$BASELINE_KEY" + else + BRANCH_NAMESPACE=$(printf '%s' "$ref_name" | tr '/:@' '-' | tr -cd '[:alnum:]_.-') + KEY="${CACHE_NAMESPACE}-${CACHE_VARIANT}-${BRANCH_NAMESPACE}" + fi + + if [ "${#KEY}" -gt 100 ]; then + KEY="${KEY:0:83}-$(printf '%s' "$KEY" | sha256sum | cut -c1-16)" + fi + if [ "${#BASELINE_KEY}" -gt 100 ]; then + BASELINE_KEY="${BASELINE_KEY:0:83}-$(printf '%s' "$BASELINE_KEY" | sha256sum | cut -c1-16)" + fi +} + +assert_keys() { + local expected_key=$1 + local expected_baseline=$2 + shift 2 + compute_keys "$@" + test "$KEY" = "$expected_key" + test "$BASELINE_KEY" = "$expected_baseline" +} + +assert_keys main-dev-6077 main-dev-baseline refs/heads/main dev 0 pr-6077-ff6b refs/heads/gh-readonly-queue/main/pr-6077 merge_group gh-readonly-queue/main/pr-6077 +assert_keys dev-dev-6072 dev-dev-baseline refs/heads/dev dev 0 pr-6072-95e4 refs/heads/gh-readonly-queue/dev/pr-6072 merge_group gh-readonly-queue/dev/pr-6072 +assert_keys main-lts-6159 main-lts-baseline main lts 6159 '' refs/heads/pull-request/6159 push pull-request/6159 +assert_keys main-dev-baseline main-dev-baseline main dev 0 '' refs/heads/main schedule main +assert_keys main-dev-deploy-release-1.2 main-dev-baseline main dev 0 '' refs/heads/deploy-release/1.2 push deploy-release/1.2 diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index 738e91baab3..8c7d329cd1c 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -354,6 +354,9 @@ jobs: run: | uv sync --locked --only-group linting + - name: Test CI cache keys + run: .github/scripts/test_cache_keys.sh + - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' @@ -612,24 +615,45 @@ jobs: - name: Compute cache config id: cache_keys shell: bash + env: + BASE_REF: ${{ github.event.merge_group.base_ref || fromJSON(steps.get-pr-info.outputs.pr-info || '{}').base.ref || github.ref_name }} + CACHE_VARIANT: ${{ needs.configure.outputs.lts == 'true' && 'lts' || 'dev' }} + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_NAME: ${{ github.ref_name }} + MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }} + PR_NUMBER: ${{ fromJSON(steps.get-pr-info.outputs.pr-info || '{}').number || 0 }} + REF: ${{ github.ref }} run: | - KEY="${{ fromJSON(steps.get-pr-info.outputs.pr-info || '{}').number || 0 }}" - - # Single coherent cache donor. Dockerfile.ci builds non-reproducible - # from-source layers: the same BuildKit cache key yields a different - # result digest on every cold build. Listing more than one cache-from - # donor lets BuildKit chain from a conflicting copy of such a layer and - # miss every layer downstream — a long cold rebuild even when a warm - # cache exists. So read EXACTLY ONE donor. - # - # Key 0 is the no-PR build cache: it is written by the daily schedule - # (from main HEAD), the merge queue, and deploy-release builds, so it is - # the integration baseline. PR builds seed from it and write their own - # ${KEY}-buildcache; baseline builds (KEY=0) seed from and write 0. - SEED="0" + BASE_REF="${BASE_REF#refs/heads/}" + CACHE_NAMESPACE=$(printf '%s' "$BASE_REF" | tr '/:@' '-' | tr -cd '[:alnum:]_.-') + if [ -z "$CACHE_NAMESPACE" ]; then + echo "Unable to derive a cache namespace from base ref: $BASE_REF" >&2 + exit 1 + fi + + if [ "$PR_NUMBER" = "0" ] && [ -n "$MERGE_GROUP_HEAD_REF" ]; then + PR_NUMBER=$(printf '%s' "$MERGE_GROUP_HEAD_REF" | sed -nE 's#.*pr-([0-9]+)-.*#\1#p') + fi + + BASELINE_KEY="${CACHE_NAMESPACE}-${CACHE_VARIANT}-baseline" + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "0" ]; then + KEY="${CACHE_NAMESPACE}-${CACHE_VARIANT}-${PR_NUMBER}" + elif [ "$REF" = "refs/heads/$BASE_REF" ] || [ "$EVENT_NAME" = "schedule" ]; then + KEY="$BASELINE_KEY" + else + BRANCH_NAMESPACE=$(printf '%s' "$GITHUB_REF_NAME" | tr '/:@' '-' | tr -cd '[:alnum:]_.-') + KEY="${CACHE_NAMESPACE}-${CACHE_VARIANT}-${BRANCH_NAMESPACE}" + fi + + if [ "${#KEY}" -gt 100 ]; then + KEY="${KEY:0:83}-$(printf '%s' "$KEY" | sha256sum | cut -c1-16)" + fi + if [ "${#BASELINE_KEY}" -gt 100 ]; then + BASELINE_KEY="${BASELINE_KEY:0:83}-$(printf '%s' "$BASELINE_KEY" | sha256sum | cut -c1-16)" + fi echo "key=$KEY" | tee -a "$GITHUB_OUTPUT" - echo "seed=$SEED" | tee -a "$GITHUB_OUTPUT" + echo "baseline=$BASELINE_KEY" | tee -a "$GITHUB_OUTPUT" - name: Parse baseimage shell: bash @@ -652,6 +676,23 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Select cache donor + id: cache_from + shell: bash + env: + BASELINE_CACHE: ${{ matrix.registry }}/megatron-lm:${{ steps.cache_keys.outputs.baseline }}-buildcache-${{ matrix.cloud }} + LEGACY_CACHE: ${{ matrix.registry }}/megatron-lm:0-buildcache-${{ matrix.cloud }} + RUN_CACHE: ${{ matrix.registry }}/megatron-lm:${{ steps.cache_keys.outputs.key }}-buildcache-${{ matrix.cloud }} + run: | + DONOR_CACHE="$RUN_CACHE" + if ! docker buildx imagetools inspect "$DONOR_CACHE" >/dev/null 2>&1; then + DONOR_CACHE="$BASELINE_CACHE" + fi + if ! docker buildx imagetools inspect "$DONOR_CACHE" >/dev/null 2>&1; then + DONOR_CACHE="$LEGACY_CACHE" + fi + echo "donor=$DONOR_CACHE" | tee -a "$GITHUB_OUTPUT" + - name: Build and push uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: @@ -662,7 +703,7 @@ jobs: build-args: | FROM_IMAGE_NAME=${{ steps.base-image.outputs.version }} IMAGE_TYPE=${{ steps.base-image.outputs.image_type }} - cache-from: type=registry,ref=${{ matrix.registry }}/megatron-lm:${{ steps.cache_keys.outputs.seed }}-buildcache-${{ matrix.cloud }},mode=max + cache-from: type=registry,ref=${{ steps.cache_from.outputs.donor }},mode=max cache-to: type=registry,ref=${{ matrix.registry }}/megatron-lm:${{ steps.cache_keys.outputs.key }}-buildcache-${{ matrix.cloud }},mode=max no-cache: false tags: | From b19b1f47cf7e289607f3be480c5f06c6ada25b16 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Fri, 31 Jul 2026 02:53:01 +0200 Subject: [PATCH 168/290] ci(nvskills): AUT-1188 fix reusable workflow startup failure (#6162) Signed-off-by: svcnemo-autobot --- .github/workflows/request-nvskills-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/request-nvskills-ci.yml b/.github/workflows/request-nvskills-ci.yml index 07c0a846c0e..3c520378283 100644 --- a/.github/workflows/request-nvskills-ci.yml +++ b/.github/workflows/request-nvskills-ci.yml @@ -17,6 +17,6 @@ jobs: permissions: contents: read pull-requests: read - uses: NVIDIA/skills/.github/workflows/team-request.yml@2528d5b9d3f125c8bc8cf644ea2134adb4322a51 # main + uses: NVIDIA/skills/.github/workflows/team-request.yml@ce70ca7f1966c243e0b6a56b67085a185121d096 # main secrets: NVSKILLS_CI_DISPATCH_TOKEN: ${{ secrets.NVSKILLS_CI_DISPATCH_TOKEN }} From d3519d5e6cb2bd20880b2134fd529592a8ad460e Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 30 Jul 2026 23:27:13 -0700 Subject: [PATCH 169/290] Add MFSDP runtime schedule design (#6031) Signed-off-by: Jingyue Wu --- .../mfsdp_backward_schedule.png | Bin 0 -> 32185 bytes .../mfsdp_forward_schedule.png | Bin 0 -> 16418 bytes .../pytorch_fsdp2_backward_schedule.png | Bin 0 -> 32273 bytes .../pytorch_fsdp2_forward_schedule.png | Bin 0 -> 18020 bytes .../fsdp/src/docs/runtime_schedule.md | 144 +++++++++++++++++ .../scripts/nccl_same_pg_two_streams_async.py | 117 ++++++++++++++ .../scripts/nccl_same_pg_two_streams_sync.py | 145 ++++++++++++++++++ 7 files changed, 406 insertions(+) create mode 100644 megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/mfsdp_backward_schedule.png create mode 100644 megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/mfsdp_forward_schedule.png create mode 100644 megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/pytorch_fsdp2_backward_schedule.png create mode 100644 megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/pytorch_fsdp2_forward_schedule.png create mode 100644 megatron/core/distributed/fsdp/src/docs/runtime_schedule.md create mode 100644 megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_async.py create mode 100644 megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_sync.py diff --git a/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/mfsdp_backward_schedule.png b/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/mfsdp_backward_schedule.png new file mode 100644 index 0000000000000000000000000000000000000000..ea0aa75d58bc5e6d24ac2659dd0cc865132c38e0 GIT binary patch literal 32185 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

f)J)CG2(Dl>=e;o zNT(saPSc!0*^4-SzIAh znoRWT0GDs+yPdWhU7@K`SgyuxY`jJ7Gy?;jjg6TwjL`b`u|H8N_xmUkB^p$uCc@Fw zZe~i>WNYTnaXR1gnGbg9Ex=|&+mUH+(4NFJXMFdxyS@ms)Wfm)FspzI)P)(0KbV`LFDCX|-z-lY%e z1QY@Mao~P?vLp}HnPzqdP>z)E1?Wp}A*b<@Q8l>TX!oyMz}d-X?s~|P{eJgo5|Y?C z@ZsO#{kYp)gV*q(Q+czf3nXD?kmNCSUXM@US17co#6; ziOZ~j*!L+u^MIS?xo$sluVtZ@XC!+gAKUV`a~~JriJ+$8gOYGb&;CBiKa|b4(Vihc zuL-fSRv>qUMtqtRLl=js&&;oU4bJf7*4`x&QHk!e*DI)}*@-GXIOE{Yo_RZ!eEFH- zO#-{C)hCK@j=r`uBfggFT+^YMF*kJPDqkTe)uxLbF2-PTG#-PsA%uOSJYP<=*iZIe zv|WCkksDUGehL&+&Crldg9bJqBto1h59KWvUIIHTJdE5IT=drnv;MQ9d-Gq+OxayV z_#rfVB3xG*3tjjvMf#5zed|ll|Louqvv0~(`g*@gwJyRA*f@XTbIh5*GTM2Rj&-9t zZXta4F8nj-<^c}w1MGm^pbO`Ym2K9WjAep`cp_58*3%C zAP&AcnJD$5x?>1FUrhV_7$Rwzop5sTw?gRcuzb`g7;tc}Q+7C^kLaaDyZ1BH!tCz@ zZ``+xp*pxKjoq~_+nMy{j7z zxZM0U62jp5TR{(@Ywq_am1`p9r#n#eIX>+=R%}qT5dQn!Lc5oWzQTKi7MXT}#20Bz z;6PJ^M6$WEdPluKXZNT+nO25an$bn-=zF)+6hU-LhC=2An?DxZe;n1lZa#+J2$Q~? z65iNbAJrRPJ(z4hdk3B85r`4IC)mAw89?kI=)3M?DELd~_C!!lOFb~;N4cOu+ws|+ zTPgqHuR48Ia`lhLg|=>55>a%~caZH8GWBQUeW$8WtwmGYBl6C-d(k1;H7@1x4%zr< zhP@PdhpP33%2Pf(eA@!L#l=xdKDF|PE~XK61Jjj&kMA+u00iX-1(KYYuTuV_yFTWu z9heMqNo8_pV-D`AAQ`)f`MIKsUSTMe`sNFGKmd-lr3*!uar~|I(o?(uL`Elsx2}9pAGV#a2nlkMTIH-rAfmt8RuzfZo7@Jf zj!MHl6}^dkSIUhm5o|PXf}L@w=3Fcg>;$^_oku}H<=jXsP;_HaUnjgR23|za0V)XuIJBPzQVU_~R>n)73n(e$)83KzUigl~8vC-Ex8y zk!RNrS6-!j@wIG^msd=ZonP@HfUuKFw24E@@H1q4gokOj<)=y}$ndsR*w%nsJ6V$%V9nBZO^x7d z%pkb4ka}4NzlU+`ZqX)5Hkq(O?KFf%a(llSh_*mSi0gS7O5jH4ZH4{s-t8~QaH=(~2oeXz%3`c47*|*-ICA0$dqcjTk!dC0 z|Fzi(B7(AdI}hJcQJQ(`I032x`)FSdD9$yVpK~k*r^pEZBNj2WRsfU zEq*+4M}Z(<#wDqP+hggne|&>nL1SAdC`X=4usfIGOIdX6b~qH~jK9=AAmQ}RMjTNp zLcfH$hb8bw5la3cuz98w@tGJB={31`vG7i{fcgUo9bmQQrm-QoKwX4ezKK}}#$if(4ig^rU&+T#fTC#o zx19z?UzRYqQmBlx+>?J^5!A5M5unjpqjE-tOoLEJh9p>GCDs2`{^(etd9+ zpKFXfRaHQ;ff|Im(v9t=LOSE|G&2!z=PNmcs^)rIvNSOqr>^~+2Q2qF#A;7FyNM2n zykOD?rA8jvT#{~N!;CRFC$#OcbQZq?0j_U7~%@WSQ%lP@=_od1UWW+ zRj}H{7eKW?&n^!eHL0I{tA6RWItF+!R?8`rgX&T(l&$a^&7YW4*p*R)0tea04h*;6 zr*dDq)Pxn6%p3%)X!@F>WiDn*`FM<)gVddZ#01**qn;DQ_!=hOhbq0+XXN+Pxy<}^ zor*tlvtPA|1^zjEJU>^uHCWyDm>f}m-tvsUuZL3O={XD4dpr2m4u`0iQrqT^lPNul z{DH;;^{W{<21DYDbwXy&Kw1TdwU7KA*mwD6WQb=O3RA5{ZS^B9)v~0h5qtG z^(Mk)FiawA+c&!&bc19LIm&*Mv35^K#OYRez4NhM3=ygIcPi+*rTGz11KF(lFo!}| zw0cmI|21oUg$8>zHalmR@opZGG?=a;XxwHrYQRih|G^RS8(t}#JzH%@wx%-|I+a5p zo4PLUhHCCYPKU-aiO7VGT^&a`KiX=-8ii1H?F&2~cwK?BDzk9rf3S#BW$+5nmnwTG zWn$e^X4d%9D|rdGJzPZ3+*tr5JV~Sgm7vKGpHAqMco~6xB>s+)4ixsi`&2C1vm;x= zW4LvP2A>HjaBwBosy+8!Y>JUNjL@+s3y)pI#&6Pu9DJ<8UK(E7{YGI06We2kxH9x^ z`D6E`met_HETuL|Wc=SqZs6#-i1NI=rEyzcANC~`2Xx)I)dRv^@?PD0KIUxLi6o>j zGRtEp*X&Z)5krO57YMFJu(S#VLN}fbigUX#pIP|a2EJXxOQL*RmP?Flk3mV`;HS8l$QS3U@Q^(d zl2JZx`Nzt|kWdB_*i!#X&JcpKtLE1L{;p`tK7cMrYV>!exoc|cgvN++}|#G*2){j5h0M#2r;pVfB~m8*gxZ7ozb z2{65lkIn=hT-pF0(qK`QT&;EsTPAq4A4KINb~|l;>);CC6p^+>hM>=s@NkVGwNgH9 z?z^f`=r^5tL7v~04c+D>?J|)|D?-KLDE(Ap)p;JoQf%Cb_xez8T z?9nZ{s&rkMV^RBZslyp89K9tjVLQ$f#G*$pGa!N7ZUUwX1#x@EHdh<*MJ1!Zu!KNKU&`(h5Sn@btl zjJ}^J(!e0tYY1@2PR%Frp`@5xeT`T6G(s-QPTfj8m@3MTNgI0MD5G>#%0u1izQ6Db zhv-{q*oxcI%9z6zuYnE~+wAfvGF(SLe1KLaK-Iom!1rHWIt-Wb^UoYj>&W-X3}fi8 zL$0i$;_`E+u@)&m0Fr;xQQ*H18xE%?KgTkHI#q631DWJ|#+PxXNl~E*TB0;BFa524 zH|-XeOzbfPVs-oRT-}QIlSpK3Qq^_+!uUFJJlhk37R;_R5h{Fgy#8|#=RU9+l=TPN zrOtHpuk8j(K~+`cO}cz-A_Gr2n>zGw_w{*i>AhqrqFJs^2-OS{j}95=^9Siiw-!clxRwZ=&niim_| z9pI0SxuQv%>6@tzqq2~|Vt3DHU$G}u>CnnZ_vGq2@v1IYT`$LK`!*4*EA&|3Se&3w z{F92{eBco=GAk>mv`40#ziQIneVKP&#!7%0u`TPn@Wq(54Rg{hUhI7s=%oOn&(ALh zdlRN#zty-T3j2=?Q-ejSWDsvM4kk+ey9_)(N-DtZN=Fgai}APrGAW2a)w!AcQTUxn zK|qZ_X8-h@?S;{PxDl9`2>DpXs4^q*Q^@m! zC?Nq=YG=W$YNPmBoaY9(TIT-j31Y%jeS{-n){V+-VH3m5jRU;h z08^c$f-Xf6(Q7Qu6t9GL&{MW(b!5zdfXf!9us)Q9#fOLcR4KY834|5*F;fEQiwc&s zWZ{wxsVFTY$gRSmUWl9?umH!v@`3Pk%MO zwuOU9x7>?T?sh2>$WJ;8{M#JXMU!*d!BZhHT=CsXHIEBI1?+cfE+zxJicc1-M)x=8 zmLH0MH0IY&Y0wNVZmX|VX6&(Xpzt|fz#EL}gbWZ_xmq$Nq9(~O7Tm&m4n_LLnE9M8 z_)*NrGUKSS8!i=(-pyxCPg=nkVf?V-n)kfAKL*-Ry#qSc-TZVx9};#1=K)hs=)Vri_kx;XkJJ# zq+j}8DSW%3I#%>e6>Oj ztLTt)IslXZxl$zC)sROoq+nt z5yi>3LV#{UW@oqxon#k|za_7Vw59GM?=y~=VUH_J?&FGiQ(h)|2r0|5o1@JB-(m+c8WEKAn=mN7k zWhIwX&TSxV>>U!N6d5?ETFqRJ;TNOv2f4a+LlE7rNqL?

~{Oo}DL+;@k;ZHr>Wj4k#xAjoBlchAX&Phwn591Zbr%pD-gkXX58 z+wsNyKISB85%w|`8PYek+c8MU7t z`Q;D{^q7V4zb}QBr9T)(n?wLO{n~bG3O~t}P)}jRD8MTkxc8t^N}-Q1)AhLhBe!Mz z(vc`7`LVI>8GLqelE9ZGmr@LZ#4KRaNc)e&r^A8wHp5Sx}(a)Pw<{N z(%+zMAQ3Js*87(vB6OxF>}kxNei?uBuja6ym=Mpr=J&#!q0~>aR}gyr^y5>ELg1fk ze;vdjrQw&Oeed?;96&h#RT%l7!qpqd7UCCi z{jlyO|1})JVGN%G$YtEEQxQlqa1o0*tdS`Z{B6T6ejeDiqiV<6tbex63c{koT-LaX zn`r>Olyf55T&4%d|GO7qVTdK@>1*8ex-bdX2u~DT${vHklby?D1Xj@ql6G&61*X6O zD*hNPPs>M;`geOv(hhWSv={%(X#Oy8Kr20B<)-9q_$TnH*o+#|IHp%^8c*@9i*LFl zqOW&#djP;>_Fcy3%J5-$^Vda+pJDz}_)T?$vVBHj2>sLZR5AEzeMHM_xKG>3&q4+O z61y9c+c86a7(n{z!!g#6NP;X-{+aC@c#?c>hl@*newlN%| z3Ic@rq2p*(83R_v(#Swn|EZ4{wmL{Xp2O24W;I<0csOhZaT9CiFYF1)M8^TV5o(L; zmG%jeS67hx%VqbL;y*JjmST*;G@@JdlhV`DbB43r2R>BGVxVCm*}f32AC|PsSaZe~ zk07YZ+JCISs3|NX!>5JLW>_yTvMs_{Ec|Gp7=7B+l`2J;E$yg}D8`x#3*q zT*Vy2JkUl%AmL%Ve3j8WHt46$_2zknXEmA4KFPapq|Whv25zTr?%O`r9~if+C!ao2 z?cV6A?^T_S1wf2+UcH|>1u8c>pRLyhB)Aa#)91g4nq)^wqsf$N-TKCRVxVN4vv4NB z6h1+X|I4xci!;0dDr-hKyf5#Mcv5s}1dPXXMSOjP}pDA2`YMeA) z9xJs{KlhS4Z!6q$ol=kX(X#ogip%Z~7aJHBgYriu@WKYcW{g@CIVbub=Y_5uvu z(u360a^Amg;lB6@K9orG7o^jb`bGDbv|~^oEf|t4S5efPk$xJNlN`#wEbKYg?>3A1 zYf-ZEqn6!5L;x-=E=W->?w?kb<~esO3^^E*C0^Hx?wB^c0+?}L4PwddT#vpMkSwwD zw5o>Y)YBLMjNFZ$qPE__Wc-Ye8f0&HG;jA>YnkQGf~AmTUfA<*wqsS^X{39r%aSt; zAdw`7ZMeT!o$AcFKfv5Rp?1aF&w#H0CbhKv)xw7~!vhU%A#DIiC3X^O*E>5uveikF zETv0Q7^6$_ndx>h{THzt1h9`q;s8bZ{~~-F)NsfsK)gx+rg{JGho?QO$7DzC)<1wq z@F8=9xEf8Q5nmkYQt3h)6<>-z!LxdMY?@@xUam>P)N{465LD_aQeTuSrN6AYj6u1Z zSN(UNLK+8$A;=rqfmy2s?_6?)2Y}R_5JDd6+Sj$rpH$lm3WWewl%6Yg1q^{&`|-Wy za2B5?01Dgv_i~wJ|B4HpS~8d=_xpkZ5@j{Ll};ZvXSrk;jO>mdNJwB)xZh?ne5>eG z=~Du4MY;Qrc@vyOrKmZMRt)CYAnpbbLe%Ap?K;MFXkCN06nJ^G@4PulJc3aD#w-`C z!T1nRsOuCTJaLPjUyOElPZL%+5g`mN;;~^~jB;1{ zpIs=*o{TB-h2+|2<{3Xlq}Y)8$6avlD-%h9fo@IcaZVvQH%1Tlk9px1mYaGcW%7R( za`baTRte$#w;=ks zu_{-1shIA#H%E6eAyGnw9#U-3{Ntaa8P+5wY%L^qk1L<~#)e#FFLe@n@cq9{fo%T? zyBO17LoMd@OM!?Y6w}`nkqoa~B%P#^*sKpKh*u{6ps+W~vY1FV@`mvRV4E%VxB#S6X3j_$a4%AXl5gc{ODY)tw+IE1FwqY)^c{MAFX-nu z7?7-zIV%PILyrS;@(v=>OXzZ7XuppyWCpkWt99V@*iOsFL0r(?eZ-{}u#o{fCov3k z5{T$+44hx;`#K~FuJ9fg=K4S9bJ63Hw<2afHX!tiN3bsOvIHpI_YA7NK0~4GLtaMM zC87TB<;M*SY6o!1Pm+d_XnU@VU;(X;o)QsmN}h~?M0BO-lSC5Gn}6F`qJ8<})QTnp z48YUwArZlWD-N|6H%6oM=#~k`c#0Z2{18KY{iOP(`!5Ukl{o&f7@RnuU4b4(`?N-Y z7b9wS?DonU@-qsu9`Ck+Fd5Ko8c!5J(^?o~9N($4FcT2m->qzjd`M?Odf{iF7p1a`mN zBipsD&*`TR56R(6vOcFP=7VGvEQ;LLw=6avLQVHvX*EY5`1w3wOSp%(T-wWqLa~?a zOL6Hp-^>Sn8_zN}meX&|PPPese9Nus_oLG4M}V+;lCJOlxn*rdVYD&n-Ur-PXQ3D+-;fIRMg#G*5DX4#Uh1G$J?S(SPX({Fk%aK!#yvtv8*)v%Gc- z(Rznv@5^4jK#Nt!NW$aO1!0R&jHDT?QRd?%7Djt7FM!m#YkKi1&&?^Cav0SNubG6$2bgNODS_=>- zdzC2X+1)fDwZeAxyCADi-pcUt;eN2$c`ptiD+{7tAN)WaPdWuc#hP(H6-9*j)}YiA z%f1`2(zHO--*p+y_M_LaLG}v_5GaA2J*+%Y#svA>djMkBLHs05>sLV_tB|GUtF$ zBEDhR1vieV?bT|ebF!Sl&J$_ZxHeZ2C%vql@#|`hCmu#lZ+*JfPqOx>sOm8bq#bwH z(BTc4xPW4U-|OB@PM8djw%Iq-Hy$L~>YO`daxUwpe%GsJzdjers!eiCx6!Kmr0y`O zEG_RI!fxGhIx1j)`sS#dTAWIQ0-OnWM~kpRtH|g;LqLd$$ki&%Jky=Fj3=`o5l}D^ z`7^7*3DHDKM8!c%iVUPkqxS2BYEUaTeRVc2nait?Rj3UhV;vWy9)(i5iaNDO^k8te{OiM z({w~+gs)tzetk90)T?C?>bH4moPAK^x|!T@XS$oZEWcbhbJjhJ0WCQDtpKPCzk?7R z^e(kcwWqotDDj-tKz;|&FJeGvNV9Aix`Yl+?fc1+>z9GWz(PvH>6_<}u)b4*`PPv8prKXdBm#mwEsTU2BJ{HGH=`h`LyW|fYQTq-m=UzCvP1DdelXK1xP#> zfOHotAq({yZI{0s$G$qFg+UgdG$k!Fuwr3e>vUsxTyH<$AO2j5EQu>?xuE^|@tQl5 zhRt}+_^@1I2J)+&j_+bLIvz8s`Md2oW+*xk${kNQZBTjJ#h_Sw5d2r_cl$}| zx4uqrK_3WV<2Vm1`riDUKE^6V14p~H8BxmSsa~;Qn-Ws5cvuqQohwO$=h{Q3d~cj2 z7`S@)Ej(LHe?|!n9%f#_)VGN-S)@IfkfwrcH_)enSrXx$B!R_Mxp~0oIV4kPjcH3TLB9|J2wgAx^)K?YHu@S~Ug zr?Zl^JYAZ3mw>53QXWv0xm2sbpGM<}=4SRoEk>js1gZC!#ci|F+|!La!pg&o(0 zI6B!&>Np}dP#Mb~p-~~P5{Qi|if{O+7`W}UZgX%SbGlbO^0VT}IV;n9v(_e#^4(YS z+&7W7t;bR`W7Z;C?OyHF%{m+4Oo zdG4GNB?9NSVABE_LPQyRo^(n{?UBO}PRMHWwsyRR1C<}U#%yHh?#J}|}g8^F=a%btr>_0=pc{}^?*N6U16WsPv)OmYrb z-(9W)Xp!7lBb+9N)+R#B+div^rjkoD;t66Nn4J1;r<+?FE5pn0wiVf)`*#xI@}^aV z9~i7wm&He@f~n7Bo#@w~Buk3X(FJyTf`B&o?-u7Nb-+e}hte6k zx`xLYa$x-u*3BwEOlrHmpnF`MaQ)!y$Rv>53`SfBqFS<9niDVB%WGYs4@r^8)VWj} z<7V0&aaHy_7}N)~66(Lr7gT8ePL6%;jZE-DfaJ~b_s~%>yGobMCoAJsj`Nh`M^~m8 zs&*Xg3ryUD-?2O_vYBobptPJ3VyVXq)(V}JhsQ!4VHy38ajd}_n^pZFE1~Y%k6@5= z3q)0`#RXCl*%dR^60IbcRjgBxDo7-OJ4o;|SOXsvMwQQ>_fA;}4x6m>RJKrC?Vj0J zm=M|atDAlGNkuiY^LXXN>`&;eSa`=AkeIJ=^BfwN#=ENPL7MhN45?(IeJ}> znvtNznnBCE5_Ts=2W2q5SRx^+gGbq!lUpoAGHuMTig@Le0ti zP4IU${?VTkAnlxP)gWLuw4LzbLP;mjkj!0NRyuFBfEpEqd8Xvy{oKLo{md(NC3G1V z+#a#m;;Oh?82jP5p(2o``=cGJPu{beB7!@}cRLirw&UKVxjk##pId9<*L#?bwUW$N zKS?32Q2mNK`A?YZ$F4J9Gn;(UIP4j4G`|^f@9$vWxRHwYJ@xA&v5ps# z45)P%>3xnDfRyBE8j2Bk36*|b4BO`qQvJ&2i$MDxfeK_na zBu8Jx^Wlu8LI}Rfd!L-Ys#eN+;$~&8BYIYOBv+0PXL5Q(MKi>vJW%5NO9DQXp2D=# z3$32K?*Yiz0T!>?Qs=VE5gHTW#5)-RDULHp^mkQOu^b#Sel$=rBF^j_MOB zwr6njZ}w_98X<&-zu%vg`sYN55v-wlf9!j8b{e*|{YkU73{|8+s)AIy)c5^y?5g{$ z^U9ZVl(aFpfFcVPV3ENNT0w_=*E<~y8>Nmp$tI)gjWaw|q)3_!jZ`;Y4&uYu_a3s+ zG$aZVWbNYM=BB2Gynd5-B|rPdqJiFYyyT!>B4eVYK95YfSu?hW$g`+{KCZL9Xb}ax zj)JX=gj)NNP`HUJBLPH57&t-?(!KvRV}FSUYx2S)2=Zq2_+;rY$NAALA7(|E#u;(q ztADLOud#gF7nbx)uBbcKY;!2__Z`he`v=3))Xf!Ziuzu9zfzUna!q*0?W>!eew=qM z2X!;`NwGrtI(8?08kWuz2yCNLa$;&`XqotfUnG@#)OfhwpnL+rtV1Q^h)Dg7r3F$+ zG>uZN@ef;xTsC3M+iUD>RBf?Blaq&S9n6z4m{qG1imf39MSt#=`5(}Xw6^HhEw;S` zIu*I4`?H+z+*7vUd%MV*+I2B~TEu2!c>;|E&7XZ*x%Oi17adiz`eBRsNXqAI(sUwd7gNQ!do1`;vn-C~JwUiz8SkRYQL zm{Q^Od3t}R)K*n`vg|K`3L|o?gYMS1)A|RqLUYH2?1^0O^p$uII^m*PzRDXtkSuRe z_D~^VVQUb6wW@w#QN|VCeSv*JN7#{3-g1`8in%}ek{0I#xE`qC5FF{#|3W6i>e0?9_K@X?u(_YJ-n79jn(&E$Z>n)|6oO@NE}?=0$A4V(er z1waV}rBrX(#v5O`4Vq7N!|mCoXMG*jN9p*U6)nkj)j15jX3otMtVw)0_Yw#~^d}sU z);s!qV*$S~8Z``7vCkm<$i$SO5byir4i!)68xp1wUq&QHv30okPUL!eI1l(*EEn_r ze$OpB)?1@Se7>L>eQ@Ck$ew9PfFLH`-kB1U z+(?wB%LCxtf>YG@d<^ZPu0}l+z+JSYt48=pz^y}L?TQ+ZIjf8d!x$hO%?|6#5(5XI z*VCh&%|Bo5LbKdhEW?d3aM4GTow4RyQBxx1S@v?g$-EvIhn^$;#*{4e?R-{}{KK|^ zZ`h*cyMF;0Qy1@W$qbsf_UqxFjkx3WBs*CPZLpY&nzZNEptDQn=9GteKo=E=G_7ED z-{=>7GZyBG>VA}T{LKxE$f1v~;z$ly294>$+INFx&~__4jnrvxHr#ual7jN;hlkzR zti>~V476q61RU3qkKM*4&KJeI6q0z1*Vi)2UNXeZ-kiHfJzvV)BqNk`hN@J$FqFu1 zDl|RTOuLkD`}&r(4alZl&aV|&j_<8pN4JYehckr>*4aTyGRok3R5IV}*^Vn_+nOBs zxz3Ju-*`!${&e|V$Nc?WQjjUhAa3CBit9BK2k)VigaEec!H-({LVz&Jzi{ve42wDb zuCqJo2>)2k=@HyT*TCOjb9WYm@kA5@7UejX9AewpmxX<{@|1cFbk4n6UQ!EX7CsPt z4XD0;-TLJqavGbTw?4ynV1Kn%yqR$f$frnTxqMT?ON))neyNoRVcx`M?I5(d@^*7` zkL`J^S3t-G!4TKd37x==?U@W6Is2{9W<#=A>S^}uqqu(ma%Y2kcoX3;|20Xb$6tM; zpcy~bcy&Rv0Cr1Mz?rveOnZL zi(8)^{nDjs0rXgNrPTg-)akFc_?v9Dh_oQV=?qM&!F-e^KY>ozyKuC=q&WKFcft2N zAJ1_5;HN`=tJyt6(3p(feDu7I(jfi^#%;)tNGH^V@`@3ETA zSN9h(?LZ$_6M{F0bEhYPx|@E3{kW|TmuQgcPQM4SudJ4N8gt~P+-U;CSz;0Lo(|?z zK@}?BI&^FEh3s=F0gN-LbZO~RF4XNcKdSP%aWV*ly$e=MPs{)0xT;GU=gBx#ZaYXj zcD?k815vZat)5N^Oq{#Jz%5!%@nf|O$4}T4u$JMZ_otJ`u=syvb3GpEz z^M?eR5VmhF`+E|F^3M5U+|iV2kIySug`He-A?1jiY@EDR9z+9nqr$F~toRGHj_ny; z!#f{8JZv>wi+zz-G1>)@5$n?mVCv(DXF&B0dcpGIU(|hld6O!zm)`z1Ik*|AlU$YhX zP4t!6<=lI9qMP`R!YV!Yx82$7dq@sRc(BY0JaQJOejMUjSP!Eh>I}}$8W$n(Ymfv{fn|D=B z6KrhoVQj0V_B0T~xzDl2>BuGP0$IzEBeJaL-<9~dg5Q`9|HwY`P{--FAD??KBO2-0 zrq^wh9BVgQV-cEq&hw(&RHsRB#-CeSZE$N0IZ)W{$YJq|B?Hyb?HTF_hS!JL<7>4+ z&e*F(HSMrP!V~Nct9S)mjKdiW~12`DRJ3tOKhyougzGqFQUMbfo3sftcV`HvVqR>1HRf6~{HD~BL$-vrU;4ZTgbB_HSs6O|we;x%^|rhX79=2x<&7`ilLE_WBS+l0xh zkCu;Ns-5XX9PAhqJF*UCM(Wj%Kq}b?-e28vNKog`_SZx5N>5h=9HV1`V&F*NLt!sy z8O`+C;lA^;au}?g=C^0*?h$dsM?SU^jV2y)ru$)Ca;uXxji`b$i6o2~4<$|9JN=2o z%uV2A5&AwNtVfSIo)h?+CvCK82%kWVNPT%<-3Oie<$btj2+hq#kV0e)LN4mHdghqmRmLn~C;S-~#|Y9F z7+mZ@+AT&65ff1a^8Cfirj!8mssTCbBT)kreXGyH8{dCvk&@qYkc`U@a*o4-E}zZV z1O9O5N+i1J1bkwmEmA~1ErMi9eABD+`ik`OKacA{&6jSt1A|wKr?>;{D?(Yfj%{N_ z3tzx7xWvkz|0I?q4{?Li-o{9C77c?4o=t2u2J0*}c4sWM}CDcOtjT z4YnW2nESI=gVEt)*V!7LuTzs}8K0gE9e6m@6WmJAy~&V4Lf|+IO)*ksZ9360NRKb9h2# zmO@UsL5F!c2r1tr)&Ob&DkPA5%HSc{Z z+J$S0E`622TT*EAP5WX#jR)N&_+-2oj*dV=h5eQ9QF8dypZ?LQsQTz-*U~@Kpi!2n zpw86W`-zFr^%{`yova?tSXGUf?DYdyAyKb=hjD+Dha|q~Pw`6$7LyFK!=*N>v+n%f zl`-KJi_pN#f&Ja;AK^t@e`HOXMn1#}cA;!f|Hy`DI#6(5N@X?hYCeEsy*S}tG*yW> z>CRIn+@cEcR-4-6QT?p(`Bi(OTg_tNnSjspJec*ihc>R)R7R4xOlr+sEz0{Kmb1<= zDvX1}k^F9NbC>#3u!ey(_w67an1CbEN>yA%Ul0*qW!$Q=TQ(zX_dIUo)qn(H`lXHN zGU_ciW+jL!@+(O=Dc8NE~3C3{uG^quj;K;%TS1p%>Rrv zO&gomW-A|xO^DQ)$?3<2)@Xnhp(G&2-R$v%C+I4Ccf0|L`1H!B;6Q)GX-*2v z&BIHKTsk@gqY_B<6JRvK=;PpYqUU@~G1ij*&Wyr!-o$(%Y@Y#*g=6*01qqgcG&^ic z<$4MGx2z5UrzM$wYYOBs0@vqehOSXo|kU;d@3#3OcO>#us6{50>`Y zyTn9Nrd=rE=Hz6dfp}=SKZ*vk#V@50O?`L7iM|X_7Y7x=hQu(@y6l!}uF{{G(5z+W z<}hwM?&$a^_7U~Ha=;)4Q{W+@LBK^+QXNJeO<6ZTvJYZJnPyn3O|SVz>3p*8u@W|4 zCEq;!DCHUDj9T2dLo-S%yc;mRbhm%-qH8Djt?`r9Z-)!-VI_^=os@A}^I^jE`f{@p zAJJ_tk5J8oA;B<|UGj?tX@Ss$p~gWrg&F(S`)2=Wx75!(!GtdkRa?HAgJ4XEgIOy{ z6y!nbuQacEs{c)D>pI^f(ws0A_b^+=jFch70NVuE;kkStix@*4pOL$TY@@W_wHu;) zyAH_9up#rET(l&@tKn_T>1N$LgpSr&uS7P?XavWmaNyw>8i~l`S9f@A^vxgB9_g)Dh;}8pnQb;(Y?G#$ z8x&f~Gmt}hs62lXLu5*7m^^|9ao3R-j zYHAuK0$CyktHO$P+Ij1`7Bw!Yt*}VwhX%^V_5XVKmZWk}smdLBiMF1%2oz z=zN!RS?M26Pk)JXF^uI?jJdtZ;ydH{vy9|JSt6T4*=Mf0oJ+~c%gwJAOtZ|pT`zhd z2)oA)2a}#rN8yG#(zGUs_5DINmFrdeNKl6IX*B?27+y+3Rgo1NJ6000yDVLYrg|!S zYNEupR`b9bpN*iqv0D_zk*JYRluhGw#D{^G{)YD>n_xk@Y9;nY_CfVaqY#Z0<-y^% z-zY;mD+u2^DML3duxP@VMbW!NBIeCPH&cppbI{%Gt`Bl$ls}(ARYf67ZgaSwO?r1P zl>tZ6uXc1k85qjtM~qRNI|i~l8hxcVz1uX6F?QeOh#0Ep`5T;GkMV92#csqdG@Z&} z1Z}$lv4{r2=*_VD&Yk|_@0evNyRtUx$l)3 zI2e4@PO0&yK_QtHe;!7zo6mm@#cJ*rb8LyP40Ml6=YbcnrbhZ;ybFNak5(sG1LyP- z4RKQgdBv7ChH>?W?}^%}s0Tw45!wj%M~^F~`&9K;5nasZ@hkLqjk7hVKNQow6n~ieUk&JcBgiMTMd=G`*OH!~s z$m`@#j#(i4<1h@yjPGal;C^OPlN>L5y`@c&765Eh67z1s`pq)xN(f|s?+y$$>CXAG zHc>LXEpymq=2$=MR`2o5cf2!H@maz(GK7uQ|66ymrVTuq-w@PR*rnVCQQ9uy*)w%S z>&x*&LC^R21XIWzLOx~XnOEnInk8@{`sIIayMuNaMFdnAn-1O1J6j&>=i@>Xb!f38 zBDvpJ&0h5M8Xge8NBW#7_PG_wZ|$gn+2p(N2o^81Z;0dq8-r`1OSaLAKHmAFn;bH2 zcTxrpo0+>8^X4aQC)rn&_^QV9ZV2Hj{~4dB+t|>9BHlMh=rpn0=bC@?x=&(&x`!J` z1&1MSZF${mvQF0Y@~1nPBL#C%q`FgmH=$U_BF_7cy*H5=yG(Z^ivJ7}Xu&r<>EeST zEwJ5vt^8K1Wmwm~YKO^+L@@w^>OV(AtaQ{vE(VQK*-?%yS=k?cyW;utohA>B5s8qG zoFl}dcz<+9m rLghY-A^xW*Hg+of|9=&)`ZDa@=+bEfMgo7y{_LHk0=Qh<;Pd|n)=eoq literal 0 HcmV?d00001 diff --git a/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/mfsdp_forward_schedule.png b/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/mfsdp_forward_schedule.png new file mode 100644 index 0000000000000000000000000000000000000000..84f321371fa6c6f7a55e0ab413e4a0e8d866181b GIT binary patch literal 16418 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 aGvgOPa{_ndj_>`yp{cH|R-tMg_WuCdY%`ny literal 0 HcmV?d00001 diff --git a/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/pytorch_fsdp2_backward_schedule.png b/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/pytorch_fsdp2_backward_schedule.png new file mode 100644 index 0000000000000000000000000000000000000000..49d3f51c2d8e6a114a040614d8aa98cfa72c3b1b GIT binary patch literal 32273 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 z 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 literal 0 HcmV?d00001 diff --git a/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/pytorch_fsdp2_forward_schedule.png b/megatron/core/distributed/fsdp/src/docs/images/runtime_schedule/pytorch_fsdp2_forward_schedule.png new file mode 100644 index 0000000000000000000000000000000000000000..0e5fc49b54e486ccc9c2a307ef26ada740631709 GIT binary patch literal 18020 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 oH-MCa)zkSv5}I1x^`AdL|8|tq#-JO((_ + +# MFSDP Runtime Schedule + +The sub-design proposes the MFSDP runtime schedule for overlapping, double buffering, and +prefetching. + +# Proposed Schedule + +## Forward + +![MFSDP forward runtime schedule][mfsdp-forward-schedule] + +## Backward + +![MFSDP backward runtime schedule][mfsdp-backward-schedule] + +## Legend + +- AO/DO: allocation/deallocation of allgather output, typically a NCCL user buffer +- AI/DI: allocation/deallocation of reduce scatter input, typically a NCCL user buffer +- CI/CO: copy into/out of communication input +- Arrows: inter-stream dependency +- GA: gradient accumulation +- \\_i: \ for the i-th layer. Note backprop goes in reverse direction, so + i+1 happens before i. + +## Prefetching + +During forward propagation, AG\_{i+1} needs to be prefetched before DO\_i. Otherwise, +AG\_{i+1} and Fwd\_i wouldn’t overlap. Without prefetching, AG\_{i+1} (triggered by layer +i+1) would be enqueued after DO\_i (triggered by layer i), which happens after Fwd\_i. + +Similarly, during backprop, AG\_{i-1} needs to be prefetched before DO\_i and RS\_i. +Otherwise, AG\_{i-1} and Bwd\_i wouldn’t overlap. When AG and RS share a NCCL communicator, +the communicator enforces the host-side launch order even if the operations use independent +CUDA streams[^1]. Without prefetching, AG\_{i-1} would therefore happen after RS\_i, which +happens after Bwd\_i. An independent communicator for AG removes this ordering constraint, +but prefetching is still needed to initiate AG early enough to overlap with Bwd\_i. + +[PR #5719](https://github.com/NVIDIA/Megatron-LM/pull/5719) prefetches the next +`FsdpModule` from the static `nn.Module.modules()` traversal order. This naturally implements +double buffering and can be extended to prefetch more aggressively. + +This is simplest to implement but has several limitations that can be partially addressed +with a record-and-replay mechanism. + +1. Static order may differ from runtime order. For example, `parent_module.forward()` may + call its submodules in a different order from `parent_module.__init__`. It might also run + a submodule multiple times. +2. Even runtime order might not be deterministic run-to-run. This will break CUDA graph + capture as well. +3. Different input sizes may require different runtime orders for efficiency. We could + capture or compute multiple orders for the runtime to choose from. + +# Alternatives considered + +## FSDP1 + +FSDP1 uses `record_stream` to avoid deallocating allgather output too early. This has caused +[non-determinism](https://dev-discuss.pytorch.org/t/fsdp-cudacachingallocator-an-outsider-newb-perspective/1486) +in memory usage. Therefore, FSDP2 +[has avoided using `record_stream`](https://github.com/pytorch/pytorch/issues/114299). +Instead, it adds extra stream synchronization so allocation and deallocation of a buffer +happen on the same stream. + +## FSDP2 + +Due to per-parameter sharding, FSDP2 issues more data copy than MFSDP. This simplifies the +implementation: for example, we don’t have to prefetch AG i+1 (or delay DO i) to overlap +AG i+1 and Fwd i. + +In addition, backprop and copy-in run on different streams, disabling their fusion. + +### Forward + +![PyTorch FSDP2 forward runtime schedule][pytorch-fsdp2-forward-schedule] + +FSDP2 enqueues copy-in kernels to a separate stream from allgather. We omitted that in the +figure. + +### Backward + +![PyTorch FSDP2 backward runtime schedule][pytorch-fsdp2-backward-schedule] + +## Single Communication Stream + +Prototype: [PR #5416](https://github.com/NVIDIA/Megatron-LM/pull/5416) + +PyTorch’s CUDA caching allocator maintains memory pools on a per-stream basis. Consolidating +communication onto a single stream, rather than using separate streams for AllGather and +ReduceScatter, can reduce allocator fragmentation by allowing allocations to be reused from +the same stream-local pool. + +A potential drawback is the introduction of artificial dependencies between AllGather (AG) +and ReduceScatter (RS) operations. When AG and RS use separate NCCL communicators and CUDA +streams, they can make independent progress and potentially overlap. Anecdotal measurements +on some workloads have shown performance gains from this overlap, so consolidating +communication onto one stream may trade away performance for lower allocator fragmentation. +The trade-off is workload- and system-dependent and should be measured. + +## Prefetching via Delayed Execution + +[PR #5124](https://github.com/NVIDIA/Megatron-LM/pull/5124) implements prefetching via +delayed execution. During forward propagation, `DO_i` is delayed until after `AG_{i+1}`, +equivalently before `AO_{i+2}`. During backpropagation, `DO_i` and `RS_i` are delayed until +after `AG_{i-1}`. The delayed actions are placed into queues. + +The main advantage of this approach is that it does not require predicting the next +`FsdpModule`, making it applicable even when the module execution order is dynamic or +difficult to determine ahead of time. The downside is that the implementation is considerably +more complex. In particular, the post-backward logic is spread across multiple locations +because delayed operations must be executed through callbacks, making the control flow harder +to follow and maintain. For now, we have decided not to pursue this design. + +### Double buffering + +By default, each all-gather (`AG`) drains its queue to a target length of one, effectively +providing double buffering. The remaining delayed operations are flushed during the +post-forward and post-backward hooks of the root `FsdpModule`. + +If needed, the target queue length—or alternatively, a maximum memory budget for delayed +operations—can be configured on a per-`FsdpModule` basis. This allows prefetching to be tuned +more or less aggressively, trading off memory consumption against communication/computation +overlap. + +[^1]: The [`async_op=True`](scripts/nccl_same_pg_two_streams_async.py) and + [`async_op=False`](scripts/nccl_same_pg_two_streams_sync.py) profiling + scripts demonstrate this. In both cases, the kernels run in sequence. + +[mfsdp-forward-schedule]: images/runtime_schedule/mfsdp_forward_schedule.png + +[mfsdp-backward-schedule]: images/runtime_schedule/mfsdp_backward_schedule.png + +[pytorch-fsdp2-forward-schedule]: images/runtime_schedule/pytorch_fsdp2_forward_schedule.png + +[pytorch-fsdp2-backward-schedule]: images/runtime_schedule/pytorch_fsdp2_backward_schedule.png diff --git a/megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_async.py b/megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_async.py new file mode 100644 index 00000000000..355235e66da --- /dev/null +++ b/megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_async.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Probe same-process-group NCCL stream behavior for asynchronous collectives. + +Launch a reduce-scatter and an all-gather on different user CUDA streams using +one NCCL process group. Profile the script with: + + nsys profile --force-overwrite=true --trace=cuda,nvtx --sample=none \ + --cpuctxsw=none --cuda-memory-usage=false \ + --capture-range=cudaProfilerApi --capture-range-end=stop \ + --export=sqlite --output=/tmp/nccl_same_pg_two_streams_async \ + uv run torchrun --nproc-per-node=2 \ + megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_async.py + +Then inspect the NCCL kernels with: + + nsys stats --report cuda_gpu_trace --force-export=true \ + /tmp/nccl_same_pg_two_streams_async.nsys-rep | grep -i nccl + +The trace shows the reduce-scatter kernel completing before the all-gather +kernel starts, even though the operations are issued on different user streams. +""" + +from __future__ import annotations + +import logging +import os +import time + +import torch +import torch.distributed as dist + +ELEMENTS = 64 * 1024 * 1024 +logger = logging.getLogger(__name__) + + +def launch_collectives( + *, + rs_stream: torch.cuda.Stream, + ag_stream: torch.cuda.Stream, + rs_in: torch.Tensor, + rs_out: torch.Tensor, + ag_in: torch.Tensor, + ag_out: torch.Tensor, +) -> None: + """Launch reduce-scatter and all-gather on separate user CUDA streams.""" + torch.cuda.nvtx.range_push("launch_reduce_scatter_on_rs_stream") + with torch.cuda.stream(rs_stream): + rs_work = dist.reduce_scatter_tensor(rs_out, rs_in, async_op=True) + torch.cuda.nvtx.range_pop() + + torch.cuda.nvtx.range_push("launch_all_gather_on_ag_stream") + with torch.cuda.stream(ag_stream): + ag_work = dist.all_gather_into_tensor(ag_out, ag_in, async_op=True) + torch.cuda.nvtx.range_pop() + + rs_work.wait() + ag_work.wait() + + +def main() -> None: + """Run and profile the asynchronous collective probe.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + + rank = int(os.environ["RANK"]) + local_rank = int(os.environ.get("LOCAL_RANK", rank)) + world_size = int(os.environ["WORLD_SIZE"]) + if world_size != 2: + raise RuntimeError(f"Expected exactly 2 ranks, got {world_size}.") + + torch.cuda.set_device(local_rank) + device = torch.device("cuda", local_rank) + dist.init_process_group("nccl") + + rs_stream = torch.cuda.Stream(device=device) + ag_stream = torch.cuda.Stream(device=device) + + ag_in = torch.full((ELEMENTS,), rank + 1, dtype=torch.bfloat16, device=device) + ag_out = torch.empty((world_size * ELEMENTS,), dtype=torch.bfloat16, device=device) + rs_in = torch.full((world_size * ELEMENTS,), rank + 1, dtype=torch.bfloat16, device=device) + rs_out = torch.empty((ELEMENTS,), dtype=torch.bfloat16, device=device) + + launch_kwargs = { + "rs_stream": rs_stream, + "ag_stream": ag_stream, + "rs_in": rs_in, + "rs_out": rs_out, + "ag_in": ag_in, + "ag_out": ag_out, + } + + # Warm up communicator initialization and one steady-state iteration outside + # the profiler range. + launch_collectives(**launch_kwargs) + torch.cuda.synchronize(device) + dist.barrier() + + if rank == 0: + logger.info("rank0 torch rs_stream.cuda_stream=%s", rs_stream.cuda_stream) + logger.info("rank0 torch ag_stream.cuda_stream=%s", ag_stream.cuda_stream) + + torch.cuda.cudart().cudaProfilerStart() + torch.cuda.nvtx.range_push("profile_async_same_pg") + launch_collectives(**launch_kwargs) + torch.cuda.nvtx.range_pop() + torch.cuda.synchronize(device) + torch.cuda.cudart().cudaProfilerStop() + + dist.barrier() + if rank == 0: + logger.info("done") + time.sleep(0.2) + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_sync.py b/megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_sync.py new file mode 100644 index 00000000000..417c45642bc --- /dev/null +++ b/megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_sync.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Probe same-process-group NCCL stream behavior for synchronous collectives. + +With ``async_op=False``, ProcessGroupNCCL launches each NCCL call on the current +user CUDA stream. This script issues a reduce-scatter and an all-gather +sequentially on separate user streams. Profile it with: + + nsys profile --force-overwrite=true --trace=cuda,nvtx --sample=none \ + --cpuctxsw=none --cuda-memory-usage=false \ + --capture-range=cudaProfilerApi --capture-range-end=stop \ + --export=sqlite --output=/tmp/nccl_same_pg_two_streams_sync \ + uv run torchrun --nproc-per-node=2 \ + megatron/core/distributed/fsdp/src/docs/scripts/nccl_same_pg_two_streams_sync.py + +Then inspect the NCCL kernels with: + + nsys stats --report cuda_gpu_trace --force-export=true \ + /tmp/nccl_same_pg_two_streams_sync.nsys-rep | grep -i nccl + +The trace shows the reduce-scatter kernel completing before the all-gather +kernel starts, even though the operations use different user streams. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import time + +import torch +import torch.distributed as dist + +DEFAULT_ELEMENTS = 64 * 1024 * 1024 +logger = logging.getLogger(__name__) + + +def _reduce_scatter_sync( + stream: torch.cuda.Stream, input_tensor: torch.Tensor, output_tensor: torch.Tensor +) -> None: + """Launch a synchronous reduce-scatter on the given user CUDA stream.""" + with torch.cuda.stream(stream): + torch.cuda.nvtx.range_push("sync_reduce_scatter_on_rs_stream") + dist.reduce_scatter_tensor(output_tensor, input_tensor, async_op=False) + torch.cuda.nvtx.range_pop() + + +def _all_gather_sync( + stream: torch.cuda.Stream, input_tensor: torch.Tensor, output_tensor: torch.Tensor +) -> None: + """Launch a synchronous all-gather on the given user CUDA stream.""" + with torch.cuda.stream(stream): + torch.cuda.nvtx.range_push("sync_all_gather_on_ag_stream") + dist.all_gather_into_tensor(output_tensor, input_tensor, async_op=False) + torch.cuda.nvtx.range_pop() + + +def launch_collectives( + *, + rs_stream: torch.cuda.Stream, + ag_stream: torch.cuda.Stream, + rs_in: torch.Tensor, + rs_out: torch.Tensor, + ag_in: torch.Tensor, + ag_out: torch.Tensor, +) -> None: + """Launch reduce-scatter and all-gather on separate user CUDA streams.""" + _reduce_scatter_sync(rs_stream, rs_in, rs_out) + _all_gather_sync(ag_stream, ag_in, ag_out) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Probe async_op=False NCCL stream behavior on one process group." + ) + parser.add_argument( + "--elements", + type=int, + default=DEFAULT_ELEMENTS, + help="Number of bf16 elements contributed per rank.", + ) + return parser.parse_args() + + +def main() -> None: + """Run and profile the synchronous collective probe.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + + args = parse_args() + + rank = int(os.environ["RANK"]) + local_rank = int(os.environ.get("LOCAL_RANK", rank)) + world_size = int(os.environ["WORLD_SIZE"]) + if world_size < 2: + raise RuntimeError(f"Expected at least 2 ranks, got {world_size}.") + + torch.cuda.set_device(local_rank) + device = torch.device("cuda", local_rank) + dist.init_process_group("nccl") + + rs_stream = torch.cuda.Stream(device=device) + ag_stream = torch.cuda.Stream(device=device) + + ag_in = torch.full((args.elements,), rank + 1, dtype=torch.bfloat16, device=device) + ag_out = torch.empty((world_size * args.elements,), dtype=torch.bfloat16, device=device) + rs_in = torch.full((world_size * args.elements,), rank + 1, dtype=torch.bfloat16, device=device) + rs_out = torch.empty((args.elements,), dtype=torch.bfloat16, device=device) + + launch_kwargs = { + "rs_stream": rs_stream, + "ag_stream": ag_stream, + "rs_in": rs_in, + "rs_out": rs_out, + "ag_in": ag_in, + "ag_out": ag_out, + } + + # Warm up communicator initialization and one steady-state iteration outside + # the profiler range. + launch_collectives(**launch_kwargs) + torch.cuda.synchronize(device) + dist.barrier() + + if rank == 0: + logger.info("rank0 torch rs_stream.cuda_stream=%s", rs_stream.cuda_stream) + logger.info("rank0 torch ag_stream.cuda_stream=%s", ag_stream.cuda_stream) + + torch.cuda.cudart().cudaProfilerStart() + torch.cuda.nvtx.range_push("profile_sync_same_pg") + launch_collectives(**launch_kwargs) + torch.cuda.nvtx.range_pop() + torch.cuda.synchronize(device) + torch.cuda.cudart().cudaProfilerStop() + + dist.barrier() + if rank == 0: + logger.info("done") + time.sleep(0.2) + dist.destroy_process_group() + + +if __name__ == "__main__": + main() From 83392004c81b6e477002ae84d4f0b1271518c1d1 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Fri, 31 Jul 2026 03:35:58 -0700 Subject: [PATCH 170/290] Feature/enable inference optimized qwen moe (#5700) Signed-off-by: Utkarsh Utkarsh Signed-off-by: Shanmugam Ramasamy <111910568+shanmugamr1992@users.noreply.github.com> Signed-off-by: shanmugamr1992 Co-authored-by: Utkarsh Utkarsh Co-authored-by: Claude Opus 4.8 Co-authored-by: Shanmugam Ramasamy <111910568+shanmugamr1992@users.noreply.github.com> Co-authored-by: Guihong Li Co-authored-by: shanmugamr1992 --- megatron/core/inference/moe/activations.py | 113 +++++++ megatron/core/inference/moe/fused_moe.py | 6 + megatron/core/inference/moe/vllm_fused_moe.py | 14 +- megatron/core/models/gpt/gpt_layer_specs.py | 2 +- megatron/core/models/gpt/moe_module_specs.py | 11 +- megatron/core/transformer/moe/experts.py | 3 + .../core/transformer/transformer_config.py | 12 +- megatron/training/models/gpt.py | 7 +- .../golden_values_dev_dgx_gb200.json | 296 +++++++++++++++++ .../golden_values_dev_dgx_h100.json | 298 ++++++++++++++++++ .../model_config.yaml | 89 ++++++ .../recipes/gb200/moe-dynamic-inference.yaml | 5 + .../recipes/h100/moe-dynamic-inference.yaml | 5 + .../inference/test_vllm_fused_moe.py | 80 +++++ .../models/test_moe_module_specs.py | 36 +++ 15 files changed, 969 insertions(+), 8 deletions(-) create mode 100644 tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_gb200.json create mode 100644 tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_h100.json create mode 100644 tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/model_config.yaml create mode 100644 tests/unit_tests/models/test_moe_module_specs.py diff --git a/megatron/core/inference/moe/activations.py b/megatron/core/inference/moe/activations.py index ae5e4560ce3..4dfd1e5ee1d 100644 --- a/megatron/core/inference/moe/activations.py +++ b/megatron/core/inference/moe/activations.py @@ -80,6 +80,119 @@ def padded_squared_relu( return out +@triton.jit +def _swiglu_kernel( + input_ptr, + output_ptr, + src_idx_ptr, + n_used_ptr, + N, # output width = ffn_hidden (input row width is 2N) + max_rows, + BLOCK_N: tl.constexpr, + NUM_BLOCKS: tl.constexpr, +): + """SwiGLU: SiLU(gate) * up, skipping rows beyond n_used and padding rows (perm_map == -1). + + Input row width is 2N: gate = first N cols, up = last N cols (megatron chunk convention). + Output row width is N. Fixed NUM_BLOCKS CTAs iterating rows -> CUDA-graph compatible. + """ + pid = tl.program_id(0) + n_used = tl.load(n_used_ptr) + if pid >= n_used: + return + two_N = 2 * N + for row in tl.range(pid, max_rows, NUM_BLOCKS): + if row < n_used: + if tl.load(src_idx_ptr + row) >= 0: + for n in tl.range(0, N, BLOCK_N): + o = n + tl.arange(0, BLOCK_N) + m = o < N + gate = tl.load(input_ptr + row * two_N + o, mask=m).to(tl.float32) + up = tl.load(input_ptr + row * two_N + N + o, mask=m).to(tl.float32) + silu = gate * tl.sigmoid(gate) + tl.store(output_ptr + row * N + o, (silu * up).to(tl.bfloat16), mask=m) + + +def padded_swiglu( + x: torch.Tensor, permutation_map: torch.Tensor, n_used: torch.Tensor +) -> torch.Tensor: + """SwiGLU activation (SiLU(gate) * up); skips rows beyond n_used and alignment-padding rows. + + Gated counterpart of padded_squared_relu: FC1 output is 2x wide (gate | up), so the + output width is half the input. + + Args: + x: [output_size, 2 * ffn_hidden] BF16 FC1 output. + permutation_map: [output_size] int32, original token index or -1 for padding. + n_used: scalar int32 CUDA tensor = inclusive_expert_offsets[-1]. + Returns: + [output_size, ffn_hidden] BF16. + """ + M, two_N = x.shape + assert two_N % 2 == 0, f"SwiGLU FC1 output width must be even, got {two_N}" + N = two_N // 2 + out = torch.empty(M, N, dtype=x.dtype, device=x.device) + BLOCK_N = min(triton.next_power_of_2(N), 1024) + NUM_BLOCKS = min(M, 512) + _swiglu_kernel[(NUM_BLOCKS,)]( + x, out, permutation_map, n_used, N, M, BLOCK_N=BLOCK_N, NUM_BLOCKS=NUM_BLOCKS + ) + return out + + +@triton.jit +def _silu_mul_bounded_kernel( + input_ptr, + output_ptr, + n_rows_ptr, + N, # output width (input row width is 2N) + max_rows, + BLOCK_N: tl.constexpr, + NUM_BLOCKS: tl.constexpr, # grid size (fixed for CG) +): + """SiLU(gate) * up on the first n_rows rows; rows beyond are left untouched. + + For the flat token-major FC1 layout (no permutation map): rows [0, n_rows) are + live. n_rows is a device scalar, so the fixed grid stays CUDA-graph compatible. + """ + pid = tl.program_id(0) + n_rows = tl.load(n_rows_ptr) + if pid >= n_rows: + return + two_N = 2 * N + for row in tl.range(pid, max_rows, NUM_BLOCKS): + if row < n_rows: + for n in tl.range(0, N, BLOCK_N): + o = n + tl.arange(0, BLOCK_N) + m = o < N + gate = tl.load(input_ptr + row * two_N + o, mask=m).to(tl.float32) + up = tl.load(input_ptr + row * two_N + N + o, mask=m).to(tl.float32) + silu = gate * tl.sigmoid(gate) + tl.store(output_ptr + row * N + o, (silu * up).to(tl.bfloat16), mask=m) + + +def bounded_silu_mul(x: torch.Tensor, n_rows: torch.Tensor) -> torch.Tensor: + """SwiGLU (SiLU(gate) * up) over the first n_rows rows of a flat [M, 2N] tensor. + + Args: + x: [M, 2 * ffn_hidden] BF16 FC1 output (gate = first half, up = second half). + n_rows: scalar int32/int64 CUDA tensor with the number of live rows + (e.g. valid_tokens * topk). Rows >= n_rows are skipped, not zeroed. + Returns: + [M, ffn_hidden] BF16 (rows >= n_rows undefined). + """ + M, two_N = x.shape + assert two_N % 2 == 0, f"SwiGLU FC1 output width must be even, got {two_N}" + N = two_N // 2 + out = torch.empty(M, N, dtype=x.dtype, device=x.device) + BLOCK_N = min(triton.next_power_of_2(N), 1024) + NUM_BLOCKS = min(M, 512) + _silu_mul_bounded_kernel[(NUM_BLOCKS,)]( + x, out, n_rows, N, M, BLOCK_N=BLOCK_N, NUM_BLOCKS=NUM_BLOCKS + ) + return out + + @triton.jit def _squared_relu_quantize_kernel( input_ptr, diff --git a/megatron/core/inference/moe/fused_moe.py b/megatron/core/inference/moe/fused_moe.py index f6c0af4e94e..0b201c32c1d 100644 --- a/megatron/core/inference/moe/fused_moe.py +++ b/megatron/core/inference/moe/fused_moe.py @@ -12,6 +12,7 @@ from megatron.core.inference.moe.activations import ( padded_squared_relu, + padded_swiglu, squared_relu_and_quantize_mxfp8, ) from megatron.core.inference.moe.permute import ( @@ -42,6 +43,7 @@ class ActivationType(Enum): """Activation functions supported by mcore_fused_moe.""" SQUARED_RELU = "squared_relu" + SWIGLU = "swiglu" def _bf16_grouped_mm( @@ -75,6 +77,10 @@ def _get_activation_func(activation_type: ActivationType, fused_quant: bool = Fa """ if activation_type == ActivationType.SQUARED_RELU: return squared_relu_and_quantize_mxfp8 if fused_quant else padded_squared_relu + elif activation_type == ActivationType.SWIGLU: + if fused_quant: + raise NotImplementedError("SWIGLU + MXFP8 fused-quant not implemented (bf16 only)") + return padded_swiglu else: raise ValueError(f"Unsupported activation type: {activation_type}") diff --git a/megatron/core/inference/moe/vllm_fused_moe.py b/megatron/core/inference/moe/vllm_fused_moe.py index 287d5f2828e..f6087ebbfe9 100644 --- a/megatron/core/inference/moe/vllm_fused_moe.py +++ b/megatron/core/inference/moe/vllm_fused_moe.py @@ -29,6 +29,7 @@ triton.jit = null_decorator tl = MagicMock() +from megatron.core.inference.moe.activations import bounded_silu_mul from megatron.core.inference.moe.fused_moe import ActivationType from megatron.core.inference.moe.permute import ( _get_num_sms, @@ -621,7 +622,11 @@ def vllm_fused_moe( topk_weights_flat = probs.reshape(-1).contiguous() # FC1 + activation: [max_tokens, K] → [max_tokens*topk, N] - assert activation_type == ActivationType.SQUARED_RELU + # SQUARED_RELU fuses into the GEMM epilogue (elementwise). SwiGLU pairs column c + # with c+N/2 across tiles and cannot, so FC1 runs unfused to the 2N-wide + # intermediate and gate/up is applied separately below. + assert activation_type in (ActivationType.SQUARED_RELU, ActivationType.SWIGLU) + is_swiglu = activation_type == ActivationType.SWIGLU intermediate1 = torch.empty( num_valid, N, dtype=hidden_states.dtype, device=hidden_states.device ) @@ -637,8 +642,13 @@ def vllm_fused_moe( top_k=topk, config=config, grid_size=grid_size_fc1, - fuse_squared_relu=True, + fuse_squared_relu=not is_swiglu, ) + if is_swiglu: + # intermediate1 is [num_valid, 2N] (gate | up); reduce to [num_valid, N] via + # SiLU(gate) * up over the valid_tokens*topk live rows only. + n_rows = (valid_tokens * topk).to(torch.int32) + intermediate1 = bounded_silu_mul(intermediate1, n_rows) # FC2: [max_tokens*topk, N] → [max_tokens*topk, K], without routing weights. # Routing weights are applied in the reduction kernel to avoid an extra diff --git a/megatron/core/models/gpt/gpt_layer_specs.py b/megatron/core/models/gpt/gpt_layer_specs.py index 984840b3a87..a61c36bff5b 100755 --- a/megatron/core/models/gpt/gpt_layer_specs.py +++ b/megatron/core/models/gpt/gpt_layer_specs.py @@ -613,7 +613,7 @@ def get_gpt_decoder_layer_specs( qk_l2_norm=qk_l2_norm, num_experts=config.num_moe_experts, moe_grouped_gemm=config.moe_grouped_gemm, - moe_use_legacy_grouped_gemm=config.moe_use_legacy_grouped_gemm, + moe_use_legacy_grouped_gemm=getattr(config, "moe_use_legacy_grouped_gemm", False), ) else: layer_norm_impl = LNImpl diff --git a/megatron/core/models/gpt/moe_module_specs.py b/megatron/core/models/gpt/moe_module_specs.py index 29c64ece0d5..2e666f8504d 100755 --- a/megatron/core/models/gpt/moe_module_specs.py +++ b/megatron/core/models/gpt/moe_module_specs.py @@ -83,9 +83,18 @@ def get_moe_module_spec_for_backend( # shared experts spec shared_experts = partial(_build_shared_experts, submodules=mlp) + # The inference-optimized backend needs InferenceTopKRouter (compact [tokens, topk] + # index routing); other backends keep the MoESubmodules default (training TopKRouter, + # dense [tokens, num_experts] map). Mirrors get_inference_optimized_moe_spec(). + router = InferenceTopKRouter if isinstance(backend, InferenceSpecProvider) else None + submodule_kwargs = {"router": router} if router is not None else {} + # MoE module spec return partial( - MoELayer, submodules=MoESubmodules(experts=experts, shared_experts=shared_experts) + MoELayer, + submodules=MoESubmodules( + experts=experts, shared_experts=shared_experts, **submodule_kwargs + ), ) diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index 7fc0725b9de..59f8deffeca 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -1049,6 +1049,9 @@ def _resolve_mcore_activation_type(self): func = self.config.activation_func if func == squared_relu: return McoreActivationType.SQUARED_RELU + if func == F.silu and self.config.gated_linear_unit: + # gated SiLU -> SwiGLU (padded_swiglu / vllm silu_and_mul path) + return McoreActivationType.SWIGLU raise ValueError(f"No mcore_fused_moe ActivationType mapping for activation_func={func}") def _build_concatenated_mxfp8_weights(self): diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 4f9de546161..e26af1852ca 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -1486,10 +1486,16 @@ def __post_init__(self): "to avoid costly dtype conversions during decode." ) - if self.gated_linear_unit: + # Gated linear units (SwiGLU/GeGLU) are supported by the torch and vllm + # grouped-GEMM backends only. + if self.gated_linear_unit and self.inference_grouped_gemm_backend not in ( + "torch", + "vllm", + ): raise ValueError( - "--transformer-impl='inference_optimized' does not yet support " - "gated linear units (SwiGLU/GeGLU)." + "--transformer-impl='inference_optimized' supports gated linear units " + "(SwiGLU/GeGLU) only with --inference-grouped-gemm-backend torch or vllm, " + f"got '{self.inference_grouped_gemm_backend}'." ) if self.fp8 == "mxfp8": diff --git a/megatron/training/models/gpt.py b/megatron/training/models/gpt.py index 2d8f1f3215b..9dd5dc61a80 100644 --- a/megatron/training/models/gpt.py +++ b/megatron/training/models/gpt.py @@ -51,7 +51,12 @@ def default_layer_spec(config: "GPTModelConfig", vp_stage: int) -> ModuleSpec: """Determine the most appropriate layer specification based on availability.""" transformer_cfg = config.transformer use_te = transformer_cfg.transformer_impl == "transformer_engine" - if transformer_cfg.transformer_impl == "inference_optimized": + if ( + transformer_cfg.transformer_impl == "inference_optimized" + and transformer_cfg.num_moe_experts is None + ): + # MoE models fall through to the shared num_moe_experts branch below; + # get_gpt_decoder_block_spec already handles the inference_optimized impl. return get_gpt_layer_with_inference_spec( transformer_cfg.qk_layernorm, transformer_cfg.multi_latent_attention, diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_gb200.json new file mode 100644 index 00000000000..d5ad58d7dff --- /dev/null +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_gb200.json @@ -0,0 +1,296 @@ +{ + "0": { + "input_prompt": "Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies.", + "generated_text": " Wait for the moment when the music stops, and the lights come up, and the DJ says, \"I'm going to play a song for you", + "generated_tokens": [ + 32844, + 1394, + 1278, + 4735, + 2200, + 1278, + 7146, + 30774, + 1044, + 1321, + 1278, + 26466, + 3930, + 2015, + 1044, + 1321, + 1278, + 30245, + 8223, + 1044, + 1429, + 1073, + 4525, + 4670, + 1317, + 3354, + 1261, + 6947, + 1394, + 1636 + ], + "latency": 5.455615758895874, + "ttft": 0.1964733600616455, + "cuda_graph_request_count_map": null, + "step_count": 30, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null, + "prompt_logprobs": [ + -10.26308536529541, + -3.6686012744903564, + -2.765211820602417, + -1.2471362352371216, + -0.25886666774749756, + -1.7963595390319824, + -2.3826982975006104, + -2.005277156829834, + -2.1537668704986572, + -5.902537822723389, + -0.8171463012695312, + -2.451366662979126, + -3.5347471237182617, + -4.176225662231445, + -1.9377293586730957, + -1.8986971378326416, + -2.208488941192627, + -7.125059127807617, + -0.03947625681757927, + -1.896262526512146, + -5.065513610839844, + -8.724845886230469, + -9.797682762145996, + -0.8522734045982361, + -4.8190717697143555, + -0.8440366983413696, + -2.34858775138855, + -0.018953992053866386, + -0.033671360462903976, + -3.3865456581115723, + -8.721802711486816, + -1.240919828414917, + -6.703701019287109, + -3.847231864929199, + -3.783085823059082, + -4.237866401672363, + -2.211496353149414, + -1.098684310913086, + -0.24628256261348724, + -0.782801628112793, + -4.699396133422852, + -9.357192993164062, + -0.01337755098938942, + -3.178358554840088, + -1.261933445930481, + -3.9715685844421387, + -0.8101860880851746, + -0.0019434866262599826, + -2.984713554382324, + -10.534708976745605, + -3.219189405441284, + -1.1806306838989258, + -4.912747383117676, + -0.19139771163463593, + -0.06405364722013474, + -1.2801450490951538, + -2.2146944999694824, + -4.376565456390381, + -0.35896021127700806, + -4.080334186553955, + -0.36786431074142456, + -0.14922010898590088, + -2.7157769203186035, + -10.706482887268066, + -0.04886607080698013, + -3.277998924255371, + -0.857931911945343, + -4.759526252746582, + -0.2628035545349121, + -2.599381446838379, + -0.8584806323051453, + -1.7231507301330566, + -5.753799915313721, + -16.95536231994629, + -2.6928939819335938, + -0.14019668102264404, + -7.3763227462768555, + -1.018878698348999, + -2.032576084136963, + -1.4948521852493286, + -0.28880226612091064, + -5.916466236114502, + -0.0070032840594649315, + -7.832877159118652, + -2.7072856426239014, + -2.9258873462677, + -2.9755988121032715 + ], + "generated_logprobs": [ + -2.364122152328491, + -0.4002877473831177, + -1.4633910655975342, + -2.3765389919281006, + -0.6101698279380798, + -1.327094316482544, + -1.968715786933899, + -1.6896401643753052, + -0.7726075649261475, + -0.4865952730178833, + -1.2753514051437378, + -1.5785223245620728, + -1.0156301259994507, + -0.4440307021141052, + -0.4929894208908081, + -0.0452902615070343, + -1.273757815361023, + -2.244354248046875, + -2.712334632873535, + -0.828876793384552, + -0.42665401101112366, + -2.900454521179199, + -1.5460847616195679, + -1.5565733909606934, + -0.05441073700785637, + -1.3596826791763306, + -1.324223279953003, + -1.2751247882843018, + -1.2279154062271118, + -0.49001315236091614 + ], + "logprobs": [ + -10.26308536529541, + -3.6686012744903564, + -2.765211820602417, + -1.2471362352371216, + -0.25886666774749756, + -1.7963595390319824, + -2.3826982975006104, + -2.005277156829834, + -2.1537668704986572, + -5.902537822723389, + -0.8171463012695312, + -2.451366662979126, + -3.5347471237182617, + -4.176225662231445, + -1.9377293586730957, + -1.8986971378326416, + -2.208488941192627, + -7.125059127807617, + -0.03947625681757927, + -1.896262526512146, + -5.065513610839844, + -8.724845886230469, + -9.797682762145996, + -0.8522734045982361, + -4.8190717697143555, + -0.8440366983413696, + -2.34858775138855, + -0.018953992053866386, + -0.033671360462903976, + -3.3865456581115723, + -8.721802711486816, + -1.240919828414917, + -6.703701019287109, + -3.847231864929199, + -3.783085823059082, + -4.237866401672363, + -2.211496353149414, + -1.098684310913086, + -0.24628256261348724, + -0.782801628112793, + -4.699396133422852, + -9.357192993164062, + -0.01337755098938942, + -3.178358554840088, + -1.261933445930481, + -3.9715685844421387, + -0.8101860880851746, + -0.0019434866262599826, + -2.984713554382324, + -10.534708976745605, + -3.219189405441284, + -1.1806306838989258, + -4.912747383117676, + -0.19139771163463593, + -0.06405364722013474, + -1.2801450490951538, + -2.2146944999694824, + -4.376565456390381, + -0.35896021127700806, + -4.080334186553955, + -0.36786431074142456, + -0.14922010898590088, + -2.7157769203186035, + -10.706482887268066, + -0.04886607080698013, + -3.277998924255371, + -0.857931911945343, + -4.759526252746582, + -0.2628035545349121, + -2.599381446838379, + -0.8584806323051453, + -1.7231507301330566, + -5.753799915313721, + -16.95536231994629, + -2.6928939819335938, + -0.14019668102264404, + -7.3763227462768555, + -1.018878698348999, + -2.032576084136963, + -1.4948521852493286, + -0.28880226612091064, + -5.916466236114502, + -0.0070032840594649315, + -7.832877159118652, + -2.7072856426239014, + -2.9258873462677, + -2.9755988121032715, + -2.364122152328491, + -0.4002877473831177, + -1.4633910655975342, + -2.3765389919281006, + -0.6101698279380798, + -1.327094316482544, + -1.968715786933899, + -1.6896401643753052, + -0.7726075649261475, + -0.4865952730178833, + -1.2753514051437378, + -1.5785223245620728, + -1.0156301259994507, + -0.4440307021141052, + -0.4929894208908081, + -0.0452902615070343, + -1.273757815361023, + -2.244354248046875, + -2.712334632873535, + -0.828876793384552, + -0.42665401101112366, + -2.900454521179199, + -1.5460847616195679, + -1.5565733909606934, + -0.05441073700785637, + -1.3596826791763306, + -1.324223279953003, + -1.2751247882843018, + -1.2279154062271118, + -0.49001315236091614 + ] + }, + "throughput": [ + 2.034594591378174, + 5.564547477468577, + 5.2841820475809005, + 5.47987548144677, + 5.489748500310461, + 5.5113217874064695, + 5.488251731201581, + 5.489464456090739 + ], + "mem-max-allocated-bytes": 30659123200, + "lifetime_prefill_token_count": 88 +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_h100.json new file mode 100644 index 00000000000..bf510060fa9 --- /dev/null +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/golden_values_dev_dgx_h100.json @@ -0,0 +1,298 @@ +{ + "0": { + "input_prompt": "Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies.", + "generated_text": " Wait for the moment when the music stops, and the lights come up, and the DJ says, \"I'm going to play a song for you", + "generated_tokens": [ + 32844, + 1394, + 1278, + 4735, + 2200, + 1278, + 7146, + 30774, + 1044, + 1321, + 1278, + 26466, + 3930, + 2015, + 1044, + 1321, + 1278, + 30245, + 8223, + 1044, + 1429, + 1073, + 4525, + 4670, + 1317, + 3354, + 1261, + 6947, + 1394, + 1636 + ], + "latency": 4.658271074295044, + "ttft": 0.15854811668395996, + "cuda_graph_request_count_map": null, + "step_count": 30, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null, + "prompt_logprobs": [ + -10.293252944946289, + -3.716251850128174, + -2.8317055702209473, + -1.2464544773101807, + -0.2591174840927124, + -1.791695475578308, + -2.3808443546295166, + -2.00931453704834, + -2.1425540447235107, + -6.077279090881348, + -0.858441948890686, + -2.413856267929077, + -3.5147483348846436, + -4.1333465576171875, + -1.9693496227264404, + -1.8686254024505615, + -2.318774700164795, + -7.2168731689453125, + -0.03999466449022293, + -1.8926615715026855, + -5.031061172485352, + -8.748196601867676, + -9.905678749084473, + -0.7831851840019226, + -4.823017597198486, + -0.8517996072769165, + -2.2906551361083984, + -0.02102702669799328, + -0.03639509528875351, + -3.4814400672912598, + -8.717294692993164, + -1.2549344301223755, + -6.63994026184082, + -3.754265785217285, + -3.775294303894043, + -4.187918663024902, + -2.2120625972747803, + -1.0773918628692627, + -0.22668257355690002, + -0.8210052847862244, + -4.717593193054199, + -9.101178169250488, + -0.013496698811650276, + -3.161458730697632, + -1.2833906412124634, + -3.9933042526245117, + -0.7499399185180664, + -0.002040805760771036, + -2.96846866607666, + -10.53866195678711, + -3.2272119522094727, + -1.1545158624649048, + -4.900886058807373, + -0.21137981116771698, + -0.06306853890419006, + -1.3629783391952515, + -2.2003962993621826, + -4.379840850830078, + -0.36900594830513, + -4.048164367675781, + -0.39393556118011475, + -0.14044521749019623, + -2.711949348449707, + -10.646138191223145, + -0.04656114801764488, + -3.380455493927002, + -0.8181529641151428, + -4.758600234985352, + -0.2629872262477875, + -2.5767107009887695, + -0.843501091003418, + -1.6030611991882324, + -5.800514221191406, + -16.95305061340332, + -2.9422059059143066, + -0.13231781125068665, + -7.411016464233398, + -1.090275764465332, + -2.1541879177093506, + -1.556337833404541, + -0.28904998302459717, + -5.916282653808594, + -0.007025183644145727, + -7.742945194244385, + -2.650775909423828, + -2.9270660877227783, + -3.0080981254577637 + ], + "generated_logprobs": [ + -2.342200517654419, + -0.439301460981369, + -1.5168120861053467, + -2.386837959289551, + -0.5973772406578064, + -1.307364821434021, + -1.9625905752182007, + -1.704308271408081, + -0.7901337742805481, + -0.46505871415138245, + -1.2885141372680664, + -1.5861717462539673, + -0.960959792137146, + -0.40262749791145325, + -0.4481161832809448, + -0.0424172468483448, + -1.275164008140564, + -2.1298739910125732, + -2.6976256370544434, + -0.8189972639083862, + -0.4352183938026428, + -2.8580269813537598, + -1.5504716634750366, + -1.5744516849517822, + -0.04782837629318237, + -1.3560791015625, + -1.3296492099761963, + -1.230303406715393, + -1.301802396774292, + -0.4764270484447479 + ], + "logprobs": [ + -10.293252944946289, + -3.716251850128174, + -2.8317055702209473, + -1.2464544773101807, + -0.2591174840927124, + -1.791695475578308, + -2.3808443546295166, + -2.00931453704834, + -2.1425540447235107, + -6.077279090881348, + -0.858441948890686, + -2.413856267929077, + -3.5147483348846436, + -4.1333465576171875, + -1.9693496227264404, + -1.8686254024505615, + -2.318774700164795, + -7.2168731689453125, + -0.03999466449022293, + -1.8926615715026855, + -5.031061172485352, + -8.748196601867676, + -9.905678749084473, + -0.7831851840019226, + -4.823017597198486, + -0.8517996072769165, + -2.2906551361083984, + -0.02102702669799328, + -0.03639509528875351, + -3.4814400672912598, + -8.717294692993164, + -1.2549344301223755, + -6.63994026184082, + -3.754265785217285, + -3.775294303894043, + -4.187918663024902, + -2.2120625972747803, + -1.0773918628692627, + -0.22668257355690002, + -0.8210052847862244, + -4.717593193054199, + -9.101178169250488, + -0.013496698811650276, + -3.161458730697632, + -1.2833906412124634, + -3.9933042526245117, + -0.7499399185180664, + -0.002040805760771036, + -2.96846866607666, + -10.53866195678711, + -3.2272119522094727, + -1.1545158624649048, + -4.900886058807373, + -0.21137981116771698, + -0.06306853890419006, + -1.3629783391952515, + -2.2003962993621826, + -4.379840850830078, + -0.36900594830513, + -4.048164367675781, + -0.39393556118011475, + -0.14044521749019623, + -2.711949348449707, + -10.646138191223145, + -0.04656114801764488, + -3.380455493927002, + -0.8181529641151428, + -4.758600234985352, + -0.2629872262477875, + -2.5767107009887695, + -0.843501091003418, + -1.6030611991882324, + -5.800514221191406, + -16.95305061340332, + -2.9422059059143066, + -0.13231781125068665, + -7.411016464233398, + -1.090275764465332, + -2.1541879177093506, + -1.556337833404541, + -0.28904998302459717, + -5.916282653808594, + -0.007025183644145727, + -7.742945194244385, + -2.650775909423828, + -2.9270660877227783, + -3.0080981254577637, + -2.342200517654419, + -0.439301460981369, + -1.5168120861053467, + -2.386837959289551, + -0.5973772406578064, + -1.307364821434021, + -1.9625905752182007, + -1.704308271408081, + -0.7901337742805481, + -0.46505871415138245, + -1.2885141372680664, + -1.5861717462539673, + -0.960959792137146, + -0.40262749791145325, + -0.4481161832809448, + -0.0424172468483448, + -1.275164008140564, + -2.1298739910125732, + -2.6976256370544434, + -0.8189972639083862, + -0.4352183938026428, + -2.8580269813537598, + -1.5504716634750366, + -1.5744516849517822, + -0.04782837629318237, + -1.3560791015625, + -1.3296492099761963, + -1.230303406715393, + -1.301802396774292, + -0.4764270484447479 + ] + }, + "throughput": [ + 2.3304007637648834, + 6.32366590848818, + 6.396973111935408, + 6.371799291627996, + 6.431814924368525, + 6.440822539177039, + 6.433822662809243, + 6.438126830194765 + ], + "mem-max-allocated-bytes": 30659137536, + "lifetime_prefill_token_count": 88, + "async_sched_step_count": 0, + "async_sched_compaction_step_count": 0 +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/model_config.yaml new file mode 100644 index 00000000000..483d2280172 --- /dev/null +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch/model_config.yaml @@ -0,0 +1,89 @@ +# Inference functional test: DeepSeek 16B MoE with inference_optimized + SwiGLU (vllm backend). +# Regression guard for PR #5700 — enables gated MoE on the inference-optimized path and +# wires InferenceTopKRouter for correct [tokens, topk] routing format. + +ENV_VARS: + CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 + NCCL_ALGO: Ring + CUBLAS_WORKSPACE_CONFIG: :4096:8 +TEST_TYPE: frozen-start +MODE: inference +MODEL_ARGS: + --log-num-zeros-in-grad: true + --log-validation-ppl-to-tensorboard: true + --log-timers-to-tensorboard: true + --log-memory-to-tensorboard: true + --timing-log-level: 0 + --load: ${CHECKPOINT_LOAD_PATH}/model/deepseek_16b_pyt/dcp/mcore-v1_bf16/checkpoints + --tokenizer-model: ${CHECKPOINT_LOAD_PATH}/model/deepseek_16b_pyt/dcp/mcore-v1_bf16/multiMixV8.gpt4o_nc_sd.500000.128k.vocab.json + --tokenizer-type: TikTokenizer + --tiktoken-pattern: v2 + --distributed-backend: nccl + --log-interval: 1 + --transformer-impl: inference_optimized + --inference-grouped-gemm-backend: vllm + --inference-moe-token-dispatcher-type: nvls + --tensor-model-parallel-size: 4 + --pipeline-model-parallel-size: 1 + --expert-model-parallel-size: 4 + --expert-tensor-parallel-size: 1 + --sequence-parallel: true + --use-mcore-models: true + --moe-grouped-gemm: true + --num-experts: 64 + --moe-router-topk: 6 + --moe-router-dtype: fp32 + --moe-z-loss-coeff: 0 + --moe-router-load-balancing-type: seq_aux_loss + --moe-aux-loss-coeff: 1e-3 + --moe-router-score-function: sigmoid + --untie-embeddings-and-output-weights: true + --disable-bias-linear: true + --init-method-std: 0.014 + --position-embedding-type: rope + --rotary-base: 1000000 + --rotary-percent: 1.0 + --num-layers: 27 + --hidden-size: 2048 + --moe-ffn-hidden-size: 1408 + --moe-shared-expert-intermediate-size: 2816 + --ffn-hidden-size: 10944 + --num-attention-heads: 16 + --kv-channels: 128 + --normalization: RMSNorm + --swiglu: true + --attention-dropout: 0.0 + --hidden-dropout: 0.0 + --seq-length: 4096 + --max-position-embeddings: 4096 + --micro-batch-size: 1 + --ckpt-format: torch_dist + --ckpt-fully-parallel-save: true + --ckpt-fully-parallel-load: true + --ckpt-assume-constant-structure: true + --dist-ckpt-strictness: log_unexpected + --bf16: true + --attention-backend: flash + --no-create-attention-mask-in-dataloader: true + --num-workers: 8 + --use-checkpoint-args: true + --no-use-tokenizer-model-from-checkpoint-args: true + --no-load-optim: true + --deterministic-mode: true + --save-interval: 2000 + --temperature: 1.0 + --top_k: 1 + --return-log-probs: true + --num-tokens-to-generate: 30 + --max-tokens-to-oom: 3600000 + --inference-max-seq-length: 4096 + --inference-ckpt-non-strict: true + --output-path: ${INFERENCE_OUTPUT_PATH} + --prompts: "Time travel to 2008, and go to a bar or a club or one of the myriad disco-basements on the Lower East Side that does not quite know which of those it is. Dance awkwardly in a room full of other glittered-up nerds, and wait for something to happen, buoyed on the feeling that this is the big swollen heart of life, that this is New York like the movies." + --incoming-requests-per-sec: -1 + --inference-repeat-n: 8 + --inference-dynamic-batching-buffer-size-gb: 20 +METRICS: + - "generated_tokens" + - "logprobs" diff --git a/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml b/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml index d1d6ea865b4..436a86b017f 100644 --- a/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml +++ b/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml @@ -63,3 +63,8 @@ products: - environment: [dev] scope: [mr] platforms: [dgx_gb200] + - test_case: [gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch] + products: + - environment: [dev] + scope: [mr, mr-github] + platforms: [dgx_gb200] diff --git a/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml b/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml index 889542638e4..8df28139ae3 100644 --- a/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml +++ b/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml @@ -82,3 +82,8 @@ products: - environment: [dev] scope: [mr] platforms: [dgx_h100] + - test_case: [gpt_dynamic_inference_tp4_pp1_ep4_16B_inference_optimized_swiglu_logitsmatch] + products: + - environment: [dev] + scope: [mr, mr-github] + platforms: [dgx_h100] diff --git a/tests/unit_tests/inference/test_vllm_fused_moe.py b/tests/unit_tests/inference/test_vllm_fused_moe.py index 9cc6212b9a0..d60bc12bd3b 100644 --- a/tests/unit_tests/inference/test_vllm_fused_moe.py +++ b/tests/unit_tests/inference/test_vllm_fused_moe.py @@ -84,6 +84,55 @@ def _make_moe_inputs( return hidden, probs, routing_map, fc1_weight, fc2_weight +def _make_swiglu_inputs(max_tokens, hidden_size, ffn_hidden, topk, num_experts, seed=42): + """Create random inputs for SwiGLU fused MoE: FC1 output is 2*ffn_hidden (gate|up).""" + torch.manual_seed(seed) + hidden = torch.randn(max_tokens, hidden_size, device="cuda", dtype=torch.bfloat16) + probs = torch.rand(max_tokens, topk, device="cuda", dtype=torch.float32) + routing_map = torch.randint(0, num_experts, (max_tokens, topk), device="cuda") + fc1_weight = ( + torch.randn(num_experts, 2 * ffn_hidden, hidden_size, device="cuda", dtype=torch.bfloat16) + * 0.01 + ) + fc2_weight = ( + torch.randn(num_experts, hidden_size, ffn_hidden, device="cuda", dtype=torch.bfloat16) + * 0.01 + ) + return hidden, probs, routing_map, fc1_weight, fc2_weight + + +def _ref_sequential_moe_swiglu( + hidden_states, + probs, + fc1_weight, + fc2_weight, + routing_map, + num_local_experts, + local_expert_start, + valid_tokens, +): + """PyTorch reference for SwiGLU MoE: gate|up split -> SiLU(gate)*up -> FC2.""" + vt = valid_tokens if isinstance(valid_tokens, int) else valid_tokens.item() + max_tokens, topk = routing_map.shape + hidden_size = hidden_states.shape[1] + n_half = fc1_weight.shape[1] // 2 + out = torch.zeros(max_tokens, hidden_size, device="cuda", dtype=torch.float32) + for t in range(vt): + acc = torch.zeros(hidden_size, device="cuda", dtype=torch.float32) + for k in range(topk): + eid = routing_map[t, k].item() + lid = eid - local_expert_start + if 0 <= lid < num_local_experts: + h = hidden_states[t].float() + fc1_out = h @ fc1_weight[lid].float().T # [2*ffn_hidden] + gate, up = fc1_out[:n_half], fc1_out[n_half:] + activated = torch.nn.functional.silu(gate) * up + fc2_out = activated @ fc2_weight[lid].float().T + acc += probs[t, k].item() * fc2_out + out[t] = acc + return out + + # ────────────────────────────────────────────────────────────────────── # _get_default_config (mirrors vLLM's get_default_config) # ────────────────────────────────────────────────────────────────────── @@ -447,6 +496,37 @@ def test_matches_reference(self, max_tokens, hidden_size, ffn_hidden, topk, num_ assert result.dtype == torch.float32 torch.testing.assert_close(result, expected, atol=5e-2, rtol=5e-2) + @pytest.mark.parametrize( + "max_tokens,hidden_size,ffn_hidden,topk,num_experts", + [(4, 64, 64, 2, 4), (16, 128, 128, 4, 8), (32, 128, 256, 6, 8), (128, 64, 128, 8, 16)], + ) + def test_matches_reference_swiglu(self, max_tokens, hidden_size, ffn_hidden, topk, num_experts): + """SwiGLU (SiLU(gate)*up) output matches the sequential per-token reference.""" + from megatron.core.inference.moe.fused_moe import ActivationType + from megatron.core.inference.moe.vllm_fused_moe import vllm_fused_moe + + hidden, probs, routing_map, fc1_weight, fc2_weight = _make_swiglu_inputs( + max_tokens, hidden_size, ffn_hidden, topk, num_experts + ) + + result = vllm_fused_moe( + hidden, + probs, + fc1_weight, + fc2_weight, + ActivationType.SWIGLU, + num_experts, + 0, + _vt(max_tokens), + routing_map, + ) + expected = _ref_sequential_moe_swiglu( + hidden, probs, fc1_weight, fc2_weight, routing_map, num_experts, 0, max_tokens + ) + + assert result.shape == (max_tokens, hidden_size) + torch.testing.assert_close(result, expected, atol=5e-2, rtol=5e-2) + @pytest.mark.parametrize( "local_start,num_local,num_experts", [(0, 4, 8), (4, 4, 8), (0, 2, 8), (6, 2, 8)] ) diff --git a/tests/unit_tests/models/test_moe_module_specs.py b/tests/unit_tests/models/test_moe_module_specs.py new file mode 100644 index 00000000000..e87facf34f0 --- /dev/null +++ b/tests/unit_tests/models/test_moe_module_specs.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Tests that the inference-optimized backend wires InferenceTopKRouter.""" + +import pytest + +from megatron.core.models.backends import InferenceSpecProvider, LocalSpecProvider +from megatron.core.models.gpt.moe_module_specs import get_moe_module_spec_for_backend +from megatron.core.transformer.moe.moe_layer import MoESubmodules +from megatron.core.transformer.moe.router import InferenceTopKRouter, TopKRouter + + +def _router_of(spec): + """Return the router builder from a get_moe_module_spec_for_backend() result.""" + submodules = spec.keywords["submodules"] + assert isinstance(submodules, MoESubmodules) + return submodules.router + + +class TestMoeModuleSpecRouter: + @pytest.mark.parametrize("moe_grouped_gemm", [True, False]) + def test_inference_backend_wires_inference_router(self, moe_grouped_gemm): + """InferenceSpecProvider must select InferenceTopKRouter.""" + spec = get_moe_module_spec_for_backend( + InferenceSpecProvider(), num_experts=8, moe_grouped_gemm=moe_grouped_gemm + ) + assert _router_of(spec) is InferenceTopKRouter + + @pytest.mark.parametrize("moe_grouped_gemm", [True, False]) + def test_non_inference_backend_uses_default_router(self, moe_grouped_gemm): + """Non-inference backends keep the MoESubmodules default (training router).""" + spec = get_moe_module_spec_for_backend( + LocalSpecProvider(), num_experts=8, moe_grouped_gemm=moe_grouped_gemm + ) + # No router override -> dataclass default. + assert _router_of(spec) is TopKRouter From 57deb435477fd6dc275242cff94fce1c5055a936 Mon Sep 17 00:00:00 2001 From: wdykas Date: Fri, 31 Jul 2026 12:40:04 -0400 Subject: [PATCH 171/290] Bs invar moe (#4871) Signed-off-by: William Dykas Signed-off-by: root Signed-off-by: root Signed-off-by: root Signed-off-by: root Signed-off-by: root Signed-off-by: root Signed-off-by: root Co-authored-by: root Co-authored-by: root Co-authored-by: root Co-authored-by: root Co-authored-by: root Co-authored-by: root Co-authored-by: root --- megatron/core/inference/config.py | 13 +- .../attention_context/mamba_metadata.py | 9 +- .../inference/contexts/dynamic_context.py | 42 +- megatron/core/inference/contexts/gpu_view.py | 14 +- .../core/inference/engines/dynamic_engine.py | 35 +- .../core/inference/moe/batch_invariant.py | 275 +++++++ megatron/core/inference/moe/fused_moe.py | 38 +- megatron/core/inference/moe/permute.py | 63 +- .../common/language_module/language_module.py | 6 + megatron/core/ssm/mamba_mixer.py | 91 ++- .../core/ssm/ops/batch_invariant_decode.py | 369 +++++++++ megatron/core/ssm/ops/ssd_bmm.py | 51 +- megatron/core/ssm/ops/ssd_chunk_scan.py | 86 ++- megatron/core/ssm/ops/ssd_chunk_state.py | 74 +- megatron/core/ssm/ops/ssd_state_passing.py | 103 ++- .../core/tensor_parallel/inference_layers.py | 18 +- .../custom_layers/batch_invariant_kernels.py | 716 +++++++++++++++++- .../core/transformer/moe/batch_invariant.py | 92 +++ megatron/core/transformer/moe/moe_utils.py | 58 +- megatron/core/transformer/moe/router.py | 10 +- .../core/transformer/moe/token_dispatcher.py | 7 +- .../moe/token_dispatcher_inference.py | 17 +- .../core/transformer/transformer_config.py | 47 ++ megatron/rl/rl_utils.py | 12 +- pyproject.toml | 5 +- .../attention_metadata/test_mamba_metadata.py | 9 + .../contexts/test_dynamic_prefix_caching.py | 53 +- .../inference/contexts/test_gpu_view.py | 17 + tests/unit_tests/inference/test_hybrid_moe.py | 80 +- .../test_moe_dispatching_and_routing.py | 269 +++++++ .../unit_tests/inference/test_moe_permute.py | 75 ++ .../models/test_gpt_model_batch_invariant.py | 9 +- .../unit_tests/rl/test_rl_batch_invariant.py | 84 +- .../ssm/ops/test_batch_invariant_decode.py | 715 +++++++++++++++++ tests/unit_tests/ssm/ops/test_ssm_kernel.py | 2 + .../moe/test_moe_batch_invariant.py | 255 +++++++ .../test_te_layers_batch_invariant.py | 50 +- uv.lock | 11 + 38 files changed, 3755 insertions(+), 125 deletions(-) create mode 100644 megatron/core/inference/moe/batch_invariant.py create mode 100644 megatron/core/ssm/ops/batch_invariant_decode.py create mode 100644 megatron/core/transformer/moe/batch_invariant.py create mode 100644 tests/unit_tests/ssm/ops/test_batch_invariant_decode.py create mode 100644 tests/unit_tests/transformer/moe/test_moe_batch_invariant.py diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index 46d2dbca1b0..6b3e715d531 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -38,7 +38,7 @@ class MambaInferenceStateConfig: """The dtype to use for the Mamba conv state tensor. Defaults to the model dtype.""" ssm_states_dtype: torch.dtype - """The dtype to use for the Mamba SSM state tensor. Defaults to the model dtype.""" + """The dtype to use for Mamba SSM state. Batch-invariant mode requires FP32.""" mamba_chunk_size: int = 128 """The chunk size used by the Mamba SSM Triton kernels.""" @@ -61,7 +61,16 @@ def from_model( ) if conv_states_dtype is None: conv_states_dtype = model.config.params_dtype - if ssm_states_dtype is None: + if model.config.batch_invariant_mode: + if ssm_states_dtype not in (None, torch.float32): + raise ValueError( + "batch_invariant_mode requires FP32 Mamba SSM states; " + f"got {ssm_states_dtype}." + ) + # State passing carries an unrounded FP32 boundary value across + # chunks. Rounding the cache to BF16 changes the next transition. + ssm_states_dtype = torch.float32 + elif ssm_states_dtype is None: ssm_states_dtype = model.config.params_dtype mamba_chunk_size = 128 for layer_type, layer in zip(decoder.layer_type_list, decoder.layers): diff --git a/megatron/core/inference/contexts/attention_context/mamba_metadata.py b/megatron/core/inference/contexts/attention_context/mamba_metadata.py index 9984b2dd71a..045ede4b502 100644 --- a/megatron/core/inference/contexts/attention_context/mamba_metadata.py +++ b/megatron/core/inference/contexts/attention_context/mamba_metadata.py @@ -21,6 +21,7 @@ def __init__( max_intermediate_count: int, mamba_chunk_size: int = 128, d_conv: int = 0, + decode_indices_dtype: torch.dtype = torch.int64, ): """ Initializes the Mamba slot allocator. @@ -36,12 +37,15 @@ def __init__( mamba_chunk_size (int): The chunk size used by the Mamba SSM Triton kernels. d_conv (int): Convolution window size (from mamba_conv_states_shape[-1]). Used for vectorized conv state extraction at intermediate offsets. + decode_indices_dtype (torch.dtype): Dtype for decode state-slot indices. """ self.max_requests = max_requests self.max_tokens = max_tokens self.mamba_chunk_size = mamba_chunk_size self.d_conv = d_conv self.device = torch.cuda.current_device() + assert decode_indices_dtype in (torch.int32, torch.int64) + self.decode_indices_dtype = decode_indices_dtype # Maximum possible chunks across all batch configurations self.max_chunks = max_tokens // mamba_chunk_size + max_requests @@ -52,9 +56,10 @@ def __init__( ) # Map from requests to slots in the static Mamba state buffer for active decode requests. - # int64 so selective_state_update can index directly without a per-layer upcast kernel; + # Non-BIK decode uses int64 for selective_state_update; BIK uses int32 + # for the exact causal-conv1d update kernel. self._batch_indices_decode_buffer = torch.full( - (self.max_requests,), -1, dtype=torch.int64, device=self.device + (self.max_requests,), -1, dtype=self.decode_indices_dtype, device=self.device ) # Map from requests to slots in the static Mamba state buffer for active prefill requests diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 97459d62ece..2fe9b0a7aca 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -315,6 +315,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC else: self.num_attention_heads_per_partition = 1 + self.batch_invariant_mode = model_config.batch_invariant_mode self.num_speculative_tokens = inference_config.num_speculative_tokens assert self.num_speculative_tokens < inference_config.block_size_tokens, ( f"num_speculative_tokens ({self.num_speculative_tokens}) must be < " @@ -357,6 +358,20 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC self.mamba_ssm_states_dtype = mamba_inference_state_config.ssm_states_dtype self.mamba_chunk_size = mamba_inference_state_config.mamba_chunk_size + if self.batch_invariant_mode: + assert not self.enable_prefix_caching, ( + "batch_invariant_mode does not support Mamba prefix caching; " + "set enable_prefix_caching=False." + ) + assert self.num_speculative_tokens == 0, ( + "batch_invariant_mode for Mamba dynamic inference only supports " + "one-token decode; set num_speculative_tokens=0." + ) + assert self.mamba_ssm_states_dtype == torch.float32, ( + "batch_invariant_mode requires FP32 Mamba SSM states so state-passing " + "boundaries are not rounded between decode chunks." + ) + # For hybrid models, the layer map converts the global layer index to the # corresponding attention layer index or Mamba layer index depending on the # layer type. @@ -722,6 +737,13 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC # Deal with chunked prefill self.enable_chunked_prefill = inference_config.enable_chunked_prefill + if self.batch_invariant_mode and self.is_hybrid_model and self.enable_chunked_prefill: + # A chunk plus its final token must fit in one step; otherwise a prompt + # of that length can never advance without an invalid one-token tail. + assert self.max_tokens > self.mamba_chunk_size, ( + "batch-invariant Mamba chunked prefill requires max_tokens > " + f"mamba_chunk_size ({self.mamba_chunk_size})." + ) # FlashInfer. if inference_config.use_flashinfer_fused_rope is True: @@ -877,6 +899,7 @@ def _allocate_mamba_states(self): max_intermediate_count=self.max_mamba_intermediate_states_per_step, mamba_chunk_size=self.mamba_chunk_size, d_conv=self.mamba_conv_states_shape[-1], + decode_indices_dtype=self._mamba_decode_indices_dtype, ) # Bind the unified CPU/GPU buffers so the per-step Mamba metadata # fields ride along with the single coalesced H2D in @@ -1081,12 +1104,18 @@ def initialize_all_tensors(self) -> None: ) # Mamba section (hybrid models only). Must match the MambaMetadata # shapes (mirrors the layout documented in ContextGPUView). - # batch_indices_decode is int64; all other fields are int32. + # batch_indices_decode is int32 in batch-invariant mode and int64 otherwise; + # all other fields are int32. if self.is_hybrid_model: - # mamba_batch_indices_decode is int64; pad to 8-byte alignment. - _mamba_align_pad = (8 - _pre_mamba_bytes % 8) % 8 + self._mamba_decode_indices_dtype = ( + torch.int32 if self.batch_invariant_mode else torch.int64 + ) + _decode_index_bytes = 4 if self.batch_invariant_mode else 8 + _mamba_align_pad = ( + _decode_index_bytes - _pre_mamba_bytes % _decode_index_bytes + ) % _decode_index_bytes self._max_mamba_chunks = self.max_tokens // self.mamba_chunk_size + self.max_requests - _mamba_batch_indices_decode_bytes = self.max_requests * 8 + _mamba_batch_indices_decode_bytes = self.max_requests * _decode_index_bytes _mamba_batch_indices_prefill_bytes = self.max_requests * 4 _mamba_seq_idx_bytes = self.max_tokens * 4 _mamba_cu_seqlens_bytes = (self.max_requests + 1) * 4 @@ -1250,7 +1279,7 @@ def initialize_all_tensors(self) -> None: _off += _mamba_align_pad self._cpu_mamba_batch_indices_decode = self._cpu_bookkeeping_buf[ _off : _off + _mamba_batch_indices_decode_bytes - ].view(torch.int64) + ].view(self._mamba_decode_indices_dtype) _off += _mamba_batch_indices_decode_bytes self._cpu_mamba_batch_indices_prefill = self._cpu_bookkeeping_buf[ _off : _off + _mamba_batch_indices_prefill_bytes @@ -1297,6 +1326,9 @@ def initialize_all_tensors(self) -> None: max_kv_blocks=self.max_kv_block_count, device=torch.cuda.current_device(), max_mamba_chunks=self._max_mamba_chunks, + mamba_decode_indices_dtype=( + self._mamba_decode_indices_dtype if self.is_hybrid_model else torch.int64 + ), ) self._bookkeeping_h2d_done_event = torch.cuda.Event() diff --git a/megatron/core/inference/contexts/gpu_view.py b/megatron/core/inference/contexts/gpu_view.py index 2066375d19e..d92205f137f 100644 --- a/megatron/core/inference/contexts/gpu_view.py +++ b/megatron/core/inference/contexts/gpu_view.py @@ -31,7 +31,9 @@ def __init__( max_kv_blocks: int, device: torch.device, max_mamba_chunks: int = 0, + mamba_decode_indices_dtype: torch.dtype = torch.int64, ): + assert mamba_decode_indices_dtype in (torch.int32, torch.int64) # Field layout (must match DynamicInferenceContext's CPU buffer layout): # int64 token fields first (auto 8-byte alignment), then int32 token # fields, then int32 request fields, then int32 MHA fields, then @@ -63,7 +65,7 @@ def __init__( mha_block_table_bytes = max_bs * max_kv_blocks * 4 # Mamba section, only present for hybrid models. - # mamba_batch_indices_decode int64 (max_bs,) + # mamba_batch_indices_decode int32 or int64 (max_bs,) # mamba_batch_indices_prefill int32 (max_bs,) # mamba_seq_idx int32 (1, max_tokens) # mamba_cu_seqlens int32 (max_bs + 1,) @@ -86,9 +88,11 @@ def __init__( ) if max_mamba_chunks > 0: - # mamba_batch_indices_decode is int64; pad to 8-byte alignment. - mamba_align_pad = (8 - pre_mamba_bytes % 8) % 8 - mamba_batch_indices_decode_bytes = max_bs * 8 + decode_index_bytes = 4 if mamba_decode_indices_dtype == torch.int32 else 8 + mamba_align_pad = ( + decode_index_bytes - pre_mamba_bytes % decode_index_bytes + ) % decode_index_bytes + mamba_batch_indices_decode_bytes = max_bs * decode_index_bytes mamba_batch_indices_prefill_bytes = max_bs * 4 mamba_seq_idx_bytes = max_tokens * 4 mamba_cu_seqlens_bytes = (max_bs + 1) * 4 @@ -202,7 +206,7 @@ def __init__( off += mamba_align_pad self.mamba_batch_indices_decode = self._buf[ off : off + mamba_batch_indices_decode_bytes - ].view(torch.int64) + ].view(mamba_decode_indices_dtype) off += mamba_batch_indices_decode_bytes self.mamba_batch_indices_prefill = self._buf[ off : off + mamba_batch_indices_prefill_bytes diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index a7eb5c5ff6c..8b5b63a7aae 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -1636,6 +1636,26 @@ def get_prefix_coordination_metrics(self) -> dict: """ return {"waits": self._prefix_coordination_waits} + def _mamba_batch_invariant_prefill_chunk_length( + self, req: DynamicInferenceRequest, capacity: int + ) -> int: + """Raw prefill length that computes an aligned chunk within ``capacity``. + + Non-final calls must start and end at Mamba chunk boundaries. The final + prompt call may be shorter because it seeds the decode replay tail. + """ + remaining = len(req.remaining_prompt_tokens) + if capacity >= remaining: + return remaining + + chunk_size = self.context.mamba_chunk_size + computed_tokens = (capacity // chunk_size) * chunk_size + if remaining - computed_tokens == 1: + computed_tokens -= chunk_size + if computed_tokens <= 0: + return 0 + return computed_tokens + def schedule_waiting_requests(self) -> None: """Try to schedule requests from the waiting pool.""" # Keep track of which requests get scheduled. @@ -1887,6 +1907,9 @@ def schedule_chunked_prefill(self): # is_continuing_chunked_prefill is True if we are scheduling next # chunk of a existing chunked prefill request is_continuing_chunked_prefill = self.context.chunked_prefill_request_id >= 0 + batch_invariant_mamba_prefill = ( + self.context.batch_invariant_mode and self.context.is_hybrid_model + ) # Check for conflicting block hashes. if prefix_caching_enabled and not is_continuing_chunked_prefill: @@ -1939,7 +1962,15 @@ def schedule_chunked_prefill(self): else: computed_chunk = computed_budget - prefill_chunk_length = prefix_skip + computed_chunk + if batch_invariant_mamba_prefill: + prefill_chunk_length = self._mamba_batch_invariant_prefill_chunk_length( + req, computed_chunk + ) + if prefill_chunk_length == 0: + can_schedule = False + break + else: + prefill_chunk_length = prefix_skip + computed_chunk # Mamba prefix caching: keep chunk boundaries block-aligned. # compute_and_store_offsets() records a recurrent-state snapshot at a @@ -1975,7 +2006,7 @@ def schedule_chunked_prefill(self): # See https://github.com/Dao-AILab/flash-attention/issues/1537 # The -1 is safe after CG snapping: is_applicable_for_batch_dim matches on # cg.token_count >= real.token_count, so the snapped CG still covers token_count-1. - if remaining_len - prefill_chunk_length == 1: + if not batch_invariant_mamba_prefill and remaining_len - prefill_chunk_length == 1: if computed_chunk > 1: prefill_chunk_length -= 1 else: diff --git a/megatron/core/inference/moe/batch_invariant.py b/megatron/core/inference/moe/batch_invariant.py new file mode 100644 index 00000000000..06dfddc2869 --- /dev/null +++ b/megatron/core/inference/moe/batch_invariant.py @@ -0,0 +1,275 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Batch-invariant inference MoE helpers.""" + +from typing import Optional +from unittest.mock import MagicMock + +import torch + +from megatron.core.inference.communication.torch_symm_triton.barrier import symm_mem_sync +from megatron.core.inference.communication.torch_symm_triton.utils import ( + is_device_nvls_capable, + sync_threads, +) +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + grouped_gemm_batch_invariant, + grouped_gemm_batch_invariant_alignment, + is_batch_invariant_mode_enabled, +) +from megatron.core.utils import null_decorator + +try: + import triton + import triton.language as tl + + HAVE_TRITON = True +except ImportError: + HAVE_TRITON = False + +if not HAVE_TRITON: + triton = MagicMock() + triton.jit = null_decorator + tl = MagicMock() + +try: + from torch._C._distributed_c10d import _SymmetricMemory +except ImportError: + _SymmetricMemory = MagicMock() + + +def enabled() -> bool: + """Return whether global batch-invariant mode is active.""" + return is_batch_invariant_mode_enabled() + + +def grouped_mm(x_bf16: torch.Tensor, weight: torch.Tensor, offs: torch.Tensor) -> torch.Tensor: + """Batch-invariant BF16 grouped GEMM used by inference fused MoE.""" + return grouped_gemm_batch_invariant( + x_bf16, weight, offs=offs.to(torch.int32), m_total=x_bf16.shape[0] + ) + + +def grouped_mm_alignment() -> int: + """Per-expert row alignment required by the batch-invariant grouped GEMM.""" + return grouped_gemm_batch_invariant_alignment() + + +@triton.jit +def _squared_relu_with_probs_kernel( + input_ptr, + output_ptr, + permutation_map_ptr, + n_used_ptr, + probs_ptr, + hidden_size, + max_rows, + BLOCK_SIZE: tl.constexpr, + NUM_BLOCKS: tl.constexpr, +): + """Apply squared ReLU and router probabilities in training order.""" + pid = tl.program_id(0) + n_used = tl.load(n_used_ptr) + if pid >= n_used: + return + + for row in tl.range(pid, max_rows, NUM_BLOCKS): + if row < n_used: + if tl.load(permutation_map_ptr + row) >= 0: + prob = tl.load(probs_ptr + row) + for offset in tl.range(0, hidden_size, BLOCK_SIZE): + cols = offset + tl.arange(0, BLOCK_SIZE) + mask = cols < hidden_size + value = tl.load(input_ptr + row * hidden_size + cols, mask=mask).to(tl.float32) + value = tl.maximum(value, 0.0) + value = (value * value).to(tl.bfloat16) + value = (value.to(tl.float32) * prob).to(tl.bfloat16) + tl.store(output_ptr + row * hidden_size + cols, value, mask=mask) + + +def squared_relu_with_probs( + x: torch.Tensor, permutation_map: torch.Tensor, n_used: torch.Tensor, probs: torch.Tensor +) -> torch.Tensor: + """Match training's BF16 squared-ReLU rounding before the FP32 probability multiply.""" + num_rows, hidden_size = x.shape + out = torch.empty_like(x) + block_size = min(triton.next_power_of_2(hidden_size), 1024) + num_blocks = min(num_rows, 512) + _squared_relu_with_probs_kernel[(num_blocks,)]( + x, + out, + permutation_map, + n_used, + probs, + hidden_size, + num_rows, + BLOCK_SIZE=block_size, + NUM_BLOCKS=num_blocks, + ) + return out + + +@triton.jit +def _ordered_reduce_scatter_v_kernel( + local_ptr, + buffer_ptrs_dev, + signal_pad_ptrs, + local_tokens, + rank_token_offset_ptr, + ep_max_tokens_ptr, + input_byte_offset, + HIDDEN_SIZE: tl.constexpr, + BLOCK_SIZE: tl.constexpr, + RANK: tl.constexpr, + WORLD_SIZE: tl.constexpr, +): + """Reduce peer rows with an explicit rank-order FP32 sum.""" + pid = tl.program_id(axis=0) + + ep_max_tokens = tl.load(ep_max_tokens_ptr) + if pid >= ep_max_tokens: + return + + symm_mem_sync( + signal_pad_ptrs, + None, + RANK, + WORLD_SIZE, + hasPreviousMemAccess=False, + hasSubsequentMemAccess=True, + ) + sync_threads() + + tid = tl.arange(0, BLOCK_SIZE) + rank_token_offset = tl.load(rank_token_offset_ptr) + buffer_ptrs = buffer_ptrs_dev.to(tl.pointer_type(tl.uint64)) + + for token_offset in range(pid, local_tokens, tl.num_programs(axis=0)): + global_token = rank_token_offset + token_offset + + for channel_offset in range(0, HIDDEN_SIZE, BLOCK_SIZE): + offsets = channel_offset + tid + mask = offsets < HIDDEN_SIZE + acc = tl.zeros([BLOCK_SIZE], dtype=tl.float32) + + for src_rank in tl.range(0, WORLD_SIZE): + peer_base = tl.load(buffer_ptrs + src_rank).to(tl.pointer_type(tl.uint8)) + peer_ptr = (peer_base + input_byte_offset).to(tl.pointer_type(tl.float32)) + values = tl.load( + peer_ptr + global_token * HIDDEN_SIZE + offsets, mask=mask, other=0.0 + ) + acc += values + + tl.store(local_ptr + token_offset * HIDDEN_SIZE + offsets, acc, mask=mask) + + +def ordered_reduce_scatter_v( + output_tensor: torch.Tensor, + input_tensor: torch.Tensor, + symm_mem_hdl: _SymmetricMemory, + rank_token_offset: torch.Tensor, + ep_max_tokens: torch.Tensor, + per_rank_max_tokens: int, + input_byte_offset: int = 0, + **kwargs, +) -> torch.Tensor: + """Reduce-scatter variable token rows with a fixed FP32 rank order.""" + assert HAVE_TRITON, "Triton is required for ordered_reduce_scatter_v." + assert ( + output_tensor.ndim == 2 and input_tensor.ndim == 2 + ), "output_tensor and input_tensor must be 2-D [tokens, hidden_size]." + assert is_device_nvls_capable( + output_tensor.device + ), "ordered_reduce_scatter_v requires a Hopper+ GPU with NVLink (SM >= 9)." + assert ( + output_tensor.dtype == input_tensor.dtype == torch.float32 + ), "ordered_reduce_scatter_v requires fp32 input and output tensors." + assert ( + rank_token_offset.numel() == 1 + and rank_token_offset.dtype == torch.int32 + and rank_token_offset.is_cuda + ), "rank_token_offset must be a scalar int32 CUDA tensor." + + hidden_size = output_tensor.shape[1] + assert ( + input_tensor.shape[1] == hidden_size + ), f"input and output hidden_size mismatch: {input_tensor.shape[1]} vs {hidden_size}" + + max_num_blocks = kwargs.get("max_num_blocks", 128) + block_size = min(triton.next_power_of_2(hidden_size), 1024) + num_warps = max(1, block_size // 32) + num_blocks = min(per_rank_max_tokens, max_num_blocks) + + _ordered_reduce_scatter_v_kernel[(num_blocks, 1, 1)]( + output_tensor, + symm_mem_hdl.buffer_ptrs_dev, + symm_mem_hdl.signal_pad_ptrs_dev, + local_tokens=output_tensor.shape[0], + rank_token_offset_ptr=rank_token_offset, + ep_max_tokens_ptr=ep_max_tokens, + input_byte_offset=input_byte_offset, + HIDDEN_SIZE=hidden_size, + BLOCK_SIZE=block_size, + RANK=symm_mem_hdl.rank, + WORLD_SIZE=symm_mem_hdl.world_size, + num_warps=num_warps, + ) + return output_tensor + + +@triton.jit +def _unpermute_tokens_in_expert_order_kernel( + expert_out_ptr, # [output_size, hidden_dim] bf16 expert outputs + inverse_map_ptr, # [num_tokens, num_local_experts] permuted row or -1 + valid_tokens_ptr, # scalar int32 CUDA tensor: number of valid tokens + output_ptr, # [num_tokens, hidden_dim] fp32 output buffer + hidden_dim, + num_local_experts: tl.constexpr, + BLOCK_H: tl.constexpr, +): + """Token-local batch-invariant unpermute. + + Each program owns one output token and one hidden tile. Contributions are + accumulated in fp32 by increasing local expert id, avoiding atomic-add order. + """ + tok = tl.program_id(0) + block_h = tl.program_id(1) + valid_tokens = tl.load(valid_tokens_ptr) + offsets = block_h * BLOCK_H + tl.arange(0, BLOCK_H) + mask_h = offsets < hidden_dim + + acc = tl.zeros([BLOCK_H], dtype=tl.float32) + if tok < valid_tokens: + for lid in tl.range(0, num_local_experts): + pos = tl.load(inverse_map_ptr + tok * num_local_experts + lid) + if pos >= 0: + vals = tl.load(expert_out_ptr + pos * hidden_dim + offsets, mask=mask_h).to( + tl.float32 + ) + acc += vals + tl.store(output_ptr + tok * hidden_dim + offsets, acc, mask=mask_h) + + +def unpermute_tokens_in_expert_order( + expert_output: torch.Tensor, + inverse_map: torch.Tensor, + valid_tokens: torch.Tensor, + out: Optional[torch.Tensor], +) -> torch.Tensor: + """Reduce local expert contributions token-by-token in fixed expert order.""" + _, hidden_dim = expert_output.shape + num_tokens, num_local_experts = inverse_map.shape + if out is None: + out = torch.empty(num_tokens, hidden_dim, dtype=torch.float32, device=expert_output.device) + + BLOCK_H = min(triton.next_power_of_2(hidden_dim), 1024) + grid = (num_tokens, triton.cdiv(hidden_dim, BLOCK_H)) + _unpermute_tokens_in_expert_order_kernel[grid]( + expert_output, + inverse_map, + valid_tokens, + out, + hidden_dim, + num_local_experts, + BLOCK_H=BLOCK_H, + ) + return out diff --git a/megatron/core/inference/moe/fused_moe.py b/megatron/core/inference/moe/fused_moe.py index 0b201c32c1d..ad55040144d 100644 --- a/megatron/core/inference/moe/fused_moe.py +++ b/megatron/core/inference/moe/fused_moe.py @@ -22,6 +22,8 @@ ) from megatron.core.inference.quantization.mxfp8_tensor import MXFP8Tensor +from . import batch_invariant + try: from torch.nn.functional import grouped_mm @@ -136,8 +138,17 @@ def mcore_fused_moe( use_mxfp8 = isinstance(fc1_weight, MXFP8Tensor) # Fused quant kernels only apply to MXFP8 path use_fused_quant = use_mxfp8 and not disable_fused_quant_kernels + batch_invariant_mode = batch_invariant.enabled() - if use_mxfp8: + if batch_invariant_mode: + # The MXFP8 path uses scaled_grouped_mm and is not batch invariant. + assert not use_mxfp8, ( + "batch_invariant_mode requires the bf16 grouped GEMM path; got " + "MXFP8 weights. Disable mxfp8 or batch_invariant_mode." + ) + mm_fn = batch_invariant.grouped_mm + expert_alignment = batch_invariant.grouped_mm_alignment() + elif use_mxfp8: assert ( HAVE_SCALED_GMM ), "torch.nn.functional.scaled_grouped_mm not available. Install PyTorch 2.10+." @@ -158,6 +169,7 @@ def mcore_fused_moe( # --- Pre-processing: permute --- if use_fused_quant: # Fused permute + MXFP8 quantize: single kernel produces MXFP8Tensor + batch_invariant_inverse_map = None hidden_states, permuted_probs, permutation_map, offs = permute_and_quantize_mxfp8( hidden_states, probs, @@ -168,7 +180,7 @@ def mcore_fused_moe( alignment=expert_alignment, ) else: - hidden_states, permuted_probs, permutation_map, offs = permute_tokens( + permuted = permute_tokens( hidden_states, probs, routing_map, @@ -176,7 +188,12 @@ def mcore_fused_moe( num_local_experts, valid_tokens, alignment=expert_alignment, + return_batch_invariant_inverse_map=batch_invariant_mode, ) + hidden_states, permuted_probs, permutation_map, offs = permuted[:4] + # Maps each (token, local expert) pair to its row in the expert-grouped buffer, + # allowing batch-invariant unpermute to read contributions in fixed expert order. + batch_invariant_inverse_map = permuted[4] if batch_invariant_mode else None # --- FC1 -> activation -> FC2 --- # Quantize if MXFP8 path and hidden_states not already quantized (fused permute+quant @@ -189,7 +206,13 @@ def mcore_fused_moe( # number of rows actually used by experts this iteration (valid tokens + alignment # padding within expert blocks). Passed to activation and unpermute to skip unused rows. n_used = offs[-1:] - activation_out = activation_func(fc1_output, permutation_map, n_used) + if batch_invariant_mode: + # Match training: BF16 activation, FP32 probability multiply, then BF16 before FC2. + activation_out = batch_invariant.squared_relu_with_probs( + fc1_output, permutation_map, n_used, permuted_probs + ) + else: + activation_out = activation_func(fc1_output, permutation_map, n_used) # Fused activation+quant returns MXFP8Tensor; otherwise quantize separately. if use_mxfp8 and not isinstance(activation_out, MXFP8Tensor): activation_out = MXFP8Tensor.from_bf16(activation_out, backend="triton") @@ -197,5 +220,12 @@ def mcore_fused_moe( # --- Post-processing: unpermute --- return unpermute_tokens( - fc2_output, permuted_probs, permutation_map, max_tokens, n_used, valid_tokens, out=out + fc2_output, + None if batch_invariant_mode else permuted_probs, + permutation_map, + max_tokens, + n_used, + valid_tokens, + out=out, + batch_invariant_inverse_map=batch_invariant_inverse_map, ) diff --git a/megatron/core/inference/moe/permute.py b/megatron/core/inference/moe/permute.py index 6906c877061..f65ac5c4200 100644 --- a/megatron/core/inference/moe/permute.py +++ b/megatron/core/inference/moe/permute.py @@ -15,6 +15,8 @@ from megatron.core.utils import null_decorator +from . import batch_invariant + try: import triton import triton.language as tl @@ -239,6 +241,7 @@ def _permute_tokens_kernel( out_hidden_ptr, # [output_size, hidden_dim] output: permuted hidden states out_probs_ptr, # [output_size] output: permuted probabilities out_src_idx_ptr, # [output_size] output: permutation_map (original token index, -1 for padding) + inverse_map_ptr, # [max_tokens, num_local_experts] token/local-expert -> permuted row counters_ptr, # [num_local_experts] exclusive offsets, atomically incremented valid_tokens_ptr, # scalar int32 CUDA tensor: number of valid tokens this iteration hidden_dim, # hidden dimension @@ -248,6 +251,7 @@ def _permute_tokens_kernel( num_local_experts: tl.constexpr, # number of experts on this rank BLOCK_H: tl.constexpr, # tile size for copying hidden_dim NUM_BLOCKS: tl.constexpr, # grid size (fixed for CG) + HAS_INVERSE: tl.constexpr, # whether to write inverse_map_ptr ): """Permute tokens into expert-grouped order. @@ -282,6 +286,8 @@ def _permute_tokens_kernel( tl.store(out_probs_ptr + pos, tl.load(probs_ptr + tok * topk + k)) # Record source token index for unpermute tl.store(out_src_idx_ptr + pos, tok) + if HAS_INVERSE: + tl.store(inverse_map_ptr + tok * num_local_experts + lid, pos) def permute_tokens( @@ -292,6 +298,7 @@ def permute_tokens( num_local_experts: int, valid_tokens: torch.Tensor, alignment: int = 1, + return_batch_invariant_inverse_map: bool = False, ) -> tuple: """Permute tokens into expert-grouped order. @@ -308,15 +315,22 @@ def permute_tokens( valid_tokens: scalar int32 CUDA tensor with the number of valid tokens this iteration. Fixed address; value updated each step before graph replay. alignment: per-expert token alignment (default 1). + return_batch_invariant_inverse_map: if True, also return the map used by + batch-invariant unpermute. Returns: - (permuted_hidden, permuted_probs, permutation_map, inclusive_offsets) + By default, returns the original 4-tuple: + (permuted_hidden, permuted_probs, permutation_map, inclusive_offsets). + If return_batch_invariant_inverse_map=True, appends the inverse map as a + fifth return value. - permuted_hidden: [output_size, hidden_size] - permuted_probs: [output_size] - permutation_map: [output_size] int32, maps each permuted row back to its original token index. Used by unpermute_tokens to scatter expert outputs back and by activation kernels to skip padding rows (-1). - inclusive_offsets: [num_local_experts] int32 cumulative offsets for grouped_mm + - inverse map: [max_tokens, num_local_experts] int32 map from token/local-expert + to permuted row, only present when requested. """ max_tokens, hidden_dim = hidden_states.shape topk = probs.shape[1] @@ -342,12 +356,22 @@ def permute_tokens( ) permuted_probs = torch.empty(output_size, dtype=probs.dtype, device=probs.device) permutation_map = torch.empty(output_size, dtype=torch.int32, device=probs.device) + batch_invariant_inverse_map = None + if return_batch_invariant_inverse_map: + batch_invariant_inverse_map = torch.full( + (max_tokens, num_local_experts), -1, dtype=torch.int32, device=probs.device + ) # Only initialize [0, n_used) to -1; activation and unpermute kernels are gated # by the same inclusive_expert_offsets[-1] pointer so they never read beyond n_used. init_permutation_map(permutation_map, inclusive_expert_offsets[-1:]) BLOCK_H = min(triton.next_power_of_2(hidden_dim), 1024) max_pairs = max_tokens * topk NUM_BLOCKS = min(max_pairs, 512) + # The inverse-map pointer is unused when HAS_INVERSE=False. Reuse an existing + # int32 device tensor instead of allocating a dummy buffer for that kernel variant. + inverse_map_ptr = ( + batch_invariant_inverse_map if batch_invariant_inverse_map is not None else permutation_map + ) _permute_tokens_kernel[(NUM_BLOCKS,)]( hidden_states, probs, @@ -355,6 +379,7 @@ def permute_tokens( permuted_hidden, permuted_probs, permutation_map, + inverse_map_ptr, exclusive_expert_offsets, valid_tokens, hidden_dim, @@ -364,7 +389,16 @@ def permute_tokens( num_local_experts, BLOCK_H=BLOCK_H, NUM_BLOCKS=NUM_BLOCKS, + HAS_INVERSE=batch_invariant_inverse_map is not None, ) + if return_batch_invariant_inverse_map: + return ( + permuted_hidden, + permuted_probs, + permutation_map, + inclusive_expert_offsets, + batch_invariant_inverse_map, + ) return permuted_hidden, permuted_probs, permutation_map, inclusive_expert_offsets @@ -434,12 +468,13 @@ def _unpermute_tokens_kernel( def unpermute_tokens( expert_output: torch.Tensor, - permuted_probs: torch.Tensor, + permuted_probs: Optional[torch.Tensor], permutation_map: torch.Tensor, num_tokens: int, n_used: torch.Tensor, valid_tokens: torch.Tensor, out: torch.Tensor = None, + batch_invariant_inverse_map: torch.Tensor = None, ) -> torch.Tensor: """Unpermute expert outputs back to original token order. @@ -448,7 +483,8 @@ def unpermute_tokens( Args: expert_output: [output_size, hidden_dim] expert outputs in permuted order. - permuted_probs: [output_size] fp32 routing probabilities. + permuted_probs: [output_size] fp32 routing probabilities, or None when + batch-invariant inference applied them before FC2. permutation_map: [output_size] int32, original token index or -1 for padding. num_tokens: max token count (output buffer height); always fixed for CG. n_used: scalar int32 CUDA tensor = inclusive_expert_offsets[-1]. Rows @@ -460,10 +496,25 @@ def unpermute_tokens( Pass a symmetric memory tensor to scatter directly into it, avoiding a separate copy before RSV. If None, a local buffer is allocated. """ - assert ( - permuted_probs.dtype == torch.float32 - ), f"permuted_probs must be fp32, got {permuted_probs.dtype}" output_size, hidden_dim = expert_output.shape + + # Triton kernel below uses tl.atomic_add (non-deterministic). Batch-invariant + # MoE instead reduces each token independently in fixed local-expert order, + # so unrelated tokens cannot affect the accumulation tree. + if batch_invariant.enabled(): + assert ( + batch_invariant_inverse_map is not None + ), "batch-invariant MoE unpermute requires its inverse map" + # The expert-order kernel stores every row tok < valid_tokens, including zero + # rows for tokens with no local expert contribution. Rows beyond + # valid_tokens are not read by the graphed RSV combine. + return batch_invariant.unpermute_tokens_in_expert_order( + expert_output, batch_invariant_inverse_map, valid_tokens, out + ) + + assert ( + permuted_probs is not None and permuted_probs.dtype == torch.float32 + ), "permuted_probs must be fp32" BLOCK_H = min(triton.next_power_of_2(hidden_dim), 1024) if out is None: out = torch.empty(num_tokens, hidden_dim, dtype=torch.float32, device=expert_output.device) diff --git a/megatron/core/models/common/language_module/language_module.py b/megatron/core/models/common/language_module/language_module.py index 9fc94365045..53522dd8b2b 100644 --- a/megatron/core/models/common/language_module/language_module.py +++ b/megatron/core/models/common/language_module/language_module.py @@ -110,6 +110,12 @@ def _set_attention_backend(self): Transformer engine works based on optout. By default all three attention backend flags are set to 1. So if the user choses a particular attention backend we set the other two to 0. If the user choses local, we set all 3 TE env variables to 0. """ + if self.config.batch_invariant_mode: + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + assert_te_supports_batch_invariant_attention, + ) + + assert_te_supports_batch_invariant_attention() def check_and_set_env_variable( env_variable_name: str, expected_value: int, attn_type: AttnBackend diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index 7a44c8a493a..f6ae07dd230 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -25,6 +25,7 @@ from megatron.core.inference.utils import InferenceMode from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.ssm.ops.batch_invariant_decode import MambaBatchInvariantDecode from megatron.core.ssm.ops.causal_conv1d_triton import causal_conv1d_update from megatron.core.ssm.ops.intermediate_extraction import ( scatter_intermediate_conv, @@ -60,10 +61,12 @@ try: from causal_conv1d import causal_conv1d_fn + from causal_conv1d import causal_conv1d_update as causal_conv1d_update_cuda from causal_conv1d.causal_conv1d_varlen import causal_conv1d_varlen_states except ImportError: causal_conv1d_fn = None + causal_conv1d_update_cuda = None try: from mamba_ssm.ops.triton.layernorm_gated import RMSNorm as RMSNormGated @@ -488,6 +491,10 @@ def forward( return self._dynamic_inference(hidden_states, inference_context) else: assert inference_context.is_static_batching() + assert not self.config.batch_invariant_mode, ( + "batch_invariant_mode for Mamba inference is only supported with " + "DynamicInferenceContext." + ) assert not self.config.sequence_parallel conv_state, ssm_state = self._get_states_from_cache(inference_context, batch) if inference_context.seqlen_offset > 0: @@ -988,12 +995,25 @@ def _ssm_prefill( chunk_starts = cu_chunk_seqlens[:-1] seq_idx_for_varlen = seq_idx[0, chunk_starts].contiguous() + # Batch-invariant decode replays the partial prefill tail, so keep + # the cached SSM state at the last complete chunk boundary. + if self.config.batch_invariant_mode: + prefill_lens = (cu_seqlens[1:] - cu_seqlens[:-1]).to(torch.long) + tail_lens = prefill_lens % self.chunk_size + has_boundary = prefill_lens >= self.chunk_size + # A partial tail uses the preceding full chunk's state. + boundary_chunk_indices = ( + last_chunk_indices.to(torch.long) - (tail_lens > 0).to(torch.long) + ).clamp(min=0) + # Extraction is enabled when the slot allocator wired buffers in via # the caller. When enabled, the chunk scan returns its raw states so # our Triton kernels do a fused gather+conditional-scatter directly, # skipping the dense intermediate tensor and the padded-slot writes. extract_intermediates = ( - intermediate_chunk_indices is not None and intermediate_ssm_out is not None + not self.config.batch_invariant_mode + and intermediate_chunk_indices is not None + and intermediate_ssm_out is not None ) ssm_varlen_result = mamba_chunk_scan_combined_varlen( x=x, @@ -1011,16 +1031,16 @@ def _ssm_prefill( if self.D_has_hdim else self.cp.get_D() ), - z=z if not self.rmsnorm else None, + z=z if (self.config.batch_invariant_mode or not self.rmsnorm) else None, dt_bias=self.cp.get_dt_bias().float(), initial_states=initial_ssm_state, - return_raw_states=extract_intermediates, + return_raw_states=self.config.batch_invariant_mode or extract_intermediates, dt_softplus=True, dt_limit=(0.0, float("inf")), state_dtype=ssm_state.dtype, ) - if extract_intermediates: + if self.config.batch_invariant_mode or extract_intermediates: ssm_varlen_states, raw_ssm_states = ssm_varlen_result else: ssm_varlen_states = ssm_varlen_result @@ -1029,7 +1049,26 @@ def _ssm_prefill( y = y.unsqueeze(0) z = z.unsqueeze(0) - tensor_masked_update(ssm_state, batch_indices, ssm_varlen_states) + if self.config.batch_invariant_mode: + boundary_mask = has_boundary.view(-1, 1, 1, 1) + cache_states = torch.where( + boundary_mask, raw_ssm_states[boundary_chunk_indices], initial_ssm_state + ) + else: + cache_states = ssm_varlen_states + + tensor_masked_update(ssm_state, batch_indices, cache_states) + if self.config.batch_invariant_mode: + self._get_batch_invariant_decoder().seed( + x, + z.squeeze(0), + dt, + B, + C, + cu_seqlens, + batch_indices, + max_requests=ssm_state.shape[0], + ) if extract_intermediates: # Fused gather+conditional-scatter for SSM: read row @@ -1090,7 +1129,7 @@ def _ssm_prefill( if self.rmsnorm: z = rearrange(z, "b l h p -> l b (h p)").contiguous() z = self.cp.post_conv_ssm(z) - y = self.norm(y, z) + y = self.norm(y, None if self.config.batch_invariant_mode else z) return y @@ -1112,6 +1151,12 @@ def _get_decode_A_neg_exp(self) -> torch.Tensor: self._A_neg_exp_cache_stale = False return self._A_neg_exp_cache.view(-1, 1, 1).expand(-1, self.headdim, self.d_state) + def _get_batch_invariant_decoder(self) -> MambaBatchInvariantDecode: + """Batch-invariant decode adapter, created on first use.""" + if not hasattr(self, "_batch_invariant_decoder"): + self._batch_invariant_decoder = MambaBatchInvariantDecode(self) + return self._batch_invariant_decoder + def train(self, mode: bool = True): """Mark the decode cache stale; weights may have updated.""" if mode: @@ -1161,7 +1206,30 @@ def _ssm_decode( ) # Conv step - if causal_conv1d_update is None: + if self.config.batch_invariant_mode: + # Match the causal-conv1d arithmetic used by the training forward. + assert ( + causal_conv1d_update_cuda is not None + ), "Batch-invariant Mamba decode requires causal-conv1d" + assert seq_len == 1, "Batch-invariant Mamba decode supports one token per request" + assert ( + intermediate_conv_state is None + ), "Batch-invariant Mamba decode does not support speculative decoding" + assert ( + batch_indices is not None and batch_indices.dtype == torch.int32 + ), "Batch-invariant Mamba decode requires int32 dynamic-batching indices" + + xBC_dtype = xBC.dtype + xBC = causal_conv1d_update_cuda( + xBC.to(conv_state.dtype).squeeze(1), + conv_state, + rearrange(self.conv1d_weight, "d 1 w -> d w").to(conv_state.dtype), + self.conv1d_bias.to(conv_state.dtype), + self.activation, + conv_state_indices=batch_indices, + ).unsqueeze(1) + xBC = xBC.to(xBC_dtype) + elif causal_conv1d_update is None: # TODO(ksanthanam): Consider deprecating this path assert seq_len == 1, "Native PyTorch fallback only supports 1 token at a time" xBC_squeeze = xBC.squeeze(1) @@ -1197,7 +1265,12 @@ def _ssm_decode( dim=-1, ) # SSM step - if selective_state_update is None: + if self.config.batch_invariant_mode: + assert ( + batch_indices is not None + ), "batch_invariant_mode for Mamba decode requires batch_indices from dynamic batching." + y = self._get_batch_invariant_decoder().step(x, z, dt, B, C, batch_indices, ssm_state) + elif selective_state_update is None: # Fallback uses 1D A; the decode cache is pre-expanded for Triton. A = -torch.exp(self.A_log.float()) # TODO(ksanthanam): Consider deprecating this path @@ -1286,7 +1359,7 @@ def _ssm_decode( y = rearrange(y, "b s h p -> b s (h p)") if self.rmsnorm: - y = self.norm(y, z) + y = self.norm(y, None if self.config.batch_invariant_mode else z) return y diff --git a/megatron/core/ssm/ops/batch_invariant_decode.py b/megatron/core/ssm/ops/batch_invariant_decode.py new file mode 100644 index 00000000000..fd532849d19 --- /dev/null +++ b/megatron/core/ssm/ops/batch_invariant_decode.py @@ -0,0 +1,369 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +"""Batch-invariant Mamba decode using buffered chunk replay.""" + +from dataclasses import dataclass + +import torch +import triton +import triton.language as tl + +from megatron.core.ssm.ops.ssd_bmm import _bmm_chunk_fwd +from megatron.core.ssm.ops.ssd_chunk_scan import _chunk_scan_fwd +from megatron.core.ssm.ops.ssd_chunk_state import _chunk_cumsum_fwd, _chunk_state_fwd +from megatron.core.ssm.ops.ssd_state_passing import _state_passing_fwd + + +@triton.jit +def _masked_update_rows_kernel( + states_ptr, + indices_ptr, + values_ptr, + state_row_stride, + value_row_stride, + ROW_SIZE: tl.constexpr, + BLOCK_SIZE: tl.constexpr, +): + """Copy contiguous rows, skipping entries whose destination index is -1.""" + src_row = tl.program_id(0) + dst_row = tl.load(indices_ptr + src_row) + if dst_row < 0: + return + + offsets = tl.program_id(1) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) + mask = offsets < ROW_SIZE + values = tl.load(values_ptr + src_row * value_row_stride + offsets, mask=mask) + tl.store(states_ptr + dst_row * state_row_stride + offsets, values, mask=mask) + + +def _masked_update_rows(states: torch.Tensor, indices: torch.Tensor, values: torch.Tensor) -> None: + """Copy rows into persistent BIK buffers without touching inactive graph lanes.""" + assert states.ndim == values.ndim == 2 + assert states.stride(1) == values.stride(1) == 1 + assert indices.dtype == torch.int32 and indices.numel() == values.shape[0] + row_size = states.shape[1] + assert values.shape[1] == row_size + + block_size = min(triton.next_power_of_2(row_size), 1024) + grid = (values.shape[0], triton.cdiv(row_size, block_size)) + _masked_update_rows_kernel[grid]( + states, + indices, + values, + states.stride(0), + values.stride(0), + ROW_SIZE=row_size, + BLOCK_SIZE=block_size, + ) + + +def _mamba_chunk_scan_decode_rows( + x, + z, + dt, + A, + B, + C, + chunk_size, + chunk_starts, + slots, + target_rows, + chunk_flags, + initial_states, + out, + D=None, + dt_bias=None, + dt_softplus=False, + dt_limit=(0.0, float("inf")), +): + """Run the training scan pipeline over buffered decode chunks. + + Each kernel computes only the row or boundary consumed by this decode step, + while preserving the training kernel's arithmetic for that result. + """ + dA_cumsum, dt = _chunk_cumsum_fwd( + dt, + A, + chunk_size, + None, + dt_bias=dt_bias, + dt_softplus=dt_softplus, + dt_limit=dt_limit, + chunk_starts=chunk_starts, + target_rows=target_rows, + ) + states = _chunk_state_fwd( + B, + x, + dt, + dA_cumsum, + None, + states_in_fp32=True, + chunk_flags=chunk_flags, + chunk_starts=chunk_starts, + ) + CB = _bmm_chunk_fwd( + C, + B, + chunk_size, + None, + output_dtype=torch.float32, + target_rows=target_rows, + chunk_starts=chunk_starts, + ) + # Scan before state passing because both read the incoming live state and + # state passing overwrites crossing slots with the outgoing boundary state. + _chunk_scan_fwd( + CB, + x, + dt, + dA_cumsum, + C, + states, + None, + out, + slots, + D=D, + z=z, + initial_states=initial_states, + target_rows=target_rows, + chunk_starts=chunk_starts, + ) + _state_passing_fwd( + states.flatten(-2), + dA_cumsum, + None, + initial_states=initial_states.flatten(-2), + seq_idx=slots, + dst_states=initial_states.flatten(-2), + dst_indices=slots, + dst_flags=chunk_flags, + ) + + +@dataclass +class BatchInvariantDecodeBuffers: + """Per-slot persistent state for the buffered decode scan.""" + + x: torch.Tensor # (max_requests, chunk_size, nheads, headdim) + z: torch.Tensor # (max_requests, chunk_size, nheads, headdim) + dt: torch.Tensor # (max_requests, chunk_size, nheads) + B: torch.Tensor # (max_requests, chunk_size, ngroups, dstate) + C: torch.Tensor # (max_requests, chunk_size, ngroups, dstate) + # Tokens buffered since the slot's last chunk boundary; doubles as the + # write cursor for the next token. + num_buffered: torch.Tensor # (max_requests,) int32 + # Per-entry target-row output, allocated once and sliced per step. + out: torch.Tensor # (max_requests, nheads, headdim) + target_rows: torch.Tensor # (max_requests,) int32 + chunk_flags: torch.Tensor # (max_requests,) int32 + + @classmethod + def allocate( + cls, + max_requests: int, + chunk_size: int, + nheads: int, + headdim: int, + ngroups: int, + dstate: int, + device: torch.device, + dtype: torch.dtype, + ) -> "BatchInvariantDecodeBuffers": + """Allocate the per-slot decode buffers.""" + return cls( + x=torch.zeros(max_requests, chunk_size, nheads, headdim, device=device, dtype=dtype), + z=torch.zeros(max_requests, chunk_size, nheads, headdim, device=device, dtype=dtype), + dt=torch.zeros(max_requests, chunk_size, nheads, device=device, dtype=dtype), + B=torch.zeros(max_requests, chunk_size, ngroups, dstate, device=device, dtype=dtype), + C=torch.zeros(max_requests, chunk_size, ngroups, dstate, device=device, dtype=dtype), + num_buffered=torch.zeros(max_requests, device=device, dtype=torch.int32), + out=torch.empty(max_requests, nheads, headdim, device=device, dtype=dtype), + target_rows=torch.empty(max_requests, device=device, dtype=torch.int32), + chunk_flags=torch.empty(max_requests, device=device, dtype=torch.int32), + ) + + def seed( + self, + x: torch.Tensor, + z: torch.Tensor, + dt: torch.Tensor, + B: torch.Tensor, + C: torch.Tensor, + cu_seqlens: torch.Tensor, + batch_indices: torch.Tensor, + ) -> None: + """Store each prefill's unfinished chunk for decode replay.""" + chunk_size = self.x.shape[1] + num_seqs = cu_seqlens.numel() - 1 + + seq_starts = cu_seqlens[:-1].to(torch.long) + seq_ends = cu_seqlens[1:].to(torch.long) + prefill_lens = seq_ends - seq_starts + # Covers every case: prefill_len < chunk_size gives prefill_len, + # boundary-aligned gives 0. + tail_lens = prefill_lens % chunk_size + + # Fill unused rows with a valid token from the same sequence. The row-gated + # kernel evaluates a full M-block, so finite padding prevents masked NaNs + # from reaching the target row through tensor-core operations. + offsets = torch.arange(chunk_size, device=x.device, dtype=torch.long) + safe_tail_lens = torch.clamp(tail_lens, min=1) + safe_tail_offsets = torch.minimum(offsets.unsqueeze(0), (safe_tail_lens - 1).unsqueeze(1)) + safe_tail_starts = torch.where( + tail_lens > 0, seq_ends - tail_lens, torch.clamp(seq_ends - 1, min=0) + ) + tail_token_idx = (safe_tail_starts.unsqueeze(1) + safe_tail_offsets).clamp( + max=x.shape[0] - 1 + ) + + slots = batch_indices[:num_seqs] + _masked_update_rows(self.x.flatten(1), slots, x[tail_token_idx].flatten(1)) + _masked_update_rows(self.z.flatten(1), slots, z[tail_token_idx].flatten(1)) + _masked_update_rows(self.dt.flatten(1), slots, dt[tail_token_idx].flatten(1)) + _masked_update_rows(self.B.flatten(1), slots, B[tail_token_idx].flatten(1)) + _masked_update_rows(self.C.flatten(1), slots, C[tail_token_idx].flatten(1)) + _masked_update_rows( + self.num_buffered.unsqueeze(1), slots, tail_lens.to(torch.int32).unsqueeze(1) + ) + + +def batch_invariant_decode_buffered_scan( + buffers: BatchInvariantDecodeBuffers, + x: torch.Tensor, # (decode_batch_size, 1, nheads, headdim) + z: torch.Tensor, # (decode_batch_size, 1, nheads, headdim) + dt: torch.Tensor, # (decode_batch_size, 1, nheads) + B: torch.Tensor, # (decode_batch_size, 1, ngroups, dstate) + C: torch.Tensor, # (decode_batch_size, 1, ngroups, dstate) + A: torch.Tensor, + D: torch.Tensor, + dt_bias: torch.Tensor, + batch_indices: torch.Tensor, + ssm_state: torch.Tensor, +) -> torch.Tensor: + """Run one decode token with full chunk-scan arithmetic. + + Mutates the replay buffers and commits ``ssm_state`` when a chunk fills. + """ + decode_batch_size, tokens_per_entry, nheads, headdim = x.shape + dstate = B.shape[-1] + chunk_size = buffers.x.shape[1] + assert tokens_per_entry == 1, ( + "batch-invariant Mamba decode assumes one new token per request " + "per call (no speculative decoding)." + ) + assert ssm_state.dtype == torch.float32, ( + "batch-invariant Mamba decode requires an FP32 SSM state cache to preserve " + "the state-passing carry across chunk boundaries." + ) + output_capacity = buffers.out.shape[0] + assert decode_batch_size <= output_capacity, ( + f"decode batch size {decode_batch_size} exceeds the output buffer capacity " + f"({output_capacity}); increase max_requests." + ) + + out = buffers.out[:decode_batch_size] + target_rows = buffers.target_rows[:decode_batch_size] + chunk_flags = buffers.chunk_flags[:decode_batch_size] + + active = batch_indices >= 0 + safe_slots = batch_indices.clamp_min(0) + write_pos = buffers.num_buffered[safe_slots].to(torch.long) + buffer_rows = torch.where(active, safe_slots * chunk_size + write_pos, -1).to(torch.int32) + + _masked_update_rows(buffers.x.view(-1, nheads * headdim), buffer_rows, x[:, 0].flatten(1)) + _masked_update_rows(buffers.z.view(-1, nheads * headdim), buffer_rows, z[:, 0].flatten(1)) + _masked_update_rows(buffers.dt.view(-1, nheads), buffer_rows, dt[:, 0]) + _masked_update_rows( + buffers.B.view(-1, buffers.B.shape[-2] * dstate), buffer_rows, B[:, 0].flatten(1) + ) + _masked_update_rows( + buffers.C.view(-1, buffers.C.shape[-2] * dstate), buffer_rows, C[:, 0].flatten(1) + ) + + crossed = active & (write_pos + 1 == chunk_size) + target_rows.copy_(torch.where(active, write_pos, -1).to(torch.int32)) + chunk_flags.copy_(crossed.to(torch.int32)) + out.zero_() + + # Run the gated pipeline over the buffers and ssm_state in place. State + # passing writes crossing slots' boundary states straight into + # ssm_state, so no scatter is needed afterwards. + _mamba_chunk_scan_decode_rows( + buffers.x.view(-1, nheads, headdim), + buffers.z.view(-1, nheads, headdim), + buffers.dt.view(-1, nheads), + A, + buffers.B.view(-1, buffers.B.shape[-2], dstate), + buffers.C.view(-1, buffers.C.shape[-2], dstate), + chunk_size, + chunk_starts=batch_indices * chunk_size, + slots=batch_indices, + target_rows=target_rows, + chunk_flags=chunk_flags, + initial_states=ssm_state, + out=out, + D=D, + dt_bias=dt_bias, + dt_softplus=True, + ) + + next_write_pos = torch.where(crossed, 0, write_pos + 1).to(torch.int32) + _masked_update_rows( + buffers.num_buffered.unsqueeze(1), batch_indices, next_write_pos.unsqueeze(1) + ) + + return out.unsqueeze(1) + + +class MambaBatchInvariantDecode: + """Adapter between a MambaMixer and the buffered decode.""" + + def __init__(self, mixer): + # Training applies z inside the chunk scan before RMSNormGated, so + # decode buffers and replays z through that same kernel path. + assert mixer.rmsnorm, "batch_invariant_mode requires rmsnorm=True" + self.mixer = mixer + self.buffers: BatchInvariantDecodeBuffers | None = None + + def _get_buffers(self, max_requests, x, B) -> BatchInvariantDecodeBuffers: + if self.buffers is None: + nheads, headdim = x.shape[-2:] + ngroups, dstate = B.shape[-2:] + self.buffers = BatchInvariantDecodeBuffers.allocate( + max_requests, + self.mixer.chunk_size, + nheads, + headdim, + ngroups, + dstate, + x.device, + x.dtype, + ) + return self.buffers + + def seed(self, x, z, dt, B, C, cu_seqlens, batch_indices, max_requests) -> None: + """Seed replay buffers from the prefill tail.""" + buffers = self._get_buffers(max_requests, x, B) + buffers.seed(x, z, dt, B, C, cu_seqlens, batch_indices) + + def step(self, x, z, dt, B, C, batch_indices, ssm_state) -> torch.Tensor: + """Run one decode step using the mixer's flattened layouts.""" + mixer = self.mixer + batch = x.shape[0] + x = x.view(batch, 1, -1, mixer.headdim) + z = z.view(batch, 1, -1, mixer.headdim) + B = B.view(batch, 1, mixer.ngroups_local_tp, -1) + C = C.view(batch, 1, mixer.ngroups_local_tp, -1) + + A = -torch.exp(mixer.cp.get_A_log().float()) + D = mixer.cp.get_D() + if mixer.D_has_hdim: + D = D.float().view(-1, mixer.headdim) + dt_bias = mixer.cp.get_dt_bias().float() + + buffers = self._get_buffers(ssm_state.shape[0], x, B) + + y = batch_invariant_decode_buffered_scan( + buffers, x, z, dt, B, C, A, D, dt_bias, batch_indices, ssm_state + ) + return y.reshape(batch, 1, -1) diff --git a/megatron/core/ssm/ops/ssd_bmm.py b/megatron/core/ssm/ops/ssd_bmm.py index 0cbb07fdbf5..1b3a819ee14 100644 --- a/megatron/core/ssm/ops/ssd_bmm.py +++ b/megatron/core/ssm/ops/ssd_bmm.py @@ -67,7 +67,8 @@ def _bmm_chunk_fwd_kernel( a_ptr, b_ptr, out_ptr, - cu_chunk_seqlens_ptr, + chunk_offsets_ptr, + target_rows_ptr, # Matrix dimensions seqlen, chunk_size: tl.constexpr, @@ -85,6 +86,7 @@ def _bmm_chunk_fwd_kernel( stride_outn: tl.constexpr, # Meta-parameters IS_CAUSAL: tl.constexpr, + HAS_TARGET_ROWS: tl.constexpr, dot_dtype: tl.constexpr, BLOCK_SIZE_M: tl.constexpr, BLOCK_SIZE_N: tl.constexpr, @@ -99,9 +101,22 @@ def _bmm_chunk_fwd_kernel( if IS_CAUSAL: if pid_n * BLOCK_SIZE_N >= (pid_m + 1) * BLOCK_SIZE_M: return + if HAS_TARGET_ROWS: + # Keep the target row's tile and its causal column tiles. + tr = tl.load(target_rows_ptr + pid_c) + if tr < 0: + return + if pid_m != tr // BLOCK_SIZE_M: + return + if pid_n * BLOCK_SIZE_N > tr: + return - chunk_seqlen_start = tl.load(cu_chunk_seqlens_ptr + pid_c) - chunk_seqlen_end = tl.load(cu_chunk_seqlens_ptr + pid_c + 1) + chunk_seqlen_start = tl.load(chunk_offsets_ptr + pid_c) + if HAS_TARGET_ROWS: + # Fixed windows need only a start offset. + chunk_seqlen_end = chunk_seqlen_start + chunk_size + else: + chunk_seqlen_end = tl.load(chunk_offsets_ptr + pid_c + 1) a_ptr += chunk_seqlen_start * stride_a_seqlen + pid_h * stride_a_head b_ptr += chunk_seqlen_start * stride_b_seqlen + pid_h * stride_b_head @@ -140,7 +155,16 @@ def _bmm_chunk_fwd_kernel( tl.store(out_ptrs, out, mask=(offs_m[:, None] < chunk_size) & (offs_n[None, :] < chunk_size)) -def _bmm_chunk_fwd(a, b, chunk_size, cu_chunk_seqlens, causal=False, output_dtype=None): +def _bmm_chunk_fwd( + a, + b, + chunk_size, + cu_chunk_seqlens, + causal=False, + output_dtype=None, + target_rows=None, + chunk_starts=None, +): """ Argument: a: (seqlen, ngroups, k) @@ -149,9 +173,23 @@ def _bmm_chunk_fwd(a, b, chunk_size, cu_chunk_seqlens, causal=False, output_dtyp cu_chunk_seq_lens: (nchunks+1,) causal: if True, then out[i, j] for i > j will be arbitrary, only out[i, j] for i <= j are guaranteed to be correct. + target_rows: optional (nchunks,) int32. Decode mode: compute only the + M-block containing target_rows[c] (and N-blocks up to it); other + output entries are left uninitialized. Return: out: (nchunks, ngroups, chunk_size, chunk_size) """ + has_target_rows = target_rows is not None + assert ( + chunk_starts is not None + ) == has_target_rows, "target_rows and chunk_starts must be provided together" + if has_target_rows: + # chunk_starts has one fixed-window start per chunk. + chunk_offsets = chunk_starts + nchunks = len(chunk_starts) + else: + chunk_offsets = cu_chunk_seqlens + nchunks = len(cu_chunk_seqlens) - 1 seqlen, ngroups, k = a.shape assert b.shape == a.shape if a.stride(-1) != 1 and a.stride(0) != 1: @@ -159,7 +197,6 @@ def _bmm_chunk_fwd(a, b, chunk_size, cu_chunk_seqlens, causal=False, output_dtyp if b.stride(-1) != 1 and b.stride(0) != 1: b = b.contiguous() - nchunks = len(cu_chunk_seqlens) - 1 # Allocates output. out_dtype = a.dtype if output_dtype is None else output_dtype out = torch.empty((nchunks, ngroups, chunk_size, chunk_size), device=a.device, dtype=out_dtype) @@ -178,7 +215,8 @@ def _bmm_chunk_fwd(a, b, chunk_size, cu_chunk_seqlens, causal=False, output_dtyp a_ptr=a, b_ptr=b, out_ptr=out, - cu_chunk_seqlens_ptr=cu_chunk_seqlens, + chunk_offsets_ptr=chunk_offsets, + target_rows_ptr=target_rows, seqlen=seqlen, chunk_size=chunk_size, K=k, @@ -194,6 +232,7 @@ def _bmm_chunk_fwd(a, b, chunk_size, cu_chunk_seqlens, causal=False, output_dtyp stride_outm=out.stride(-2), stride_outn=out.stride(-1), IS_CAUSAL=causal, + HAS_TARGET_ROWS=has_target_rows, dot_dtype=dot_dtype, ) return out diff --git a/megatron/core/ssm/ops/ssd_chunk_scan.py b/megatron/core/ssm/ops/ssd_chunk_scan.py index 521a294db5d..8066965b12b 100644 --- a/megatron/core/ssm/ops/ssd_chunk_scan.py +++ b/megatron/core/ssm/ops/ssd_chunk_scan.py @@ -88,7 +88,8 @@ def _chunk_scan_fwd_kernel( states_ptr, D_ptr, initstates_ptr, - cu_chunk_seqlens_ptr, + chunk_offsets_ptr, + target_rows_ptr, # Matrix dimensions chunk_size: tl.constexpr, hdim: tl.constexpr, @@ -133,6 +134,7 @@ def _chunk_scan_fwd_kernel( HAS_D: tl.constexpr, D_HAS_HDIM: tl.constexpr, HAS_Z: tl.constexpr, + HAS_TARGET_ROWS: tl.constexpr, BLOCK_SIZE_M: tl.constexpr, BLOCK_SIZE_N: tl.constexpr, BLOCK_SIZE_K: tl.constexpr, @@ -145,9 +147,20 @@ def _chunk_scan_fwd_kernel( num_pid_n = tl.cdiv(hdim, BLOCK_SIZE_N) pid_m = tl.program_id(axis=0) // num_pid_n pid_n = tl.program_id(axis=0) % num_pid_n + if HAS_TARGET_ROWS: + # Keep the tile containing the only output row consumed. + target_row = tl.load(target_rows_ptr + pid_c) + if target_row < 0: + return + if pid_m != target_row // BLOCK_SIZE_M: + return cb_ptr += pid_c * stride_cb_chunk + (pid_h // nheads_ngroups_ratio) * stride_cb_head - chunk_seqlen_start = tl.load(cu_chunk_seqlens_ptr + pid_c) - chunk_seqlen_end = tl.load(cu_chunk_seqlens_ptr + pid_c + 1) + chunk_seqlen_start = tl.load(chunk_offsets_ptr + pid_c) + if HAS_TARGET_ROWS: + # Fixed windows need only a start offset. + chunk_seqlen_end = chunk_seqlen_start + chunk_size + else: + chunk_seqlen_end = tl.load(chunk_offsets_ptr + pid_c + 1) x_ptr += chunk_seqlen_start * stride_x_seqlen + pid_h * stride_x_head dt_ptr += pid_c * stride_dt_chunk + pid_h * stride_dt_head dA_cumsum_ptr += pid_c * stride_dA_cs_chunk + pid_h * stride_dA_cs_head @@ -159,20 +172,30 @@ def _chunk_scan_fwd_kernel( seq_idx_ptr += pid_c * stride_seq_idx_chunk seq_idx = tl.load(seq_idx_ptr) - seq_idx_prev = tl.load(seq_idx_ptr - stride_seq_idx_chunk, mask=pid_c >= 1, other=-1) - - if HAS_INITSTATES and (seq_idx != seq_idx_prev): + if HAS_TARGET_ROWS: + # Each fixed window starts from its indexed cached state. prev_states_ptr = ( initstates_ptr + seq_idx * stride_init_states_batch + pid_h * stride_init_states_head ) prev_states_hdim = stride_init_states_hdim prev_states_dstate = stride_init_states_dstate else: - prev_states_ptr = ( - states_ptr + (pid_c - 1) * stride_states_chunk + pid_h * stride_states_head - ) - prev_states_hdim = stride_states_hdim - prev_states_dstate = stride_states_dstate + seq_idx_prev = tl.load(seq_idx_ptr - stride_seq_idx_chunk, mask=pid_c >= 1, other=-1) + + if HAS_INITSTATES and (seq_idx != seq_idx_prev): + prev_states_ptr = ( + initstates_ptr + + seq_idx * stride_init_states_batch + + pid_h * stride_init_states_head + ) + prev_states_hdim = stride_init_states_hdim + prev_states_dstate = stride_init_states_dstate + else: + prev_states_ptr = ( + states_ptr + (pid_c - 1) * stride_states_chunk + pid_h * stride_states_head + ) + prev_states_hdim = stride_states_hdim + prev_states_dstate = stride_states_dstate chunk_size_limit = chunk_seqlen_end - chunk_seqlen_start @@ -305,13 +328,26 @@ def _chunk_scan_fwd_kernel( ).to(tl.float32) acc *= z * tl.sigmoid(z) - out_ptr += chunk_seqlen_start * stride_out_seqlen + pid_h * stride_out_head - out_ptrs = out_ptr + ( - stride_out_seqlen * offs_out_m[:, None] + offs_out_n[None, :] * stride_out_hdim - ) - tl.store( - out_ptrs, acc, mask=(offs_out_m[:, None] < chunk_size_limit) & (offs_out_n[None, :] < hdim) - ) + if HAS_TARGET_ROWS: + # Store just the target row to a compact (nchunks, nheads, hdim) + # output; nothing else is consumed downstream. Same acc values as + # the full store, only the mask is narrower. + tr = tl.load(target_rows_ptr + pid_c) + out_ptr += pid_c * stride_out_seqlen + pid_h * stride_out_head + # All M-lanes alias the same output row (row stride 0); the mask + # keeps only lane tr, so one lane stores per column. + out_ptrs = out_ptr + (offs_out_m[:, None] * 0 + offs_out_n[None, :] * stride_out_hdim) + tl.store(out_ptrs, acc, mask=(offs_out_m[:, None] == tr) & (offs_out_n[None, :] < hdim)) + else: + out_ptr += chunk_seqlen_start * stride_out_seqlen + pid_h * stride_out_head + out_ptrs = out_ptr + ( + stride_out_seqlen * offs_out_m[:, None] + offs_out_n[None, :] * stride_out_hdim + ) + tl.store( + out_ptrs, + acc, + mask=(offs_out_m[:, None] < chunk_size_limit) & (offs_out_n[None, :] < hdim), + ) def _chunk_scan_fwd( @@ -327,8 +363,18 @@ def _chunk_scan_fwd( D=None, z=None, initial_states=None, + target_rows=None, + chunk_starts=None, ): assert seq_idx is not None, "this implementation requires seq_idx" + has_target_rows = target_rows is not None + assert ( + chunk_starts is not None + ) == has_target_rows, "target_rows and chunk_starts must be provided together" + if has_target_rows: + chunk_offsets = chunk_starts + else: + chunk_offsets = cu_chunk_seqlens seqlen, nheads, headdim = x.shape _, nchunks, chunk_size = dt.shape @@ -375,7 +421,8 @@ def _chunk_scan_fwd( states_ptr=states, D_ptr=D, initstates_ptr=initial_states, - cu_chunk_seqlens_ptr=cu_chunk_seqlens, + chunk_offsets_ptr=chunk_offsets, + target_rows_ptr=target_rows, chunk_size=chunk_size, hdim=headdim, dstate=dstate, @@ -417,6 +464,7 @@ def _chunk_scan_fwd( HAS_D=D is not None, D_HAS_HDIM=D.dim() == 2 if D is not None else True, HAS_Z=z is not None, + HAS_TARGET_ROWS=has_target_rows, BLOCK_SIZE_DSTATE=max(triton.next_power_of_2(dstate), 16), IS_TRITON_22=TRITON_22, HAS_INITSTATES=initial_states is not None, diff --git a/megatron/core/ssm/ops/ssd_chunk_state.py b/megatron/core/ssm/ops/ssd_chunk_state.py index 473af1491aa..8a21de1bef7 100644 --- a/megatron/core/ssm/ops/ssd_chunk_state.py +++ b/megatron/core/ssm/ops/ssd_chunk_state.py @@ -51,11 +51,13 @@ def _chunk_cumsum_fwd_kernel( dt_bias_ptr, dt_out_ptr, dA_cumsum_ptr, - cu_chunk_seqlens_ptr, + chunk_offsets_ptr, + target_rows_ptr, # Matrix dimension seqlen, nheads: tl.constexpr, chunk_size: tl.constexpr, + HAS_TARGET_ROWS: tl.constexpr, dt_min: tl.constexpr, dt_max: tl.constexpr, # Strides @@ -79,9 +81,16 @@ def _chunk_cumsum_fwd_kernel( # https://github.com/triton-lang/triton/issues/1058 pid_c = tl.program_id(axis=0).to(tl.int64) pid_h = tl.program_id(axis=1) - - chunk_seqlen_start = tl.load(cu_chunk_seqlens_ptr + pid_c) - chunk_seqlen_end = tl.load(cu_chunk_seqlens_ptr + pid_c + 1) + if HAS_TARGET_ROWS: + if tl.load(target_rows_ptr + pid_c) < 0: + return + + chunk_seqlen_start = tl.load(chunk_offsets_ptr + pid_c) + if HAS_TARGET_ROWS: + # Fixed windows need only a start offset. + chunk_seqlen_end = chunk_seqlen_start + chunk_size + else: + chunk_seqlen_end = tl.load(chunk_offsets_ptr + pid_c + 1) dt_ptr += chunk_seqlen_start * stride_dt_seqlen dt_out_ptr += pid_c * stride_dt_out_chunk @@ -179,7 +188,8 @@ def _chunk_state_fwd_kernel( states_ptr, dt_ptr, dA_cumsum_ptr, - cu_chunk_seqlens_ptr, + chunk_offsets_ptr, + chunk_flags_ptr, # Matrix dimensions hdim: tl.constexpr, dstate: tl.constexpr, @@ -204,6 +214,7 @@ def _chunk_state_fwd_kernel( stride_dA_cs_chunk: tl.int64, stride_dA_cs_csize: tl.constexpr, # Meta-parameters + HAS_CHUNK_FLAGS: tl.constexpr, BLOCK_SIZE_M: tl.constexpr, BLOCK_SIZE_N: tl.constexpr, BLOCK_SIZE_K: tl.constexpr, @@ -213,8 +224,16 @@ def _chunk_state_fwd_kernel( num_pid_n = tl.cdiv(dstate, BLOCK_SIZE_N) pid_m = tl.program_id(axis=0) // num_pid_n pid_n = tl.program_id(axis=0) % num_pid_n - chunk_seqlen_start = tl.load(cu_chunk_seqlens_ptr + pid_c) - chunk_seqlen_end = tl.load(cu_chunk_seqlens_ptr + pid_c + 1) + if HAS_CHUNK_FLAGS: + # Only completed chunks need a boundary state. + if tl.load(chunk_flags_ptr + pid_c) == 0: + return + chunk_seqlen_start = tl.load(chunk_offsets_ptr + pid_c) + if HAS_CHUNK_FLAGS: + # Chunk flags are paired with fixed-window starts. + chunk_seqlen_end = chunk_seqlen_start + chunk_size + else: + chunk_seqlen_end = tl.load(chunk_offsets_ptr + pid_c + 1) b_ptr += chunk_seqlen_start * stride_b_seqlen + (pid_h // nheads_ngroups_ratio) * stride_b_head x_ptr += chunk_seqlen_start * stride_x_seqlen + pid_h * stride_x_head dt_ptr += pid_c * stride_dt_chunk + pid_h * stride_dt_head @@ -277,12 +296,23 @@ def _chunk_cumsum_fwd( dt_bias=None, dt_softplus=False, dt_limit=(0.0, float("inf")), + chunk_starts=None, + target_rows=None, ): seqlen, nheads = dt.shape assert A.shape == (nheads,) if dt_bias is not None: assert dt_bias.shape == (nheads,) - nchunks = cu_chunk_seqlens.shape[0] - 1 + has_target_rows = target_rows is not None + assert ( + chunk_starts is not None + ) == has_target_rows, "target_rows and chunk_starts must be provided together" + if has_target_rows: + chunk_offsets = chunk_starts + nchunks = chunk_starts.shape[0] + else: + chunk_offsets = cu_chunk_seqlens + nchunks = cu_chunk_seqlens.shape[0] - 1 dt_out = torch.empty(nheads, nchunks, chunk_size, device=dt.device, dtype=torch.float32) dA_cumsum = torch.empty(nheads, nchunks, chunk_size, device=dt.device, dtype=torch.float32) grid_chunk_cs = lambda META: (nchunks, triton.cdiv(nheads, META["BLOCK_SIZE_H"])) @@ -293,7 +323,8 @@ def _chunk_cumsum_fwd( dt_bias_ptr=dt_bias, dt_out_ptr=dt_out, dA_cumsum_ptr=dA_cumsum, - cu_chunk_seqlens_ptr=cu_chunk_seqlens, + chunk_offsets_ptr=chunk_offsets, + target_rows_ptr=target_rows, seqlen=seqlen, nheads=nheads, chunk_size=chunk_size, @@ -309,6 +340,7 @@ def _chunk_cumsum_fwd( stride_dA_cs_head=dA_cumsum.stride(0), stride_dA_cs_chunk=dA_cumsum.stride(1), stride_dA_cs_csize=dA_cumsum.stride(2), + HAS_TARGET_ROWS=has_target_rows, DT_SOFTPLUS=dt_softplus, HAS_DT_BIAS=dt_bias is not None, BLOCK_SIZE_CHUNK=triton.next_power_of_2(chunk_size), @@ -316,7 +348,25 @@ def _chunk_cumsum_fwd( return dA_cumsum, dt_out -def _chunk_state_fwd(B, x, dt, dA_cumsum, cu_chunk_seqlens, states=None, states_in_fp32=True): +def _chunk_state_fwd( + B, + x, + dt, + dA_cumsum, + cu_chunk_seqlens, + states=None, + states_in_fp32=True, + chunk_flags=None, + chunk_starts=None, +): + has_chunk_flags = chunk_flags is not None + assert ( + chunk_starts is not None + ) == has_chunk_flags, "chunk_flags and chunk_starts must be provided together" + if has_chunk_flags: + chunk_offsets = chunk_starts + else: + chunk_offsets = cu_chunk_seqlens seqlen, nheads, headdim = x.shape _, nchunks, chunk_size = dt.shape _, ngroups, dstate = B.shape @@ -345,7 +395,8 @@ def _chunk_state_fwd(B, x, dt, dA_cumsum, cu_chunk_seqlens, states=None, states_ states_ptr=states, dt_ptr=dt, dA_cumsum_ptr=dA_cumsum, - cu_chunk_seqlens_ptr=cu_chunk_seqlens, + chunk_offsets_ptr=chunk_offsets, + chunk_flags_ptr=chunk_flags, hdim=headdim, dstate=dstate, chunk_size=chunk_size, @@ -367,6 +418,7 @@ def _chunk_state_fwd(B, x, dt, dA_cumsum, cu_chunk_seqlens, states=None, states_ stride_dA_cs_head=dA_cumsum.stride(0), stride_dA_cs_chunk=dA_cumsum.stride(1), stride_dA_cs_csize=dA_cumsum.stride(2), + HAS_CHUNK_FLAGS=has_chunk_flags, ) return states diff --git a/megatron/core/ssm/ops/ssd_state_passing.py b/megatron/core/ssm/ops/ssd_state_passing.py index 65b81a0ec31..9c12537d7f1 100644 --- a/megatron/core/ssm/ops/ssd_state_passing.py +++ b/megatron/core/ssm/ops/ssd_state_passing.py @@ -32,6 +32,9 @@ def _state_passing_fwd_kernel( initstates_ptr, seq_idx_ptr, cu_chunk_seqlens_ptr, + dst_states_ptr, + dst_indices_ptr, + dst_flags_ptr, # Matrix dimensions dim: tl.constexpr, nchunks, @@ -51,8 +54,12 @@ def _state_passing_fwd_kernel( stride_initstates_head: tl.int64, stride_initstates_dim: tl.constexpr, stride_seq_idx_chunk: tl.constexpr, + stride_dst_batch: tl.int64, + stride_dst_head: tl.int64, + stride_dst_dim: tl.constexpr, # Meta-parameters HAS_INITSTATES: tl.constexpr, + HAS_DST_STATES: tl.constexpr, BLOCK_SIZE: tl.constexpr, ): pid_h = tl.program_id(axis=1) @@ -66,7 +73,7 @@ def _state_passing_fwd_kernel( states_ptrs = states_ptr + offs_m * stride_states_dim out_ptrs = out_ptr + offs_m * stride_out_dim - if HAS_INITSTATES: + if HAS_INITSTATES and not HAS_DST_STATES: initstates_ptrs = ( initstates_ptr + pid_h * stride_initstates_head + offs_m * stride_initstates_dim ) @@ -75,27 +82,46 @@ def _state_passing_fwd_kernel( else: states = tl.zeros((BLOCK_SIZE,), dtype=tl.float32) - prev_seq_idx = 0 + prev_seq_idx = tl.full((), 0, tl.int64) for c in range(nchunks): - new_states = tl.load(states_ptrs, mask=offs_m < dim, other=0.0).to(tl.float32) - dA_cs = tl.load(dA_cs_ptr).to(tl.float32) - seq_idx = tl.load(seq_idx_ptr + c * stride_seq_idx_chunk) - # we have started a new sequence - if prev_seq_idx != seq_idx: - if HAS_INITSTATES: - initstates_ptrs = ( - initstates_ptr - + seq_idx * stride_initstates_batch - + pid_h * stride_initstates_head - + offs_m * stride_initstates_dim - ) - states = tl.load(initstates_ptrs, mask=offs_m < dim, other=0.0).to(tl.float32) + if HAS_DST_STATES: + dst_flag = tl.load(dst_flags_ptr + c) != 0 + else: + dst_flag = True + if dst_flag: + new_states = tl.load(states_ptrs, mask=offs_m < dim, other=0.0).to(tl.float32) + dA_cs = tl.load(dA_cs_ptr).to(tl.float32) + seq_idx = tl.load(seq_idx_ptr + c * stride_seq_idx_chunk).to(tl.int64) + if HAS_DST_STATES: + # Destination chunks start from their indexed initial state. + is_new_seq = True else: - states = tl.zeros((BLOCK_SIZE,), dtype=tl.float32) + is_new_seq = prev_seq_idx != seq_idx + if is_new_seq: + if HAS_INITSTATES: + initstates_ptrs = ( + initstates_ptr + + seq_idx * stride_initstates_batch + + pid_h * stride_initstates_head + + offs_m * stride_initstates_dim + ) + states = tl.load(initstates_ptrs, mask=offs_m < dim, other=0.0).to(tl.float32) + else: + states = tl.zeros((BLOCK_SIZE,), dtype=tl.float32) - prev_seq_idx = seq_idx - states = tl.exp(dA_cs) * states + new_states - tl.store(out_ptrs, states, mask=offs_m < dim) + prev_seq_idx = seq_idx + states = tl.exp(dA_cs) * states + new_states + if HAS_DST_STATES: + dst_idx = tl.load(dst_indices_ptr + c).to(tl.int64) + dst_ptrs = ( + dst_states_ptr + + dst_idx * stride_dst_batch + + pid_h * stride_dst_head + + offs_m * stride_dst_dim + ) + tl.store(dst_ptrs, states, mask=offs_m < dim) + else: + tl.store(out_ptrs, states, mask=offs_m < dim) states_ptrs += stride_states_chunk dA_cs_ptr += stride_dA_cs_chunk @@ -103,20 +129,44 @@ def _state_passing_fwd_kernel( def _state_passing_fwd( - states, dA_cumsum, cu_chunk_seqlens, seq_idx, initial_states=None, out_dtype=None + states, + dA_cumsum, + cu_chunk_seqlens, + seq_idx, + initial_states=None, + out_dtype=None, + dst_states=None, + dst_indices=None, + dst_flags=None, ): + """ + dst_states/dst_indices/dst_flags write flagged boundary states directly to + dst_states without allocating an output tensor. + """ nchunks, nheads, dim = states.shape chunk_size = dA_cumsum.shape[-1] assert dA_cumsum.shape == (nheads, nchunks, chunk_size) seqlen = seq_idx.shape[-1] - out_dtype = states.dtype if out_dtype is None else out_dtype - out = torch.empty((nchunks, nheads, dim), device=states.device, dtype=out_dtype) + has_dst = dst_states is not None + assert (dst_indices is not None) == has_dst and ( + dst_flags is not None + ) == has_dst, "dst_states, dst_indices, and dst_flags must be provided together" + if not has_dst: + out_dtype = states.dtype if out_dtype is None else out_dtype + out = torch.empty((nchunks, nheads, dim), device=states.device, dtype=out_dtype) + else: + out = states initial_states_strides = ( (initial_states.stride(0), initial_states.stride(1), initial_states.stride(2)) if initial_states is not None else (0, 0, 0) ) + if has_dst: + assert dst_states.shape[1] == nheads and dst_states.shape[2] == dim + dst_strides = ( + (dst_states.stride(0), dst_states.stride(1), dst_states.stride(2)) if has_dst else (0, 0, 0) + ) grid = lambda META: (triton.cdiv(dim, META["BLOCK_SIZE"]), nheads) with torch.cuda.device(states.device.index): @@ -127,6 +177,9 @@ def _state_passing_fwd( initstates_ptr=initial_states, seq_idx_ptr=seq_idx, cu_chunk_seqlens_ptr=cu_chunk_seqlens, + dst_states_ptr=dst_states, + dst_indices_ptr=dst_indices, + dst_flags_ptr=dst_flags, dim=dim, nchunks=nchunks, seqlen=seqlen if seq_idx is not None else 0, @@ -144,6 +197,10 @@ def _state_passing_fwd( stride_initstates_head=initial_states_strides[1], stride_initstates_dim=initial_states_strides[2], stride_seq_idx_chunk=seq_idx.stride(0), + stride_dst_batch=dst_strides[0], + stride_dst_head=dst_strides[1], + stride_dst_dim=dst_strides[2], HAS_INITSTATES=initial_states is not None, + HAS_DST_STATES=has_dst, ) - return out + return None if has_dst else out diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 2adefc58634..87ba3023d2a 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -24,6 +24,10 @@ gather_from_tensor_model_parallel_region, reduce_scatter_to_sequence_parallel_region, ) +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + is_batch_invariant_mode_enabled, + rmsnorm_batch_invariant, +) from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import get_tensor_model_parallel_group_if_none @@ -41,6 +45,9 @@ def _te_rms_norm_kernel(x: torch.Tensor, weight: torch.Tensor, eps: float): + # Use the same RMSNorm kernel as the training recompute. + if is_batch_invariant_mode_enabled(): + return rmsnorm_batch_invariant(x, weight, eps).to(x.dtype) x_shape = x.shape x = x.view(-1, x.size(-1)) out, _, _ = tex.rmsnorm_fwd( @@ -413,11 +420,14 @@ def _matmul_reduce_scatter(self, x, residual=None): # RS requires bf16 (hardware multimem reduce is bf16-only). # Check the matmul output shape: if it is NVLS-eligible, the RS output # (world_size times smaller on dim 0) is too. + # TP sequence-parallel RS: use NCCL in batch-invariant mode to match + # the training reduction path. This does not affect MoE EP NVLS. can_use_nvls = ( self.triton_nvls_kernels_allowed and x.dtype == torch.bfloat16 and are_tensors_nvls_eligible(x) and symm_mem_buffer["handle"] is not None + and not is_batch_invariant_mode_enabled() ) if can_use_nvls: @@ -540,7 +550,13 @@ def inference_reduce_scatter_to_sequence_parallel_region( config, 'inference_disable_triton_nvls_kernels', False ) - if triton_nvls_kernels_allowed and SymmetricMemoryManager.is_initialized("tp"): + # TP sequence-parallel RS: use NCCL in batch-invariant mode to match + # training. This does not affect MoE EP NVLS. + if ( + triton_nvls_kernels_allowed + and SymmetricMemoryManager.is_initialized("tp") + and not is_batch_invariant_mode_enabled() + ): buf = SymmetricMemoryManager.get_buffer("tp", process_group=tp_group) symm_mem_buffer = buf.maybe_get_tensor(list(x.size()), dtype=x.dtype) diff --git a/megatron/core/transformer/custom_layers/batch_invariant_kernels.py b/megatron/core/transformer/custom_layers/batch_invariant_kernels.py index a83e298eee2..abb21f36edb 100644 --- a/megatron/core/transformer/custom_layers/batch_invariant_kernels.py +++ b/megatron/core/transformer/custom_layers/batch_invariant_kernels.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional import torch +from packaging.version import Version try: import triton @@ -28,15 +29,60 @@ tl = MagicMock() HAVE_TRITON = False +try: + import deep_gemm + + HAVE_DEEPGEMM_BF16 = all( + hasattr(deep_gemm, name) + for name in ( + "m_grouped_bf16_gemm_nt_contiguous", + "k_grouped_bf16_gemm_tn_contiguous", + "bf16_gemm_nn", + ) + ) +except ImportError: + deep_gemm = None + HAVE_DEEPGEMM_BF16 = False + __all__ = [ "set_batch_invariant_mode", "is_batch_invariant_mode_enabled", "disable_batch_invariant_mode", "enable_batch_invariant_mode", + "grouped_gemm_batch_invariant", + "grouped_gemm_batch_invariant_alignment", + "assert_te_supports_batch_invariant_attention", + "te_supports_batch_invariant_attention", + "HAVE_DEEPGEMM_BF16", ] _LOGGER = logging.getLogger(__name__) +_TE_BATCH_INVARIANT_COMMIT = "cb4a45fd" +_TE_BATCH_INVARIANT_MIN_VERSION = Version("2.18") + + +def te_supports_batch_invariant_attention() -> bool: + """Return whether TE supports explicit FlashAttention version selection.""" + import transformer_engine + + te_version = Version(transformer_engine.__version__) + te_revision = te_version.local or "" + return te_version >= _TE_BATCH_INVARIANT_MIN_VERSION or te_revision.startswith( + _TE_BATCH_INVARIANT_COMMIT + ) + + +def assert_te_supports_batch_invariant_attention() -> None: + """Require TE's explicit FlashAttention version selection.""" + import transformer_engine + + te_version = Version(transformer_engine.__version__) + assert te_supports_batch_invariant_attention(), ( + "Batch-invariant attention requires TransformerEngine PR #3204 " + f"({_TE_BATCH_INVARIANT_COMMIT}) or TransformerEngine >= " + f"{_TE_BATCH_INVARIANT_MIN_VERSION}; found {te_version}." + ) def _matmul_launch_metadata( @@ -312,7 +358,7 @@ def log_softmax(input: torch.Tensor, dim: int = -1) -> torch.Tensor: Args: input: Input tensor dim: Dimension along which to compute log_softmax (only -1 or last dim supported) - >> Stashed changes + Returns: Tensor with log_softmax applied along the specified dimension """ @@ -478,13 +524,52 @@ def mean_dim( return output +# Production uses DeepGEMM for bf16 and the deterministic Triton kernel for +# intentional higher-precision operations such as the fp32 MoE router. +_BATCH_INVARIANT_BACKENDS = ("deepgemm", "triton") +_BATCH_INVARIANT_BACKEND: str = "deepgemm" + + +def _mm_deepgemm(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """`a @ b` via DeepGEMM `bf16_gemm_nn`. Both inputs are row-major. + + Bitwise-identical to `torch.mm` on Hopper/Blackwell, deterministic across + runs, batch-invariant. + """ + if a.dtype != torch.bfloat16: + raise RuntimeError( + f"The DeepGEMM batch-invariant backend requires bf16 inputs " + f"(got {a.dtype}); use backend='triton' for fp16/fp32." + ) + M = a.shape[0] + N = b.shape[1] + d = torch.empty(M, N, device=a.device, dtype=a.dtype) + deep_gemm.bf16_gemm_nn(a, b, d) + return d + + def mm_batch_invariant(a, b): - """Batch-invariant replacement for `aten::mm` using a persistent matmul kernel.""" + """Batch-invariant replacement for `aten::mm`.""" + if ( + _BATCH_INVARIANT_BACKEND == "deepgemm" + and a.dtype == torch.bfloat16 + and b.dtype == torch.bfloat16 + ): + return _mm_deepgemm(a, b) return matmul_persistent(a, b) def addmm_batch_invariant(bias, a, b): - """Batch-invariant replacement for `aten::addmm` using a persistent matmul kernel.""" + """Batch-invariant replacement for `aten::addmm`.""" + if ( + _BATCH_INVARIANT_BACKEND == "deepgemm" + and a.dtype == torch.bfloat16 + and b.dtype == torch.bfloat16 + ): + out = _mm_deepgemm(a, b) + if bias is not None: + out = out + bias + return out return matmul_persistent(a, b, bias=bias) @@ -525,6 +610,7 @@ def get_batch_invariant_attention_block_size() -> AttentionBlockSize: _MEG_TE_GENERAL_GEMM_ORIG = None _TE_RMSNORM_FUNC_ORIGS: Dict[str, Any] = {} _TE_GEMM_FUNC_ORIGS: Dict[str, Any] = {} +_TE_GROUPED_GEMM_FUNC_ORIGS: Dict[str, Any] = {} _TE_APPLY_NORM_ORIGS: Dict[str, Any] = {} @@ -622,6 +708,10 @@ def _patched(*args, **kwargs): _TE_RMSNORM_FUNC_ORIGS[name] = orig setattr(te_layernorm_mod, name, _make_rmsnorm_patched(orig)) + # Patch TE.general_grouped_gemm at every known import site so that + # TEGroupedMLP (forward + dgrad + wgrad) goes through DeepGEMM in bf16. + _te_patch_general_grouped_gemm() + # Patch the fused-module normalization entry (`apply_normalization`). TE's # fused LayerNormLinear / LayerNormMLP call this instead of RMSNorm.forward, # so without this patch their internal RMSNorm runs TE's tex kernel, whose @@ -651,6 +741,234 @@ def _patched(*args, **kwargs): mod.apply_normalization = _te_apply_normalization_patched +def _te_patch_general_grouped_gemm() -> None: + """Replace TE.general_grouped_gemm with a batch-invariant dispatcher. + + Patches the symbol at three import sites — the consumer module + (transformer_engine.pytorch.module.grouped_linear), the package-level + re-export (transformer_engine.pytorch.cpp_extensions), and the source + module (transformer_engine.pytorch.cpp_extensions.gemm). Stores originals + in _TE_GROUPED_GEMM_FUNC_ORIGS so the unpatch can restore them. + """ + te_grouped_linear_mod = _import_module_if_available( + "transformer_engine.pytorch.module.grouped_linear" + ) + if te_grouped_linear_mod is not None and hasattr(te_grouped_linear_mod, "general_grouped_gemm"): + key = "module.grouped_linear.general_grouped_gemm" + if key not in _TE_GROUPED_GEMM_FUNC_ORIGS: + _TE_GROUPED_GEMM_FUNC_ORIGS[key] = te_grouped_linear_mod.general_grouped_gemm + te_grouped_linear_mod.general_grouped_gemm = _te_general_grouped_gemm_patched + + te_cpp = _import_module_if_available("transformer_engine.pytorch.cpp_extensions") + if te_cpp is not None and hasattr(te_cpp, "general_grouped_gemm"): + key = "cpp_extensions.general_grouped_gemm" + if key not in _TE_GROUPED_GEMM_FUNC_ORIGS: + _TE_GROUPED_GEMM_FUNC_ORIGS[key] = te_cpp.general_grouped_gemm + te_cpp.general_grouped_gemm = _te_general_grouped_gemm_patched + + te_cpp_gemm = _import_module_if_available("transformer_engine.pytorch.cpp_extensions.gemm") + if te_cpp_gemm is not None and hasattr(te_cpp_gemm, "general_grouped_gemm"): + key = "cpp_extensions.gemm.general_grouped_gemm" + if key not in _TE_GROUPED_GEMM_FUNC_ORIGS: + _TE_GROUPED_GEMM_FUNC_ORIGS[key] = te_cpp_gemm.general_grouped_gemm + te_cpp_gemm.general_grouped_gemm = _te_general_grouped_gemm_patched + + +def _te_unpatch_general_grouped_gemm() -> None: + """Restore the originals captured by _te_patch_general_grouped_gemm.""" + module_paths = { + "module.grouped_linear.general_grouped_gemm": ( + "transformer_engine.pytorch.module.grouped_linear", + "general_grouped_gemm", + ), + "cpp_extensions.general_grouped_gemm": ( + "transformer_engine.pytorch.cpp_extensions", + "general_grouped_gemm", + ), + "cpp_extensions.gemm.general_grouped_gemm": ( + "transformer_engine.pytorch.cpp_extensions.gemm", + "general_grouped_gemm", + ), + } + for key, (mod_name, attr) in module_paths.items(): + if key not in _TE_GROUPED_GEMM_FUNC_ORIGS: + continue + mod = _import_module_if_available(mod_name) + if mod is not None and hasattr(mod, attr): + setattr(mod, attr, _TE_GROUPED_GEMM_FUNC_ORIGS[key]) + _TE_GROUPED_GEMM_FUNC_ORIGS.pop(key, None) + + +def _is_bf16_grouped_path(A, B, quantization_params, gelu: bool) -> bool: + """Decide if TE's general_grouped_gemm call can be served by DeepGEMM bf16.""" + if gelu: + return False + if not HAVE_DEEPGEMM_BF16: + return False + if not isinstance(A, (list, tuple)) or not isinstance(B, (list, tuple)): + return False + if len(A) != len(B) or len(A) == 0: + return False + if quantization_params is not None and any(q is not None for q in quantization_params): + return False + for t in (*A, *B): + if not isinstance(t, torch.Tensor): + return False + if t.dtype != torch.bfloat16: + return False + return True + + +def _te_general_grouped_gemm_patched( + A, + B, + out, + quantization_params=None, + out_dtype=None, + layout: str = "TN", + m_splits=None, + gelu: bool = False, + grad: bool = False, + accumulate: bool = False, + bias=None, + use_bias: bool = False, + use_split_accumulator: bool = False, + D_dtype=None, + single_output: bool = False, +): + """Batch-invariant replacement for TE general_grouped_gemm. + + Dispatches by (layout, single_output, grad) to forward / dgrad / wgrad + implementations backed by DeepGEMM. Unsupported calls fail rather than + silently using a kernel that is not guaranteed batch invariant. + """ + # TE versions differ here: + # old: general_grouped_gemm(A, B, out, out_dtype, ...) + # new: general_grouped_gemm(A, B, out, quantization_params, out_dtype, ...) + if out_dtype is None and isinstance(quantization_params, torch.dtype): + out_dtype = quantization_params + quantization_params = None + + if not _is_bf16_grouped_path(A, B, quantization_params, gelu): + raise RuntimeError( + "Batch-invariant grouped GEMM requires unquantized BF16 tensor sequences " + "with GELU fusion disabled." + ) + + # Dispatch by TE's call convention. + # In TE _GroupedLinear: + # forward -> layout="TN", single_output=True, grad=False (A=weights, B=inputmats) + # dgrad -> layout="NN", single_output=True, grad=True (A=weights, B=grad_y) + # wgrad -> layout="NT", single_output=False, grad=True (A=inputmats, B=grad_y) + if single_output and layout == "TN" and not grad: + return _batch_invariant_te_grouped_forward(A, B, out, m_splits, bias, use_bias, accumulate) + if single_output and layout == "NN" and grad: + return _batch_invariant_te_grouped_dgrad(A, B, out, m_splits, accumulate) + if (not single_output) and layout == "NT" and grad: + return _batch_invariant_te_grouped_wgrad(A, B, out, m_splits, use_bias, accumulate) + raise RuntimeError( + "Unsupported batch-invariant grouped GEMM call: " + f"layout={layout!r}, single_output={single_output}, grad={grad}." + ) + + +def _stack_weights_for_deepgemm(weights: List[torch.Tensor]) -> torch.Tensor: + """Stack a per-expert weight list into a contiguous [E, N, K] buffer.""" + if not weights: + return torch.empty(0) + return torch.stack([w.contiguous() for w in weights], dim=0) + + +def _batch_invariant_te_grouped_forward(A, B, out, m_splits, bias, use_bias, accumulate): + """TE forward: Y = X @ W^T per expert, then optional bias. + + A = weights: List[Tensor[N, K]] + B = inputmats: List[Tensor[m_i, K]] + out: [single Tensor[M_total, N]] (single_output=True) + """ + assert not accumulate, "Forward never accumulates" + assert len(out) == 1, "single_output=True forward expects a single out tensor" + out_buf = out[0] + w_stack = _stack_weights_for_deepgemm(A) + x_cat = torch.cat([b.contiguous() for b in B], dim=0) + m_total = x_cat.shape[0] + m_indices = _m_splits_to_m_indices(m_splits, x_cat.device, m_total) + + y = _bf16_grouped_gemm_contiguous(x_cat, w_stack, m_indices, m_splits) + if use_bias and bias is not None: + offset = 0 + for i, m in enumerate(m_splits): + if m == 0: + continue + b_i = bias[i] if i < len(bias) else None + if b_i is not None and b_i.numel() > 0: + y[offset : offset + m] = y[offset : offset + m] + b_i.to(y.dtype) + offset += m + + if y.dtype != out_buf.dtype: + y = y.to(out_buf.dtype) + out_buf.copy_(y) + # TE's contract: (out_list, bias_or_grad_bias, gelu_input) + return out, bias if use_bias else [None] * len(A), None + + +def _batch_invariant_te_grouped_dgrad(A, B, out, m_splits, accumulate): + """TE dgrad: dX = dY @ W per expert. + + A = weights: List[Tensor[N, K]] + B = grad_y_per_expert: List[Tensor[m_i, N]] + out: [single Tensor[M_total, K]] (single_output=True) + """ + assert not accumulate, "Dgrad never accumulates" + assert len(out) == 1 + out_buf = out[0] + w_stack = _stack_weights_for_deepgemm(A) + dy_cat = torch.cat([b.contiguous() for b in B], dim=0) + m_total = dy_cat.shape[0] + m_indices = _m_splits_to_m_indices(m_splits, dy_cat.device, m_total) + # NT call interprets B as [E, out_dim, in_dim]; for dgrad we need W as [E, K, N] + w_kn = w_stack.transpose(1, 2).contiguous() + dx = _bf16_grouped_gemm_contiguous(dy_cat, w_kn, m_indices, m_splits) + if dx.dtype != out_buf.dtype: + dx = dx.to(out_buf.dtype) + out_buf.copy_(dx) + return out, [None] * len(A), None + + +def _batch_invariant_te_grouped_wgrad(A, B, out, m_splits, use_bias, accumulate): + """TE wgrad: dW[g] = dY[g]^T @ X[g], plus optional dbias[g] = sum(dY[g], dim=0). + + A = inputmats: List[Tensor[m_i, K]] + B = grad_y: List[Tensor[m_i, N]] + out: List[Tensor[N, K]] per expert (single_output=False) + """ + E = len(m_splits) + x_cat = torch.cat([a.contiguous() for a in A], dim=0) + dy_cat = torch.cat([b.contiguous() for b in B], dim=0) + m_total = x_cat.shape[0] + assert sum(m_splits) == m_total + dw_stack = _bf16_grouped_gemm_wgrad_contiguous(dy_cat, x_cat, m_splits) + + grad_bias = [None] * E + if use_bias: + offset = 0 + for i, m in enumerate(m_splits): + if m > 0: + grad_bias[i] = dy_cat[offset : offset + m].sum(dim=0) + offset += m + + for i in range(E): + target = out[i] + contrib = dw_stack[i] + if contrib.dtype != target.dtype: + contrib = contrib.to(target.dtype) + if accumulate: + target.add_(contrib) + else: + target.copy_(contrib) + return out, grad_bias, None + + def _te_unpatch_for_batch_invariant(): """Restore original Transformer Engine functions if they were patched.""" global _TE_GENERAL_GEMM_ORIG, _TE_RMSNORM_ORIG_FWD, _MEG_TE_GENERAL_GEMM_ORIG @@ -731,6 +1049,9 @@ def _te_unpatch_for_batch_invariant(): else: _TE_GEMM_FUNC_ORIGS.pop(key, None) + # Restore TE general_grouped_gemm at every patched import site. + _te_unpatch_general_grouped_gemm() + def _extract_te_gemm_args(args: tuple, kwargs: Dict[str, Any]): """Utility to parse TE general_gemm flexible signature. @@ -780,7 +1101,7 @@ def forward( opA = opA.reshape(-1, opA.shape[-1]) elif opA.dim() < 2: raise ValueError(f"opA has insufficient dimensions: {opA.shape}") - assert opA.dim() == 2, f"opA must be 2D for matmul_persistent, got shape {opA.shape}" + assert opA.dim() == 2, f"opA must be 2D, got shape {opA.shape}" # Flatten all leading dims of opB except the last feature dim to match TE behavior if opB.dim() >= 2: @@ -792,7 +1113,7 @@ def forward( opB_2d = opB # Perform GEMM: (N_total, K) @ (K, O) -> (N_total, O) - base_2d = matmul_persistent(opB_2d, opA, bias=None) + base_2d = mm_batch_invariant(opB_2d, opA) # Reshape back to original leading dims with output features at the end out = base_2d.reshape(*leading_shape, base_2d.shape[-1]) @@ -1052,6 +1373,252 @@ def rmsnorm_batch_invariant(x: torch.Tensor, weight: torch.Tensor, eps: float) - return BatchInvariantRMSNormFn.apply(x, weight, eps, False) +# --------------------------------------------------------------------------- +# Batch-invariant grouped GEMM (DeepGEMM-backed). Used by MoE so that training +# (TEGroupedMLP via patched TE.general_grouped_gemm) and inference +# (InferenceGroupedMLP via patched _bf16_grouped_mm) produce bitwise-identical +# outputs for the same inputs. This is what gives RL rollout==train log-prob +# parity for MoE models. +# --------------------------------------------------------------------------- + + +def _require_deepgemm_bf16(op: str) -> None: + """Raise a clear error if DeepGEMM bf16 grouped bindings are unavailable.""" + if not HAVE_DEEPGEMM_BF16: + raise RuntimeError( + f"Batch-invariant grouped GEMM ({op}) requires DeepGEMM with bf16 bindings. " + "Install via `uv pip install -e .[batch_invariant]` (pins a DeepGEMM commit " + "that exposes m_grouped_bf16_gemm_nt_contiguous), or disable " + "transformer_config.batch_invariant_mode for MoE models." + ) + + +def _offs_to_m_indices(offs: torch.Tensor, m_total: int) -> torch.Tensor: + """Convert inclusive cumulative per-expert offsets to per-row expert ids. + + offs: int32 [num_experts] inclusive offsets — offs[i] is the (exclusive) end + row of expert i in the contiguous M dimension. Equivalently, offs[i] is + the start of expert i+1. + Returns: int32 [m_total] m_indices[r] = expert id for row r. Rows past offs[-1] + (post-padding tail when m_total > offs[-1]) get -1; DeepGEMM skips + those rows. + """ + rows = torch.arange(m_total, device=offs.device, dtype=torch.int32) + # For row r, expert id = bisect_right(offs, r). torch.searchsorted is deterministic. + m_indices = torch.searchsorted(offs, rows, right=True).to(torch.int32) + n_used = offs[-1].to(torch.int32) + m_indices = torch.where(rows < n_used, m_indices, torch.full_like(m_indices, -1)) + return m_indices + + +def _m_splits_to_m_indices(m_splits: List[int], device: torch.device, m_total: int) -> torch.Tensor: + """Convert TE per-expert token counts (List[int]) to int32 [m_total] m_indices. + + No padding rows in TE training path — sum(m_splits) == m_total exactly. + """ + assert sum(m_splits) == m_total, f"m_splits sum ({sum(m_splits)}) != m_total ({m_total})" + parts = [ + torch.full((n,), i, device=device, dtype=torch.int32) + for i, n in enumerate(m_splits) + if n > 0 + ] + if not parts: + return torch.empty(0, device=device, dtype=torch.int32) + return torch.cat(parts, dim=0) + + +# DeepGEMM's contiguous M-grouped and K-grouped bf16 GEMMs require each +# per-expert block on the grouped axis to be a multiple of this alignment +# (typically 128 on SM90/SM100). We pad inputs to satisfy this, then strip the +# padding from the output. Padding rows are zeros (correct identity for the +# reduction sum) and tagged with m_indices=-1 for the M-grouped case so the +# kernel can skip them in store. +_DEEPGEMM_M_ALIGNMENT: Optional[int] = None + + +def _deepgemm_m_alignment() -> int: + """Lazily fetch DeepGEMM's required per-expert block alignment.""" + global _DEEPGEMM_M_ALIGNMENT + if _DEEPGEMM_M_ALIGNMENT is None: + _DEEPGEMM_M_ALIGNMENT = int(deep_gemm.get_m_alignment_for_contiguous_layout()) + return _DEEPGEMM_M_ALIGNMENT + + +def grouped_gemm_batch_invariant_alignment() -> int: + """Return the M alignment required by the DeepGEMM grouped-GEMM backend.""" + _require_deepgemm_bf16("get_m_alignment_for_contiguous_layout") + return _deepgemm_m_alignment() + + +def _pad_for_m_grouped(a: torch.Tensor, counts: List[int]) -> tuple: + """Pad an M-grouped contiguous input to satisfy DeepGEMM's per-expert M alignment. + + Returns the padded input, row-to-expert map, and padded counts. + The padded layout groups expert i's true rows contiguously at the start of + its 128-aligned block; remaining rows in the block are zero with m_indices=-1. + """ + alignment = _deepgemm_m_alignment() + padded_counts = [((count + alignment - 1) // alignment) * alignment for count in counts] + M_pad = sum(padded_counts) + if M_pad == 0: + return ( + torch.empty(0, a.shape[1], device=a.device, dtype=a.dtype), + torch.empty(0, device=a.device, dtype=torch.int32), + padded_counts, + ) + + a_padded = torch.zeros(M_pad, a.shape[1], device=a.device, dtype=a.dtype) + m_indices_padded = torch.full((M_pad,), -1, device=a.device, dtype=torch.int32) + src = 0 + dst = 0 + for i, (count, padded_count) in enumerate(zip(counts, padded_counts)): + if count > 0: + a_padded[dst : dst + count] = a[src : src + count] + m_indices_padded[dst : dst + count] = i + src += count + dst += padded_count + return a_padded, m_indices_padded, padded_counts + + +def _bf16_grouped_gemm_contiguous( + a: torch.Tensor, b: torch.Tensor, m_indices: torch.Tensor, counts: List[int] +) -> torch.Tensor: + """bf16 M-grouped GEMM via DeepGEMM. Deterministic / batch-invariant. + + a: [M_total, K] bf16, contiguous, expert-grouped (rows of expert i + are contiguous; m_indices is sorted). + b: [E, N, K] bf16, contiguous (per-expert weights, NT layout — + DeepGEMM transposes B internally). + m_indices: [M_total] int32, expert id per row (-1 to skip). + Returns: [M_total, N] bf16 with rows in the same order as `a`. + + Handles DeepGEMM's per-expert M alignment requirement by padding/unpadding + internally; the caller does not need pre-padded inputs. + """ + _require_deepgemm_bf16("m_grouped_bf16_gemm_nt_contiguous") + assert ( + a.dtype == torch.bfloat16 and b.dtype == torch.bfloat16 + ), f"bf16 grouped GEMM requires bf16; got a.dtype={a.dtype}, b.dtype={b.dtype}" + assert a.is_contiguous() and b.is_contiguous(), "a, b must be contiguous" + assert ( + m_indices.dtype == torch.int32 and m_indices.is_contiguous() + ), "m_indices must be int32 contiguous" + M_total, K = a.shape + E, N, K_b = b.shape + assert K == K_b, f"K mismatch between a ({K}) and b ({K_b})" + assert ( + m_indices.shape[0] == M_total + ), f"m_indices length {m_indices.shape[0]} != M_total {M_total}" + + assert len(counts) == E and sum(counts) == M_total + a_padded, m_indices_padded, padded_counts = _pad_for_m_grouped(a, counts) + M_pad = a_padded.shape[0] + if M_pad == 0: + return torch.zeros(M_total, N, device=a.device, dtype=torch.bfloat16) + + d_padded = torch.empty(M_pad, N, device=a.device, dtype=torch.bfloat16) + deep_gemm.m_grouped_bf16_gemm_nt_contiguous(a_padded, b, d_padded, m_indices_padded) + + # Strip padding: copy each expert's true rows back to a [M_total, N] tensor. + d = torch.empty(M_total, N, device=a.device, dtype=torch.bfloat16) + src = 0 + dst = 0 + for count, padded_count in zip(counts, padded_counts): + if count > 0: + d[src : src + count] = d_padded[dst : dst + count] + src += count + dst += padded_count + return d + + +def _bf16_grouped_gemm_aligned_contiguous( + a: torch.Tensor, b: torch.Tensor, m_indices: torch.Tensor +) -> torch.Tensor: + """DeepGEMM M-grouped GEMM for already aligned expert blocks. + + This path is used by inference CUDA graphs. The caller is responsible for + using `grouped_gemm_batch_invariant_alignment()` when building expert + offsets, so no device-to-host count extraction or dynamic padding is needed + on the captured path. + """ + _require_deepgemm_bf16("m_grouped_bf16_gemm_nt_contiguous") + assert ( + a.dtype == torch.bfloat16 and b.dtype == torch.bfloat16 + ), f"bf16 grouped GEMM requires bf16; got a.dtype={a.dtype}, b.dtype={b.dtype}" + assert a.is_contiguous() and b.is_contiguous(), "a, b must be contiguous" + assert ( + m_indices.dtype == torch.int32 and m_indices.is_contiguous() + ), "m_indices must be int32 contiguous" + M_total, K = a.shape + E, N, K_b = b.shape + assert K == K_b, f"K mismatch between a ({K}) and b ({K_b})" + assert ( + m_indices.shape[0] == M_total + ), f"m_indices length {m_indices.shape[0]} != M_total {M_total}" + + d = torch.empty(M_total, N, device=a.device, dtype=torch.bfloat16) + if M_total == 0: + return d + deep_gemm.m_grouped_bf16_gemm_nt_contiguous(a, b, d, m_indices) + return d + + +def _bf16_grouped_gemm_wgrad_contiguous( + grad_y: torch.Tensor, x: torch.Tensor, counts: List[int] +) -> torch.Tensor: + """K-grouped TN GEMM producing per-expert weight gradients via DeepGEMM. + + grad_y: [M_total, N] bf16, contiguous, expert-grouped. + x: [M_total, K] bf16, contiguous, expert-grouped (same row ordering). + Returns: [E, N, K] bf16 stacked per-expert wgrad. + + DeepGEMM's k_grouped_bf16 kernel computes in fp32 and requires fp32 d/c + accumulators; we cast the result back to bf16. Per-expert K alignment is + handled by padding internally. + """ + _require_deepgemm_bf16("k_grouped_bf16_gemm_tn_contiguous") + assert grad_y.dtype == torch.bfloat16 and x.dtype == torch.bfloat16 + assert grad_y.is_contiguous() and x.is_contiguous() + M_total, N = grad_y.shape + M_total_b, K = x.shape + assert M_total == M_total_b + + num_experts = len(counts) + assert sum(counts) == M_total + alignment = _deepgemm_m_alignment() + padded_counts = [((count + alignment - 1) // alignment) * alignment for count in counts] + M_pad = sum(padded_counts) + if M_pad == 0: + return torch.zeros(num_experts, N, K, device=grad_y.device, dtype=torch.bfloat16) + + grad_y_pad = torch.zeros(M_pad, N, device=grad_y.device, dtype=torch.bfloat16) + x_pad = torch.zeros(M_pad, K, device=x.device, dtype=torch.bfloat16) + src = 0 + dst = 0 + for c, cp in zip(counts, padded_counts): + if c > 0: + grad_y_pad[dst : dst + c] = grad_y[src : src + c] + x_pad[dst : dst + c] = x[src : src + c] + src += c + dst += cp + + ks_tensor = torch.tensor(padded_counts, dtype=torch.int32, device=grad_y.device) + d_fp32 = torch.zeros(num_experts, N, K, device=grad_y.device, dtype=torch.float32) + c_zero = torch.zeros(num_experts, N, K, device=grad_y.device, dtype=torch.float32) + deep_gemm.k_grouped_bf16_gemm_tn_contiguous( + grad_y_pad, x_pad, d_fp32, padded_counts, ks_tensor, c_zero + ) + return d_fp32.to(torch.bfloat16) + + +def grouped_gemm_batch_invariant( + a: torch.Tensor, b: torch.Tensor, *, offs: torch.Tensor, m_total: int +) -> torch.Tensor: + """Run the graph-safe grouped GEMM over pre-aligned inference expert blocks.""" + m_indices = _offs_to_m_indices(offs, m_total).contiguous() + return _bf16_grouped_gemm_aligned_contiguous(a.contiguous(), b.contiguous(), m_indices) + + def _te_rmsnorm_forward_patched(self, x: torch.Tensor) -> torch.Tensor: """Patched TE RMSNorm.forward that routes to batch-invariant implementation with autograd support. @@ -1069,11 +1636,30 @@ def is_batch_invariant_mode_enabled(): return _batch_invariant_MODE -def enable_batch_invariant_mode(): - """Enable global batch-invariant mode and patch Aten/TE kernels.""" - global _batch_invariant_MODE, _batch_invariant_LIB +def enable_batch_invariant_mode(backend: str = "deepgemm"): + """Enable global batch-invariant mode and patch Aten/TE kernels. + + Args: + backend: which kernel to dispatch `aten::mm`/`aten::addmm` through. + "deepgemm" (default) routes bf16 CUDA inputs through DeepGEMM + `bf16_gemm_nn`. "triton" routes through the batch-invariant + Triton `matmul_persistent` kernel (works for bf16/fp16/fp32 and + on any CUDA device). Grouped GEMM always uses DeepGEMM regardless. + """ + global _batch_invariant_MODE, _batch_invariant_LIB, _BATCH_INVARIANT_BACKEND if _batch_invariant_MODE: return + if backend not in _BATCH_INVARIANT_BACKENDS: + raise ValueError( + f"Unknown batch-invariant backend {backend!r}; " + f"expected one of {_BATCH_INVARIANT_BACKENDS}." + ) + if backend == "deepgemm" and not HAVE_DEEPGEMM_BF16: + raise RuntimeError( + "The DeepGEMM batch-invariant backend requires DeepGEMM with " + "bf16 bindings. Install DeepGEMM or use backend='triton'." + ) + _BATCH_INVARIANT_BACKEND = backend dispatch_key = getattr(torch.accelerator.current_accelerator(), "type", "cpu").upper() _batch_invariant_MODE = True _batch_invariant_LIB = torch.library.Library("aten", "IMPL") @@ -1083,6 +1669,10 @@ def enable_batch_invariant_mode(): _batch_invariant_LIB.impl("aten::mean.dim", mean_batch_invariant, dispatch_key) # Also patch Transformer Engine kernels when available _te_patch_for_batch_invariant() + # Pin the Mamba autotuners so rollout and training processes can't end + # up on different tile configs (and therefore different fp32 reduction + # orders) through autotune timing noise. + _pin_mamba_autotuners() def disable_batch_invariant_mode(): @@ -1094,33 +1684,133 @@ def disable_batch_invariant_mode(): _batch_invariant_LIB = None # Restore Transformer Engine kernels if previously patched _te_unpatch_for_batch_invariant() + _unpin_mamba_autotuners() + + +# (autotuner, original configs list) pairs saved by _pin_mamba_autotuners. +_PINNED_AUTOTUNERS: list = [] + +# Rollout uses the repo kernels while training uses mamba_ssm. Pin a config +# present in both copies so autotune timing cannot change the reduction order. +_PINNED_MAMBA_CONFIGS = { + "_bmm_chunk_fwd_kernel": {"BLOCK_SIZE_M": 64, "BLOCK_SIZE_N": 128, "BLOCK_SIZE_K": 32}, + "_chunk_scan_fwd_kernel": {"BLOCK_SIZE_M": 32, "BLOCK_SIZE_N": 64, "BLOCK_SIZE_K": 32}, + "_chunk_state_fwd_kernel": {"BLOCK_SIZE_M": 64, "BLOCK_SIZE_N": 128, "BLOCK_SIZE_K": 32}, + "_chunk_cumsum_fwd_kernel": {"BLOCK_SIZE_H": 8}, + "_state_passing_fwd_kernel": {"BLOCK_SIZE": 1024}, +} + + +def _pin_mamba_autotuners(): + """Pin the Mamba chunked-scan forward kernels to fixed tile configs. + + BLOCK sizes determine the fp32 reduction grouping inside tl.dot loops, + so rollout/train parity needs the inference process (repo ssd_* kernels) + and the training process (mamba_ssm package kernels) to pick the same + config. Triton's autotuner re-benchmarks per process and timing noise + can flip the winner; we've seen that break parity in practice. Pinning + both sides to the same config removes the benchmark from the loop. + + Only the five forward kernels matter for parity; backward kernels only + affect gradients. + """ + global _PINNED_AUTOTUNERS + try: + from triton.runtime.autotuner import Autotuner + except ImportError: + return + + kernels = [] + try: + from megatron.core.ssm.ops import ssd_bmm as r_bmm + from megatron.core.ssm.ops import ssd_chunk_scan as r_scan + from megatron.core.ssm.ops import ssd_chunk_state as r_state + from megatron.core.ssm.ops import ssd_state_passing as r_pass + + kernels += [ + r_bmm._bmm_chunk_fwd_kernel, + r_scan._chunk_scan_fwd_kernel, + r_state._chunk_cumsum_fwd_kernel, + r_state._chunk_state_fwd_kernel, + r_pass._state_passing_fwd_kernel, + ] + except ImportError: + pass + try: + from mamba_ssm.ops.triton import ssd_bmm as p_bmm + from mamba_ssm.ops.triton import ssd_chunk_scan as p_scan + from mamba_ssm.ops.triton import ssd_chunk_state as p_state + from mamba_ssm.ops.triton import ssd_state_passing as p_pass + + kernels += [ + p_bmm._bmm_chunk_fwd_kernel, + p_scan._chunk_scan_fwd_kernel, + p_state._chunk_cumsum_fwd_kernel, + p_state._chunk_state_fwd_kernel, + p_pass._state_passing_fwd_kernel, + ] + except (ImportError, AttributeError): + pass + + for kernel in kernels: + if not isinstance(kernel, Autotuner) or len(kernel.configs) <= 1: + continue + name = getattr(getattr(kernel, "fn", None), "__name__", "") + expected = _PINNED_MAMBA_CONFIGS[name] + chosen = next( + cfg + for cfg in kernel.configs + if all(cfg.kwargs.get(key) == value for key, value in expected.items()) + ) + _PINNED_AUTOTUNERS.append((kernel, kernel.configs)) + kernel.configs = [chosen] + if hasattr(kernel, "cache"): + kernel.cache.clear() + + +def _unpin_mamba_autotuners(): + """Restore the original autotune config lists saved by _pin_mamba_autotuners.""" + global _PINNED_AUTOTUNERS + for kernel, original in _PINNED_AUTOTUNERS: + kernel.configs = original + if hasattr(kernel, "cache"): + kernel.cache.clear() + _PINNED_AUTOTUNERS = [] @contextlib.contextmanager -def set_batch_invariant_mode(enabled: bool = True): +def set_batch_invariant_mode(enabled: bool = True, backend: Optional[str] = None): """Context manager to toggle global batch-invariant mode. When `enabled` is True, batch-invariant kernels are enabled for the duration of the context; when False, they are disabled for the duration. This implementation is re-entrant and correctly restores the previous state even under nesting. + The helper default remains "triton" for tests that exercise non-bf16 operators. """ global _batch_invariant_MODE, _batch_invariant_LIB # Save the previous on/off state so we can correctly restore it, even under # nested usage or when toggling from True->False inside an outer True scope. prev_enabled = _batch_invariant_MODE + prev_backend = _BATCH_INVARIANT_BACKEND # Apply the requested state only if it differs from the current one. if enabled and not prev_enabled: - enable_batch_invariant_mode() + enable_batch_invariant_mode(backend=backend or "triton") + elif enabled and prev_enabled and backend is not None and backend != prev_backend: + raise RuntimeError( + "Cannot switch batch-invariant backend inside an active context " + f"(active={prev_backend!r}, requested={backend!r})." + ) elif not enabled and prev_enabled: disable_batch_invariant_mode() try: yield finally: - # Restore the previous state. If we turned BIK on at entry, turn it off here. - # If we turned it off at entry (inside an outer True scope), turn it back on. + # Restore the previous state. If we turned batch-invariant mode on at + # entry, turn it off here. If we turned it off at entry (inside an + # outer True scope), turn it back on. if enabled and not prev_enabled: disable_batch_invariant_mode() elif not enabled and prev_enabled: - enable_batch_invariant_mode() + enable_batch_invariant_mode(backend=prev_backend) diff --git a/megatron/core/transformer/moe/batch_invariant.py b/megatron/core/transformer/moe/batch_invariant.py new file mode 100644 index 00000000000..9baf66dab65 --- /dev/null +++ b/megatron/core/transformer/moe/batch_invariant.py @@ -0,0 +1,92 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Batch-invariant MoE permutation helpers.""" + +from typing import Optional + +import torch + +from megatron.core import parallel_state + + +def build_inverse_permutation_map( + routing_map: torch.Tensor, + flat_sorted: torch.Tensor, + sorted_indices: torch.Tensor, + num_out_tokens: int, +) -> torch.Tensor: + """Build token/top-k -> permuted-row and expert-id map for batch-invariant unpermute. + + The regular permutation map is row -> token. Batch-invariant unpermute needs + the inverse ownership model so each output token can read its routed rows and + add them in a fixed order. + """ + num_tokens = routing_map.size(0) + assert isinstance( + num_out_tokens, int + ), "batch-invariant graph unpermute requires static num_out_tokens" + assert ( + num_out_tokens % num_tokens == 0 + ), "batch-invariant graph unpermute expects fixed top-k per token" + + topk = num_out_tokens // num_tokens + row_ids = torch.arange(num_out_tokens, device=routing_map.device, dtype=torch.long) + expert_ids = torch.div(flat_sorted, num_tokens, rounding_mode='floor').to(torch.long) + token_ids = sorted_indices.to(torch.long) + + slots_by_token_expert = routing_map.bool().to(torch.long).cumsum(dim=1) - 1 + row_slots = slots_by_token_expert[token_ids, expert_ids] + linear_slots = token_ids * topk + row_slots + + inverse_rows = torch.full((num_tokens, topk), -1, device=routing_map.device, dtype=torch.long) + inverse_experts = torch.full( + (num_tokens, topk), -1, device=routing_map.device, dtype=torch.long + ) + inverse_rows.view(-1).scatter_(0, linear_slots, row_ids) + inverse_experts.view(-1).scatter_(0, linear_slots, expert_ids) + return torch.stack((inverse_rows, inverse_experts), dim=0) + + +def unpermute( + permuted_tokens: torch.Tensor, + restore_shape: torch.Size, + *, + probs: Optional[torch.Tensor], + num_experts: int, + inverse_map: torch.Tensor, +) -> torch.Tensor: + """Batch-invariant MoE unpermute. + + Accumulation is token-owned. The AllToAll inverse map avoids data-dependent + shapes and adds contributions by EP rank then top-k slot, matching the + inference NVLS rank-ordered combine. + """ + input_dtype = permuted_tokens.dtype + output_tokens = torch.zeros(restore_shape, dtype=torch.float32, device=permuted_tokens.device) + ep_size = parallel_state.get_expert_model_parallel_world_size() or 1 + assert num_experts % ep_size == 0, "batch-invariant MoE expects contiguous EP shards" + experts_per_rank = num_experts // ep_size + inverse_rows = inverse_map[0] + inverse_experts = inverse_map[1] + topk = inverse_rows.size(1) + + for ep_rank in range(ep_size): + rank_partial = torch.zeros_like(output_tokens) + start_expert = ep_rank * experts_per_rank + end_expert = start_expert + experts_per_rank + + for k in range(topk): + row_ids = inverse_rows[:, k] + expert_ids = inverse_experts[:, k] + valid_mask = (row_ids >= 0) & (expert_ids >= start_expert) & (expert_ids < end_expert) + + safe_rows = row_ids.clamp_min(0) + chunk = permuted_tokens.index_select(0, safe_rows).to(torch.float32) + if probs is not None: + safe_experts = expert_ids.clamp_min(0) + chunk = chunk * probs.gather(1, safe_experts.unsqueeze(1)).to(torch.float32) + chunk.masked_fill_(~valid_mask.unsqueeze(-1), 0.0) + rank_partial += chunk + + output_tokens += rank_partial + + return output_tokens.to(dtype=input_dtype) diff --git a/megatron/core/transformer/moe/moe_utils.py b/megatron/core/transformer/moe/moe_utils.py index 9b7cf177c79..dfdb9a14460 100644 --- a/megatron/core/transformer/moe/moe_utils.py +++ b/megatron/core/transformer/moe/moe_utils.py @@ -19,7 +19,14 @@ ) from megatron.core.tensor_parallel.mappings import reduce_from_tensor_model_parallel_region from megatron.core.transformer.cuda_graphs import is_graph_capturing +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + is_batch_invariant_mode_enabled, +) from megatron.core.transformer.enums import CudaGraphModule +from megatron.core.transformer.moe.batch_invariant import ( + build_inverse_permutation_map as build_batch_invariant_inverse_permutation_map, +) +from megatron.core.transformer.moe.batch_invariant import unpermute as batch_invariant_unpermute from megatron.core.transformer.moe.moe_logging import get_moe_metrics_tracker from megatron.core.transformer.moe.router_replay import RouterReplay from megatron.core.transformer.transformer_config import TransformerConfig @@ -343,6 +350,7 @@ def permute( drop_and_pad: bool = False, tokens_per_expert: Optional[torch.Tensor] = None, align_size: int = 0, + return_batch_invariant_inverse_map: bool = False, ) -> Tuple[ torch.Tensor, Optional[torch.Tensor], @@ -375,6 +383,8 @@ def permute( tokens_per_expert (torch.Tensor, optional): Tensor of shape `[num_experts]` containing actual token counts per expert. align_size (int, optional): The alignment size for the input tensor for fp8 or fp4. + return_batch_invariant_inverse_map (bool, optional): Return a fixed-shape + batch-invariant inverse map in the `pad_offsets` slot for graph-safe unpermute. Returns: Tuple[ @@ -387,6 +397,10 @@ def permute( The permuted tokens, (optional) permuted probs, sorted indices, (optional) pad_offsets, (optional) padded_tokens_per_expert. """ + if return_batch_invariant_inverse_map: + assert not fused, "batch-invariant MoE permute requires the unfused path" + assert not drop_and_pad, "batch-invariant MoE supports dynamic dropless routing only" + if fused and probs is None: if not HAVE_TE or fused_permute is None: raise ValueError("fused_permute is not available. Please install TE >= 2.1.0.") @@ -421,6 +435,7 @@ def permute( num_tokens, hidden = tokens.shape num_experts = routing_map.shape[1] permuted_probs = None + batch_invariant_inverse_map = None if drop_and_pad and not (num_out_tokens is None): capacity = num_out_tokens // num_experts assert not routing_map.requires_grad @@ -447,6 +462,7 @@ def permute( assert ( num_out_tokens is not None ), "num_out_tokens is required for the argsort-based permute" + routing_map_for_inverse = routing_map # mask [num_tokens, num_experts] -> [num_experts, num_tokens] routing_map = routing_map.bool().T.contiguous() @@ -461,10 +477,21 @@ def permute( if probs is not None: permuted_probs = probs.T.contiguous().reshape(-1)[flat_sorted] + if return_batch_invariant_inverse_map: + batch_invariant_inverse_map = build_batch_invariant_inverse_permutation_map( + routing_map_for_inverse, flat_sorted, sorted_indices, num_out_tokens + ) + # use the mapping to permute the tokens permuted_input = tokens.index_select(0, sorted_indices) - return permuted_input, permuted_probs, sorted_indices, None, tokens_per_expert + return ( + permuted_input, + permuted_probs, + sorted_indices, + batch_invariant_inverse_map, + tokens_per_expert, + ) def unpermute( @@ -476,6 +503,7 @@ def unpermute( fused: bool = False, drop_and_pad: bool = False, pad_offsets: Optional[torch.Tensor] = None, + batch_invariant_inverse_map: Optional[torch.Tensor] = None, ) -> torch.Tensor: """ Restore the original order of tokens after permutation. If probs are provided, it @@ -501,10 +529,18 @@ def unpermute( Tensor of per-expert cumulative padding offsets used to remove padding added during permutation. This is the fourth output of `moe_permute_and_pad_with_probs` and is required when unpermuting padded outputs. Defaults to None. + batch_invariant_inverse_map (torch.Tensor, optional): Fixed-shape + `[2, num_tokens, topk]` map from token/top-k slot to permuted row and + global expert id. Used by batch-invariant CUDA graph paths. Returns: torch.Tensor: The tokens restored to their original order. """ + batch_invariant_mode = is_batch_invariant_mode_enabled() + if batch_invariant_mode: + assert not fused, "batch-invariant MoE unpermute requires the unfused path" + assert not drop_and_pad, "batch-invariant MoE supports dynamic dropless routing only" + if fused: if not HAVE_TE or fused_unpermute is None: raise ValueError("fused_unpermute is not available. Please install TE >= 2.1.0.") @@ -519,6 +555,19 @@ def unpermute( **extra_kwargs, ) + if batch_invariant_mode: + assert routing_map is not None, "batch-invariant MoE unpermute requires routing_map" + assert ( + batch_invariant_inverse_map is not None + ), "batch-invariant MoE unpermute requires the AllToAll inverse map" + return batch_invariant_unpermute( + permuted_tokens, + restore_shape, + probs=probs, + num_experts=routing_map.size(1), + inverse_map=batch_invariant_inverse_map, + ) + _, hidden = restore_shape input_dtype = permuted_tokens.dtype @@ -820,7 +869,12 @@ def _compute_topk( ) else: # Sorting top-k turned off during inference - return torch.topk(scores, k=topk, dim=1, sorted=torch.is_grad_enabled()) + return torch.topk( + scores, + k=topk, + dim=1, + sorted=torch.is_grad_enabled() or is_batch_invariant_mode_enabled(), + ) def compute_topk(scores, topk, num_groups=None, group_topk=None): # Default behavior if no replay is active diff --git a/megatron/core/transformer/moe/router.py b/megatron/core/transformer/moe/router.py index e4591ce3acf..55bbe96f5f3 100644 --- a/megatron/core/transformer/moe/router.py +++ b/megatron/core/transformer/moe/router.py @@ -7,6 +7,9 @@ from megatron.core.inference.utils import InferenceMode from megatron.core.jit import jit_fuser +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + is_batch_invariant_mode_enabled, +) from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.moe.moe_logging import get_moe_metrics_tracker from megatron.core.transformer.moe.moe_utils import ( @@ -953,7 +956,12 @@ def _forward(self, input: torch.Tensor, padding_mask: Optional[torch.Tensor] = N if self.qb_beta is not None: precomputed_indices = (logits - self.qb_beta).topk(self.topk, dim=1).indices - probs, top_indices = self._compiled_topk_routing( + routing = ( + topk_routing_with_score_function + if is_batch_invariant_mode_enabled() + else self._compiled_topk_routing + ) + probs, top_indices = routing( logits, self.topk, use_pre_softmax=self.config.moe_router_pre_softmax, diff --git a/megatron/core/transformer/moe/token_dispatcher.py b/megatron/core/transformer/moe/token_dispatcher.py index 5743a047960..256f38cc6fb 100644 --- a/megatron/core/transformer/moe/token_dispatcher.py +++ b/megatron/core/transformer/moe/token_dispatcher.py @@ -482,6 +482,9 @@ def __init__( 'hidden_shape', 'probs', ] + self.batch_invariant_inverse_permutation_mapping = None + if self.config.batch_invariant_mode: + self.cudagraph_attrs.append('batch_invariant_inverse_permutation_mapping') self.shared_experts = None @@ -660,7 +663,7 @@ def dispatch_preprocess( permutated_local_input_tokens, permuted_probs, self.reversed_local_input_permutation_mapping, - _, + self.batch_invariant_inverse_permutation_mapping, _, ) = permute( hidden_states, @@ -669,6 +672,7 @@ def dispatch_preprocess( num_out_tokens=self.num_out_tokens, fused=self.config.moe_permute_fusion, drop_and_pad=self.drop_and_pad, + return_batch_invariant_inverse_map=self.config.batch_invariant_mode, ) return permutated_local_input_tokens, permuted_probs @@ -888,6 +892,7 @@ def combine_postprocess(self, permutated_local_input_tokens): routing_map=self.routing_map, fused=self.config.moe_permute_fusion, drop_and_pad=self.drop_and_pad, + batch_invariant_inverse_map=self.batch_invariant_inverse_permutation_mapping, ) # Reshape the output tensor diff --git a/megatron/core/transformer/moe/token_dispatcher_inference.py b/megatron/core/transformer/moe/token_dispatcher_inference.py index b08d88f2641..1a2ae10b24a 100644 --- a/megatron/core/transformer/moe/token_dispatcher_inference.py +++ b/megatron/core/transformer/moe/token_dispatcher_inference.py @@ -32,7 +32,7 @@ multimem_all_gatherv_3tensor, multimem_reduce_scatter_v, ) -from megatron.core.inference.moe import InferenceGroupedGemmBackend +from megatron.core.inference.moe import InferenceGroupedGemmBackend, batch_invariant from megatron.core.inference.moe.metadata import fused_metadata_update from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.process_groups_config import ProcessGroupCollection @@ -617,9 +617,13 @@ def combine_preprocess(self, expert_output): def token_combine(self, hidden_states): """ReduceScatter-V: sum expert outputs across EP ranks, scatter to local tokens. + In batch-invariant mode, the symmetric RSV buffer is still used for data + visibility, but the rank reduction is an explicit fp32 rank-order loop + rather than a hardware multimem reduction. + Args: - hidden_states: [global_max, hidden_size] expert outputs (fp32 when - written directly to the RSV buffer, bf16 otherwise). + hidden_states: [global_max, hidden_size] expert outputs (fp32 + when written directly to the RSV buffer, bf16 otherwise). Returns: [local_tokens, hidden_size] bf16 local token outputs. @@ -637,7 +641,12 @@ def token_combine(self, hidden_states): dtype=rsv["tensor"].dtype, device=hidden_states.device, ) - multimem_reduce_scatter_v( + reduce_scatter_v = ( + batch_invariant.ordered_reduce_scatter_v + if batch_invariant.enabled() + else multimem_reduce_scatter_v + ) + reduce_scatter_v( output, rsv["tensor"], rsv["handle"], diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index e26af1852ca..944a3ff244b 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -1534,6 +1534,20 @@ def __post_init__(self): "Set inference_grouped_gemm_backend to 'torch' for MXFP8." ) + if self.batch_invariant_mode: + if self.inference_grouped_gemm_backend != InferenceGroupedGemmBackend.TORCH: + raise ValueError( + "batch_invariant_mode requires " "inference_grouped_gemm_backend='torch'." + ) + if ( + self.expert_model_parallel_size > 1 + and self.inference_moe_token_dispatcher_type != "nvls" + ): + raise ValueError( + "batch_invariant_mode with inference-optimized MoE and expert " + "parallelism requires inference_moe_token_dispatcher_type='nvls'." + ) + if self.num_moe_experts is not None and self.num_moe_experts <= 0: raise ValueError("num_moe_experts must be non-negative.") @@ -2879,6 +2893,10 @@ def _scope_to_str(s): ) if self.batch_invariant_mode: + assert self.params_dtype == torch.bfloat16, ( + "Batch invariant mode supports BF16 model parameters only; " + f"got {self.params_dtype}." + ) assert ( self.attention_backend == AttnBackend.flash ), "Batch invariant mode only supports FlashAttention (--attention-backend flash)" @@ -2899,6 +2917,35 @@ def _scope_to_str(s): assert ( self.attention_dropout == 0.0 ), "Batch invariant mode does not support attention dropout" + if (self.num_moe_experts or 0) > 0: + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + HAVE_DEEPGEMM_BF16, + ) + + if self.transformer_impl != "inference_optimized": + assert self.moe_token_dispatcher_type == "alltoall", ( + "Batch-invariant MoE training requires " + "moe_token_dispatcher_type='alltoall'." + ) + assert HAVE_DEEPGEMM_BF16, ( + "batch_invariant_mode=True with MoE requires DeepGEMM with bf16 " + "grouped-GEMM bindings (m_grouped_bf16_gemm_nt_contiguous). " + "Install via `uv pip install -e .[batch_invariant]`." + ) + assert not ( + self.fp8 or self.fp4 + ), "Batch-invariant MoE is bf16-only. Disable fp8/fp4 to use it." + assert not (self.moe_permute_fusion or self.moe_permute_fusion_into_hybridep), ( + "Batch-invariant MoE requires the unfused permute/unpermute path so " + "top-k reductions use the fixed batch-invariant add tree." + ) + assert not ( + self.moe_pad_expert_input_to_capacity + or self.moe_pad_experts_for_cuda_graph_inference + ), ( + "Batch-invariant MoE supports dynamic dropless routing only. " + "Disable MoE capacity/expert padding." + ) @dataclass diff --git a/megatron/rl/rl_utils.py b/megatron/rl/rl_utils.py index c99f30b5c43..0b834def67b 100644 --- a/megatron/rl/rl_utils.py +++ b/megatron/rl/rl_utils.py @@ -739,6 +739,9 @@ def selective_log_softmax(logits, index): # logsumexp approach is unstable with bfloat16, fall back to slightly less efficent approach per_token_logps = [] for row_logits, row_labels in zip(logits, index): # loop to reduce peak mem consumption + # Match inference by running batch-invariant log-softmax in FP32. + if use_bik_logsoftmax: + row_logits = row_logits.float() row_logps = torch.nn.functional.log_softmax(row_logits, dim=-1) row_per_token_logps = row_logps.gather(dim=-1, index=row_labels.unsqueeze(-1)).squeeze( -1 @@ -1733,9 +1736,12 @@ def prepare_data_for_update( use_single_mempool=args.cuda_graph_use_single_mempool, ) - dtype = ( - torch.bfloat16 if args.bf16 else (torch.float16 if args.fp16 else torch.float32) - ) + if is_batch_invariant_mode_enabled(): + dtype = torch.float32 + else: + dtype = ( + torch.bfloat16 if args.bf16 else (torch.float16 if args.fp16 else torch.float32) + ) pg_collection = get_attr_wrapped_model(model, "pg_collection") pp_group = pg_collection.pp diff --git a/pyproject.toml b/pyproject.toml index 8aa583b7464..5d723711c56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,7 +174,8 @@ linting = [ "pylint==3.2.6", ] ci = ["python-gitlab", "slack-sdk", "pandas"] -no_pypi_wheels = ["flash_mla", "emerging_optimizers"] +batch_invariant = ["deep_gemm"] +no_pypi_wheels = ["flash_mla", "emerging_optimizers", "deep_gemm"] [tool.uv] managed = true @@ -185,6 +186,7 @@ no-build-isolation-package = [ "mamba-ssm", "transformer-engine", "transformer-engine-torch", + "deep_gemm", "fast-hadamard-transform", ] link-mode = "copy" @@ -229,6 +231,7 @@ requires-dist = ["torch", "packaging", "ninja"] flash_mla = [ { git = "https://github.com/deepseek-ai/FlashMLA", rev = "nv_dev" }, ] +deep_gemm = { git = "https://github.com/deepseek-ai/DeepGEMM.git", rev = "714dd1a4a980f7937a74343d19a8eba4fe321480" } transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" } nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "e3935393a290aed1822af52139b4b8ee270fed1f" } emerging_optimizers = { git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git", rev = "v0.2.0" } diff --git a/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py b/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py index 99bb046d97d..43a7d57d8ba 100644 --- a/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py +++ b/tests/unit_tests/inference/contexts/attention_metadata/test_mamba_metadata.py @@ -31,6 +31,15 @@ def metadata_context(self): yield metadata metadata.reset() + @pytest.mark.internal + @pytest.mark.parametrize("dtype", [torch.int32, torch.int64]) + def test_decode_indices_dtype(self, dtype): + metadata = MambaMetadata( + max_requests=4, max_tokens=16, max_intermediate_count=1, decode_indices_dtype=dtype + ) + + assert metadata._batch_indices_decode_buffer.dtype == dtype + def _run_update_test( self, metadata: MambaMetadata, diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index 59e896d1285..0f096e1ab6f 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -23,6 +23,7 @@ ) from megatron.core.inference.sampling_params import SamplingParams from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.transformer_config import TransformerConfig from tests.unit_tests.test_utilities import Utils @@ -66,13 +67,15 @@ def _ctx( prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, mamba_config=None, prefix_caching_mamba_gb=None, + batch_invariant_mode=False, + enable_chunked_prefill=False, ): DynamicInferenceContext.ROUNDER = rounder DynamicInferenceContext.TOKEN_ROUNDER = rounder DynamicInferenceContext.REQUEST_ROUNDER = rounder transformer_config = TransformerConfig( - params_dtype=torch.float32, + params_dtype=torch.bfloat16 if batch_invariant_mode else torch.float32, num_layers=4, kv_channels=8, num_attention_heads=2, @@ -80,7 +83,14 @@ def _ctx( tensor_model_parallel_size=1, pipeline_model_parallel_size=1, use_cpu_initialization=True, + batch_invariant_mode=batch_invariant_mode, + attention_backend=AttnBackend.flash if batch_invariant_mode else AttnBackend.auto, + flash_attention_version=4 if batch_invariant_mode else None, + attention_dropout=0.0 if batch_invariant_mode else 0.1, ) + if batch_invariant_mode: + max_tokens = 512 if max_tokens is None else max_tokens + max_requests = 64 if max_requests is None else max_requests inference_config = InferenceConfig( max_sequence_length=max_sequence_length, buffer_size_gb=buffer_size_gb, @@ -92,6 +102,7 @@ def _ctx( use_flashinfer_fused_rope=None, unified_memory_level=0, enable_prefix_caching=enable_prefix_caching, + enable_chunked_prefill=enable_chunked_prefill, prefix_caching_eviction_policy=prefix_caching_eviction_policy, prefix_caching_mamba_gb=prefix_caching_mamba_gb, ) @@ -919,6 +930,46 @@ def test_mamba_prefill_skip_and_zero_prefill(self): ctx5.release_memory_blocks_from_request_indexes([0]) assert not msa5.has_state(bid5) and bh5 not in msa5.hash_to_block_id + @pytest.mark.internal + def test_batch_invariant_mamba_chunked_prefill_scheduler_alignment(self): + ctx = self._mctx( + block_size_tokens=32, batch_invariant_mode=True, enable_prefix_caching=False + ) + engine = _StubEngine(ctx, enable_chunked_prefill=True) + req = self._req(ctx, self._prompt(500), enable_prefix_caching=False) + + assert engine._mamba_batch_invariant_prefill_chunk_length(req, 300) == 256 + assert engine._mamba_batch_invariant_prefill_chunk_length(req, 100) == 0 + + short_req = self._req(ctx, self._prompt(200), request_id=2, enable_prefix_caching=False) + assert engine._mamba_batch_invariant_prefill_chunk_length(short_req, 300) == 200 + + one_left_req = self._req( + ctx, self._prompt(ctx.mamba_chunk_size + 1), request_id=3, enable_prefix_caching=False + ) + assert ( + engine._mamba_batch_invariant_prefill_chunk_length(one_left_req, ctx.mamba_chunk_size) + == 0 + ) + assert ( + engine._mamba_batch_invariant_prefill_chunk_length( + one_left_req, ctx.mamba_chunk_size + 1 + ) + == ctx.mamba_chunk_size + 1 + ) + + with pytest.raises(AssertionError, match="max_tokens > mamba_chunk_size"): + self._mctx( + batch_invariant_mode=True, + enable_prefix_caching=False, + enable_chunked_prefill=True, + max_tokens=ctx.mamba_chunk_size, + max_requests=64, + ) + + with pytest.raises(AssertionError, match="does not support Mamba prefix caching"): + self._mctx(batch_invariant_mode=True) + @pytest.mark.internal def test_mamba_intermediate_offsets(self): bs = 256 diff --git a/tests/unit_tests/inference/contexts/test_gpu_view.py b/tests/unit_tests/inference/contexts/test_gpu_view.py index 63a838f563e..1bd48aeaa21 100644 --- a/tests/unit_tests/inference/contexts/test_gpu_view.py +++ b/tests/unit_tests/inference/contexts/test_gpu_view.py @@ -93,3 +93,20 @@ def test_layout_with_and_without_mamba(self, max_mamba_chunks): for name in MAMBA_VIEWS_INT32: assert getattr(v, name) is not None assert getattr(v, name).dtype == torch.int32 + + @pytest.mark.parametrize("dtype", [torch.int32, torch.int64]) + def test_mamba_decode_indices_dtype(self, dtype): + """The runtime-selected decode dtype must not change the remaining layout.""" + v = ContextGPUView( + max_requests=MAX_REQUESTS, + max_tokens=MAX_TOKENS, + max_kv_blocks=MAX_KV_BLOCKS, + device=torch.device("cuda"), + max_mamba_chunks=MAX_MAMBA_CHUNKS, + mamba_decode_indices_dtype=dtype, + ) + + assert v.mamba_batch_indices_decode.dtype == dtype + assert v.mamba_batch_indices_decode.shape == (MAX_REQUESTS,) + assert v.mamba_batch_indices_prefill.dtype == torch.int32 + assert v.mamba_conv_seq_start.shape == (MAX_TOKENS,) diff --git a/tests/unit_tests/inference/test_hybrid_moe.py b/tests/unit_tests/inference/test_hybrid_moe.py index a8cc9b743e6..9587cc2d285 100644 --- a/tests/unit_tests/inference/test_hybrid_moe.py +++ b/tests/unit_tests/inference/test_hybrid_moe.py @@ -31,14 +31,19 @@ from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.ssm.mamba_mixer import _check_mamba_sequence_packing_support from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.attention import HAVE_FA3, HAVE_FA4 from megatron.core.transformer.cuda_graphs import _CudagraphGlobalRecord, delete_cuda_graphs +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + te_supports_batch_invariant_attention, +) +from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.moe.token_dispatcher_inference import NVLSAllGatherVDispatcher from megatron.core.utils import is_fa_min_version from tests.unit_tests.inference.test_moe_dispatching_and_routing import ( NANOV3_BASE, _make_base_config, ) -from tests.unit_tests.test_utilities import Utils +from tests.unit_tests.test_utilities import Utils, clear_nvte_env_vars # Request state constants for parametrized tests. NONE = "none" # 0 requests (dummy rank) @@ -57,6 +62,13 @@ # ranks form data-parallel replicas, each running the same EP combo # independently. _EP_SIZE = 4 +requires_te_batch_invariant_attention = pytest.mark.skipif( + not te_supports_batch_invariant_attention() or not (HAVE_FA3 or HAVE_FA4), + reason=( + "Batch-invariant attention requires TransformerEngine PR #3204 or >= 2.18 " + "and FlashAttention-3 or -4." + ), +) # Combinatorial sweep: unordered combinations with repetition of ALL_STATES # across the EP ranks. Since rank assignment is symmetric (shuffling ranks @@ -261,6 +273,72 @@ def _assert_cuda_graphs_were_replayed(expect_replayed, rank, label): class TestDynamicInferenceNVLS(_TestDynamicInferenceBase): """NVLS dispatcher: combinatorial sweep of EP request states.""" + @requires_te_batch_invariant_attention + @torch.inference_mode() + def test_batch_invariant_prefill_matches_full_forward(self): + """Dynamic prefill should exactly match the full-sequence forward.""" + from megatron.core.inference.inference_request import DynamicInferenceRequest + from megatron.core.inference.sampling_params import SamplingParams + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + set_batch_invariant_mode, + ) + + model_parallel_cuda_manual_seed(123, inference_rng_tracker=True, force_reset_rng=True) + clear_nvte_env_vars() + config = _make_base_config( + num_layers=3, + batch_invariant_mode=True, + attention_backend=AttnBackend.flash, + attention_dropout=0.0, + flash_attention_version=4 if HAVE_FA4 else 3, + inference_grouped_gemm_backend="torch", + inference_moe_token_dispatcher_type="nvls", + ) + model = HybridModel( + config=config, + hybrid_stack_spec=hybrid_inference_stack_spec, + vocab_size=self.VOCAB_SIZE, + max_sequence_length=self.MAX_SEQ_LEN, + hybrid_layer_pattern="ME*", + ).cuda() + model.eval() + + input_ids = torch.arange(64, device="cuda", dtype=torch.long).unsqueeze(0) + with set_batch_invariant_mode(True): + InferenceMode.unset_active() + full_logits = model( + input_ids=input_ids, + position_ids=None, + attention_mask=None, + runtime_gather_output=True, + ) + + ctx = self._build_context( + model, + num_cuda_graphs=0, + use_cuda_graphs_for_non_decode_steps=False, + max_requests=4, + max_tokens=128, + ) + request = DynamicInferenceRequest( + request_id=0, + prompt_tokens=input_ids.cpu().squeeze(0), + sampling_params=SamplingParams(num_tokens_to_generate=1, termination_id=-1), + ) + ctx.add_request(request) + ctx.initialize_attention_state() + + InferenceMode.set_active() + inference_logits = model( + input_ids=input_ids, + position_ids=None, + attention_mask=None, + inference_context=ctx, + runtime_gather_output=True, + ) + + torch.testing.assert_close(inference_logits, full_logits, atol=0, rtol=0) + # ------------------------------------------------------------------ # test_ep_state_cross_product: combinatorial sweep with mixed CUDA graphs # ------------------------------------------------------------------ diff --git a/tests/unit_tests/inference/test_moe_dispatching_and_routing.py b/tests/unit_tests/inference/test_moe_dispatching_and_routing.py index 5b21ab4c364..3d5353ab000 100644 --- a/tests/unit_tests/inference/test_moe_dispatching_and_routing.py +++ b/tests/unit_tests/inference/test_moe_dispatching_and_routing.py @@ -230,6 +230,21 @@ def test_init(self): assert dispatcher.topk == NANOV3_BASE["moe_router_topk"] assert dispatcher.ep_size == Utils.world_size + def test_init_rejects_batch_invariant_ep(self): + """Batch-invariant MoE on the inference EP path is NVLS-only on this branch.""" + if Utils.world_size == 1: + pytest.skip("NCCL batch-invariant rejection is only relevant with expert parallelism.") + + with pytest.raises(ValueError, match="requires inference_moe_token_dispatcher_type"): + self._make_dispatcher( + batch_invariant_mode=True, + attention_backend=AttnBackend.flash, + flash_attention_version=4, + attention_dropout=0.0, + inference_grouped_gemm_backend="torch", + inference_moe_token_dispatcher_type="nccl", + ) + @pytest.mark.parametrize("use_allgather_v", [False, True]) def test_dispatch_combine(self, use_allgather_v): """Dispatch+combine correctness for both CG (equal-count) and prefill (variable-count) paths. @@ -326,6 +341,9 @@ def _make_dispatcher(self): NVLSAllGatherVDispatcher, ) + if Utils.world_size <= 0 or Utils.world_size & (Utils.world_size - 1): + pytest.skip("NVLS Triton symmetric-memory barrier requires power-of-two EP size.") + config = _make_base_config(expert_model_parallel_size=Utils.world_size) num_local_experts = config.num_moe_experts // Utils.world_size ep_rank = torch.distributed.get_rank() if Utils.world_size > 1 else 0 @@ -440,6 +458,257 @@ def test_cuda_graph_dispatch_combine(self, max_rank_tokens, seed): expected_combined = (global_hidden[start:end].float() * ep_size).bfloat16() torch.testing.assert_close(graph_combined, expected_combined, atol=0, rtol=0) + def test_cuda_graph_batch_invariant_combine_uses_ordered_symmetric_memory(self, monkeypatch): + """Batch-invariant mode should use ordered peer loads on NVLS dispatcher. + + The graph path still writes local partials into the symmetric RSV buffer, + but the combine must not use multimem.ld_reduce in batch-invariant mode. + """ + from megatron.core.inference.moe import batch_invariant + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + set_batch_invariant_mode, + ) + + if Utils.world_size < 2: + pytest.skip("Ordered RSV combine requires expert-parallel world_size > 1.") + + torch.manual_seed(2026) + torch.cuda.manual_seed(2026) + + dispatcher = self._make_dispatcher() + ep_size = dispatcher.ep_size + hidden_size = NANOV3_BASE["hidden_size"] + topk = NANOV3_BASE["moe_router_topk"] + num_experts = NANOV3_BASE["num_moe_experts"] + rank = torch.distributed.get_rank() if ep_size > 1 else 0 + + max_rank_tokens = 24 + tokens_per_rank = [max(1, max_rank_tokens + r - (ep_size - 1)) for r in range(ep_size)] + local_tokens = tokens_per_rank[rank] + total_tokens = sum(tokens_per_rank) + global_max = _NVLS_ENGINE_MAX_TOKENS * ep_size + + global_hidden = torch.randn(total_tokens, hidden_size, device="cuda", dtype=torch.bfloat16) + global_probs = torch.randn(total_tokens, topk, device="cuda", dtype=torch.float32) + global_routing_map = torch.randint(0, num_experts, (total_tokens, topk), device="cuda") + if ep_size > 1: + torch.distributed.broadcast(global_hidden, src=0) + torch.distributed.broadcast(global_probs, src=0) + torch.distributed.broadcast(global_routing_map, src=0) + + start = sum(tokens_per_rank[:rank]) + end = start + local_tokens + static_hidden = global_hidden[start:end].contiguous() + static_probs = global_probs[start:end].contiguous() + static_routing_map = global_routing_map[start:end].contiguous() + + ordered_calls = {"value": 0} + orig_ordered_reduce_scatter_v = batch_invariant.ordered_reduce_scatter_v + + def _tracked_ordered_reduce_scatter_v(*args, **kwargs): + ordered_calls["value"] += 1 + return orig_ordered_reduce_scatter_v(*args, **kwargs) + + monkeypatch.setattr( + batch_invariant, "ordered_reduce_scatter_v", _tracked_ordered_reduce_scatter_v + ) + + with torch.no_grad(), set_batch_invariant_mode(True): + s = torch.cuda.Stream() + s.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(s): + for _ in range(3): + dispatcher.routing_map = static_routing_map + dispatcher._local_tokens = local_tokens + d_hidden, _ = dispatcher.token_dispatch(static_hidden, static_probs) + dispatcher.token_combine(d_hidden.clone()) + torch.cuda.current_stream().wait_stream(s) + + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph): + dispatcher.routing_map = static_routing_map + dispatcher._local_tokens = local_tokens + d_hidden, _ = dispatcher.token_dispatch(static_hidden, static_probs) + assert d_hidden.shape[0] == global_max + graph_combined = dispatcher.token_combine(d_hidden.clone()) + + graph.replay() + + assert ordered_calls["value"] > 0 + assert graph_combined.shape == (local_tokens, hidden_size) + expected_combined = (global_hidden[start:end].float() * ep_size).bfloat16() + torch.testing.assert_close(graph_combined, expected_combined, atol=0, rtol=0) + + def test_cuda_graph_batch_invariant_moe_layer_uses_ordered_rsv(self, monkeypatch): + """A real inference MoE layer should use ordered RSV combine in batch-invariant mode. + + This catches the production branch in InferenceGroupedMLP: mcore_fused_moe + writes deterministic local partials into the symmetric RSV buffer, then + token_combine uses explicit rank-order fp32 loads. + """ + from megatron.core.inference.moe import batch_invariant + from megatron.core.models.gpt.moe_module_specs import get_inference_optimized_moe_spec + from megatron.core.parallel_state import get_expert_model_parallel_group + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + HAVE_DEEPGEMM_BF16, + set_batch_invariant_mode, + ) + from megatron.core.transformer.moe.token_dispatcher_inference import ( + NVLSAllGatherVDispatcher, + ) + + if Utils.world_size < 2: + pytest.skip("NVLS RSV branch test requires expert-parallel world_size > 1.") + if Utils.world_size & (Utils.world_size - 1): + pytest.skip("NVLS Triton symmetric-memory barrier requires power-of-two EP size.") + if not HAVE_DEEPGEMM_BF16: + pytest.skip("Batch-invariant torch MoE path requires DeepGEMM bf16 grouped kernels.") + + torch.manual_seed(2027) + torch.cuda.manual_seed(2027) + + config = _make_base_config( + expert_model_parallel_size=Utils.world_size, + inference_grouped_gemm_backend="torch", + inference_moe_token_dispatcher_type="nvls", + batch_invariant_mode=True, + attention_backend=AttnBackend.flash, + flash_attention_version=4, + attention_dropout=0.0, + moe_shared_expert_intermediate_size=None, + ) + ep_group = get_expert_model_parallel_group() + NVLSAllGatherVDispatcher.allocate_buffers( + per_rank_worst_case_token_count=_NVLS_ENGINE_MAX_TOKENS, + topk=config.moe_router_topk, + hidden_size=config.hidden_size, + ep_group=ep_group, + ) + + layer = get_inference_optimized_moe_spec()(config=config).cuda().eval() + assert isinstance(layer._inference_token_dispatcher, NVLSAllGatherVDispatcher) + layer.token_dispatcher = layer._inference_token_dispatcher + layer.shared_expert_overlap = layer._inference_token_dispatcher.shared_experts is not None + assert not hasattr(layer.experts, "_batch_invariant_global_unpermute") + + used_rsv = {"value": False} + orig_get_rsv_tensor = NVLSAllGatherVDispatcher._get_rsv_tensor.__func__ + + def _tracked_get_rsv_tensor(cls): + tensor = orig_get_rsv_tensor(cls) + used_rsv["value"] = used_rsv["value"] or tensor is not None + return tensor + + monkeypatch.setattr( + NVLSAllGatherVDispatcher, "_get_rsv_tensor", classmethod(_tracked_get_rsv_tensor) + ) + ordered_calls = {"value": 0} + orig_ordered_reduce_scatter_v = batch_invariant.ordered_reduce_scatter_v + + def _tracked_ordered_reduce_scatter_v(*args, **kwargs): + ordered_calls["value"] += 1 + return orig_ordered_reduce_scatter_v(*args, **kwargs) + + monkeypatch.setattr( + batch_invariant, "ordered_reduce_scatter_v", _tracked_ordered_reduce_scatter_v + ) + + local_tokens = 16 + hidden_states = torch.randn( + local_tokens, 1, config.hidden_size, device="cuda", dtype=torch.bfloat16 + ) + probs = torch.randn( + local_tokens, config.moe_router_topk, device="cuda", dtype=torch.float32 + ) + routing_map = ( + torch.arange(local_tokens * config.moe_router_topk, device="cuda") + .reshape(local_tokens, config.moe_router_topk) + .remainder(config.num_moe_experts) + .to(torch.int64) + ) + + def _run_expert_and_combine(): + preprocessed_hidden, preprocessed_probs = layer.preprocess( + hidden_states, probs, routing_map + ) + dispatched_hidden, dispatched_probs = layer.dispatch( + preprocessed_hidden, preprocessed_probs + ) + output, _ = layer.routed_experts_compute(dispatched_hidden, dispatched_probs) + output = layer.combine(output) + return layer.postprocess(output, None) + + with torch.no_grad(), InferenceMode.active(), set_batch_invariant_mode(True): + s = torch.cuda.Stream() + s.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(s): + for _ in range(3): + _run_expert_and_combine() + torch.cuda.current_stream().wait_stream(s) + + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph): + graph_output = _run_expert_and_combine() + + graph.replay() + + assert used_rsv["value"] + assert ordered_calls["value"] > 0 + assert graph_output.shape == hidden_states.shape + assert graph_output.dtype == torch.bfloat16 + + def test_batch_invariant_moe_matches_training(self): + """The NVLS inference MoE path should exactly match training AllToAll.""" + from megatron.core.models.gpt.moe_module_specs import get_inference_optimized_moe_spec + from megatron.core.parallel_state import get_expert_model_parallel_group + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + HAVE_DEEPGEMM_BF16, + set_batch_invariant_mode, + ) + from megatron.core.transformer.moe.token_dispatcher_inference import ( + NVLSAllGatherVDispatcher, + ) + + if Utils.world_size < 2: + pytest.skip("Training-to-inference MoE parity requires expert parallelism.") + if Utils.world_size & (Utils.world_size - 1): + pytest.skip("NVLS Triton symmetric-memory barrier requires power-of-two EP size.") + if not HAVE_DEEPGEMM_BF16: + pytest.skip("Batch-invariant torch MoE path requires DeepGEMM bf16 grouped kernels.") + + torch.manual_seed(2028) + torch.cuda.manual_seed(2028) + + config = _make_base_config( + expert_model_parallel_size=Utils.world_size, + inference_grouped_gemm_backend="torch", + inference_moe_token_dispatcher_type="nvls", + batch_invariant_mode=True, + attention_backend=AttnBackend.flash, + flash_attention_version=4, + attention_dropout=0.0, + moe_shared_expert_intermediate_size=None, + ) + NVLSAllGatherVDispatcher.allocate_buffers( + per_rank_worst_case_token_count=_NVLS_ENGINE_MAX_TOKENS, + topk=config.moe_router_topk, + hidden_size=config.hidden_size, + ep_group=get_expert_model_parallel_group(), + ) + + layer = get_inference_optimized_moe_spec()(config=config).cuda().eval() + local_tokens = 17 + torch.distributed.get_rank() + hidden_states = torch.randn( + local_tokens, 1, config.hidden_size, device="cuda", dtype=torch.bfloat16 + ) + + with torch.no_grad(), set_batch_invariant_mode(True): + training_output, _ = layer(hidden_states.clone()) + with InferenceMode.active(): + inference_output, _ = layer(hidden_states.clone()) + + torch.testing.assert_close(inference_output, training_output, atol=0, rtol=0) + # ────────────────────────────────────────────────────────────────────── # mask_routing_padding kernel diff --git a/tests/unit_tests/inference/test_moe_permute.py b/tests/unit_tests/inference/test_moe_permute.py index 6bddf515b14..8be6aec7f59 100644 --- a/tests/unit_tests/inference/test_moe_permute.py +++ b/tests/unit_tests/inference/test_moe_permute.py @@ -49,6 +49,37 @@ def _make_inputs(num_tokens, hidden_dim, topk, num_experts, seed=42): return hidden, probs, routing_map +def test_batch_invariant_squared_relu_applies_probs_before_fc2(): + """Match training's probability placement and BF16 rounding before FC2.""" + from megatron.core.activations import squared_relu + from megatron.core.inference.moe.activations import padded_squared_relu + from megatron.core.inference.moe.batch_invariant import squared_relu_with_probs + + torch.manual_seed(17) + rows, hidden, output_size = 37, 1856, 512 + x = torch.randn(rows, hidden, device="cuda", dtype=torch.bfloat16) + probs = torch.rand(rows, device="cuda", dtype=torch.float32) + fc2_weight = torch.randn(output_size, hidden, device="cuda", dtype=torch.bfloat16) + permutation_map = torch.arange(rows, device="cuda", dtype=torch.int32) + + unweighted = padded_squared_relu(x, permutation_map, _vt(rows)) + actual = squared_relu_with_probs(x, permutation_map, _vt(rows), probs) + expected_unweighted = squared_relu(x) + expected = (squared_relu(x) * probs.unsqueeze(1)).to(torch.bfloat16) + + assert torch.equal(unweighted, expected_unweighted) + assert torch.equal(actual, expected) + + training_output = expected @ fc2_weight.T + inference_output = actual @ fc2_weight.T + old_inference_output = ((unweighted @ fc2_weight.T).float() * probs.unsqueeze(1)).to( + torch.bfloat16 + ) + + assert torch.equal(inference_output, training_output) + assert not torch.equal(old_inference_output, training_output) + + @pytest.mark.internal class TestComputeLocalTokensPerExpert: @@ -393,6 +424,50 @@ def test_multiple_topk_accumulation(self, topk): result[0], torch.full((hidden_dim,), expected_val, device="cuda"), atol=1e-4, rtol=1e-4 ) + def test_batch_invariant_unpermute_is_token_local(self): + """Unrelated earlier tokens must not affect another token's top-k sum.""" + from megatron.core.inference.moe.permute import unpermute_tokens + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + set_batch_invariant_mode, + ) + + hidden_dim = 8 + permutation_map = torch.empty(3, dtype=torch.int32, device="cuda") + probs = torch.ones(3, device="cuda", dtype=torch.float32) + + # Token 1 has two unit contributions in both layouts. Layout B adds a + # huge unrelated token-0 contribution before it; token 1 must not drift. + expert_output_a = torch.ones(2, hidden_dim, device="cuda", dtype=torch.bfloat16) + inverse_a = torch.tensor([[-1, -1], [0, 1]], dtype=torch.int32, device="cuda") + + expert_output_b = torch.ones(3, hidden_dim, device="cuda", dtype=torch.bfloat16) + expert_output_b[0] = 1e20 + inverse_b = torch.tensor([[0, -1], [1, 2]], dtype=torch.int32, device="cuda") + + with set_batch_invariant_mode(True): + out_a = unpermute_tokens( + expert_output_a, + probs[:2], + permutation_map[:2], + 2, + _vt(2), + _vt(2), + batch_invariant_inverse_map=inverse_a, + ) + out_b = unpermute_tokens( + expert_output_b, + probs, + permutation_map, + 2, + _vt(3), + _vt(2), + batch_invariant_inverse_map=inverse_b, + ) + + expected = torch.full((hidden_dim,), 2.0, device="cuda") + torch.testing.assert_close(out_a[1], expected, rtol=0.0, atol=0.0) + torch.testing.assert_close(out_b[1], expected, rtol=0.0, atol=0.0) + @pytest.mark.internal class TestPermuteUnpermuteRoundtrip: diff --git a/tests/unit_tests/models/test_gpt_model_batch_invariant.py b/tests/unit_tests/models/test_gpt_model_batch_invariant.py index b52fd64f592..1e93687bcd7 100644 --- a/tests/unit_tests/models/test_gpt_model_batch_invariant.py +++ b/tests/unit_tests/models/test_gpt_model_batch_invariant.py @@ -18,7 +18,10 @@ from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec from megatron.core.models.gpt.gpt_model import GPTModel from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.custom_layers.batch_invariant_kernels import set_batch_invariant_mode +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + set_batch_invariant_mode, + te_supports_batch_invariant_attention, +) from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.module import Float16Module from megatron.core.transformer.transformer_config import TransformerConfig @@ -48,6 +51,10 @@ # Batch-invariant mode requires an explicit FlashAttention version; pick the newest # one available so training and inference run the same kernel. _BIK_FA_VERSION = 4 if HAVE_FA4 else 3 +pytestmark = pytest.mark.skipif( + not te_supports_batch_invariant_attention(), + reason="Batch-invariant attention requires TransformerEngine PR #3204 or >= 2.18.", +) class DummyTokenizer: diff --git a/tests/unit_tests/rl/test_rl_batch_invariant.py b/tests/unit_tests/rl/test_rl_batch_invariant.py index ab339755307..53545093e72 100644 --- a/tests/unit_tests/rl/test_rl_batch_invariant.py +++ b/tests/unit_tests/rl/test_rl_batch_invariant.py @@ -14,7 +14,7 @@ def test_selective_log_softmax_batch_invariant(): B, S, V = 4, 7, 16 device = torch.device("cuda") - logits = torch.randn(B, S, V, dtype=torch.float32, device=device) + logits = torch.randn(B, S, V, dtype=torch.bfloat16, device=device) labels = torch.randint(low=0, high=V, size=(B, S), device=device) # Randomly permute the batch dimension; a batch-invariant implementation should @@ -30,4 +30,86 @@ def test_selective_log_softmax_batch_invariant(): # Undo the permutation on the permuted outputs and compare elementwise. # If the kernel is batch invariant, each example's output should not depend # on its position in the batch. + assert bik_logps.dtype == torch.float32 assert torch.equal(bik_logps, bik_logps_perm[perm.argsort()]) + + +def test_moe_unpermute_batch_invariant_inverse_map_rank_tree(): + from megatron.core import parallel_state + from megatron.core.transformer.moe.moe_utils import unpermute + + hidden = 4 + tokens = torch.tensor( + [[1e20], [1.0], [-1e20], [1.0]], device="cuda", dtype=torch.float32 + ).expand(4, hidden) + sorted_indices = torch.zeros(4, device="cuda", dtype=torch.int64) + routing_map = torch.ones(1, 4, device="cuda", dtype=torch.bool) + inverse_map = torch.tensor([[[0, 1, 2, 3]], [[0, 1, 2, 3]]], device="cuda", dtype=torch.int64) + + parallel_state.set_expert_model_parallel_world_size(2) + try: + with set_batch_invariant_mode(True): + out = unpermute( + tokens, + sorted_indices, + (1, hidden), + routing_map=routing_map, + batch_invariant_inverse_map=inverse_map, + ) + finally: + parallel_state.set_expert_model_parallel_world_size(None) + + torch.testing.assert_close(out[0], torch.zeros(hidden, device="cuda"), rtol=0.0, atol=0.0) + + +def test_moe_batch_invariant_permute_unpermute_cuda_graph_non_padded(): + from megatron.core import parallel_state + from megatron.core.transformer.moe.moe_utils import permute, unpermute + + torch.manual_seed(123) + num_tokens, hidden, num_experts, topk = 6, 8, 4, 2 + tokens = torch.randn(num_tokens, hidden, device="cuda", dtype=torch.bfloat16) + routing_map = torch.zeros(num_tokens, num_experts, device="cuda", dtype=torch.bool) + routing_map[:, 0] = True + routing_map[:, 2] = True + probs = torch.rand(num_tokens, num_experts, device="cuda", dtype=torch.float32) + + def _run(): + permuted, _, sorted_indices, inverse_map, _ = permute( + tokens, + routing_map, + probs=probs, + num_out_tokens=num_tokens * topk, + return_batch_invariant_inverse_map=True, + ) + return unpermute( + permuted, + sorted_indices, + tokens.shape, + probs=probs, + routing_map=routing_map, + batch_invariant_inverse_map=inverse_map, + ) + + parallel_state.set_expert_model_parallel_world_size(2) + try: + with torch.no_grad(), set_batch_invariant_mode(True): + stream = torch.cuda.Stream() + stream.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(stream): + for _ in range(3): + expected = _run() + torch.cuda.current_stream().wait_stream(stream) + + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph): + graph_out = _run() + graph.replay() + finally: + parallel_state.set_expert_model_parallel_world_size(None) + + torch.testing.assert_close(graph_out, expected, rtol=0.0, atol=0.0) + reference = (tokens.float() * probs[:, 0, None] + tokens.float() * probs[:, 2, None]).to( + tokens.dtype + ) + torch.testing.assert_close(graph_out, reference, rtol=0.0, atol=0.0) diff --git a/tests/unit_tests/ssm/ops/test_batch_invariant_decode.py b/tests/unit_tests/ssm/ops/test_batch_invariant_decode.py new file mode 100644 index 00000000000..904a6ab9e22 --- /dev/null +++ b/tests/unit_tests/ssm/ops/test_batch_invariant_decode.py @@ -0,0 +1,715 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +"""Tests for batch-invariant Mamba decode.""" + +import unittest + +import torch + +try: + from mamba_ssm.ops.triton.ssd_combined import mamba_chunk_scan_combined + + from megatron.core.ssm.ops.batch_invariant_decode import ( + BatchInvariantDecodeBuffers, + batch_invariant_decode_buffered_scan, + ) + from megatron.core.ssm.ops.ssd_combined import mamba_chunk_scan_combined_varlen + + HAVE_BATCH_INVARIANT_DECODE = True +except ImportError: + HAVE_BATCH_INVARIANT_DECODE = False + + +def _full_scan(x, dt, A, B, C, D, dt_bias, chunk_size, initial_states=None): + """Reference: a single `mamba_chunk_scan_combined` over the whole sequence.""" + y, final = mamba_chunk_scan_combined( + x, + dt, + A, + B, + C, + chunk_size, + D=D, + z=x, + dt_bias=dt_bias, + dt_softplus=True, + initial_states=initial_states, + return_final_states=True, + ) + return y, final + + +@unittest.skipIf(not HAVE_BATCH_INVARIANT_DECODE, "mamba_ssm / batch_invariant_decode unavailable") +@unittest.skipIf(not torch.cuda.is_available(), "CUDA required") +class TestBatchInvariantDecodeBufferedScan(unittest.TestCase): + """Verify the batch-invariant decode scan matches a full-sequence scan bitwise.""" + + @classmethod + def setUpClass(cls): + # Pin the Mamba autotuners exactly like enable_batch_invariant_mode + # does in production: without pinning, autotune timing noise can pick + # different tile configs per process and flake the bitwise asserts. + from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + _pin_mamba_autotuners, + ) + + _pin_mamba_autotuners() + + def setUp(self): + torch.manual_seed(0) + # No global flags: batch_invariant_decode_buffered_scan is a pure tensor-ops function + # and batch-invariant mode by design does not require + # torch.use_deterministic_algorithms. + self.device = torch.device("cuda") + self.dtype = torch.bfloat16 + # Small but non-trivial mamba dims. + self.nh = 8 + self.headdim = 32 + self.ngroups = 1 + self.dstate = 16 + self.chunk_size = 32 + self.A = -torch.exp(torch.randn(self.nh, device=self.device, dtype=torch.float32).abs()) + self.D = torch.randn(self.nh, device=self.device, dtype=torch.float32) * 0.1 + self.dt_bias = torch.randn(self.nh, device=self.device, dtype=torch.float32) * 0.01 + + def _make_seq(self, total_len): + """Generate a (1, total_len, ...) random mamba input sequence.""" + nh, p, ng, n = self.nh, self.headdim, self.ngroups, self.dstate + return ( + torch.randn(1, total_len, nh, p, device=self.device, dtype=self.dtype) * 0.1, + torch.randn(1, total_len, nh, device=self.device, dtype=self.dtype).abs() * 0.1, + torch.randn(1, total_len, ng, n, device=self.device, dtype=self.dtype) * 0.1, + torch.randn(1, total_len, ng, n, device=self.device, dtype=self.dtype) * 0.1, + ) + + def _make_bufs(self, max_requests): + return BatchInvariantDecodeBuffers.allocate( + max_requests, + self.chunk_size, + self.nh, + self.headdim, + self.ngroups, + self.dstate, + self.device, + self.dtype, + ) + + def _make_ssm_state(self, max_requests): + """Production BIK state cache: FP32 carry across Mamba chunks.""" + return torch.zeros( + max_requests, + self.nh, + self.headdim, + self.dstate, + device=self.device, + dtype=torch.float32, + ) + + def _seed_from_prefill(self, bufs, x, dt, B, C, prefill_len, slot, max_requests): + """Run the prefill through the reference scan, store its ssm_state at + the slot, and seed the batch-invariant buffer with the partial-chunk tail.""" + # Production batch-invariant prefill keeps ssm_state at a full Mamba chunk + # boundary. Short prefills therefore keep the zero initial boundary; + # longer prefills store the largest chunk-aligned prefix state. + ssm_state = self._make_ssm_state(max_requests) + if prefill_len >= self.chunk_size: + # Prefill on the largest chunk-aligned prefix; the tail goes in the buffer. + aligned = (prefill_len // self.chunk_size) * self.chunk_size + _, final = _full_scan( + x[:, :aligned], + dt[:, :aligned], + self.A, + B[:, :aligned], + C[:, :aligned], + self.D, + self.dt_bias, + self.chunk_size, + initial_states=None, + ) + ssm_state[slot] = final[0] + + cu = torch.tensor([0, prefill_len], dtype=torch.int32, device=self.device) + batch_indices = torch.tensor([slot], dtype=torch.int32, device=self.device) + # Buffer seeding expects the flat layout used by the mixer's prefill path. + # Here total == prefill_len since we have 1 sequence. + bufs.seed( + x[0, :prefill_len], + x[0, :prefill_len], + dt[0, :prefill_len], + B[0, :prefill_len], + C[0, :prefill_len], + cu, + batch_indices, + ) + return ssm_state + + def _decode_one_step(self, bufs, x, dt, B, C, pos, slot, ssm_state): + """Call batch_invariant_decode_buffered_scan for the single token at index `pos`.""" + batch_indices = torch.tensor([slot], dtype=torch.int32, device=self.device) + return batch_invariant_decode_buffered_scan( + bufs, + x[:, pos : pos + 1], + x[:, pos : pos + 1], + dt[:, pos : pos + 1], + B[:, pos : pos + 1], + C[:, pos : pos + 1], + self.A, + self.D, + self.dt_bias, + batch_indices, + ssm_state, + ) + + def _varlen_boundary_state_from_prefill(self, x, dt, B, C, prefill_len, initial_states=None): + """Production-shaped varlen prefill returning the last full chunk boundary state.""" + chunk_boundaries = [0] + pos = self.chunk_size + while pos < prefill_len: + chunk_boundaries.append(pos) + pos += self.chunk_size + chunk_boundaries.append(prefill_len) + + cu_chunk_seqlens = torch.tensor(chunk_boundaries, dtype=torch.int32, device=self.device) + last_chunk_indices = torch.tensor( + [len(chunk_boundaries) - 2], dtype=torch.int32, device=self.device + ) + tail_len = prefill_len % self.chunk_size + has_boundary = prefill_len >= self.chunk_size + boundary_idx = last_chunk_indices.to(torch.long) + if tail_len != 0: + boundary_idx = boundary_idx - 1 + boundary_idx = boundary_idx.clamp(min=0) + + out = torch.zeros_like(x[0, :prefill_len]) + seq_idx = torch.zeros(len(chunk_boundaries) - 1, dtype=torch.int32, device=self.device) + _, chunk_states = mamba_chunk_scan_combined_varlen( + x=x[0, :prefill_len], + dt=dt[0, :prefill_len], + A=self.A, + B=B[0, :prefill_len], + C=C[0, :prefill_len], + chunk_size=self.chunk_size, + cu_chunk_seqlens=cu_chunk_seqlens, + last_chunk_indices=last_chunk_indices, + seq_idx=seq_idx, + out=out, + D=self.D, + z=None, + dt_bias=self.dt_bias, + initial_states=initial_states, + return_raw_states=True, + dt_softplus=True, + dt_limit=(0.0, float("inf")), + state_dtype=torch.float32, + ) + final_state = chunk_states[last_chunk_indices] + boundary_state = chunk_states[boundary_idx] + if not has_boundary: + boundary_state = ( + torch.zeros_like(boundary_state) if initial_states is None else initial_states + ) + return final_state, boundary_state + + def _assert_bitwise(self, a, b, msg): + # bf16 outputs — bitwise-equal is the actual batch-invariant claim. + diff = (a.float() - b.float()).abs().max().item() + self.assertEqual(diff, 0.0, f"{msg}: max_abs_diff={diff:.3e}") + + def test_single_decode_matches_full_scan(self): + """Default case: prefill > chunk_size, single decode token, partial tail.""" + max_requests, slot = 4, 1 + for prefill_len in [33, 50, 95, 128]: + with self.subTest(prefill_len=prefill_len): + total = prefill_len + 1 + x, dt, B, C = self._make_seq(total) + # Reference: full scan over the whole (prefill + 1) sequence. + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + # batch-invariant: seed from prefill, then one decode step. + bufs = self._make_bufs(max_requests) + ssm_state = self._seed_from_prefill( + bufs, x, dt, B, C, prefill_len, slot, max_requests + ) + y_batch_invariant = self._decode_one_step( + bufs, x, dt, B, C, prefill_len, slot, ssm_state + ) + self._assert_bitwise( + y_batch_invariant[0, 0], y_full[0, prefill_len], f"prefill_len={prefill_len}" + ) + + def test_rejects_bf16_state_cache(self): + """A rounded state cache cannot preserve carry across multiple chunks.""" + x, dt, B, C = self._make_seq(1) + bufs = self._make_bufs(max_requests=2) + ssm_state = torch.zeros( + 2, self.nh, self.headdim, self.dstate, device=self.device, dtype=torch.bfloat16 + ) + with self.assertRaisesRegex(AssertionError, "requires an FP32 SSM state cache"): + self._decode_one_step(bufs, x, dt, B, C, pos=0, slot=0, ssm_state=ssm_state) + + def test_inference_config_uses_fp32_state_cache(self): + """BIK model-derived inference config cannot select a rounded state dtype.""" + from types import SimpleNamespace + from unittest.mock import patch + + from megatron.core.inference.config import MambaInferenceStateConfig + from megatron.core.models.hybrid.hybrid_layer_allocation import Symbols + + model = SimpleNamespace( + config=SimpleNamespace(batch_invariant_mode=True, params_dtype=torch.bfloat16) + ) + decoder = SimpleNamespace( + layer_type_list=[Symbols.MAMBA], + layers=[SimpleNamespace(mixer=SimpleNamespace(chunk_size=self.chunk_size))], + mamba_state_shapes_per_request=lambda: ((4, 8), (8, 32, 16)), + ) + with patch("megatron.core.inference.config.get_attr_wrapped_model", return_value=decoder): + config = MambaInferenceStateConfig.from_model(model) + self.assertEqual(config.ssm_states_dtype, torch.float32) + with self.assertRaisesRegex(ValueError, "requires FP32 Mamba SSM states"): + MambaInferenceStateConfig.from_model(model, ssm_states_dtype=torch.bfloat16) + model.config.batch_invariant_mode = False + config = MambaInferenceStateConfig.from_model(model) + self.assertEqual(config.ssm_states_dtype, torch.bfloat16) + + def test_dynamic_prefill_uses_boundary_state_not_prompt_end_state(self): + """Production prefill returns the prompt-end state too, but batch-invariant decode + must keep the cache at the last full chunk boundary and put the tail in + the replay buffer.""" + max_requests, slot = 4, 1 + for prefill_len in [31, 33, 50, 95, 128]: + with self.subTest(prefill_len=prefill_len): + total = prefill_len + 1 + x, dt, B, C = self._make_seq(total) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + + _, boundary_state = self._varlen_boundary_state_from_prefill( + x, dt, B, C, prefill_len + ) + ssm_state = torch.randn_like(self._make_ssm_state(max_requests)) + ssm_state[slot] = boundary_state[0] + + bufs = self._make_bufs(max_requests) + cu = torch.tensor([0, prefill_len], dtype=torch.int32, device=self.device) + batch_indices = torch.tensor([slot], dtype=torch.int32, device=self.device) + bufs.seed( + x[0, :prefill_len], + x[0, :prefill_len], + dt[0, :prefill_len], + B[0, :prefill_len], + C[0, :prefill_len], + cu, + batch_indices, + ) + y_batch_invariant = self._decode_one_step( + bufs, x, dt, B, C, prefill_len, slot, ssm_state + ) + self._assert_bitwise( + y_batch_invariant[0, 0], + y_full[0, prefill_len], + f"dynamic prefill boundary state prefill_len={prefill_len}", + ) + + def test_chunked_prefill_handoff_matches_full_scan(self): + """Splitting prefill at a Mamba boundary preserves exact decode output.""" + max_requests, slot = 2, 0 + first_chunk_len = 2 * self.chunk_size + + for final_chunk_len in [20, self.chunk_size + 13]: + with self.subTest(final_chunk_len=final_chunk_len): + prefill_len = first_chunk_len + final_chunk_len + x, dt, B, C = self._make_seq(prefill_len + 1) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + + _, first_boundary = self._varlen_boundary_state_from_prefill( + x[:, :first_chunk_len], + dt[:, :first_chunk_len], + B[:, :first_chunk_len], + C[:, :first_chunk_len], + first_chunk_len, + ) + _, final_boundary = self._varlen_boundary_state_from_prefill( + x[:, first_chunk_len:prefill_len], + dt[:, first_chunk_len:prefill_len], + B[:, first_chunk_len:prefill_len], + C[:, first_chunk_len:prefill_len], + final_chunk_len, + initial_states=first_boundary, + ) + + ssm_state = self._make_ssm_state(max_requests) + ssm_state[slot] = final_boundary[0] + bufs = self._make_bufs(max_requests) + cu = torch.tensor([0, final_chunk_len], dtype=torch.int32, device=self.device) + bufs.seed( + x[0, first_chunk_len:prefill_len], + x[0, first_chunk_len:prefill_len], + dt[0, first_chunk_len:prefill_len], + B[0, first_chunk_len:prefill_len], + C[0, first_chunk_len:prefill_len], + cu, + torch.tensor([slot], dtype=torch.int32, device=self.device), + ) + y_batch_invariant = self._decode_one_step( + bufs, x, dt, B, C, prefill_len, slot, ssm_state + ) + self._assert_bitwise( + y_batch_invariant[0, 0], + y_full[0, prefill_len], + f"chunked prefill final_chunk_len={final_chunk_len}", + ) + + def test_seed_ignores_nonfinite_physical_padding_rows(self): + """Dynamic prefill can carry padded physical token rows after the real + prefix. Seed must duplicate a valid per-sequence tail token into unused + replay-buffer rows; otherwise masked future rows can still poison the + row-gated Triton dot as 0 * NaN.""" + max_requests, slot = 4, 0 + prefill_len = self.chunk_size + 1 + total = prefill_len + 1 + x, dt, B, C = self._make_seq(total) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + + _, boundary_state = self._varlen_boundary_state_from_prefill(x, dt, B, C, prefill_len) + ssm_state = self._make_ssm_state(max_requests) + ssm_state[slot] = boundary_state[0] + + nan_x = torch.full_like(x[0, :1], float("nan")) + nan_dt = torch.full_like(dt[0, :1], float("nan")) + nan_B = torch.full_like(B[0, :1], float("nan")) + nan_C = torch.full_like(C[0, :1], float("nan")) + + bufs = self._make_bufs(max_requests) + cu = torch.tensor([0, prefill_len], dtype=torch.int32, device=self.device) + batch_indices = torch.tensor([slot], dtype=torch.int32, device=self.device) + bufs.seed( + torch.cat([x[0, :prefill_len], nan_x], dim=0), + torch.cat([x[0, :prefill_len], nan_x], dim=0), + torch.cat([dt[0, :prefill_len], nan_dt], dim=0), + torch.cat([B[0, :prefill_len], nan_B], dim=0), + torch.cat([C[0, :prefill_len], nan_C], dim=0), + cu, + batch_indices, + ) + self.assertTrue(torch.isfinite(bufs.x[slot]).all()) + self.assertTrue(torch.isfinite(bufs.z[slot]).all()) + self.assertTrue(torch.isfinite(bufs.dt[slot]).all()) + self.assertTrue(torch.isfinite(bufs.B[slot]).all()) + self.assertTrue(torch.isfinite(bufs.C[slot]).all()) + + y_batch_invariant = self._decode_one_step(bufs, x, dt, B, C, prefill_len, slot, ssm_state) + self._assert_bitwise( + y_batch_invariant[0, 0], y_full[0, prefill_len], "nonfinite physical padding rows" + ) + + def test_short_prefill_uses_zero_boundary_state(self): + """prefill_len < chunk_size: decode replays from the zero boundary.""" + max_requests, slot = 2, 0 + for prefill_len in [1, 7, 16, 31]: + with self.subTest(prefill_len=prefill_len): + total = prefill_len + 1 + x, dt, B, C = self._make_seq(total) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + bufs = self._make_bufs(max_requests) + ssm_state = self._seed_from_prefill( + bufs, x, dt, B, C, prefill_len, slot, max_requests + ) + y_batch_invariant = self._decode_one_step( + bufs, x, dt, B, C, prefill_len, slot, ssm_state + ) + self._assert_bitwise( + y_batch_invariant[0, 0], y_full[0, prefill_len], f"prefill_len={prefill_len}" + ) + + def test_multi_step_decode_across_chunk_boundary(self): + """Step decode several times so the per-slot buffer fills, crosses + a chunk boundary, and resets. Each step must match the full scan.""" + max_requests, slot = 2, 0 + prefill_len = 20 # < chunk_size, so first decode step will keep growing buf + n_decode = self.chunk_size + 5 # enough to cross at least one boundary + total = prefill_len + n_decode + x, dt, B, C = self._make_seq(total) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + + bufs = self._make_bufs(max_requests) + ssm_state = self._seed_from_prefill(bufs, x, dt, B, C, prefill_len, slot, max_requests) + + for k in range(n_decode): + pos = prefill_len + k + y_batch_invariant = self._decode_one_step(bufs, x, dt, B, C, pos, slot, ssm_state) + self._assert_bitwise( + y_batch_invariant[0, 0], + y_full[0, pos], + f"step k={k} (pos={pos}, num_buffered_before={bufs.num_buffered[slot].item()})", + ) + + def test_multi_slot_independent_streams(self): + """Two slots with different prefill lengths decoded in the same call — + each slot's output must match its own full scan.""" + max_requests = 4 + slots = [0, 2] + prefill_lens = [25, 70] # one short, one long with a boundary state + x_per_slot, dt_per_slot, B_per_slot, C_per_slot = [], [], [], [] + y_refs = [] + for plen in prefill_lens: + x, dt, B, C = self._make_seq(plen + 1) + x_per_slot.append(x) + dt_per_slot.append(dt) + B_per_slot.append(B) + C_per_slot.append(C) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + y_refs.append(y_full[0, plen]) + + bufs = self._make_bufs(max_requests) + # Per-slot seeding (each slot's prefill done independently). + ssm_state = self._make_ssm_state(max_requests) + for slot, plen, x, dt, B, C in zip( + slots, prefill_lens, x_per_slot, dt_per_slot, B_per_slot, C_per_slot + ): + partial = self._seed_from_prefill(bufs, x, dt, B, C, plen, slot, max_requests) + ssm_state[slot] = partial[slot] + + # Both slots step at once. + x_step = torch.cat( + [x_per_slot[i][:, prefill_lens[i] : prefill_lens[i] + 1] for i in range(2)], dim=0 + ) + dt_step = torch.cat( + [dt_per_slot[i][:, prefill_lens[i] : prefill_lens[i] + 1] for i in range(2)], dim=0 + ) + B_step = torch.cat( + [B_per_slot[i][:, prefill_lens[i] : prefill_lens[i] + 1] for i in range(2)], dim=0 + ) + C_step = torch.cat( + [C_per_slot[i][:, prefill_lens[i] : prefill_lens[i] + 1] for i in range(2)], dim=0 + ) + batch_indices = torch.tensor(slots, dtype=torch.int32, device=self.device) + y_batch_invariant = batch_invariant_decode_buffered_scan( + bufs, + x_step, + x_step, + dt_step, + B_step, + C_step, + self.A, + self.D, + self.dt_bias, + batch_indices, + ssm_state, + ) + for i, plen in enumerate(prefill_lens): + self._assert_bitwise( + y_batch_invariant[i, 0], y_refs[i], f"multi-slot slot={slots[i]} prefill_len={plen}" + ) + + def test_inactive_padding_entries(self): + """batch_indices mixing -1 padding entries with active slot 0 (the CUDA- + graph padding pattern). Padding entries must not write replay buffers, + perturb slot 0, or produce nonzero output.""" + max_requests, slot = 3, 0 + prefill_len = 50 + n_decode = 8 + total = prefill_len + n_decode + x, dt, B, C = self._make_seq(total) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + + bufs = self._make_bufs(max_requests) + ssm_state = self._seed_from_prefill(bufs, x, dt, B, C, prefill_len, slot, max_requests) + batch_indices = torch.tensor([slot, -1, -1], dtype=torch.int32, device=self.device) + inactive_counts = bufs.num_buffered[1:].clone() + for k in range(n_decode): + pos = prefill_len + k + + # Entry 0 carries the real token; padding entries carry garbage. + def pad3(t): + junk = torch.randn(2, *t.shape[1:], device=t.device, dtype=t.dtype) + return torch.cat([t, junk], dim=0) + + y_batch_invariant = batch_invariant_decode_buffered_scan( + bufs, + pad3(x[:, pos : pos + 1]), + pad3(x[:, pos : pos + 1]), + pad3(dt[:, pos : pos + 1]), + pad3(B[:, pos : pos + 1]), + pad3(C[:, pos : pos + 1]), + self.A, + self.D, + self.dt_bias, + batch_indices, + ssm_state, + ) + self._assert_bitwise(y_batch_invariant[0, 0], y_full[0, pos], f"padded step k={k}") + # Padding entries must return zeros. + self.assertEqual(y_batch_invariant[1:].abs().max().item(), 0.0) + torch.testing.assert_close(bufs.num_buffered[1:], inactive_counts) + + def test_seed_skips_padding_entries(self): + """Padded prefill entries must not write any persistent replay state.""" + prefill_len = 20 + x, dt, B, C = self._make_seq(prefill_len) + bufs = self._make_bufs(3) + bufs.x[1].normal_() + bufs.z[1].normal_() + bufs.dt[1].normal_() + bufs.B[1].normal_() + bufs.C[1].normal_() + bufs.num_buffered[1] = 7 + before = tuple( + tensor[1].clone() + for tensor in (bufs.x, bufs.z, bufs.dt, bufs.B, bufs.C, bufs.num_buffered) + ) + + bufs.seed( + x[0], + x[0], + dt[0], + B[0], + C[0], + torch.tensor([0, prefill_len, prefill_len], dtype=torch.int32, device=self.device), + torch.tensor([0, -1], dtype=torch.int32, device=self.device), + ) + + for tensor, expected in zip( + (bufs.x, bufs.z, bufs.dt, bufs.B, bufs.C, bufs.num_buffered), before + ): + torch.testing.assert_close(tensor[1], expected) + + def test_cuda_graph_replay_matches_full_scan(self): + """A captured decode step advances persistent state exactly across replays.""" + max_requests, slot = 2, 0 + prefill_len = 20 + x, dt, B, C = self._make_seq(prefill_len + 2) + y_full, _ = _full_scan(x, dt, self.A, B, C, self.D, self.dt_bias, self.chunk_size) + + # Compile Triton before capture without touching the graph's buffers. + warmup_bufs = self._make_bufs(max_requests) + warmup_state = self._seed_from_prefill( + warmup_bufs, x, dt, B, C, prefill_len, slot, max_requests + ) + self._decode_one_step(warmup_bufs, x, dt, B, C, prefill_len, slot, warmup_state) + + bufs = self._make_bufs(max_requests) + ssm_state = self._seed_from_prefill(bufs, x, dt, B, C, prefill_len, slot, max_requests) + batch_indices = torch.tensor([slot], dtype=torch.int32, device=self.device) + static_x = x[:, prefill_len : prefill_len + 1].clone() + static_z = static_x.clone() + static_dt = dt[:, prefill_len : prefill_len + 1].clone() + static_B = B[:, prefill_len : prefill_len + 1].clone() + static_C = C[:, prefill_len : prefill_len + 1].clone() + + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph): + graph_output = batch_invariant_decode_buffered_scan( + bufs, + static_x, + static_z, + static_dt, + static_B, + static_C, + self.A, + self.D, + self.dt_bias, + batch_indices, + ssm_state, + ) + + # Capture executes once, so restore the replay cursor before the first replay. + cu = torch.tensor([0, prefill_len], dtype=torch.int32, device=self.device) + bufs.seed( + x[0, :prefill_len], + x[0, :prefill_len], + dt[0, :prefill_len], + B[0, :prefill_len], + C[0, :prefill_len], + cu, + batch_indices, + ) + graph.replay() + self._assert_bitwise(graph_output[0, 0], y_full[0, prefill_len], "CUDA graph replay step 0") + + static_x.copy_(x[:, prefill_len + 1 : prefill_len + 2]) + static_z.copy_(x[:, prefill_len + 1 : prefill_len + 2]) + static_dt.copy_(dt[:, prefill_len + 1 : prefill_len + 2]) + static_B.copy_(B[:, prefill_len + 1 : prefill_len + 2]) + static_C.copy_(C[:, prefill_len + 1 : prefill_len + 2]) + graph.replay() + self._assert_bitwise( + graph_output[0, 0], y_full[0, prefill_len + 1], "CUDA graph replay step 1" + ) + + def test_crossing_with_dominant_carried_state(self): + """Repeated boundary crossings where the carried state dominates the output + (weak decay: A ~ -0.01 → exp(dA_cs) ≈ 1). Guards the pipeline + ordering and FP32 state-passing carry. With strong decay, either + corruption can round away in BF16 and hide.""" + max_requests, slot = 2, 0 + prefill_len = 20 + # Cross twice: the second transition detects an accidental BF16 + # store/reload of state passing's FP32 carry. + n_decode = 2 * self.chunk_size + 5 + total = prefill_len + n_decode + x, dt, B, C = self._make_seq(total) + + weak_A = self.A * 0.01 + y_full, _ = mamba_chunk_scan_combined( + x, + dt, + weak_A, + B, + C, + self.chunk_size, + D=self.D, + z=x, + dt_bias=self.dt_bias, + dt_softplus=True, + initial_states=None, + return_final_states=True, + ) + + bufs = self._make_bufs(max_requests) + ssm_state = self._make_ssm_state(max_requests) + cu = torch.tensor([0, prefill_len], dtype=torch.int32, device=self.device) + batch_indices = torch.tensor([slot], dtype=torch.int32, device=self.device) + bufs.seed( + x[0, :prefill_len], + x[0, :prefill_len], + dt[0, :prefill_len], + B[0, :prefill_len], + C[0, :prefill_len], + cu, + batch_indices, + ) + for k in range(n_decode): + pos = prefill_len + k + y_batch_invariant = batch_invariant_decode_buffered_scan( + bufs, + x[:, pos : pos + 1], + x[:, pos : pos + 1], + dt[:, pos : pos + 1], + B[:, pos : pos + 1], + C[:, pos : pos + 1], + weak_A, + self.D, + self.dt_bias, + batch_indices, + ssm_state, + ) + self._assert_bitwise(y_batch_invariant[0, 0], y_full[0, pos], f"weak-decay step k={k}") + + def test_deterministic_across_calls(self): + """Same inputs → bitwise-identical output across repeated invocations.""" + max_requests, slot = 2, 0 + prefill_len = 50 + total = prefill_len + 1 + x, dt, B, C = self._make_seq(total) + + outs = [] + for _ in range(3): + bufs = self._make_bufs(max_requests) + ssm_state = self._seed_from_prefill(bufs, x, dt, B, C, prefill_len, slot, max_requests) + outs.append(self._decode_one_step(bufs, x, dt, B, C, prefill_len, slot, ssm_state)) + for i in range(1, len(outs)): + self.assertTrue( + torch.equal(outs[0], outs[i]), f"determinism: run {i} differs from run 0" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/ssm/ops/test_ssm_kernel.py b/tests/unit_tests/ssm/ops/test_ssm_kernel.py index 6ef5610f819..62c25f43f0f 100644 --- a/tests/unit_tests/ssm/ops/test_ssm_kernel.py +++ b/tests/unit_tests/ssm/ops/test_ssm_kernel.py @@ -2,6 +2,7 @@ import math import unittest +from types import SimpleNamespace from unittest.mock import MagicMock import torch @@ -82,6 +83,7 @@ def setUp(self): # Create the Mixer instance directly self.mixer = MagicMock(spec=MambaMixer) + self.mixer.config = SimpleNamespace(batch_invariant_mode=False) self.mixer.d_state = self.d_state self.mixer.d_conv = self.d_conv self.mixer.headdim = self.headdim diff --git a/tests/unit_tests/transformer/moe/test_moe_batch_invariant.py b/tests/unit_tests/transformer/moe/test_moe_batch_invariant.py new file mode 100644 index 00000000000..a163d5ea09d --- /dev/null +++ b/tests/unit_tests/transformer/moe/test_moe_batch_invariant.py @@ -0,0 +1,255 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""Tests for batch-invariant MoE grouped GEMM.""" +import pytest +import torch +import torch.nn.functional as F + +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + HAVE_DEEPGEMM_BF16, + _bf16_grouped_gemm_contiguous, + _m_splits_to_m_indices, + _offs_to_m_indices, + set_batch_invariant_mode, +) + + +def _hopper_or_newer() -> bool: + if not torch.cuda.is_available(): + return False + major, _ = torch.cuda.get_device_capability() + return major >= 9 + + +pytestmark = [ + pytest.mark.skipif( + not HAVE_DEEPGEMM_BF16, + reason="DeepGEMM with bf16 grouped bindings is required for MoE batch-invariant tests.", + ), + pytest.mark.skipif( + not _hopper_or_newer(), reason="DeepGEMM bf16 grouped kernels require Hopper (sm_90+)." + ), +] + + +# --------------------------------------------------------------------------- +# Index-conversion helpers +# --------------------------------------------------------------------------- + + +def test_m_splits_to_m_indices_basic(): + m_splits = [3, 0, 5, 2] + m_total = sum(m_splits) + out = _m_splits_to_m_indices(m_splits, torch.device("cuda"), m_total) + expected = torch.tensor([0, 0, 0, 2, 2, 2, 2, 2, 3, 3], dtype=torch.int32, device="cuda") + assert torch.equal(out, expected) + + +def test_offs_to_m_indices_basic(): + # Three experts with 4/2/3 tokens, plus 1 row of post-padding (-1). + offs = torch.tensor([4, 6, 9], dtype=torch.int32, device="cuda") + m_total = 10 # one trailing pad row past offs[-1]=9 + out = _offs_to_m_indices(offs, m_total) + expected = torch.tensor([0, 0, 0, 0, 1, 1, 2, 2, 2, -1], dtype=torch.int32, device="cuda") + assert torch.equal(out, expected) + + +# --------------------------------------------------------------------------- +# Kernel-level invariance +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("E", [2, 4, 8]) +def test_grouped_gemm_split_invariance(E): + """Splitting the M dimension at expert boundaries must give bitwise-identical + output to the full call.""" + torch.manual_seed(0) + K, N = 128, 96 + # Build expert-grouped tokens: 32 tokens per expert + per_expert = 32 + M = per_expert * E + x = torch.randn(M, K, device="cuda", dtype=torch.bfloat16) + w = torch.randn(E, N, K, device="cuda", dtype=torch.bfloat16) + m_indices = torch.repeat_interleave( + torch.arange(E, device="cuda", dtype=torch.int32), + torch.tensor([per_expert] * E, device="cuda", dtype=torch.int32), + ).contiguous() + + counts = [per_expert] * E + y_full = _bf16_grouped_gemm_contiguous(x, w, m_indices, counts) + + # Split into halves at expert boundaries (mid-expert split is not legal for + # contiguous-layout DeepGEMM — must split on a boundary). + half = (E // 2) * per_expert + y0 = _bf16_grouped_gemm_contiguous( + x[:half].contiguous(), w, m_indices[:half].contiguous(), counts[: E // 2] + [0] * (E // 2) + ) + y1 = _bf16_grouped_gemm_contiguous( + x[half:].contiguous(), w, m_indices[half:].contiguous(), [0] * (E // 2) + counts[E // 2 :] + ) + y_cat = torch.cat([y0, y1], dim=0) + assert torch.equal( + y_full, y_cat + ), f"max abs diff: {(y_full.float() - y_cat.float()).abs().max().item()}" + + +def test_grouped_gemm_per_expert_token_count_invariance(): + """For a fixed expert id, the per-row output must be identical regardless of + how many *other* expert rows surround it in the batch.""" + torch.manual_seed(1) + E, K, N = 4, 64, 48 + w = torch.randn(E, N, K, device="cuda", dtype=torch.bfloat16) + x_target = torch.randn(8, K, device="cuda", dtype=torch.bfloat16) + + # Layout A: just expert 1's tokens. + m_indices_A = torch.full((8,), 1, dtype=torch.int32, device="cuda") + y_A = _bf16_grouped_gemm_contiguous(x_target, w, m_indices_A, [0, 8, 0, 0]) + + # Layout B: expert 0 (16 rows), then expert 1 (8 rows, same x_target), + # then expert 3 (12 rows). + x_pad0 = torch.randn(16, K, device="cuda", dtype=torch.bfloat16) + x_pad3 = torch.randn(12, K, device="cuda", dtype=torch.bfloat16) + x_B = torch.cat([x_pad0, x_target, x_pad3], dim=0).contiguous() + m_indices_B = torch.cat( + [ + torch.full((16,), 0, dtype=torch.int32, device="cuda"), + torch.full((8,), 1, dtype=torch.int32, device="cuda"), + torch.full((12,), 3, dtype=torch.int32, device="cuda"), + ], + dim=0, + ).contiguous() + y_B = _bf16_grouped_gemm_contiguous(x_B, w, m_indices_B, [16, 8, 0, 12]) + + # The 8 rows assigned to expert 1 inside y_B must match y_A bitwise. + y_B_target = y_B[16 : 16 + 8] + assert torch.equal(y_A, y_B_target) + + +# --------------------------------------------------------------------------- +# End-to-end: TEGroupedMLP batch-invariance +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _moe_env(): + """Spin up a tiny model-parallel env for MoELayer construction.""" + from megatron.core.utils import is_te_min_version + from tests.unit_tests.test_utilities import Utils + + if not is_te_min_version("1.9.0.dev0"): + pytest.skip("TE GroupedLinear requires TE >= 1.9.0.dev0") + Utils.initialize_model_parallel(1, 1) + model_parallel_cuda_manual_seed(123) + try: + yield + finally: + Utils.destroy_model_parallel() + + +def _build_moe_layer(hidden_size=64, ffn=128, num_experts=4, topk=1): + from megatron.core.models.gpt.gpt_layer_specs import ( + get_gpt_layer_with_transformer_engine_submodules, + ) + from megatron.core.transformer.enums import AttnBackend + from megatron.core.transformer.moe.moe_layer import MoELayer + from megatron.core.transformer.spec_utils import get_submodules + from megatron.core.transformer.transformer_config import TransformerConfig + + cfg = TransformerConfig( + num_layers=1, + hidden_size=hidden_size, + num_attention_heads=4, + num_moe_experts=num_experts, + moe_ffn_hidden_size=ffn, + moe_grouped_gemm=True, + moe_router_topk=topk, + moe_token_dispatcher_type="alltoall", + moe_router_load_balancing_type="sinkhorn", + gated_linear_unit=False, + activation_func=F.gelu, + add_bias_linear=False, + params_dtype=torch.bfloat16, + bf16=True, + attention_backend=AttnBackend.flash, + flash_attention_version=4, + attention_dropout=0.0, + batch_invariant_mode=True, + ) + submodules = get_submodules( + get_gpt_layer_with_transformer_engine_submodules( + cfg.num_moe_experts, moe_grouped_gemm=True + ).mlp + ) + return MoELayer(cfg, submodules).cuda().eval(), cfg + + +def test_tegroupedmlp_batch_invariant_split(_moe_env): + """Splitting the batch and concatenating outputs must give bitwise-identical + results to the full batch — the basic batch-invariance contract.""" + from megatron.core.transformer.moe.experts import TEGroupedMLP + + layer, cfg = _build_moe_layer() + assert isinstance(layer.experts, TEGroupedMLP) + + torch.manual_seed(0) + M = 48 + x = torch.randn(M, 1, cfg.hidden_size, device="cuda", dtype=torch.bfloat16) + + with torch.no_grad(), set_batch_invariant_mode(True): + y_full, _ = layer(x) + y0, _ = layer(x[: M // 2]) + y1, _ = layer(x[M // 2 :]) + y_cat = torch.cat([y0, y1], dim=0) + assert torch.equal(y_full, y_cat), ( + f"TEGroupedMLP not batch-invariant under halving; max abs diff: " + f"{(y_full.float() - y_cat.float()).abs().max().item()}" + ) + + +def test_tegroupedmlp_per_token_invariance_across_batch_sizes(_moe_env): + """The strongest batch-invariance check: a fixed set of "target" tokens must + produce the *exact same output* regardless of what other tokens surround + them in the batch. This is what RL log-prob parity needs.""" + layer, cfg = _build_moe_layer() + torch.manual_seed(1) + + # 8 target tokens whose outputs we lock in by running them alone. + target = torch.randn(8, 1, cfg.hidden_size, device="cuda", dtype=torch.bfloat16) + with torch.no_grad(), set_batch_invariant_mode(True): + y_target_alone, _ = layer(target) + + # Now embed those same 8 tokens at different positions inside larger batches + # of varying sizes, with random surrounding tokens. + for pad_left, pad_right in [(0, 16), (40, 0), (24, 24), (5, 13), (1, 1)]: + left = torch.randn(pad_left, 1, cfg.hidden_size, device="cuda", dtype=torch.bfloat16) + right = torch.randn(pad_right, 1, cfg.hidden_size, device="cuda", dtype=torch.bfloat16) + big = torch.cat([left, target, right], dim=0) + with torch.no_grad(), set_batch_invariant_mode(True): + y_big, _ = layer(big) + y_target_in_big = y_big[pad_left : pad_left + 8] + assert torch.equal(y_target_alone, y_target_in_big), ( + f"Per-token output drifted when batch shape changed " + f"(pad_left={pad_left}, pad_right={pad_right}); " + f"max abs diff: " + f"{(y_target_alone.float() - y_target_in_big.float()).abs().max().item()}" + ) + + +def test_tegroupedmlp_invariance_under_permutation(_moe_env): + """Permuting the input batch and undoing the permutation in the output + yields bitwise-identical results. Different routing distribution per + micro-batch position, same kernel output.""" + layer, cfg = _build_moe_layer() + torch.manual_seed(2) + M = 32 + x = torch.randn(M, 1, cfg.hidden_size, device="cuda", dtype=torch.bfloat16) + + perm = torch.randperm(M, device="cuda") + with torch.no_grad(), set_batch_invariant_mode(True): + y_ref, _ = layer(x) + y_perm, _ = layer(x[perm]) + y_unperm = y_perm[perm.argsort()] + assert torch.equal(y_ref, y_unperm), ( + f"MoE output not invariant to batch permutation; max abs diff: " + f"{(y_ref.float() - y_unperm.float()).abs().max().item()}" + ) diff --git a/tests/unit_tests/transformer/test_te_layers_batch_invariant.py b/tests/unit_tests/transformer/test_te_layers_batch_invariant.py index 685e9332025..9431db4afd7 100644 --- a/tests/unit_tests/transformer/test_te_layers_batch_invariant.py +++ b/tests/unit_tests/transformer/test_te_layers_batch_invariant.py @@ -16,7 +16,12 @@ ) from megatron.core.tensor_parallel.layers import ColumnParallelLinear from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.custom_layers.batch_invariant_kernels import set_batch_invariant_mode +from megatron.core.transformer.custom_layers.batch_invariant_kernels import ( + HAVE_DEEPGEMM_BF16, + assert_te_supports_batch_invariant_attention, + set_batch_invariant_mode, + te_supports_batch_invariant_attention, +) from megatron.core.transformer.enums import AttnBackend, AttnMaskType from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import init_method_normal, is_te_min_version @@ -38,6 +43,26 @@ # Batch-invariant mode requires an explicit FlashAttention version. _BIK_FA_VERSION = 4 if HAVE_FA4 else 3 +requires_te_batch_invariant_attention = pytest.mark.skipif( + not te_supports_batch_invariant_attention(), + reason="Batch-invariant attention requires TransformerEngine PR #3204 or >= 2.18.", +) + + +@pytest.mark.parametrize("te_version", ("2.17.0+cb4a45fd", "2.18.0", "2.19.0.dev0")) +def test_batch_invariant_accepts_compatible_te(monkeypatch, te_version): + import transformer_engine + + monkeypatch.setattr(transformer_engine, "__version__", te_version) + assert_te_supports_batch_invariant_attention() + + +def test_batch_invariant_rejects_incompatible_te(monkeypatch): + import transformer_engine + + monkeypatch.setattr(transformer_engine, "__version__", "2.17.0+deadbeef") + with pytest.raises(AssertionError, match="TransformerEngine PR #3204"): + assert_te_supports_batch_invariant_attention() # ============================================================================ @@ -330,6 +355,7 @@ def test_column_parallel_linear_batch_invariant_randomized(): not (is_te_min_version("2.10.0") and HAVE_FA3), reason="TE attention BIK tests require TE >= 2.10.0 and FlashAttention-3", ) +@requires_te_batch_invariant_attention def test_te_attention_layer_batch_invariant_randomized(): torch.backends.cuda.matmul.allow_tf32 = False torch.backends.cudnn.allow_tf32 = False @@ -754,3 +780,25 @@ def test_bik_te_general_gemm_numerical_parity(dtype): C_bik = _te_general_gemm(A, B, out_dtype=dtype, layout="TN")[0] torch.testing.assert_close(C_bik, C_ref, **_tols(dtype)) + + +@pytest.mark.skipif(not HAVE_DEEPGEMM_BF16, reason="DeepGEMM bf16 bindings are unavailable") +def test_bik_te_general_gemm_deepgemm_backend_supports_fp32_router(): + torch.manual_seed(123) + M1, M2, K, N = 37, 23, 128, 128 + A1 = torch.randn(M1, K, **_device(torch.float32)) + A2 = torch.randn(M2, K, **_device(torch.float32)) + A = torch.cat([A1, A2], dim=0) + B = torch.randn(K, N, **_device(torch.float32)) + + with set_batch_invariant_mode(True, backend="deepgemm"): + full = _te_general_gemm(A, B, out_dtype=torch.float32, layout="TN")[0] + chunks = torch.cat( + [ + _te_general_gemm(A1, B, out_dtype=torch.float32, layout="TN")[0], + _te_general_gemm(A2, B, out_dtype=torch.float32, layout="TN")[0], + ], + dim=1, + ) + + assert torch.equal(full, chunks) diff --git a/uv.lock b/uv.lock index 1ea2c0b1eb0..10a5bca9178 100644 --- a/uv.lock +++ b/uv.lock @@ -1012,6 +1012,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "deep-gemm" +version = "2.5.0+local" +source = { git = "https://github.com/deepseek-ai/DeepGEMM.git?rev=714dd1a4a980f7937a74343d19a8eba4fe321480#714dd1a4a980f7937a74343d19a8eba4fe321480" } + [[package]] name = "defusedxml" version = "0.7.1" @@ -2220,6 +2225,9 @@ training = [ ] [package.dev-dependencies] +batch-invariant = [ + { name = "deep-gemm" }, +] build = [ { name = "cython" }, { name = "hatchling" }, @@ -2251,6 +2259,7 @@ linting = [ { name = "ruff" }, ] no-pypi-wheels = [ + { name = "deep-gemm" }, { name = "emerging-optimizers" }, { name = "flash-mla" }, ] @@ -2320,6 +2329,7 @@ requires-dist = [ provides-extras = ["training", "mlm", "dev", "lts", "te", "ssm"] [package.metadata.requires-dev] +batch-invariant = [{ name = "deep-gemm", git = "https://github.com/deepseek-ai/DeepGEMM.git?rev=714dd1a4a980f7937a74343d19a8eba4fe321480" }] build = [ { name = "cython", specifier = ">=3.0.0" }, { name = "hatchling" }, @@ -2351,6 +2361,7 @@ linting = [ { name = "ruff", specifier = "~=0.9.0" }, ] no-pypi-wheels = [ + { name = "deep-gemm", git = "https://github.com/deepseek-ai/DeepGEMM.git?rev=714dd1a4a980f7937a74343d19a8eba4fe321480" }, { name = "emerging-optimizers", git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git?rev=v0.2.0" }, { name = "flash-mla", git = "https://github.com/deepseek-ai/FlashMLA?rev=nv_dev" }, ] From 4ac5ad0da972e428768a32716b1a362aa2240144 Mon Sep 17 00:00:00 2001 From: Ajay Date: Fri, 31 Jul 2026 09:44:14 -0700 Subject: [PATCH 172/290] chore: update pytest version to 9.1.1 in pyproject.toml and uv.lock (#6170) Signed-off-by: Ajay Balasa --- pyproject.toml | 2 +- uv.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5d723711c56..033c9ae002b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,7 @@ test = [ "coverage", "nltk", "wrapt", - "pytest==8.3.5", + "pytest==9.1.1", "pytest-mock", "mock", "pytest-cov", diff --git a/uv.lock b/uv.lock index 10a5bca9178..0f63104f4c9 100644 --- a/uv.lock +++ b/uv.lock @@ -2372,7 +2372,7 @@ test = [ { name = "nltk" }, { name = "pydantic" }, { name = "pygithub" }, - { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, @@ -3967,17 +3967,18 @@ wheels = [ [[package]] name = "pytest" -version = "8.3.5" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] From ee3864907a6b2973e742839c31643110b7e3aa9c Mon Sep 17 00:00:00 2001 From: Lawrence McAfee <85179052+lmcafee-nvidia@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:07:44 -0400 Subject: [PATCH 173/290] Fix LRU block accounting for resumed requests (#5995) Signed-off-by: Lawrence McAfee Signed-off-by: Keshav Santhanam Co-authored-by: Keshav Santhanam --- .../run_prefix_cache_lru_resume_repro.sh | 145 +++++++++ examples/inference/utils.py | 2 +- megatron/core/inference/config.py | 10 +- .../inference/contexts/dynamic_context.py | 265 +++++++++------ .../inference/contexts/kv_block_allocator.py | 200 +++++++----- .../contexts/mamba_slot_allocator.py | 6 +- .../core/inference/engines/dynamic_engine.py | 37 ++- megatron/core/inference/inference_request.py | 5 +- megatron/training/arguments.py | 12 +- megatron/training/config/inference_config.py | 9 +- .../contexts/test_dynamic_context.py | 305 +++++++++++++++--- .../contexts/test_dynamic_prefix_caching.py | 116 ++++++- .../contexts/test_kv_block_allocator.py | 253 +++++++++++---- .../inference/engines/test_dynamic_engine.py | 146 ++++++++- .../engines/test_hybrid_prefix_caching_e2e.py | 4 +- .../inference/test_inference_request.py | 50 ++- .../inference/test_wandb_logging.py | 6 +- .../test_text_generation_controller.py | 22 +- 18 files changed, 1238 insertions(+), 355 deletions(-) create mode 100755 examples/inference/advanced/run_prefix_cache_lru_resume_repro.sh diff --git a/examples/inference/advanced/run_prefix_cache_lru_resume_repro.sh b/examples/inference/advanced/run_prefix_cache_lru_resume_repro.sh new file mode 100755 index 00000000000..e6bb599e0c8 --- /dev/null +++ b/examples/inference/advanced/run_prefix_cache_lru_resume_repro.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# Reproduce LRU prefix-cache exhaustion while a block-aligned request resumes. +# +# This launcher targets the nemo_minitron-0.5b checkpoint used by the +# gpt_dynamic_inference_tp1_pp1_583m_prefix_caching_lru functional test. +# +# On an unpatched main checkout, the third request exits with: +# AssertionError: active_request_count == 0 with no hidden chunked prefill. +# +# On a checkout containing the resume accounting fix, all four requests finish. +# +# Usage: +# bash examples/inference/advanced/run_prefix_cache_lru_resume_repro.sh \ +# --checkpoint /path/to/nemo_minitron-0.5b/v1 \ +# --tokenizer-model /path/to/multiMixV8.gpt4o_nc_sd.500000.128k.vocab.json + +set -euo pipefail + +CHECKPOINT="" +TOKENIZER_MODEL="" +OUTPUT_PATH="${TMPDIR:-/tmp}/prefix-cache-lru-resume-results.json" + +while [[ $# -gt 0 ]]; do + case "$1" in + --checkpoint) + CHECKPOINT="$2" + shift 2 + ;; + --tokenizer-model) + TOKENIZER_MODEL="$2" + shift 2 + ;; + --output) + OUTPUT_PATH="$2" + shift 2 + ;; + -h|--help) + sed -n '2,15p' "$0" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + echo "Run with --help for usage." >&2 + exit 1 + ;; + esac +done + +if [[ -z "$CHECKPOINT" ]]; then + echo "Error: --checkpoint is required." >&2 + exit 1 +fi +if [[ -z "$TOKENIZER_MODEL" ]]; then + echo "Error: --tokenizer-model is required." >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$REPO_ROOT" + +if [[ -z "${PYTHON_BIN:-}" ]]; then + if [[ -x /opt/venv/bin/python ]]; then + PYTHON_BIN=/opt/venv/bin/python + else + PYTHON_BIN=python + fi +fi + +REPRO_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/prefix-cache-lru-resume.XXXXXX")" +trap 'rm -rf "$REPRO_TMPDIR"' EXIT +PROMPT_FILE="$REPRO_TMPDIR/prompts.jsonl" + +# Each prompt is unique and round-trips through the production tokenizer to +# exactly one 256-token KV block. +"$PYTHON_BIN" - "$TOKENIZER_MODEL" "$PROMPT_FILE" <<'PY' +import json +import sys + +from megatron.core.tokenizers.text.libraries.tiktoken_tokenizer import TikTokenTokenizer + +tokenizer = TikTokenTokenizer(sys.argv[1], pattern="v2") +with open(sys.argv[2], "w", encoding="utf-8") as prompt_file: + for request_idx in range(4): + source = f"unique prefix-cache request {request_idx}: " + "hi " * 1024 + token_ids = tokenizer.text_to_ids(source)[:256] + text = tokenizer.ids_to_text(token_ids) + assert len(tokenizer.text_to_ids(text)) == 256 + prompt_file.write(json.dumps({"text": text}) + "\n") +PY + +export CUDA_DEVICE_MAX_CONNECTIONS=1 +export NVTE_ALLOW_NONDETERMINISTIC_ALGO=0 +export NCCL_ALGO=Ring +export CUBLAS_WORKSPACE_CONFIG=:4096:8 + +"$PYTHON_BIN" -m torch.distributed.run \ + --nproc-per-node 1 \ + examples/inference/advanced/gpt_dynamic_inference.py \ + --tiktoken-pattern v2 \ + --use-mcore-models \ + --tokenizer-type TikTokenizer \ + --tokenizer-model "$TOKENIZER_MODEL" \ + --auto-detect-ckpt-format \ + --max-tokens-to-oom 3600000 \ + --inference-max-seq-length 4096 \ + --attention-backend flash \ + --use-checkpoint-args \ + --micro-batch-size 1 \ + --no-load-optim \ + --no-use-tokenizer-model-from-checkpoint-args \ + --timing-log-level 0 \ + --load "$CHECKPOINT" \ + --distributed-backend nccl \ + --log-interval 1 \ + --transformer-impl transformer_engine \ + --tensor-model-parallel-size 1 \ + --pipeline-model-parallel-size 1 \ + --deterministic-mode \ + --ckpt-format torch_dist \ + --bf16 \ + --num-layers 24 \ + --hidden-size 1152 \ + --num-attention-heads 16 \ + --max-position-embeddings 1024 \ + --seq-length 1024 \ + --temperature 1.0 \ + --top_k 1 \ + --num-tokens-to-generate 2 \ + --termination-id -1 \ + --inference-dynamic-batching-buffer-size-gb 0.1 \ + --inference-dynamic-batching-block-size 256 \ + --inference-dynamic-batching-max-requests 1 \ + --inference-dynamic-batching-max-tokens 256 \ + --inference-dynamic-batching-prefix-caching \ + --inference-dynamic-batching-prefix-caching-eviction-policy lru \ + --dist-ckpt-strictness log_unexpected \ + --inference-ckpt-non-strict \ + --output-path "$OUTPUT_PATH" \ + --prompt-file "$PROMPT_FILE" \ + --prompt-file-num-truncate 4 \ + --incoming-requests-per-step 1 \ + --inference-repeat-n 1 \ + --inference-logging-step-interval 1 \ + --drain-between-batches \ + --batch-boundaries 0,1,2,3 diff --git a/examples/inference/utils.py b/examples/inference/utils.py index cb1a5dd11f9..2efe3429fdb 100644 --- a/examples/inference/utils.py +++ b/examples/inference/utils.py @@ -319,7 +319,7 @@ def build_dynamic_engine_setup_prefix( # Buffer limits config buffer_limits_str = ( f"bf: {get_mem_size_str(args.inference_dynamic_batching_buffer_size_gb*1024**3)}, " - f"{context.kv_block_allocator.active_count} chunks " + f"{context.kv_block_allocator.pool_size - 1} usable chunks " f"[r {context.max_requests}, t {context.max_tokens}]" ) diff --git a/megatron/core/inference/config.py b/megatron/core/inference/config.py index 6b3e715d531..44bbf410c7a 100644 --- a/megatron/core/inference/config.py +++ b/megatron/core/inference/config.py @@ -169,16 +169,18 @@ class InferenceConfig: buffer_size_gb: int = 20 """ - Buffer size reserved on the GPU for the KV cache. + On-GPU portion of the shared KV cache block pool. If `unified_memory_level` >= 1, then CPU memory is additionally utilized, resulting in a total buffer size of `buffer_size_gb + paused_buffer_size_gb`. """ paused_buffer_size_gb: Optional[int] = None """ - Portion of buffer reserved for paused requests. Active requests are paused when there are not - enough active blocks available to continue generating a request. The total buffer size - (active + paused) depends on `unified_memory_level` (uvm): + Memory used to derive the paused-request block retention budget. This does not reserve blocks + from active requests: active requests may use the entire shared pool of usable KV cache blocks. + When the pool cannot satisfy new allocations, paused requests retain blocks only within this + budget and excess paused requests may be evicted. The total buffer size depends on + `unified_memory_level` (uvm): - uvm 0: buffer_size_gb (paused buffer is inclusive) - uvm 1: buffer_size_gb + paused_buffer_size_gb """ diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 2fe9b0a7aca..5735b003ebf 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -515,7 +515,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC paused_buffer_size_bytes = int(paused_buffer_size_bytes * (1.0 - mamba_memory_ratio)) block_count = buffer_size_bytes // self.block_size_bytes - block_count = max(2, block_count) # need >= 1 active block + 1 dummy block + block_count = max(2, block_count) # need >= 1 usable block + 1 dummy block paused_block_count = paused_buffer_size_bytes // self.block_size_bytes elif self.is_hybrid_model and inference_config.max_requests is not None: # Auto-derive mamba/KV split from max_requests. Allocate exactly enough @@ -529,19 +529,19 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC ) mamba_max_requests = inference_config.max_requests - # Subtract mamba memory proportionally from active and paused buffers. + # Subtract Mamba memory proportionally from both configured buffer inputs. mamba_memory_ratio = mamba_memory_needed / total_memory buffer_size_bytes = int(buffer_size_bytes * (1.0 - mamba_memory_ratio)) paused_buffer_size_bytes = int(paused_buffer_size_bytes * (1.0 - mamba_memory_ratio)) block_count = buffer_size_bytes // self.block_size_bytes - block_count = max(2, block_count) # need >= 1 active block + 1 dummy block + block_count = max(2, block_count) # need >= 1 usable block + 1 dummy block paused_block_count = paused_buffer_size_bytes // self.block_size_bytes else: block_count = buffer_size_bytes // ( self.block_size_bytes + mamba_states_memory_per_request ) - block_count = max(2, block_count) # need >= 1 active block + 1 dummy block + block_count = max(2, block_count) # need >= 1 usable block + 1 dummy block paused_block_count = paused_buffer_size_bytes // ( self.block_size_bytes + mamba_states_memory_per_request ) @@ -566,10 +566,10 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC self.kv_block_allocator = KVBlockAllocator( context=self, - total_count=( + pool_size=( block_count if self.unified_memory_level == 0 else block_count + paused_block_count ), - paused_count=paused_block_count, + paused_limit=paused_block_count, enable_prefix_caching=self.enable_prefix_caching, prefix_caching_eviction_policy=self.prefix_caching_eviction_policy, ) @@ -596,7 +596,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC # Set max_requests, max_tokens. if inference_config.max_requests is None: # Maximize compute utilization by defaulting to 1 block per request. - self.max_requests = self.kv_block_allocator.total_count - 1 # -1 for dummy block + self.max_requests = self.kv_block_allocator.pool_size - 1 # -1 for dummy block # Adjust max_requests for Mamba memory constraints if necessary if self.is_hybrid_model and mamba_max_requests < self.max_requests: @@ -635,7 +635,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC self.non_graph_attn_metadata = {} self.graph_attn_metadata["mha_metadata"] = GraphedMHAMetadata( - block_count_total=self.kv_block_allocator.total_count, + block_count_total=self.kv_block_allocator.pool_size, max_kv_block_count=self.max_kv_block_count, max_requests=self.max_requests, block_size_tokens=self.block_size_tokens, @@ -643,7 +643,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC ) self.non_graph_attn_metadata["mha_metadata"] = NonGraphedMHAMetadata( - block_count_total=self.kv_block_allocator.total_count, + block_count_total=self.kv_block_allocator.pool_size, max_kv_block_count=self.max_kv_block_count, max_requests=self.max_requests, block_size_tokens=self.block_size_tokens, @@ -771,12 +771,12 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC NVLSAllGatherVDispatcher.set_real_token_count_tensor(self.gpu_view.real_token_count) # Print info. - active_blocks = self.kv_block_allocator.active_count - total_blocks = self.kv_block_allocator.total_count - paused_blocks = self.kv_block_allocator.paused_count - active_kv_bytes = active_blocks * self.block_size_bytes - total_kv_bytes = total_blocks * self.block_size_bytes - paused_kv_bytes = paused_blocks * self.block_size_bytes + pool_size = self.kv_block_allocator.pool_size + usable_blocks = pool_size - 1 + paused_limit = self.kv_block_allocator.paused_limit + pool_size_bytes = pool_size * self.block_size_bytes + usable_bytes = usable_blocks * self.block_size_bytes + paused_limit_bytes = paused_limit * self.block_size_bytes log_lines = [ "DynamicInferenceContext: configuration summary", @@ -787,9 +787,10 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC f" max_kv_blocks_per_req: {self.max_kv_block_count}", f" KV cache:", f" block_size_bytes: {get_mem_size_str(self.block_size_bytes)}", - f" active_blocks: {active_blocks} ({get_mem_size_str(active_kv_bytes)})", - f" paused_blocks: {paused_blocks} ({get_mem_size_str(paused_kv_bytes)})", - f" total_blocks: {total_blocks} ({get_mem_size_str(total_kv_bytes)})", + f" pool_size: {pool_size} ({get_mem_size_str(pool_size_bytes)})", + f" usable_blocks: {usable_blocks} ({get_mem_size_str(usable_bytes)})", + f" paused_limit: {paused_limit} " + f"({get_mem_size_str(paused_limit_bytes)})", ] if self.is_hybrid_model: @@ -860,7 +861,7 @@ def _allocate_memory_buffer(self): self.memory_buffer = torch.empty( ( self.num_attention_layers, - self.kv_block_allocator.total_count, + self.kv_block_allocator.pool_size, self.block_size_tokens, self.kv_reduced_dim, ), @@ -872,7 +873,7 @@ def _allocate_memory_buffer(self): ( 2, # key and value self.num_attention_layers, - self.kv_block_allocator.total_count, + self.kv_block_allocator.pool_size, self.block_size_tokens, self.num_attention_heads_per_partition, self.hidden_size_per_attention_head, @@ -3415,53 +3416,117 @@ def release_memory_blocks_from_request_indexes(self, request_indexes) -> None: sa._intermediate_block_ids_cpu[request_indexes] = -1 sa._eos_cache_block_id_cpu[request_indexes] = -1 + def _get_paused_request_count_within_block_budget(self) -> int: + """Count the left-most paused requests whose blocks fit the paused budget.""" + block_budget = self.kv_block_allocator.paused_limit + if self.paused_request_count == 0 or block_budget == 0: + return 0 + + if not self.enable_prefix_caching: + paused_block_counts = self.request_kv_block_counts[: self.paused_request_count] + cumulative_block_counts = paused_block_counts.cumsum(dim=0) + return int(torch.count_nonzero(cumulative_block_counts <= block_budget).item()) + + seen_block_ids: set[int] = set() + retained_request_count = 0 + for request_idx in range(self.paused_request_count): + block_ids = self.request_to_kv_block_ids[request_idx] + seen_block_ids.update(block_ids[block_ids >= 0].tolist()) + if len(seen_block_ids) > block_budget: + break + retained_request_count += 1 + return retained_request_count + + def _get_releasable_block_counts( + self, request_start_idx: int, request_end_idx: int + ) -> list[int]: + """Count blocks made allocatable by each right-most request suffix. + + The returned list has one entry for every possible suffix length, including + zero. For prefix caching, a shared block is credited only once all selected + request references account for its current allocator reference count. + """ + suffix_request_count = request_end_idx - request_start_idx + releasable_counts = [0] * (suffix_request_count + 1) + if suffix_request_count == 0: + return releasable_counts + + if not self.enable_prefix_caching: + suffix_block_counts = ( + self.request_kv_block_counts[request_start_idx:request_end_idx] + .flip(dims=[0]) + .cumsum(dim=0) + ) + return [0, *suffix_block_counts.tolist()] + + block_rows = self.request_to_kv_block_ids[request_start_idx:request_end_idx] + request_offsets, block_offsets = torch.where(block_rows >= 0) + if request_offsets.numel() == 0: + return releasable_counts + + block_ids = block_rows[request_offsets, block_offsets].long() + unique_block_ids, inverse, selected_reference_counts = torch.unique( + block_ids, return_inverse=True, return_counts=True + ) + current_reference_counts = self.kv_block_allocator.block_ref_counts[unique_block_ids] + assert torch.all( + selected_reference_counts <= current_reference_counts + ), "selected more KV block references than the allocator owns" + + # A fully selected block becomes releasable when the suffix reaches its + # left-most reference. + suffix_depths = suffix_request_count - request_offsets + release_suffix_depths = torch.zeros_like(unique_block_ids) + release_suffix_depths.scatter_reduce_( + 0, inverse, suffix_depths, reduce="amax", include_self=True + ) + fully_selected = selected_reference_counts == current_reference_counts + releases_by_suffix = torch.bincount( + release_suffix_depths[fully_selected], minlength=suffix_request_count + 1 + ) + return releases_by_suffix.cumsum(dim=0).tolist() + def resume_paused_requests( - self, active_request_count: int, newly_paused_request_ids: torch.Tensor - ) -> tuple[int, torch.Tensor]: - """Resume as many paused requests as we have space for in the active buffer. + self, active_request_count: int, newly_paused_request_ids: Optional[torch.Tensor] + ) -> tuple[int, Optional[torch.Tensor]]: + """Resume as many paused requests as compute and KV capacity permit. Args: active_request_count (int): Number of active requests. - newly_paused_request_ids (torch.Tensor): List of newly paused request ids. - next_tokens (torch.Tensor): Sampled tokens. + newly_paused_request_ids (Optional[torch.Tensor]): Newly paused request ids. Returns: - (tuple[int, torch.Tensor]) active_request_count, newly_paused_request_ids. + (tuple[int, Optional[torch.Tensor]]) Updated active count and newly paused ids. """ # Assign released blocks to paused requests. # todo: @shanmugamr, un-pause requests using FIFO, rather than LIFO. resume_request_count = 0 if self.paused_request_count > 0: - active_block_count_avail = self.kv_block_allocator.get_active_avail() - # Clone not needed: flip() makes a copy. - paused_block_counts = self.request_kv_block_counts[: self.paused_request_count] - # Flip counts before cumsum, since paused requests are resumed from - # the right-most index, so we must count resumed blocks starting from - # the right side. - paused_block_counts = paused_block_counts.flip(dims=[0]) - - # Check which paused requests will actually need a new block upon resuming + # Check which paused requests will actually need a new block upon + # resuming. Flip before cumsum because requests resume from the right. offsets = self.request_last_kv_block_offset[: self.paused_request_count] needs_new_block = ( offsets >= self.block_size_tokens - 1 - self.num_speculative_tokens - ).to(paused_block_counts.dtype) + ).to(self.request_kv_block_counts.dtype) needs_new_block = needs_new_block.flip(dims=[0]) - # Add +1 ONLY to the block counts of requests that finished their previous memory block - paused_block_counts += needs_new_block - paused_block_counts_cumsum = paused_block_counts.cumsum(dim=0) - resume_request_count = min( - torch.nonzero(paused_block_counts_cumsum <= active_block_count_avail).numel(), - self.kv_block_allocator.total_avail, - ) - # Constrain resumptions by the maximum allowed active requests and tokens max_allowed_active = min( self.max_requests, self.max_tokens // (self.num_speculative_tokens + 1) ) allowed_to_resume = max(0, max_allowed_active - active_request_count) - resume_request_count = min(resume_request_count, allowed_to_resume) + candidate_count = min(self.paused_request_count, allowed_to_resume) + + if candidate_count > 0: + new_block_counts_cumsum = needs_new_block[:candidate_count].cumsum(dim=0) + max_new_block_count = int(new_block_counts_cumsum[-1].item()) + block_count_avail = self.kv_block_allocator.pool_avail + if max_new_block_count > block_count_avail: + block_count_avail = self.kv_block_allocator.get_allocatable_count() + resume_request_count = int( + torch.count_nonzero(new_block_counts_cumsum <= block_count_avail).item() + ) self.paused_request_count -= resume_request_count active_request_count += resume_request_count @@ -3477,8 +3542,10 @@ def resume_paused_requests( num_new_blocks = needs_new_block.sum().item() if num_new_blocks > 0: - assert num_new_blocks <= self.kv_block_allocator.total_avail block_ids = self.kv_block_allocator.allocate_memory_blocks(num_new_blocks) + assert ( + block_ids is not None and block_ids.numel() == num_new_blocks + ), f"failed to allocate {num_new_blocks} blocks for resumed requests" # Apply updates only to the requests that required a new block relative_row_idx = torch.nonzero(needs_new_block).squeeze(1) @@ -3504,53 +3571,65 @@ def evict_overflow_paused_requests( active_request_count: int, next_tokens: torch.Tensor, new_speculative_tokens: Optional[torch.Tensor] = None, - ) -> Optional[tuple[torch.Tensor, torch.Tensor]]: - """Evict requests that overflow the paused buffer. + ) -> Optional[torch.Tensor]: + """Evict requests that overflow the paused-block retention budget. Args: active_request_count (int): Number of active requests. next_tokens (torch.Tensor): Sampled tokens. + new_speculative_tokens (Optional[torch.Tensor]): Speculative tokens. Returns: - (torch.Tensor) Evicted request ids. + (Optional[torch.Tensor]) Evicted request ids. """ - # Overflow paused block count. - overflow_paused_block_count = ( - self.kv_block_allocator.get_paused_used() - self.kv_block_allocator.paused_count - ) - - # Nothing to evict? - if overflow_paused_block_count <= 0: - return None - - # Overflow paused block count. - paused_block_counts = self.request_kv_block_counts[: self.paused_request_count] - paused_block_counts_cumsum = paused_block_counts.cumsum(dim=0) - valid_paused_request_count = torch.nonzero( - paused_block_counts_cumsum <= self.kv_block_allocator.paused_count - ).numel() - overflow_paused_request_count = self.paused_request_count - valid_paused_request_count - - # Nothing to evict? (Similar to checking overflow_paused_block_count - # above, but here we allow up to one paused request to overflow into the - # active buffer. + # Keep the largest left-most paused prefix that fits the configured + # paused-block retention budget. Requests to its right must either resume + # or be evicted. + retained_paused_request_count = self._get_paused_request_count_within_block_budget() + overflow_paused_request_count = self.paused_request_count - retained_paused_request_count if overflow_paused_request_count == 0: return None - # Evict request count. (Flip paused_block_counts because evictions are - # counted from the right-most paused requests. - paused_block_counts = paused_block_counts[-overflow_paused_request_count:].flip(dims=[0]) - paused_block_counts_cumsum = paused_block_counts.cumsum(dim=0) - remaining_paused_request_counts = torch.arange( - overflow_paused_request_count - 1, - -1, - -1, - dtype=paused_block_counts_cumsum.dtype, - device='cpu', + max_allowed_active = min( + self.max_requests, self.max_tokens // (self.num_speculative_tokens + 1) ) - net_block_counts = paused_block_counts_cumsum - remaining_paused_request_counts - evict_request_count = torch.nonzero(net_block_counts >= 0)[0].item() + 1 + allowed_to_resume = max(0, max_allowed_active - active_request_count) + + needs_new_block = ( + self.request_last_kv_block_offset[: self.paused_request_count] + >= self.block_size_tokens - 1 - self.num_speculative_tokens + ) + overflow_needs_new_block = needs_new_block[ + retained_paused_request_count : self.paused_request_count + ] + releasable_block_counts = self._get_releasable_block_counts( + retained_paused_request_count, self.paused_request_count + ) + new_block_counts_by_survivor_count = overflow_needs_new_block.cumsum(dim=0) + new_block_counts_by_survivor_count = torch.cat( + (new_block_counts_by_survivor_count.new_zeros(1), new_block_counts_by_survivor_count) + ) + block_count_avail = self.kv_block_allocator.get_allocatable_count() + + # Preserve current ordering: evict the smallest right-most suffix whose + # physical releases let every remaining overflow request resume. + evict_request_count = None + for candidate_evict_count in range(overflow_paused_request_count + 1): + survivor_count = overflow_paused_request_count - candidate_evict_count + if survivor_count > allowed_to_resume: + continue + survivor_new_block_count = int( + new_block_counts_by_survivor_count[survivor_count].item() + ) + candidate_avail = block_count_avail + releasable_block_counts[candidate_evict_count] + if survivor_new_block_count <= candidate_avail: + evict_request_count = candidate_evict_count + break + + assert evict_request_count is not None + if evict_request_count == 0: + return None # Eviction index range. evict_start_idx = self.paused_request_count - evict_request_count @@ -3629,7 +3708,7 @@ def can_prepare_requests(self) -> bool: """Return whether requests can be prepared without lifecycle changes. Returns: - bool: Whether all requests are active decode requests and the active + bool: Whether all requests are active decode requests and the shared KV-block pool can satisfy the exact next-step allocation demand. """ if self.num_prefill_requests != 0 or self.paused_request_count != 0: @@ -3637,7 +3716,7 @@ def can_prepare_requests(self) -> bool: rows_requiring_new_block = self._get_async_sched_rows_requiring_new_block() num_new_blocks = int(rows_requiring_new_block.sum().item()) - return num_new_blocks <= self.kv_block_allocator.get_active_avail() + return num_new_blocks <= self.kv_block_allocator.get_allocatable_count() def prepare_requests(self) -> None: """Speculatively prepare active decode requests for the next forward pass. @@ -3666,8 +3745,7 @@ def prepare_requests(self) -> None: block_ids = None if num_new_blocks > 0: - active_block_count_avail = self.kv_block_allocator.get_active_avail() - if num_new_blocks > active_block_count_avail: + if num_new_blocks > self.kv_block_allocator.get_allocatable_count(): raise RuntimeError("Async scheduling cannot pause requests to allocate new blocks.") block_ids = self.kv_block_allocator.allocate_memory_blocks(num_new_blocks) @@ -4103,7 +4181,7 @@ def update_requests( active_request_count, newly_paused_request_ids ) - # 6.b. Evict requests that overflow the paused buffer. + # 6.b. Evict requests that overflow the paused-block retention budget. evict_request_ids = self.evict_overflow_paused_requests( active_request_count, next_tokens, new_speculative_tokens ) @@ -4113,8 +4191,13 @@ def update_requests( active_request_count, newly_paused_request_ids ) - assert active_request_count > 0 or self.chunked_prefill_request_id != -1, ( - "active_request_count == %d with no hidden chunked prefill." % active_request_count + assert ( + active_request_count > 0 + or self.total_request_count == 0 + or self.chunked_prefill_request_id != -1 + ), "active_request_count == %d with %d paused requests and no hidden chunked prefill." % ( + active_request_count, + self.paused_request_count, ) # 6.d. Swap the chunked prefill request to the end of the active requests @@ -4455,11 +4538,11 @@ def get_kvcache_utilization_stats(self) -> dict: } """ # Total usable blocks exclude the reserved dummy block. - total_blocks = max(self.kv_block_allocator.total_count - 1, 1) - block_count_avail = int(self.kv_block_allocator.total_avail) + total_blocks = max(self.kv_block_allocator.pool_size - 1, 1) + block_count_avail = int(self.kv_block_allocator.pool_avail) # Overall allocated blocks in the buffer right now. - allocated_blocks = (self.kv_block_allocator.total_count - 1) - block_count_avail + allocated_blocks = (self.kv_block_allocator.pool_size - 1) - block_count_avail allocated_blocks = int(max(0, allocated_blocks)) # Active unique blocks referenced by current active requests only. diff --git a/megatron/core/inference/contexts/kv_block_allocator.py b/megatron/core/inference/contexts/kv_block_allocator.py index 7c6df3419e0..5a7c736c5b3 100644 --- a/megatron/core/inference/contexts/kv_block_allocator.py +++ b/megatron/core/inference/contexts/kv_block_allocator.py @@ -21,16 +21,16 @@ class KVBlockAllocator: Args: context (DynamicInferenceContext): Dynamic inference context. - total_count (int): Total number of blocks in the buffer. - paused_count (int): Number of paused blocks in the buffer. Must be less - than `total_count`. + pool_size (int): Number of blocks in the pool, including the dummy block. + paused_limit (int): Paused-request block retention limit. Must leave at + least one non-dummy block outside the limit. """ def __init__( self, context: "DynamicInferenceContext", - total_count: int, - paused_count: int, + pool_size: int, + paused_limit: int, enable_prefix_caching: bool = False, prefix_caching_eviction_policy: PrefixCachingEvictionPolicy = ( PrefixCachingEvictionPolicy.REF_ZERO @@ -42,33 +42,33 @@ def __init__( self.prefix_caching_eviction_policy = prefix_caching_eviction_policy self.on_blocks_deregistered: Optional[Callable] = None - self.total_count = total_count - self.total_avail = total_count - 1 # -1 for dummy_block_idx (see below) - self.paused_count = paused_count - self.active_count = total_count - paused_count - 1 # -1 for dummy_block_idx - assert self.active_count >= 1 # ensures paused_count < total_count - 1 - self.dummy_block_idx = self.total_count - 1 + assert ( + 0 <= paused_limit <= pool_size - 2 + ), "paused block limit must leave at least one usable block outside the limit" + + self.pool_size = pool_size + self.pool_avail = pool_size - 1 # Raw free-pool count; -1 for dummy_block_idx. + self.paused_limit = paused_limit + self.dummy_block_idx = self.pool_size - 1 # Initialize block pool as a "stack" data structure (CPU for bookkeeping). - self.block_bag = torch.arange(self.total_count, dtype=torch.int32, device='cpu') + self.block_bag = torch.arange(self.pool_size, dtype=torch.int32, device='cpu') if self.enable_prefix_caching: # Block hash tracking for prefix caching: -1 = uncomputed, positive = valid hash - self.block_hashes = torch.full((self.total_count,), -1, dtype=torch.int64, device='cpu') + self.block_hashes = torch.full((self.pool_size,), -1, dtype=torch.int64, device='cpu') # Hash-to-block mapping for O(1) prefix lookup self.kv_hash_to_block_id: Dict[int, int] = {} # Reference count per block: 0 = cached (evictable), >0 = actively used - self.block_ref_counts = torch.zeros( - (self.total_count,), dtype=torch.int32, device='cpu' - ) + self.block_ref_counts = torch.zeros((self.pool_size,), dtype=torch.int32, device='cpu') # LRU timestamps for eviction ordering (higher = more recently used) # Only needed in LRU mode; RZ mode evicts immediately on ref_count==0 if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: self.block_timestamps = torch.zeros( - (self.total_count,), dtype=torch.int64, device='cpu' + (self.pool_size,), dtype=torch.int64, device='cpu' ) # Persisted prefix-chain bookkeeping for LRU eviction, maintained @@ -79,14 +79,14 @@ def __init__( # block_parent_id[b] = block id of b's parent in the prefix chain, # or -1 when b is a root block or its parent is not registered. self.block_parent_id = torch.full( - (self.total_count,), -1, dtype=torch.int64, device='cpu' + (self.pool_size,), -1, dtype=torch.int64, device='cpu' ) # block_child_count[b] = number of currently-registered children of b. # For a cached block all of its children are cached too, so this # equals its cached-child count and b is an evictable leaf exactly # when it reaches 0. self.block_child_count = torch.zeros( - (self.total_count,), dtype=torch.int64, device='cpu' + (self.pool_size,), dtype=torch.int64, device='cpu' ) # Per-block MoE routing storage (populated when routing replay is enabled) @@ -94,14 +94,15 @@ def __init__( def __str__(self): return ( - f"using: total {self.get_total_used()}/{self.total_count - 1}" - f"; active {self.get_active_used()}/{self.active_count}" - f"; paused {self.get_paused_used()}/{self.paused_count}" + f"blocks: occupied {self.get_total_used()}/{self.pool_size - 1}" + f"; allocatable {self.get_allocatable_count()}" + f"; active-used {self.get_active_used()}" + f"; paused-used {self.get_paused_used()}/{self.paused_limit}" ) def get_total_used(self): - """Compute number of total blocks used.""" - return self.total_count - self.total_avail - 1 + """Compute number of physical blocks outside the free pool.""" + return self.pool_size - self.pool_avail - 1 def get_active_used(self): """Compute number of active blocks used.""" @@ -139,18 +140,10 @@ def get_paused_used(self): return int(torch.unique(valid_ids).numel()) return 0 - def get_active_avail(self): - """Compute number of active blocks available.""" - return self.active_count - self.get_active_used() - - def get_paused_avail(self): - """Compute number of paused blocks available.""" - return self.paused_count - self.get_paused_used() - def is_memory_available(self, num_blocks: int, potential_matched_count: int = 0) -> bool: """Check if memory blocks are available. - Includes both free pool blocks and evictable cached blocks (ref_count == 0). + Includes both free pool blocks and registered, evictable cached blocks. Args: num_blocks (int): Number of blocks to check. @@ -165,16 +158,11 @@ def is_memory_available(self, num_blocks: int, potential_matched_count: int = 0) Return: (bool) Is memory available? """ - # Fast path: avoid expensive evictable count computation when free pool suffices - if self.total_avail >= num_blocks: + # Fast path: avoid computing the evictable count when the free pool + # suffices. Soon-to-be-pinned matches do not affect raw free capacity. + if self.pool_avail >= num_blocks: return True - if not self.enable_prefix_caching: - return False - if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.REF_ZERO: - return False # RZ: no cached blocks to evict - # Also count evictable cached blocks, excluding those the caller will pin. - evictable_count = int(self.get_evictable_block_count()) - potential_matched_count - return (self.total_avail + evictable_count) >= num_blocks + return self.get_allocatable_count() - potential_matched_count >= num_blocks def allocate_memory_blocks(self, num_blocks: int) -> Optional[Tensor]: """Allocate memory blocks if available, else return None. @@ -188,19 +176,19 @@ def allocate_memory_blocks(self, num_blocks: int) -> Optional[Tensor]: (Optional[Tensor]) Allocated block IDs. """ # Try to evict cached blocks if free pool is insufficient - if self.total_avail < num_blocks: + if self.pool_avail < num_blocks: if ( not self.enable_prefix_caching or self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.REF_ZERO ): return None # RZ: no eviction path; disabled: no cached blocks - blocks_needed_from_eviction = num_blocks - self.total_avail + blocks_needed_from_eviction = num_blocks - self.pool_avail if not self.evict_lru_blocks(blocks_needed_from_eviction): return None # Not enough blocks even after eviction # Now allocate from the free pool - self.total_avail -= num_blocks - block_ids = self.block_bag[self.total_avail : (self.total_avail + num_blocks)] + self.pool_avail -= num_blocks + block_ids = self.block_bag[self.pool_avail : (self.pool_avail + num_blocks)] assert num_blocks == block_ids.numel() if self.enable_prefix_caching: @@ -231,38 +219,33 @@ def release_memory_blocks(self, blocks: Tensor) -> None: return if self.enable_prefix_caching: - # When multiple requests that share the same prefix finish on the same step, - # their block IDs appear multiple times in the blocks tensor. - # Writing `self.block_ref_counts[blocks] -= 1` would only decrement reference counts - # once per unique block. This is wrong. The reference counts must be decremented - # once per occurrence of the block in the `blocks` tensor. We need `scatter`. - blocks_i64 = blocks.to(torch.int64) - self.block_ref_counts.scatter_add_( - 0, blocks_i64, torch.full_like(blocks_i64, -1, dtype=torch.int32) + unique_blocks, release_counts = torch.unique(blocks, return_counts=True) + remaining_ref_counts = self.block_ref_counts[unique_blocks] - release_counts.to( + dtype=self.block_ref_counts.dtype ) + assert torch.all( + remaining_ref_counts >= 0 + ), "released more KV block references than the allocator owns" + self.block_ref_counts[unique_blocks] = remaining_ref_counts if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.REF_ZERO: - zero_mask = self.block_ref_counts[blocks] == 0 + zero_mask = remaining_ref_counts == 0 if zero_mask.any(): - # Deduplicate so a shared block is deregistered/returned once. - self._deregister_blocks(torch.unique(blocks[zero_mask])) + self._deregister_blocks(unique_blocks[zero_mask]) elif self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: # Unregistered blocks (hash == -1, ref_count == 0) have no hash # entry to preserve for reuse (e.g., partial blocks at the end of # a request). Return them directly to the free pool so they are not # leaked. - unreg_mask = (self.block_ref_counts[blocks] == 0) & ( - self.block_hashes[blocks] == -1 - ) + unreg_mask = (remaining_ref_counts == 0) & (self.block_hashes[unique_blocks] == -1) if unreg_mask.any(): - # Deduplicate so a shared block returns to the pool once. - unreg_blocks = torch.unique(blocks[unreg_mask]) + unreg_blocks = unique_blocks[unreg_mask] num_unreg = unreg_blocks.numel() - self.block_bag[self.total_avail : self.total_avail + num_unreg] = unreg_blocks - self.total_avail += num_unreg + self.block_bag[self.pool_avail : self.pool_avail + num_unreg] = unreg_blocks + self.pool_avail += num_unreg else: num_blocks = blocks.numel() - self.block_bag[self.total_avail : self.total_avail + num_blocks] = blocks - self.total_avail += num_blocks + self.block_bag[self.pool_avail : self.pool_avail + num_blocks] = blocks + self.pool_avail += num_blocks def reset(self) -> None: """Reset the allocator to initial state. @@ -281,9 +264,9 @@ def reset(self) -> None: # generations. # Refill the existing buffer so it remains mutable when reset runs under # torch.inference_mode(), such as during CUDA graph setup. - torch.arange(self.total_count, out=self.block_bag) + torch.arange(self.pool_size, out=self.block_bag) - self.total_avail = self.total_count - 1 + self.pool_avail = self.pool_size - 1 if self.enable_prefix_caching: # Reset all block hashes @@ -312,6 +295,23 @@ def register_kv_block_hashes( ) -> None: """Register blocks in the hash-to-block mapping for discovery (batch). + Registration is idempotent: a block that already carries the hash being + registered is skipped. Callers may legitimately re-offer an already + registered block (a cache-matched block whose block-table slot a later + prefill chunk also spans), and the bookkeeping below is one-shot per + block — applying it twice adds a second child entry to the block's + parent that no deregistration can ever cancel, leaving that parent + permanently short of ``child_count == 0`` and therefore never an + evictable leaf (see ``evict_lru_blocks``). + + Re-registering a live block under a *different* hash would instead + overwrite its recorded parent while leaving the previous parent's child + count raised, so that case is rejected rather than absorbed. + + This method never touches reference counts. New blocks are pinned at + ``ref_count == 1`` by ``allocate_memory_blocks``, and additional owners + of an already registered block are pinned by the caller that matched it. + Args: block_ids: List of block IDs. block_hashes: List of computed hash values (same length as block_ids). @@ -322,13 +322,46 @@ def register_kv_block_hashes( """ if not block_ids: return + if parent_hashes is not None: + assert len(parent_hashes) == len(block_ids) + # Tensor views of the batch, used to index the per-block state arrays. id_tensor = torch.tensor(block_ids, dtype=torch.int64, device=self.block_hashes.device) hash_tensor = torch.tensor(block_hashes, dtype=torch.int64, device=self.block_hashes.device) + + # Drop blocks that already carry this hash, and reject hash changes on a + # block that is still registered. Read the stored hashes before writing + # them below, so this sees each block's pre-call state. + # Hash each block holds right now; -1 means it is not registered. + current_hashes = self.block_hashes[id_tensor] + # Per-entry: this exact (block, hash) pair is already registered -> skip it. + already_registered = current_hashes == hash_tensor + # Per-entry: block is registered, but under some other hash -> illegal. + conflict_mask = (current_hashes != -1) & ~already_registered + # Batch positions of the illegal entries, for the failure message. + conflicting = torch.nonzero(conflict_mask, as_tuple=True)[0].tolist() + assert not conflicting, "block re-registered under a different hash: " + ", ".join( + f"block {block_ids[i]} holds {int(current_hashes[i])}, given {block_hashes[i]}" + for i in conflicting + ) + if already_registered.any(): + # Batch positions of the entries that still need registering. Every + # list and tensor below is narrowed to these so that the writes, the + # hash-map update and the child-count bumps all see the same subset. + keep = torch.nonzero(~already_registered, as_tuple=True)[0] + if keep.numel() == 0: + return + keep_list = keep.tolist() + block_ids = [block_ids[i] for i in keep_list] + block_hashes = [block_hashes[i] for i in keep_list] + if parent_hashes is not None: + parent_hashes = [parent_hashes[i] for i in keep_list] + id_tensor = id_tensor[keep] + hash_tensor = hash_tensor[keep] + self.block_hashes[id_tensor] = hash_tensor - if parent_hashes is not None: - assert len(parent_hashes) == len(block_ids) # Add the new blocks to the hash map first so that a block whose parent is # elsewhere in this same batch (block k's parent is block k-1) resolves. + # Skipped blocks are already in the map, so they resolve as parents too. self.kv_hash_to_block_id.update(zip(block_hashes, block_ids)) if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: @@ -339,11 +372,14 @@ def register_kv_block_hashes( # falls back to -1. if parent_hashes is None: parent_hashes = [0] * len(block_ids) + # Parent hashes resolved to block ids, aligned with block_ids; -1 for + # a root block and for a parent hash that is no longer cached. parent_ids = [ self.kv_hash_to_block_id.get(ph, -1) if ph != 0 else -1 for ph in parent_hashes ] parent_id_tensor = torch.tensor(parent_ids, dtype=torch.int64, device=id_tensor.device) self.block_parent_id[id_tensor] = parent_id_tensor + # Per-entry: this block has a resolved parent whose count to bump. has_parent = parent_id_tensor >= 0 if has_parent.any(): self.block_child_count.scatter_add_( @@ -399,8 +435,8 @@ def _deregister_blocks(self, block_ids: Tensor) -> None: self.block_ref_counts[block_ids] = 0 # Return blocks to free pool - self.block_bag[self.total_avail : self.total_avail + num_blocks] = block_ids - self.total_avail += num_blocks + self.block_bag[self.pool_avail : self.pool_avail + num_blocks] = block_ids + self.pool_avail += num_blocks def update_timestamps(self, block_ids: Tensor) -> None: """Update LRU timestamps for accessed blocks. No-op in RZ mode. @@ -424,6 +460,22 @@ def get_evictable_block_count(self) -> Tensor: cached_mask = (self.block_ref_counts == 0) & (self.block_hashes != -1) return cached_mask.sum() + def get_allocatable_count(self) -> int: + """Compute the number of blocks available for allocation. + + Includes both blocks in the free pool and, under LRU prefix caching, + registered ref-zero blocks that can be evicted. + + Returns: + Number of blocks that can currently be allocated. + """ + if ( + self.enable_prefix_caching + and self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU + ): + return self.pool_avail + int(self.get_evictable_block_count()) + return self.pool_avail + def evict_lru_blocks(self, num_blocks_needed: int) -> bool: """Evict LRU cached blocks to free up space in the pool. @@ -453,8 +505,8 @@ def evict_lru_blocks(self, num_blocks_needed: int) -> bool: Worked example, evicting 3 from:: A(ts 1) -> B(ts 2) -> C(ts 5) (C, F are leaves under B) - \-> F(ts 3) - \-> D(ts 3) -> E(ts 5) (E is a leaf under D) + +-> F(ts 3) + +-> D(ts 3) -> E(ts 5) (E is a leaf under D) Leaf-peel evicts F(3), then C(5); B is now childless so it joins the leaves with its own ts=2 and is evicted next -> retains {A, D, E}, keeping diff --git a/megatron/core/inference/contexts/mamba_slot_allocator.py b/megatron/core/inference/contexts/mamba_slot_allocator.py index 2cae00e05d9..5f95d3f7d3e 100644 --- a/megatron/core/inference/contexts/mamba_slot_allocator.py +++ b/megatron/core/inference/contexts/mamba_slot_allocator.py @@ -60,7 +60,7 @@ def __init__( self.num_mamba_layers = num_mamba_layers gpu_device = torch.cuda.current_device() - num_blocks = context.kv_block_allocator.total_count + num_blocks = context.kv_block_allocator.pool_size # Block <-> slot mappings (CPU for bookkeeping). self.block_to_slot = torch.full((num_blocks,), -1, dtype=torch.int32, device='cpu') @@ -216,8 +216,8 @@ def _evictable_block_ids(self) -> Tensor: """Return blocks whose durable Mamba slots have no live KV owner.""" kv_alloc = self.context.kv_block_allocator - has_slot_mask = self.block_to_slot[: kv_alloc.total_count] >= 0 - ref_zero_mask = kv_alloc.block_ref_counts[: kv_alloc.total_count] == 0 + has_slot_mask = self.block_to_slot[: kv_alloc.pool_size] >= 0 + ref_zero_mask = kv_alloc.block_ref_counts[: kv_alloc.pool_size] == 0 return torch.nonzero(has_slot_mask & ref_zero_mask, as_tuple=True)[0] def _evict_lru_slots_batch(self, num_needed: int, candidate_ids: Tensor) -> list: diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 8b5b63a7aae..522ca698899 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -1138,9 +1138,8 @@ def _add_request( request.status = Status.FAILED request.add_event_error_nontransient(TokenOverflowError(request_id)) - # Check that the KV cache has enough blocks for this request's stored tokens: + # Check that the shared KV pool has enough blocks for this request's stored tokens: # the prompt, all generated tokens but the last, and the final decode step's drafts. - # Blocks are granted to a running request only from the active pool. max_stored_tokens = len(request.prompt_tokens) if request.sampling_params.num_tokens_to_generate > 1: max_stored_tokens += ( @@ -1149,7 +1148,8 @@ def _add_request( + self.context.num_speculative_tokens ) request_block_count = math.ceil(max_stored_tokens / self.context.block_size_tokens) - if request_block_count > self.context.kv_block_allocator.active_count: + usable_blocks = self.context.kv_block_allocator.pool_size - 1 + if request_block_count > usable_blocks: request.status = Status.FAILED request.add_event_error_nontransient(BlockOverflowError(request_id)) @@ -1279,7 +1279,7 @@ def post_process_requests( # Pre-compute step-level block stats (before the per-request loop) if self.track_generated_token_events: - blocks_allocated = block_allocator.total_count - block_allocator.total_avail + blocks_allocated = block_allocator.pool_size - block_allocator.pool_avail if block_allocator.enable_prefix_caching: blocks_hashed_active = int((block_allocator.block_ref_counts > 0).sum().item()) blocks_ref_count = block_allocator.block_ref_counts.sum().item() @@ -1351,7 +1351,7 @@ def post_process_requests( if block_allocator.enable_prefix_caching: event = request.add_event_generated_token( token, - blocks_total=block_allocator.total_count, + blocks_total=block_allocator.pool_size, blocks_hashed_total=blocks_allocated, blocks_hashed_active=blocks_hashed_active, blocks_ref_count=blocks_ref_count, @@ -1361,7 +1361,7 @@ def post_process_requests( else: event = request.add_event_generated_token( token, - blocks_total=block_allocator.total_count, + blocks_total=block_allocator.pool_size, blocks_hashed_total=blocks_allocated, blocks_hashed_active=blocks_hashed_active, pre_fwd_active_token_count=pre_fwd_active_token_count, @@ -2161,10 +2161,12 @@ async def async_forward(self) -> Tuple[Optional[Dict], Dict, float]: "finished_request_count": self.finished_request_count, "evicted_request_count": self.evicted_request_count, "kv_stats": kvcache_util_stats, - "total_active_block_count": self.context.kv_block_allocator.active_count, - "total_paused_block_count": self.context.kv_block_allocator.paused_count, - "total_active_used_blocks": self.context.kv_block_allocator.get_active_used(), - "total_paused_used_blocks": self.context.kv_block_allocator.get_paused_used(), + "usable_block_count": self.context.kv_block_allocator.pool_size - 1, + "occupied_block_count": self.context.kv_block_allocator.get_total_used(), + "allocatable_block_count": self.context.kv_block_allocator.get_allocatable_count(), + "active_used_block_count": self.context.kv_block_allocator.get_active_used(), + "paused_used_block_count": self.context.kv_block_allocator.get_paused_used(), + "paused_block_budget": self.context.kv_block_allocator.paused_limit, } context_state = {**pre_step_context_state, **post_step_context_state} else: @@ -2362,7 +2364,8 @@ async def async_bookkeep( output_str = ( "* rank %d | step %d | %s ... time: %.3f ms%s ... " "reqs: a %d/%d, p %d, w %d, f %d, e %d ... " - "blocks: a %d/%d, p %d/%d ... " + "blocks: occupied %d/%d, allocatable %d, active-used %d, " + "paused-used %d/%d ... " "mem: tensors %d, alloc %.1f gb, res %.1f gb." % ( self.rank, @@ -2387,10 +2390,12 @@ async def async_bookkeep( context_state["waiting_request_count"], context_state["finished_request_count"], context_state["evicted_request_count"], - context_state["total_active_used_blocks"], - context_state["total_active_block_count"], - context_state["total_paused_used_blocks"], - context_state["total_paused_block_count"], + context_state["occupied_block_count"], + context_state["usable_block_count"], + context_state["allocatable_block_count"], + context_state["active_used_block_count"], + context_state["paused_used_block_count"], + context_state["paused_block_budget"], mem["allocation.all.current"], mem["allocated_bytes.all.current"] / (1024**3), mem["reserved_bytes.all.current"] / (1024**3), @@ -2442,7 +2447,7 @@ async def async_bookkeep( kv_alloc = self.context.kv_block_allocator output_str += " ... prefix cache util: KV %d/%d blocks cached (%d evictable)" % ( len(kv_alloc.kv_hash_to_block_id), - kv_alloc.total_count, + kv_alloc.pool_size, int(kv_alloc.get_evictable_block_count()), ) msa = self.context.mamba_slot_allocator diff --git a/megatron/core/inference/inference_request.py b/megatron/core/inference/inference_request.py index d325a4e89a0..a0d2e337c7a 100644 --- a/megatron/core/inference/inference_request.py +++ b/megatron/core/inference/inference_request.py @@ -695,13 +695,16 @@ def checkpoint(self, tokenizer: MegatronTokenizer | None = None): } ) - # New request. + # Preserve prefix-cache configuration and let __post_init__ recompute hashes for the + # expanded prompt. The previous hash list may not include newly completed blocks. new_request = DynamicInferenceRequest( request_id=old_request.request_id, prompt_tokens=new_prompt_tokens, sampling_params=new_sampling_params, policy_epoch=policy_epoch, kv_cache_epoch=kv_cache_epoch, + block_size_tokens=old_request.block_size_tokens, + enable_prefix_caching=old_request.enable_prefix_caching, ) # Preserve event_add_engine from old request if it exists, otherwise set it. # This ensures TTFT calculation works correctly for evicted/resumed requests. diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 294229102b3..7137e2bbe5d 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2004,7 +2004,7 @@ def _add_inference_args(parser): help='Enable dynamic batching mode.') group.add_argument('--inference-dynamic-batching-buffer-size-gb', type=float, default=40., - help='Amount of on-GPU memory allocated for the KV cache. ' + help='On-GPU portion of the shared KV cache block pool. ' 'The total amount of memory allocated for the KV cache ' '(CPU + GPU memory) depends on the value set for the ' 'unified virtual memory (UVM) level (via ' @@ -2016,10 +2016,12 @@ def _add_inference_args(parser): 'paused_buffer_size_gb`.') group.add_argument('--inference-dynamic-batching-paused-buffer-size-gb', type=float, default=None, - help='Amount of memory reserved for paused requests in ' - 'the dynamic inference context. Active requests are ' - 'paused when there are not enough active blocks available ' - 'to continue generating a request.') + help='Memory used to derive the paused-request block retention ' + 'budget. This does not reserve blocks from active requests: ' + 'active requests may use the entire shared pool of usable KV ' + 'cache blocks. Under allocation pressure, paused requests ' + 'retain blocks only within this budget and excess paused ' + 'requests may be evicted.') group.add_argument('--inference-dynamic-batching-mamba-memory-ratio', type=float, default=None, help='Percentage of memory buffer to allocate for Mamba states. ' 'If not specified, allocates Mamba state tensors for each KV cache block. ' diff --git a/megatron/training/config/inference_config.py b/megatron/training/config/inference_config.py index 017ba3966c1..ee75517f41b 100644 --- a/megatron/training/config/inference_config.py +++ b/megatron/training/config/inference_config.py @@ -82,14 +82,15 @@ class InferenceSetupConfig: """Enable dynamic batching mode.""" inference_dynamic_batching_buffer_size_gb: float = 40.0 - """Amount of on-GPU memory allocated for the KV cache. The total amount of memory allocated for + """On-GPU portion of the shared KV cache block pool. The total amount of memory allocated for the KV cache (CPU + GPU memory) depends on the value set for the unified virtual memory (UVM) level (via inference_dynamic_batching_unified_memory_level).""" inference_dynamic_batching_paused_buffer_size_gb: float | None = None - """Amount of memory reserved for paused requests in the dynamic inference context. Active - requests are paused when there are not enough active blocks available to continue generating a - request.""" + """Memory used to derive the paused-request block retention budget. This does not reserve blocks + from active requests: active requests may use the entire shared pool of usable KV cache blocks. + Under allocation pressure, paused requests retain blocks only within this budget and excess + paused requests may be evicted.""" inference_dynamic_batching_mamba_memory_ratio: float | None = None """Percentage of memory buffer to allocate for Mamba states. If not specified, allocates Mamba diff --git a/tests/unit_tests/inference/contexts/test_dynamic_context.py b/tests/unit_tests/inference/contexts/test_dynamic_context.py index 21ca613512c..48abd7a05c8 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_context.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_context.py @@ -161,16 +161,16 @@ def test_initialize_dynamic_context(self, is_hybrid_model: bool): ) if not is_hybrid_model: - assert dynamic_context.kv_block_allocator.total_count == 491 - assert dynamic_context.kv_block_allocator.active_count == 392 + assert dynamic_context.kv_block_allocator.pool_size == 491 + assert dynamic_context.kv_block_allocator.pool_avail == 490 # We make max_requests divisible by the REQUEST_ROUNDER. assert dynamic_context.max_requests == 448 assert dynamic_context.max_tokens == 16384 assert dynamic_context.num_mamba_layers == 0 assert dynamic_context.mamba_metadata is None else: - assert dynamic_context.kv_block_allocator.total_count == 556 - assert dynamic_context.kv_block_allocator.active_count == 444 + assert dynamic_context.kv_block_allocator.pool_size == 556 + assert dynamic_context.kv_block_allocator.pool_avail == 555 assert dynamic_context.max_requests == 512 assert dynamic_context.max_tokens == 16384 assert dynamic_context.num_mamba_layers == 1 @@ -210,12 +210,12 @@ def test_is_memory_available(self, is_hybrid_model): max_tokens=None, is_hybrid_model=is_hybrid_model, ) - dynamic_context.kv_block_allocator.total_avail = 10 + dynamic_context.kv_block_allocator.pool_avail = 10 assert dynamic_context.kv_block_allocator.is_memory_available(10) assert not dynamic_context.kv_block_allocator.is_memory_available(11) assert dynamic_context.kv_block_allocator.is_memory_available(1) - dynamic_context.kv_block_allocator.total_avail = 0 + dynamic_context.kv_block_allocator.pool_avail = 0 assert not dynamic_context.kv_block_allocator.is_memory_available(1) @pytest.mark.internal @@ -531,11 +531,11 @@ def test_reset(self, is_hybrid_model: bool): assert torch.all(dynamic_context.token_to_block_idx == -1) assert torch.all(dynamic_context.token_to_local_position_within_kv_block == 0) if not is_hybrid_model: - assert dynamic_context.kv_block_allocator.active_count == 819 - assert dynamic_context.kv_block_allocator.total_count == 1024 + assert dynamic_context.kv_block_allocator.pool_size == 1024 + assert dynamic_context.kv_block_allocator.pool_avail == 1023 else: - assert dynamic_context.kv_block_allocator.active_count == 1517 - assert dynamic_context.kv_block_allocator.total_count == 1897 + assert dynamic_context.kv_block_allocator.pool_size == 1897 + assert dynamic_context.kv_block_allocator.pool_avail == 1896 assert torch.all(dynamic_context.request_to_kv_block_ids == -1) if is_hybrid_model: assert torch.all(dynamic_context.mamba_metadata.request_to_mamba_state_idx == -1) @@ -571,20 +571,20 @@ def test_allocate_and_release_memory_blocks(self, is_hybrid_model): .tolist() == expected_memory_blocks ) - assert dynamic_context.kv_block_allocator.total_avail == expected_block_count_avail + assert dynamic_context.kv_block_allocator.pool_avail == expected_block_count_avail dynamic_context.kv_block_allocator.release_memory_blocks( torch.tensor(expected_memory_blocks[-2:], device='cpu') ) - assert dynamic_context.kv_block_allocator.total_avail == expected_block_count_avail + 2 + assert dynamic_context.kv_block_allocator.pool_avail == expected_block_count_avail + 2 assert ( dynamic_context.kv_block_allocator.allocate_memory_blocks(1).item() == expected_memory_blocks[-1] ) - assert dynamic_context.kv_block_allocator.total_avail == expected_block_count_avail + 1 + assert dynamic_context.kv_block_allocator.pool_avail == expected_block_count_avail + 1 # Should return None since we allocate more blocks than what we have. assert ( dynamic_context.kv_block_allocator.allocate_memory_blocks( - dynamic_context.kv_block_allocator.total_avail + 100 + dynamic_context.kv_block_allocator.get_allocatable_count() + 100 ) == None ) @@ -692,14 +692,14 @@ def test_add_dummy_requests_parallel_populates_state(self): lengths = [req.remaining_prompt_length for req in requests] total_tokens = sum(lengths) - block_avail_before = dynamic_context.kv_block_allocator.total_avail + block_avail_before = dynamic_context.kv_block_allocator.pool_avail dynamic_context.add_dummy_requests_parallel(requests, count_as_prefill=False) assert dynamic_context.active_token_count == total_tokens assert dynamic_context.total_request_count == len(requests) assert dynamic_context.num_prefill_requests == 0 - assert dynamic_context.kv_block_allocator.total_avail == block_avail_before + assert dynamic_context.kv_block_allocator.pool_avail == block_avail_before expected_tokens = torch.cat( [torch.arange(0, 3, device='cpu'), torch.arange(3, 9, device='cpu')] @@ -891,16 +891,16 @@ def test_update_request(self, is_hybrid_model: bool): ) total_request_count = 10 - dynamic_context.kv_block_allocator.total_avail -= 11 # We align 11 blocks to the 10 requests we have. 3rd request alone we setup like it requires 2 blocks + dynamic_context.kv_block_allocator.pool_avail -= 11 # We align 11 blocks to the 10 requests we have. 3rd request alone we setup like it requires 2 blocks dynamic_context.total_request_count = total_request_count dynamic_context.request_to_kv_block_ids[0:total_request_count, 0] = torch.arange( - dynamic_context.kv_block_allocator.total_avail, - dynamic_context.kv_block_allocator.total_avail + 10, + dynamic_context.kv_block_allocator.pool_avail, + dynamic_context.kv_block_allocator.pool_avail + 10, ) dynamic_context.request_to_kv_block_ids[3][ 1 - ] = dynamic_context.kv_block_allocator.total_avail # Assign one extra block to request 3. + ] = dynamic_context.kv_block_allocator.pool_avail # Assign one extra block to request 3. dynamic_context.request_kv_length_offsets[0:total_request_count] = 10 # For 0, 1, 5, 6, the total number of tokens in last block is block size -1, so that they will all need extra blocks dynamic_context.request_kv_length_offsets[0:2] = dynamic_context.block_size_tokens - 1 @@ -1131,7 +1131,7 @@ def test_async_sched_prepare_requests_success( @pytest.mark.internal @rounder_override(8) @pytest.mark.parametrize( - "num_speculative_tokens, last_block_offsets, active_avail, expected", + "num_speculative_tokens, last_block_offsets, allocatable_count, expected", [ (0, [0, 1], 0, True), (0, [3, 1], 0, False), @@ -1141,14 +1141,14 @@ def test_async_sched_prepare_requests_success( ], ) def test_async_sched_can_prepare_requests_exact_block_demand( - self, num_speculative_tokens, last_block_offsets, active_avail, expected + self, num_speculative_tokens, last_block_offsets, allocatable_count, expected ): """Overlap capacity counts only requests crossing a block boundary.""" ctx = self._get_async_sched_context(num_speculative_tokens=num_speculative_tokens) self._setup_async_sched_decode_rows( ctx, active_request_count=len(last_block_offsets), last_block_offsets=last_block_offsets ) - ctx.kv_block_allocator.get_active_avail = mock.Mock(return_value=active_avail) + ctx.kv_block_allocator.get_allocatable_count = mock.Mock(return_value=allocatable_count) assert ctx.can_prepare_requests() is expected @@ -1174,9 +1174,12 @@ def test_async_sched_prepare_capacity_recovers_after_pause_resume(self): self._setup_async_sched_decode_rows( ctx, active_request_count=2, last_block_offsets=[ctx.block_size_tokens - 1, 0] ) - ctx.kv_block_allocator.active_count = ctx.kv_block_allocator.get_active_used() - ctx.kv_block_allocator.total_avail = 0 - ctx.kv_block_allocator.paused_count = 100 + alloc = ctx.kv_block_allocator + alloc.paused_limit = 1 + filler_blocks = alloc.allocate_memory_blocks(alloc.pool_avail) + assert filler_blocks is not None + filler_blocks = filler_blocks.clone() + assert alloc.get_allocatable_count() == 0 assert not ctx.can_prepare_requests() @@ -1187,11 +1190,13 @@ def test_async_sched_prepare_capacity_recovers_after_pause_resume(self): assert ctx.paused_request_count == 1 assert not ctx.can_prepare_requests() - ctx.kv_block_allocator.total_avail = 1 + alloc.release_memory_blocks(filler_blocks[:1]) + assert alloc.get_allocatable_count() == 1 ctx.update_requests(active_requests_mask=torch.tensor([0]), new_tokens=torch.tensor([92])) assert ctx.paused_request_count == 0 assert ctx.can_prepare_requests() + alloc.release_memory_blocks(filler_blocks[1:]) @pytest.mark.internal @rounder_override(8) @@ -1244,9 +1249,9 @@ def test_async_sched_prepare_requests_errors(self, setup, expected_message): ctx, active_request_count=2, kv_offsets=[3, 5], last_block_offsets=[3, 1] ) if "pause requests" in expected_message: - ctx.kv_block_allocator.get_active_avail = mock.Mock(return_value=0) + ctx.kv_block_allocator.pool_avail = 0 elif "evict requests" in expected_message: - ctx.kv_block_allocator.get_active_avail = mock.Mock(return_value=1) + ctx.kv_block_allocator.pool_avail = 1 ctx.kv_block_allocator.allocate_memory_blocks = mock.Mock(return_value=None) else: setup(ctx) @@ -1446,7 +1451,7 @@ def test_release_memory_blocks_for_finished_requests(self, is_hybrid_model): dynamic_context.paused_request_count = 0 # Record the available blocks before releasing memory - initial_available_blocks = dynamic_context.kv_block_allocator.total_avail + initial_available_blocks = dynamic_context.kv_block_allocator.pool_avail # Assign blocks to the requests (one block per request) for i in range(5): @@ -1481,7 +1486,7 @@ def test_release_memory_blocks_for_finished_requests(self, is_hybrid_model): assert dynamic_context.active_token_count == 2 # Verify that 3 blocks were released by checking the available blocks - assert dynamic_context.kv_block_allocator.total_avail == initial_available_blocks + 3 + assert dynamic_context.kv_block_allocator.pool_avail == initial_available_blocks + 3 if is_hybrid_model: # Request at position 3 now moves into finished request position 0 @@ -1522,7 +1527,7 @@ def test_finished_requests_with_multiple_blocks(self, is_hybrid_model): dynamic_context.paused_request_count = 0 # Record the available blocks before releasing memory - initial_available_blocks = dynamic_context.kv_block_allocator.total_avail + initial_available_blocks = dynamic_context.kv_block_allocator.pool_avail # Assign blocks to the requests: # - Request 0: 1 block @@ -1569,7 +1574,7 @@ def test_finished_requests_with_multiple_blocks(self, is_hybrid_model): assert dynamic_context.active_token_count == 0 # Verify that all 6 blocks were released by checking the available blocks - assert dynamic_context.kv_block_allocator.total_avail == initial_available_blocks + 6 + assert dynamic_context.kv_block_allocator.pool_avail == initial_available_blocks + 6 @pytest.mark.internal @rounder_override(64) @@ -1986,7 +1991,7 @@ def test_pipeline_parallel_uneven_layers(self): # Collect the total block counts on each rank (CUDA needed for NCCL all_gather) local_total_blocks = torch.tensor( - [context.kv_block_allocator.total_count], device='cuda', dtype=torch.long + [context.kv_block_allocator.pool_size], device='cuda', dtype=torch.long ) gathered_block_counts = [torch.zeros_like(local_total_blocks) for _ in range(pp_size)] torch.distributed.all_gather( @@ -2127,8 +2132,8 @@ def test_mamba_memory_ratio_allocation(self, ratio): expected_total_blocks = expected_active_blocks + expected_paused_blocks # Check that block allocator received the reduced block counts - assert context.kv_block_allocator.total_count == expected_active_blocks - assert context.kv_block_allocator.paused_count == expected_paused_blocks + assert context.kv_block_allocator.pool_size == expected_active_blocks + assert context.kv_block_allocator.paused_limit == expected_paused_blocks # max_requests should be limited by the Mamba calculation if mamba_max_requests is smaller # or the block count - 1 if that is smaller @@ -2206,7 +2211,7 @@ def test_hybrid_max_requests_auto_derives_mamba_split(self, max_requests): kv_block_size_bytes = dtype_size * 2 * 1 * block_size * num_attention_heads * kv_channels expected_active_blocks = kv_buffer_bytes // kv_block_size_bytes - assert context.kv_block_allocator.total_count == expected_active_blocks + assert context.kv_block_allocator.pool_size == expected_active_blocks assert context.max_requests == max_requests # With max_requests=1, more memory goes to KV blocks than with max_requests=64. @@ -2230,9 +2235,7 @@ def test_hybrid_max_requests_auto_derives_mamba_split(self, max_requests): unified_memory_level=0, ), ) - assert ( - context.kv_block_allocator.total_count > context_many.kv_block_allocator.total_count - ) + assert context.kv_block_allocator.pool_size > context_many.kv_block_allocator.pool_size @pytest.mark.internal @rounder_override(64) @@ -2720,10 +2723,10 @@ def test_paused_speculative_tokens_tracking(self): ctx.request_to_kv_block_ids[1, 0] = blocks[1] ctx.request_last_kv_block_id[:2] = blocks - # Force the allocator to have no available blocks. + # Force the allocator to have no free blocks. # This guarantees request 0 stays paused and cannot immediately resume. - ctx.kv_block_allocator.total_avail = 0 - ctx.kv_block_allocator.paused_count = 100 # Ensure it doesn't get completely evicted either + ctx.kv_block_allocator.pool_avail = 0 + ctx.kv_block_allocator.paused_limit = 100 # Ensure it doesn't get completely evicted either active_requests_mask = torch.tensor([1, 1], device='cpu') new_tokens = torch.tensor([99, 100], device='cpu') # Sampled @@ -2732,7 +2735,7 @@ def test_paused_speculative_tokens_tracking(self): ) # Speculative # In update_requests, request 0 will be paused to allocate a new block. - # Since total_avail is 0, it will stay paused and its tokens will be cached. + # Since raw block availability is 0, it will stay paused and cache its tokens. ctx.update_requests( active_requests_mask=active_requests_mask, new_tokens=new_tokens, @@ -3046,7 +3049,7 @@ def test_speculative_with_prefix_caching_shared_blocks(self): ctx.add_request(req1) # 3 full blocks are prefix-cacheable; the 4th (partial) block is not. first_full_blocks = [ctx.request_to_kv_block_ids[0][i].item() for i in range(3)] - avail_after_first = ctx.kv_block_allocator.total_avail + avail_after_first = ctx.kv_block_allocator.pool_avail # Second request with same prefix should share the 3 full blocks. req2 = DynamicInferenceRequest( @@ -3063,7 +3066,7 @@ def test_speculative_with_prefix_caching_shared_blocks(self): assert first_full_blocks == second_full_blocks # Only 1 new block allocated for the partial tail of the second request. - assert ctx.kv_block_allocator.total_avail == avail_after_first - 1 + assert ctx.kv_block_allocator.pool_avail == avail_after_first - 1 # Ref counts on the shared full blocks should be 2. for bid in first_full_blocks: @@ -3363,7 +3366,7 @@ def test_prefix_caching_check_availability_with_speculative(self): ctx.add_request(req1) # Exhaust the remaining pool. - while ctx.kv_block_allocator.total_avail > 0: + while ctx.kv_block_allocator.pool_avail > 0: ctx.kv_block_allocator.allocate_memory_blocks(1) # A new request with the same prefix should still be schedulable @@ -3437,6 +3440,197 @@ def test_prefix_match_exact_block_boundary(self): # Effective query length should be 3 (35 total - 32 skipped) assert ctx.request_query_lengths[1].item() == 3 + @pytest.mark.internal + @rounder_override(1) + def test_resume_uses_entire_shared_block_pool(self): + """Active requests may consume blocks inside the paused retention budget.""" + ctx = self._get_dynamic_context( + params_dtype=torch.float32, + num_layers=2, + kv_channels=8, + num_attention_heads=2, + max_sequence_length=128, + buffer_size_gb=0.01, + block_size_tokens=16, + max_tokens=64, + paused_buffer_size_gb=0.0, + max_requests=8, + ) + + # Four usable blocks and a three-block paused budget would have left an + # active partition of only one block. The shared-pool design has no such + # active cap, so an active request may already own two blocks and a paused + # request can still resume into the final free block. + ctx.kv_block_allocator = type(ctx.kv_block_allocator)( + context=ctx, pool_size=5, paused_limit=3 + ) + alloc = ctx.kv_block_allocator + blocks = alloc.allocate_memory_blocks(3) + assert blocks is not None + + ctx.total_request_count = 2 + ctx.paused_request_count = 1 + ctx.request_ids[:2] = torch.tensor([10, 11], dtype=torch.int64, device='cpu') + ctx.request_kv_block_counts[:2] = torch.tensor([1, 2], dtype=torch.int32, device='cpu') + ctx.request_to_kv_block_ids[0, 0] = blocks[0] + ctx.request_to_kv_block_ids[1, :2] = blocks[1:] + ctx.request_last_kv_block_id[0] = blocks[0] + ctx.request_last_kv_block_id[1] = blocks[-1] + ctx.request_last_kv_block_offset[:2] = torch.tensor( + [ctx.block_size_tokens - 1, 0], dtype=torch.int32, device='cpu' + ) + + assert alloc.get_active_used() == 2 + assert alloc.pool_avail == 1 + + active_request_count, newly_paused_request_ids = ctx.resume_paused_requests(1, None) + + assert active_request_count == 2 + assert newly_paused_request_ids is None + assert ctx.paused_request_count == 0 + assert ctx.request_kv_block_counts[:2].tolist() == [2, 2] + assert alloc.get_active_used() == 4 + assert alloc.pool_avail == 0 + + @pytest.mark.internal + @rounder_override(1) + def test_eviction_balances_released_blocks_with_resume_allocations(self): + """Evict the smallest right-most suffix that funds overflow resumptions.""" + ctx = self._get_dynamic_context( + params_dtype=torch.float32, + num_layers=2, + kv_channels=8, + num_attention_heads=2, + max_sequence_length=128, + buffer_size_gb=0.01, + block_size_tokens=16, + max_tokens=64, + paused_buffer_size_gb=0.0, + max_requests=8, + ) + + # Four paused requests own two blocks each, filling all eight usable + # blocks. The paused budget retains the oldest request. Evicting the + # right-most request frees two blocks, exactly enough to reactivate the + # two remaining overflow requests with one new block apiece. + ctx.kv_block_allocator = type(ctx.kv_block_allocator)( + context=ctx, pool_size=9, paused_limit=2 + ) + alloc = ctx.kv_block_allocator + blocks = alloc.allocate_memory_blocks(8) + assert blocks is not None and alloc.pool_avail == 0 + + ctx.total_request_count = 4 + ctx.paused_request_count = 4 + ctx.request_ids[:4] = torch.tensor([10, 11, 12, 13], dtype=torch.int64, device='cpu') + ctx.request_kv_block_counts[:4] = 2 + ctx.request_to_kv_block_ids[:4, :2] = blocks.reshape(4, 2) + ctx.request_last_kv_block_id[:4] = blocks.reshape(4, 2)[:, -1] + ctx.request_last_kv_block_offset[:4] = ctx.block_size_tokens - 1 + + assert ctx._get_releasable_block_counts(1, 4) == [0, 2, 4, 6] + + evicted_request_ids = ctx.evict_overflow_paused_requests( + active_request_count=0, next_tokens=torch.arange(4, dtype=torch.int64, device='cpu') + ) + + assert evicted_request_ids.tolist() == [13] + assert ctx.request_ids[:3].tolist() == [10, 11, 12] + assert ctx.total_request_count == 3 + assert ctx.paused_request_count == 3 + assert alloc.pool_avail == 2 + + active_request_count, newly_paused_request_ids = ctx.resume_paused_requests(0, None) + + assert active_request_count == 2 + assert newly_paused_request_ids is None + assert ctx.paused_request_count == 1 + assert ctx.request_kv_block_counts[:3].tolist() == [2, 3, 3] + assert alloc.pool_avail == 0 + + @pytest.mark.internal + @rounder_override(1) + def test_update_requests_allows_every_request_to_be_evicted(self): + """An all-overflow batch may become empty and be requeued by the engine.""" + ctx = self._get_dynamic_context( + params_dtype=torch.float32, + num_layers=2, + kv_channels=8, + num_attention_heads=2, + max_sequence_length=128, + buffer_size_gb=0.01, + block_size_tokens=16, + max_tokens=64, + paused_buffer_size_gb=0.0, + max_requests=8, + ) + + # The sole request owns the only usable block and needs one more. With + # a zero paused budget it must be evicted; returning an empty context is + # valid because the engine checkpoints and requeues the evicted request. + ctx.kv_block_allocator = type(ctx.kv_block_allocator)( + context=ctx, pool_size=2, paused_limit=0 + ) + alloc = ctx.kv_block_allocator + blocks = alloc.allocate_memory_blocks(1) + assert blocks is not None and alloc.pool_avail == 0 + + ctx.total_request_count = 1 + ctx.active_token_count = 1 + ctx.request_ids[0] = 10 + ctx.request_query_lengths[0] = 1 + ctx.request_kv_block_counts[0] = 1 + ctx.request_to_kv_block_ids[0, 0] = blocks[0] + ctx.request_last_kv_block_id[0] = blocks[0] + ctx.request_last_kv_block_offset[0] = ctx.block_size_tokens - 1 + + result = ctx.update_requests( + active_requests_mask=torch.ones(1, dtype=torch.int32, device='cpu'), + new_tokens=torch.tensor([99], dtype=torch.int64, device='cpu'), + ) + + assert result["evict_request_ids"].tolist() == [10] + assert ctx.total_request_count == 0 + assert ctx.paused_request_count == 0 + assert ctx.active_token_count == 0 + assert alloc.pool_avail == 1 + + @pytest.mark.internal + @rounder_override(64) + def test_releasable_block_counts_with_staggered_shared_prefixes(self): + """Count blocks at the first right-most suffix that releases every reference.""" + model_config = TransformerConfig( + params_dtype=torch.float32, num_layers=2, kv_channels=8, num_attention_heads=2 + ) + inference_config = InferenceConfig( + max_sequence_length=512, + buffer_size_gb=0.1, + block_size_tokens=16, + enable_prefix_caching=True, + unified_memory_level=0, + paused_buffer_size_gb=0.0, + max_tokens=512, + max_requests=512, + ) + ctx = DynamicInferenceContext(model_config=model_config, inference_config=inference_config) + blocks = ctx.kv_block_allocator.allocate_memory_blocks(4) + assert blocks is not None + block_a, block_b, block_c, block_d = blocks.tolist() + + # A is released by the one-request suffix, B by two requests, and C by + # three. D also has a reference outside the selected request range. + ctx.request_to_kv_block_ids[0, 0] = block_c + ctx.request_to_kv_block_ids[1, 0] = block_b + ctx.request_to_kv_block_ids[2, :4] = torch.tensor( + [block_a, block_b, block_c, block_d], dtype=torch.int32, device='cpu' + ) + ctx.request_to_kv_block_ids[3, 0] = block_d + ctx.kv_block_allocator.block_ref_counts[blocks] = torch.tensor( + [1, 2, 2, 2], dtype=torch.int32, device='cpu' + ) + + assert ctx._get_releasable_block_counts(0, 3) == [0, 1, 2, 3] + @pytest.mark.internal @rounder_override(64) def test_eviction_with_shared_prefix_blocks(self): @@ -3484,6 +3678,12 @@ def test_eviction_with_shared_prefix_blocks(self): # Both blocks should be safely shared with ref count 2 assert ctx.kv_block_allocator.block_ref_counts[shared_b0].item() == 2 + assert ctx.kv_block_allocator.block_ref_counts[shared_b1].item() == 2 + + # Evicting only the right-most sharer releases no physical blocks. Both + # requests must be selected before either shared block reaches ref-zero. + ctx.paused_request_count = 2 + assert ctx._get_releasable_block_counts(0, 2) == [0, 0, 2] # Mock the state to make req1 paused and req2 active ctx.paused_request_count = 1 @@ -3492,9 +3692,10 @@ def test_eviction_with_shared_prefix_blocks(self): ctx.request_ids[1] = 2 ctx.request_kv_block_counts[0] = 2 ctx.request_kv_block_counts[1] = 2 + assert ctx._get_releasable_block_counts(0, 1) == [0, 0] - # Exhaust the active block allocator - ctx.kv_block_allocator.total_avail = 0 + # Exhaust the free block pool. + ctx.kv_block_allocator.pool_avail = 0 # Trigger the eviction logic # next_tokens must be sized to total_request_count (1 paused + 1 active = 2) @@ -3556,9 +3757,9 @@ def test_oom_during_speculative_boundary_crossing(self): ctx.request_to_kv_block_ids[1, 0] = blocks[1] ctx.request_last_kv_block_id[:2] = blocks - # Force OOM condition (no blocks left in the active pool) - ctx.kv_block_allocator.total_avail = 0 - ctx.kv_block_allocator.paused_count = 100 # Prevent immediate eviction out of the system + # Force OOM condition (no blocks left in the free pool). + ctx.kv_block_allocator.pool_avail = 0 + ctx.kv_block_allocator.paused_limit = 100 # Prevent immediate eviction out of the system active_mask = torch.tensor([1, 1], device='cpu', dtype=torch.int32) new_tokens = torch.tensor([99, 88], device='cpu') diff --git a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py index 0f096e1ab6f..a3a01f8ce4c 100644 --- a/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py +++ b/tests/unit_tests/inference/contexts/test_dynamic_prefix_caching.py @@ -128,6 +128,25 @@ def _prompt(num_tokens, offset=0): def _block_ids(ctx, req_idx, n): return [ctx.request_to_kv_block_ids[req_idx][i].item() for i in range(n)] + @staticmethod + def _fill_pool_with_one_evictable_block(ctx): + """Exhaust the free pool while leaving exactly one LRU block evictable.""" + alloc = ctx.kv_block_allocator + drained_block_ids = alloc.allocate_memory_blocks(alloc.pool_avail) + assert drained_block_ids is not None and drained_block_ids.numel() > 0 + + cached_block_id = drained_block_ids[0].item() + cached_hash = 1 + while cached_hash in alloc.kv_hash_to_block_id: + cached_hash += 1 + alloc.register_kv_block_hashes([cached_block_id], [cached_hash], parent_hashes=[0]) + alloc.release_memory_blocks(drained_block_ids[:1]) + + assert alloc.pool_avail == 0 + assert alloc.get_allocatable_count() == 1 + assert int(alloc.get_evictable_block_count()) == 1 + return cached_block_id, cached_hash + @staticmethod def _mamba_allocate_and_register(ctx, bids): """Allocate Mamba cache slots and register hashes for a list of block IDs.""" @@ -275,10 +294,10 @@ def test_block_sharing_patterns(self): prompt = self._prompt(bs * 3) ctx.add_request(self._req(ctx, prompt.clone())) first_blocks = self._block_ids(ctx, 0, 3) - avail_after_first = alloc.total_avail + avail_after_first = alloc.pool_avail for i in range(2, 11): ctx.add_request(self._req(ctx, prompt.clone(), request_id=i)) - assert alloc.total_avail == avail_after_first + assert alloc.pool_avail == avail_after_first for req_idx in range(1, 10): assert self._block_ids(ctx, req_idx, 3) == first_blocks for bid in first_blocks: @@ -373,26 +392,26 @@ def test_block_allocation_with_prefix(self): prompt = self._prompt(bs * 4) ctx.add_request(self._req(ctx, prompt.clone())) first_blocks = self._block_ids(ctx, 0, 4) - avail = alloc.total_avail + avail = alloc.pool_avail ctx.add_request(self._req(ctx, prompt.clone(), request_id=2)) - assert self._block_ids(ctx, 1, 4) == first_blocks and alloc.total_avail == avail + assert self._block_ids(ctx, 1, 4) == first_blocks and alloc.pool_avail == avail # extended prompt allocates only new blocks ctx2 = self._ctx() alloc2 = ctx2.kv_block_allocator p2a = self._prompt(bs * 3) ctx2.add_request(self._req(ctx2, p2a)) - avail2 = alloc2.total_avail + avail2 = alloc2.pool_avail p2b = torch.cat([p2a, self._prompt(bs * 2, offset=1000)]) ctx2.add_request(self._req(ctx2, p2b, request_id=2)) - assert alloc2.total_avail == avail2 - 2 + assert alloc2.pool_avail == avail2 - 2 # check_availability accounts for prefix match ctx3 = self._ctx(buffer_size_gb=0.01, rounder=1) alloc3 = ctx3.kv_block_allocator p3 = self._prompt(ctx3.block_size_tokens * 2) ctx3.add_request(self._req(ctx3, p3.clone())) - while alloc3.total_avail > 0: + while alloc3.pool_avail > 0: alloc3.allocate_memory_blocks(1) _, _, kv_available = ctx3.check_availability(self._req(ctx3, p3.clone(), request_id=2)) assert kv_available @@ -488,7 +507,7 @@ def test_add_request_full_cache_partial_hit_pins_matched_blocks(self): assert alloc.block_ref_counts[sx].item() == 0 # Force a full pool: the new block for H2 can only come from eviction. - alloc.total_avail = 0 + alloc.pool_avail = 0 # Incoming prompt H0 -> H1 -> H2: first two blocks match the cached chain, # the third (H2) is new and must trigger a single eviction. @@ -542,7 +561,7 @@ def test_check_availability_excludes_already_pinned_matches(self): # Free pool exhausted: the one new block B needs (H2) can only come from # evicting SX. The already-pinned matches S0/S1 must not be reserved. - alloc.total_avail = 0 + alloc.pool_avail = 0 # Request B shares H0/H1 with A and needs one new block for H2. req_b = self._req(ctx, self._prompt(bs * 3), request_id=3) @@ -555,6 +574,75 @@ def test_check_availability_excludes_already_pinned_matches(self): # matches would wrongly report the request as un-addable. assert kv_cache_available is True + @pytest.mark.internal + def test_resume_boundary_crossing_evicts_lru_block_when_free_pool_empty(self): + """A boundary-crossing request resumes when only LRU capacity remains.""" + ctx = self._ctx(buffer_size_gb=0.01, rounder=1) + alloc = ctx.kv_block_allocator + bs = ctx.block_size_tokens + + ctx.add_request(self._req(ctx, self._prompt(bs))) + original_block_id = self._block_ids(ctx, 0, 1)[0] + assert ctx.request_last_kv_block_offset[0].item() == bs - 1 + assert alloc.block_ref_counts[original_block_id].item() == 1 + + cached_block_id, cached_hash = self._fill_pool_with_one_evictable_block(ctx) + + result = ctx.update_requests( + torch.ones(1, device=torch.cuda.current_device(), dtype=torch.int32), + torch.tensor([123], device=torch.cuda.current_device()), + ) + + new_block_id = ctx.request_last_kv_block_id[0].item() + assert result["newly_paused_request_ids"].numel() == 0 + assert result["evict_request_ids"] is None + assert ctx.paused_request_count == 0 + assert ctx.total_request_count == 1 + assert ctx.request_kv_block_counts[0].item() == 2 + assert new_block_id == cached_block_id + assert new_block_id != original_block_id + assert cached_hash not in alloc.kv_hash_to_block_id + assert alloc.block_hashes[new_block_id].item() == -1 + assert alloc.block_ref_counts[original_block_id].item() == 1 + assert alloc.block_ref_counts[new_block_id].item() == 1 + assert alloc.pool_avail == 0 + assert alloc.get_allocatable_count() == 0 + assert int(alloc.get_evictable_block_count()) == 0 + assert ctx.token_to_block_idx[0].item() == new_block_id + + @pytest.mark.internal + def test_resume_counts_new_blocks_independently_from_requests(self): + """With LIFO needs [0, 1, 1], one evictable block resumes exactly two requests.""" + ctx = self._ctx(buffer_size_gb=0.01, rounder=1) + alloc = ctx.kv_block_allocator + bs = ctx.block_size_tokens + + ctx.add_request(self._req(ctx, self._prompt(bs), request_id=1)) + ctx.add_request(self._req(ctx, self._prompt(bs, offset=1000), request_id=2)) + ctx.add_request(self._req(ctx, self._prompt(bs - 1, offset=2000), request_id=3)) + original_last_block_ids = ctx.request_last_kv_block_id[:3].clone() + + ctx.paused_request_count = 3 + needs_new_block_lifo = ctx.request_last_kv_block_offset[:3].flip(dims=[0]) >= bs - 1 + assert needs_new_block_lifo.tolist() == [False, True, True] + + cached_block_id, cached_hash = self._fill_pool_with_one_evictable_block(ctx) + active_request_count, newly_paused_request_ids = ctx.resume_paused_requests(0, None) + + assert active_request_count == 2 + assert newly_paused_request_ids is None + assert ctx.paused_request_count == 1 + assert ctx.total_request_count == 3 + assert ctx.request_kv_block_counts[:3].tolist() == [1, 2, 1] + assert ctx.request_last_kv_block_id[0].item() == original_last_block_ids[0].item() + assert ctx.request_last_kv_block_id[1].item() == cached_block_id + assert ctx.request_last_kv_block_id[2].item() == original_last_block_ids[2].item() + assert cached_hash not in alloc.kv_hash_to_block_id + assert alloc.block_ref_counts[cached_block_id].item() == 1 + assert alloc.pool_avail == 0 + assert alloc.get_allocatable_count() == 0 + assert int(alloc.get_evictable_block_count()) == 0 + @pytest.mark.internal def test_ref_count_refzero(self): bs = 32 @@ -567,13 +655,13 @@ def test_ref_count_refzero(self): ctx.add_request(self._req(ctx, prompt.clone(), request_id=2)) b0, b1 = self._block_ids(ctx, 0, 2) b0_hash = alloc.block_hashes[b0].item() - avail_before = alloc.total_avail + avail_before = alloc.pool_avail ctx.release_memory_blocks_from_request_indexes(torch.tensor([0])) assert alloc.block_ref_counts[b0].item() == 1 and b0_hash in alloc.kv_hash_to_block_id ctx.release_memory_blocks_from_request_indexes(torch.tensor([1])) assert alloc.block_ref_counts[b0].item() == 0 and b0_hash not in alloc.kv_hash_to_block_id assert alloc.block_hashes[b0].item() == -1 and alloc.block_hashes[b1].item() == -1 - assert alloc.total_avail == avail_before + 2 + assert alloc.pool_avail == avail_before + 2 # released blocks not discoverable ctx2 = self._ctx(prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO) @@ -721,7 +809,7 @@ def test_hybrid_memory_only(self): req1 = self._req(ctx, prompt.clone()) ctx.add_request(req1) first_blocks = self._block_ids(ctx, 0, 3) - avail = alloc.total_avail + avail = alloc.pool_avail tokens_after = ctx.active_token_count req2 = self._req(ctx, prompt.clone(), request_id=2) @@ -731,7 +819,7 @@ def test_hybrid_memory_only(self): ctx.add_request(req2) # blocks reused (pool unchanged), ref counts incremented - assert alloc.total_avail == avail + assert alloc.pool_avail == avail for bid in first_blocks: assert alloc.block_ref_counts[bid].item() == 2 # all tokens processed (none skipped) @@ -1310,7 +1398,7 @@ def _make_cpu_mamba_slot_allocator( ) -> MambaSlotAllocator: monkeypatch.setattr(torch.cuda, "current_device", lambda: "cpu") kv_allocator = SimpleNamespace( - total_count=total_blocks, + pool_size=total_blocks, block_ref_counts=torch.ones(total_blocks, dtype=torch.int32), block_timestamps=torch.zeros(total_blocks, dtype=torch.int64), block_hashes=torch.full((total_blocks,), -1, dtype=torch.int64), diff --git a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py index 07de26e9abf..6b9ada8cf5e 100644 --- a/tests/unit_tests/inference/contexts/test_kv_block_allocator.py +++ b/tests/unit_tests/inference/contexts/test_kv_block_allocator.py @@ -8,8 +8,8 @@ from megatron.core.inference.config import PrefixCachingEvictionPolicy from megatron.core.inference.contexts.kv_block_allocator import KVBlockAllocator -TOTAL_COUNT = 10 -PAUSED_COUNT = 2 +POOL_SIZE = 10 +PAUSED_LIMIT = 2 MAX_REQUESTS = 8 MAX_BLOCKS_PER_REQ = 4 @@ -40,51 +40,66 @@ def test_allocate_release_reset_round_trip_no_prefix_caching(): bag (popping IDs off the top), release returns them, reset rewinds. Also covers the surrounding invariants the allocator must preserve: - total_avail bookkeeping, the active_count >= 1 assertion at init, the - is_memory_available fast-path + no-eviction fallback, and the noop - behaviour of release([]). + pool_avail bookkeeping, paused-limit headroom validation, the computed + allocatable count, the is_memory_available fast-path + no-eviction fallback, + and the noop behaviour of release([]). """ ctx = _make_context() - # The init's active_count >= 1 assertion fires when paused saturates the pool. + # The paused limit must leave one usable non-dummy block for liveness. with pytest.raises(AssertionError): - KVBlockAllocator(ctx, total_count=3, paused_count=2) # active = 0 + KVBlockAllocator(ctx, pool_size=3, paused_limit=2) + with pytest.raises(AssertionError): + KVBlockAllocator(ctx, pool_size=3, paused_limit=-1) + with pytest.raises(AssertionError): + KVBlockAllocator(ctx, pool_size=1, paused_limit=0) - a = KVBlockAllocator(ctx, total_count=TOTAL_COUNT, paused_count=PAUSED_COUNT) - # Initial state: TOTAL_COUNT - 1 (dummy block) available, nothing used. - assert a.total_avail == TOTAL_COUNT - 1 + a = KVBlockAllocator(ctx, pool_size=POOL_SIZE, paused_limit=PAUSED_LIMIT) + # Initial state: POOL_SIZE - 1 (dummy block) available, nothing used. + assert a.pool_avail == POOL_SIZE - 1 + assert a.get_allocatable_count() == POOL_SIZE - 1 assert a.get_total_used() == 0 + assert not hasattr(a, "active_count") + assert not hasattr(a, "get_active_avail") + assert not hasattr(a, "get_paused_avail") + assert not hasattr(a, "get_allocatable_block_count") + assert str(a) == "blocks: occupied 0/9; allocatable 9; active-used 0; paused-used 0/2" # is_memory_available short-circuits True when free pool has enough. assert a.is_memory_available(5) is True # Allocate 3 → pop IDs off the top of the bag. ids = a.allocate_memory_blocks(3) assert ids is not None and ids.numel() == 3 - assert a.total_avail == TOTAL_COUNT - 1 - 3 + assert a.pool_avail == POOL_SIZE - 1 - 3 + assert a.get_allocatable_count() == POOL_SIZE - 1 - 3 # Empty release is a no-op; non-empty release returns IDs to the bag. - before = a.total_avail + before = a.pool_avail a.release_memory_blocks(torch.tensor([], dtype=torch.int32)) - assert a.total_avail == before + assert a.pool_avail == before a.release_memory_blocks(ids) - assert a.total_avail == before + 3 + assert a.pool_avail == before + 3 + assert a.get_allocatable_count() == before + 3 # Free pool exhausted: without prefix caching there's no eviction path, # so both is_memory_available and allocate_memory_blocks return failure. - small_alloc = KVBlockAllocator(ctx, total_count=4, paused_count=1) # total_avail = 3 + small_alloc = KVBlockAllocator(ctx, pool_size=4, paused_limit=1) + assert small_alloc.pool_avail == 3 + assert small_alloc.get_allocatable_count() == 3 assert small_alloc.is_memory_available(5) is False assert small_alloc.allocate_memory_blocks(5) is None - # reset rewinds the bag back to arange(total_count) and clears routing state. + # reset rewinds the bag back to arange(pool_size) and clears routing state. a.allocate_memory_blocks(4) a.reset() - assert a.total_avail == TOTAL_COUNT - 1 - assert a.block_bag.tolist() == list(range(TOTAL_COUNT)) + assert a.pool_avail == POOL_SIZE - 1 + assert a.get_allocatable_count() == POOL_SIZE - 1 + assert a.block_bag.tolist() == list(range(POOL_SIZE)) assert a.block_routing == {} def test_reset_under_inference_mode_preserves_mutable_block_bag(): - allocator = KVBlockAllocator(_make_context(), total_count=8, paused_count=0) + allocator = KVBlockAllocator(_make_context(), pool_size=8, paused_limit=0) original_block_bag = allocator.block_bag with torch.inference_mode(): @@ -94,7 +109,7 @@ def test_reset_under_inference_mode_preserves_mutable_block_bag(): allocator.release_memory_blocks(blocks) assert allocator.block_bag is original_block_bag - assert allocator.total_avail == 7 + assert allocator.pool_avail == 7 @pytest.mark.parametrize( @@ -115,11 +130,9 @@ def test_block_usage_counts_no_prefix_caching( total_request_count=total, request_kv_block_counts=torch.tensor(counts, dtype=torch.int32), ) - a = KVBlockAllocator(ctx, total_count=TOTAL_COUNT, paused_count=3) + a = KVBlockAllocator(ctx, pool_size=POOL_SIZE, paused_limit=3) assert a.get_active_used() == expected_active assert a.get_paused_used() == expected_paused - assert a.get_active_avail() == a.active_count - expected_active - assert a.get_paused_avail() == a.paused_count - expected_paused @pytest.mark.parametrize( @@ -133,8 +146,8 @@ def test_prefix_caching_state_layout(policy, expect_timestamps): does not.""" a = KVBlockAllocator( _make_context(), - total_count=8, - paused_count=2, + pool_size=8, + paused_limit=2, enable_prefix_caching=True, prefix_caching_eviction_policy=policy, ) @@ -156,8 +169,8 @@ def test_prefix_caching_allocate_and_hash_registration(): the free pool can't satisfy and no cached blocks are evictable.""" a = KVBlockAllocator( _make_context(), - total_count=8, - paused_count=2, + pool_size=8, + paused_limit=2, enable_prefix_caching=True, prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO, ) @@ -189,14 +202,57 @@ def test_prefix_caching_allocate_and_hash_registration(): # REF_ZERO has no eviction path when the free pool is short. small = KVBlockAllocator( _make_context(), - total_count=4, - paused_count=1, + pool_size=4, + paused_limit=1, enable_prefix_caching=True, prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO, ) + assert small.pool_avail == 3 + assert small.get_allocatable_count() == 3 assert small.is_memory_available(5) is False +@pytest.mark.parametrize( + "policy", [PrefixCachingEvictionPolicy.REF_ZERO, PrefixCachingEvictionPolicy.LRU] +) +def test_release_shared_block_aggregates_duplicate_references(policy): + """Releasing a shared block once per request decrements every reference but + returns the physical block to the free pool at most once.""" + a = KVBlockAllocator( + _make_context(), + pool_size=6, + paused_limit=1, + enable_prefix_caching=True, + prefix_caching_eviction_policy=policy, + ) + block = a.allocate_memory_blocks(1) + block_id = int(block.item()) + raw_avail_after_allocate = a.pool_avail + a.register_kv_block_hashes(block_ids=[block_id], block_hashes=[111]) + + # Model two requests sharing the same registered prefix block, then release + # both request references in one batched call. + a.block_ref_counts[block_id] = 2 + a.release_memory_blocks(block.repeat(2)) + + assert a.block_ref_counts[block_id].item() == 0 + if policy == PrefixCachingEvictionPolicy.REF_ZERO: + assert a.block_hashes[block_id].item() == -1 + assert a.pool_avail == raw_avail_after_allocate + 1 + assert a.get_total_used() == 0 + else: + # LRU keeps the physical block outside the free pool but exposes it + # through get_allocatable_count because it is now evictable. + assert a.block_hashes[block_id].item() == 111 + assert a.pool_avail == raw_avail_after_allocate + assert a.get_allocatable_count() == raw_avail_after_allocate + 1 + assert a.get_total_used() == 1 + + # In either policy the allocator tracks exactly one allocatable physical + # copy, whether raw-free or evictable. + assert a.get_allocatable_count() == raw_avail_after_allocate + 1 + + @pytest.mark.parametrize( "paused,total,active_assignments,paused_assignments,expected_active,expected_paused", [ @@ -219,7 +275,7 @@ def test_block_usage_counts_with_prefix_caching( total_request_count=total, request_to_kv_block_ids=request_to_kv, ) - a = KVBlockAllocator(ctx, total_count=TOTAL_COUNT, paused_count=3, enable_prefix_caching=True) + a = KVBlockAllocator(ctx, pool_size=POOL_SIZE, paused_limit=3, enable_prefix_caching=True) assert a.get_active_used() == expected_active assert a.get_paused_used() == expected_paused @@ -232,8 +288,8 @@ def test_release_shared_block_decrements_once_per_owner(): # block mixed into the final batch. a = KVBlockAllocator( _make_context(), - total_count=8, - paused_count=2, + pool_size=8, + paused_limit=2, enable_prefix_caching=True, prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO, ) @@ -241,12 +297,12 @@ def test_release_shared_block_decrements_once_per_owner(): shared, private = int(ids[0]), int(ids[1]) a.register_kv_block_hashes(block_ids=[shared], block_hashes=[111]) a.block_ref_counts[shared] += 2 # two more owners pin the shared block -> ref 3 - avail0 = a.total_avail + avail0 = a.pool_avail # One owner finishes alone: ref 3 -> 2, nothing freed yet. a.release_memory_blocks(torch.tensor([shared], dtype=torch.int32)) assert a.block_ref_counts[shared].item() == 2 - assert a.total_avail == avail0 + assert a.pool_avail == avail0 assert 111 in a.kv_hash_to_block_id # The final two owners and the private request finish in one batch: the @@ -255,9 +311,9 @@ def test_release_shared_block_decrements_once_per_owner(): assert a.block_ref_counts[shared].item() == 0 assert a.block_ref_counts[private].item() == 0 # Two distinct blocks return to the pool; the shared one only once (not twice). - assert a.total_avail == avail0 + 2 + assert a.pool_avail == avail0 + 2 assert 111 not in a.kv_hash_to_block_id # deregistered exactly once - free_region = a.block_bag[: a.total_avail].tolist() + free_region = a.block_bag[: a.pool_avail].tolist() assert len(set(free_region)) == len(free_region) # no double-returned id # LRU: a hashed shared block released by both owners in one batch must hit @@ -265,8 +321,8 @@ def test_release_shared_block_decrements_once_per_owner(): # block stays cached for reuse rather than returning to the pool. lru = KVBlockAllocator( _make_context(), - total_count=8, - paused_count=2, + pool_size=8, + paused_limit=2, enable_prefix_caching=True, prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, ) @@ -284,12 +340,12 @@ def test_release_shared_block_decrements_once_per_owner(): # --------------------------------------------------------------------------- -def _lru_allocator(total_count=16, paused_count=1): +def _lru_allocator(pool_size=16, paused_limit=1): """LRU-mode prefix-caching allocator over a fresh fake context.""" return KVBlockAllocator( _make_context(), - total_count=total_count, - paused_count=paused_count, + pool_size=pool_size, + paused_limit=paused_limit, enable_prefix_caching=True, prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.LRU, ) @@ -303,8 +359,8 @@ def _seed_cached_chain(a, block_ids, hashes, parents, timestamps): a.block_ref_counts[ids] = 0 # cached / evictable a.block_timestamps[ids] = torch.tensor(timestamps, dtype=torch.int64) # Mark the blocks as out of the free pool so _deregister_blocks (which pushes - # them back) keeps total_avail bookkeeping consistent. - a.total_avail -= len(block_ids) + # them back) keeps pool_avail bookkeeping consistent. + a.pool_avail -= len(block_ids) def _assert_prefix_invariant(a): @@ -407,7 +463,7 @@ def test_evict_lru_cached_child_with_pinned_parent_treated_as_root(): # nothing here, but in general orphan its children. a.block_ref_counts[ids] = torch.tensor([1, 0, 0], dtype=torch.int32) a.block_timestamps[ids] = torch.tensor([0, 1, 9], dtype=torch.int64) - a.total_avail -= 3 + a.pool_avail -= 3 # Only S1 and SX are candidates; the pinned S0 is excluded. assert int(a.get_evictable_block_count()) == 2 @@ -467,10 +523,10 @@ def test_evict_lru_keeps_hottest_leaf_over_cold_interior_parent(): it up, so the hot leaf E survives while the colder interior block B is evicted. A(ts 1) -> B(ts 2) -> C(ts 5) - \-> F(ts 3) - \-> D(ts 3) -> E(ts 5) + +-> F(ts 3) + +-> D(ts 3) -> E(ts 5) """ - a = _lru_allocator(total_count=8) + a = _lru_allocator(pool_size=8) # hashes: A=10, B=20, C=30, F=40, D=50, E=60 _seed_cached_chain( a, @@ -489,6 +545,84 @@ def test_evict_lru_keeps_hottest_leaf_over_cold_interior_parent(): _assert_prefix_invariant(a) +def test_register_existing_block_is_idempotent_and_keeps_parent_evictable(): + """Re-registering an already registered block must not disturb the prefix + chain. Callers can re-offer a cached block they matched earlier (a prefill + chunk boundary landing inside a matched block makes its slot part of the + next chunk's registration span), and a second child increment on that + block's parent is unrecoverable: the child can only be deregistered once, so + the parent never reaches child_count == 0, is never an evictable leaf, and + the leaf peel in evict_lru_blocks runs out of candidates while still + counting the parent as cached. + + A(ts 1) -> B(ts 2) B re-registered with its existing hash + + Both blocks are cached and evicting both must succeed. + """ + a = _lru_allocator() + _seed_cached_chain(a, block_ids=[0, 1], hashes=[10, 20], parents=[0, 10], timestamps=[1, 2]) + assert a.block_child_count[0].item() == 1 + + # Re-register the child exactly as it stands: same block, hash and parent. + a.register_kv_block_hashes(block_ids=[1], block_hashes=[20], parent_hashes=[10]) + + # The chain is unchanged -- one child on the parent, not two. + assert a.block_child_count[0].item() == 1 + assert a.block_child_count[1].item() == 0 + assert a.block_parent_id[1].item() == 0 + assert a.kv_hash_to_block_id == {10: 0, 20: 1} + + # Both cached blocks stay reachable by the leaf peel: B is evicted first, + # which makes A childless and evictable in turn. + assert int(a.get_evictable_block_count()) == 2 + assert a.evict_lru_blocks(2) is True + assert a.kv_hash_to_block_id == {} + _assert_prefix_invariant(a) + + +def test_register_mixed_batch_skips_only_the_already_registered_blocks(): + """A batch that mixes an already registered block with new ones registers + the new blocks normally. The skipped block still resolves as a parent for + its successor in the same batch, so the chain stays connected.""" + a = _lru_allocator() + _seed_cached_chain(a, block_ids=[0, 1], hashes=[10, 20], parents=[0, 10], timestamps=[1, 2]) + + # Block 1 is already registered; blocks 2 and 3 extend the chain past it. + a.register_kv_block_hashes( + block_ids=[1, 2, 3], block_hashes=[20, 30, 40], parent_hashes=[10, 20, 30] + ) + a.block_ref_counts[torch.tensor([2, 3])] = 0 + a.pool_avail -= 2 + + assert a.kv_hash_to_block_id == {10: 0, 20: 1, 30: 2, 40: 3} + assert a.block_parent_id[2].item() == 1 # resolved through the skipped block + assert a.block_parent_id[3].item() == 2 + assert a.block_child_count.tolist()[:4] == [1, 1, 1, 0] + + # The whole chain peels leaf-first without stalling. + assert a.evict_lru_blocks(4) is True + assert a.kv_hash_to_block_id == {} + _assert_prefix_invariant(a) + + +def test_register_rejects_hash_change_on_a_registered_block(): + """Registering a live block under a hash other than the one it holds would + overwrite its recorded parent while leaving the previous parent's child count + raised. That is a bookkeeping error, not a no-op, and must fail loudly.""" + a = _lru_allocator() + _seed_cached_chain(a, block_ids=[0, 1], hashes=[10, 20], parents=[0, 10], timestamps=[1, 2]) + + with pytest.raises(AssertionError, match="different hash"): + a.register_kv_block_hashes(block_ids=[1], block_hashes=[99], parent_hashes=[10]) + + # A deregistered block is free to take a new hash. + assert a.evict_lru_blocks(1) is True + a.pool_avail -= 1 + a.register_kv_block_hashes(block_ids=[1], block_hashes=[99], parent_hashes=[10]) + assert a.kv_hash_to_block_id == {10: 0, 99: 1} + assert a.block_child_count[0].item() == 1 + + def test_evict_lru_asserts_on_cyclic_parent_graph(): """The parent graph is assumed acyclic (a forest). A hash collision producing a cycle exposes no leaf, so the peel cannot collect enough blocks; this is a @@ -508,19 +642,21 @@ def test_is_memory_available_excludes_soon_to_be_pinned_blocks(): """potential_matched_count removes soon-to-be-pinned cached blocks from the evictable capacity, so availability matches what allocation can satisfy once those blocks (e.g. prefix matches) are pinned.""" - a = _lru_allocator(total_count=6, paused_count=1) + a = _lru_allocator(pool_size=6, paused_limit=1) # Drain the free pool: every block is allocated (ref_count == 1), none free. - a.allocate_memory_blocks(a.total_avail) - assert a.total_avail == 0 + a.allocate_memory_blocks(a.pool_avail) + assert a.pool_avail == 0 + assert a.get_allocatable_count() == 0 # Mark two blocks as cached/evictable, mirroring an LRU release: ref_count # drops to 0 and the hash is retained, but the block stays out of the free - # pool (total_avail unchanged). + # pool (pool_avail unchanged). a.register_kv_block_hashes(block_ids=[0, 1], block_hashes=[10, 20], parent_hashes=[0, 10]) a.block_ref_counts[torch.tensor([0, 1])] = 0 - assert a.total_avail == 0 + assert a.pool_avail == 0 + assert a.get_allocatable_count() == 2 assert int(a.get_evictable_block_count()) == 2 - # Both evictable blocks count toward availability by default. + # Both evictable blocks count toward the computed allocatable count. assert a.is_memory_available(2) is True # Excluding one (it will be pinned) leaves only one usable for the request. assert a.is_memory_available(2, potential_matched_count=1) is False @@ -528,6 +664,15 @@ def test_is_memory_available_excludes_soon_to_be_pinned_blocks(): # Excluding all evictable blocks leaves nothing to satisfy a new block. assert a.is_memory_available(1, potential_matched_count=2) is False + # Allocation must evict the cached blocks into the raw free pool before + # popping them, even though get_allocatable_count already reports their + # capacity. + allocated = a.allocate_memory_blocks(2) + assert allocated is not None and allocated.numel() == 2 + assert a.pool_avail == 0 + assert a.get_allocatable_count() == 0 + assert a.kv_hash_to_block_id == {} + def _reference_leaf_peel(block_ids, hashes, parents, timestamps, k_evict): """Independent, straightforward greedy reference: repeatedly evict the @@ -567,7 +712,7 @@ def test_evict_lru_preserves_invariant_under_random_chains(): torch.manual_seed(0) for _ in range(50): n = int(torch.randint(2, 10, (1,)).item()) - a = _lru_allocator(total_count=n + 4) + a = _lru_allocator(pool_size=n + 4) block_ids = list(range(n)) # Build a forest: block k's parent is a random earlier block or a root. hashes = [100 + k for k in range(n)] diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index 0947181b844..d8640ad8919 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -6,6 +6,8 @@ import os import random import types +from collections import deque +from contextlib import nullcontext from dataclasses import dataclass, field from functools import partial from typing import Dict, List, Optional, Tuple @@ -32,7 +34,12 @@ ) from megatron.core.inference.engines import DynamicInferenceEngine from megatron.core.inference.engines.dynamic_engine import EngineState -from megatron.core.inference.inference_request import DynamicInferenceRequest, Status +from megatron.core.inference.inference_request import ( + DynamicInferenceRequest, + DynamicInferenceRequestRecord, + Status, + compute_block_hashes_batched, +) from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( GPTInferenceWrapper, ) @@ -40,6 +47,7 @@ from megatron.core.inference.text_generation_controllers.text_generation_controller import ( TextGenerationController, ) +from megatron.core.inference.utils import InferenceMode from megatron.core.models.gpt.gpt_layer_specs import ( get_gpt_layer_local_spec, get_gpt_layer_with_inference_spec, @@ -628,6 +636,122 @@ def _run_test(cls, **test_config_kwargs): return env +def _make_prefix_cached_request_for_checkpoint(request_id: int) -> DynamicInferenceRequest: + """Build a request whose generated tokens complete one additional cache block.""" + return DynamicInferenceRequest( + request_id=request_id, + prompt_tokens=torch.tensor([1, 2, 3, 4], dtype=torch.int64), + sampling_params=SamplingParams(num_tokens_to_generate=6, termination_id=-1), + generated_tokens=[5, 6], + block_size_tokens=2, + enable_prefix_caching=True, + ) + + +def _assert_prefix_cache_checkpoint( + original: DynamicInferenceRequest, checkpointed: DynamicInferenceRequest +) -> None: + """Verify a checkpoint retained config and rehashed its expanded prompt.""" + expected_prompt = torch.cat( + ( + original.prompt_tokens, + torch.tensor( + original.generated_tokens, + dtype=original.prompt_tokens.dtype, + device=original.prompt_tokens.device, + ), + ) + ) + expected_hashes = compute_block_hashes_batched( + expected_prompt, block_size=original.block_size_tokens + ) + + assert checkpointed.enable_prefix_caching is True + assert checkpointed.block_size_tokens == original.block_size_tokens + assert torch.equal(checkpointed.prompt_tokens, expected_prompt) + assert torch.equal(checkpointed.remaining_prompt_tokens, expected_prompt) + assert checkpointed.precomputed_block_hashes == expected_hashes + assert len(checkpointed.precomputed_block_hashes) == len(original.precomputed_block_hashes) + 1 + + +def test_post_process_eviction_requeues_prefix_cached_request_with_fresh_hashes(): + """Eviction must checkpoint and requeue a prefix-enabled request without losing its config.""" + request = _make_prefix_cached_request_for_checkpoint(request_id=17) + record = DynamicInferenceRequestRecord.from_request(request) + engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) + engine.context = types.SimpleNamespace( + chunked_prefill_request_id=-1, kv_block_allocator=types.SimpleNamespace() + ) + engine.requests = {request.request_id: types.SimpleNamespace(record=record)} + engine.waiting_request_ids = deque() + engine.finished_request_count = 0 + engine.evicted_request_count = 0 + engine.track_generated_token_events = False + engine.num_speculative_tokens = 0 + engine.stop_word_being_finished_ids = set() + + active_request_ids, finished_records = engine.post_process_requests( + request_ids=torch.empty(0, dtype=torch.int64), + finished_request_ids=torch.empty(0, dtype=torch.int64), + evict_request_ids=torch.tensor([request.request_id], dtype=torch.int64), + step_time=0.0, + sample=torch.empty(0, dtype=torch.int64), + accepted_tokens=None, + log_probs=[], + consumed_chunked_prefill_request_id=-1, + ) + + assert active_request_ids == [] + assert finished_records == [] + assert list(engine.waiting_request_ids) == [request.request_id] + assert len(record.requests) == 2 + _assert_prefix_cache_checkpoint(request, engine.get_request(request.request_id)) + + +def test_recompute_suspend_resume_readds_prefix_cached_request_with_fresh_hashes(): + """RECOMPUTE suspend/resume must re-add the prefix-enabled checkpoint tail.""" + request = _make_prefix_cached_request_for_checkpoint(request_id=23) + record = DynamicInferenceRequestRecord.from_request(request) + engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) + engine.context = types.SimpleNamespace( + chunked_prefill_request_id=-1, + kv_cache_management_mode=KVCacheManagementMode.RECOMPUTE, + static_kv_memory_pointers=True, + deallocate_inference_state_buffers=mock.Mock(), + reinitialize_inference_state_buffers=mock.Mock(), + ) + engine.requests = {request.request_id: types.SimpleNamespace(record=record)} + engine.waiting_request_ids = deque() + engine.state = EngineState.RUNNING + engine.unified_memory_level = 0 + engine.use_coordinator = False + engine._add_request = mock.Mock() + engine._notify_cond_for_new_request = mock.Mock(return_value=None) + engine._loop = types.SimpleNamespace(call_soon_threadsafe=mock.Mock()) + + with ( + mock.patch.object( + DynamicInferenceEngine, + "suspend_resume_ctx", + side_effect=lambda *args, **kwargs: nullcontext(), + ), + mock.patch.object(InferenceMode, "unset_active"), + mock.patch.object(InferenceMode, "set_active"), + mock.patch.object(torch.cuda, "synchronize"), + ): + engine.suspend() + checkpointed = engine.get_request(request.request_id) + _assert_prefix_cache_checkpoint(request, checkpointed) + + engine.resume() + + assert engine.context.deallocate_inference_state_buffers.call_count == 1 + assert engine.context.reinitialize_inference_state_buffers.call_count == 1 + assert engine.state == EngineState.RUNNING + assert engine._add_request.call_count == 1 + assert engine._add_request.call_args.args[0] is checkpointed + + class TestDynamicInferenceEngine(DynamicInferenceEngineTestBase): @classmethod @@ -913,15 +1037,14 @@ def test_max_sequence_length_clamp(self) -> None: def test_generation_within_tight_kv_pool( self, num_speculative_tokens: int, exact_fit_tokens: int ) -> None: - """Admission is bounded by what the active pool can ever grant a running request: - paused-pool blocks are not grantable (a request admitted against them pauses forever) - and only stored tokens need slots; + """Admission is bounded by what the shared pool can grant a running request. + Only stored tokens need slots: the final sampled token is never stored, the last decode step stores its speculative drafts. Exact fit: 8 prompt + (exact_fit_tokens - 1) outputs + drafts = 256.""" env = self._build_test_env(DynamicEngineTestConfig()) block_size_bytes = env.engine.context.block_size_bytes - # 3-block pool: 1 active + 1 paused + 1 dummy. + # 2-block pool: 1 usable + 1 dummy. test_config = DynamicEngineTestConfig( num_requests=3, min_prompt_length=8, @@ -929,19 +1052,18 @@ def test_generation_within_tight_kv_pool( num_tokens_to_generate=None, max_sequence_length=512, num_speculative_tokens=num_speculative_tokens, - context_buffer_size_gb=3 * block_size_bytes / 1024**3, - context_paused_buffer_size_gb=block_size_bytes / 1024**3, + context_buffer_size_gb=2 * block_size_bytes / 1024**3, + context_paused_buffer_size_gb=0.0, context_max_requests=4, ) env = self._build_test_env(test_config) - # The msl-derived default budget (8 + 504) fits the old total-blocks - # bound but needs more than the 1 grantable block; fails at admission. + # The msl-derived default budget (8 + 504) exceeds the usable block. doomed_request = env.requests[0] env.engine._add_request(doomed_request) assert doomed_request.status == Status.FAILED - # One more stored token than the active block holds; fails at admission. + # One more stored token than the usable block holds; fails at admission. overflow_request = env.requests[2] overflow_request.sampling_params.num_tokens_to_generate = exact_fit_tokens + 1 env.engine._add_request(overflow_request) @@ -2877,14 +2999,14 @@ def test_max_requests(self, max_requests: int | None): f"num_requests ({len(env.requests)})." ) assert context.max_requests == 4 - # Exact step counts and KV occupancy depend on sampled token sequences. + # Exact step counts depend on sampled token sequences. # With DP-offset sampling seeds, only DP rank 0 matches the golden seed. if parallel_state.get_data_parallel_rank() == 0: if max_requests is None: assert step_count == 23 else: assert step_count == 35 - assert context.kv_block_allocator.active_count == 655 + assert context.kv_block_allocator.pool_size == 819 @pytest.mark.internal @pytest.mark.skipif( diff --git a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py index ddb2960850b..be57f8a9e15 100644 --- a/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py +++ b/tests/unit_tests/inference/engines/test_hybrid_prefix_caching_e2e.py @@ -621,7 +621,7 @@ def test_mamba_lru_eviction_e2e(self): alloc = engine.context.kv_block_allocator ctx = engine.context - assert alloc.total_count == 3, f"expected 3 total blocks, got {alloc.total_count}" + assert alloc.pool_size == 3, f"expected 3 total blocks, got {alloc.pool_size}" assert ctx.max_requests >= 1 finished = {} @@ -644,7 +644,7 @@ def _run_one(req_id, prompt): assert ( h_E0 in ctx.mamba_slot_allocator.hash_to_block_id and h_E0 in alloc.kv_hash_to_block_id ) - assert len(ctx.mamba_slot_allocator.hash_to_block_id) == 1 and alloc.total_avail == 1 + assert len(ctx.mamba_slot_allocator.hash_to_block_id) == 1 and alloc.pool_avail == 1 # F: disjoint prefix, forces eviction of E's cached block req_F = _run_one(1, prompts[1]) diff --git a/tests/unit_tests/inference/test_inference_request.py b/tests/unit_tests/inference/test_inference_request.py index 559c20af18f..6676ab734e2 100644 --- a/tests/unit_tests/inference/test_inference_request.py +++ b/tests/unit_tests/inference/test_inference_request.py @@ -168,28 +168,56 @@ def test_dynamic_inference_request_tracked_metadata_defaults_termination_id(): def test_dynamic_inference_request_record_checkpoint_and_merge(): """RequestRecord.checkpoint() rolls the current request forward — prompt becomes prompt+generated, num_tokens_to_generate is debited, and the - add_engine event is inherited (or created) so downstream tooling can find - it. RequestRecord.merge() collapses the chain back into a single request - with concatenated tokens, text, routing_indices, and the record's latency. - Both are non-trivial state machines.""" - sp = SamplingParams(num_tokens_to_generate=5, termination_id=0) - - # checkpoint() inherits event_add_engine when present. + prefix-cache configuration is inherited while hashes are recomputed for the + expanded prompt. The add_engine event is inherited (or created) so + downstream tooling can find it. RequestRecord.merge() collapses the chain + back into a single request with concatenated tokens, text, routing_indices, + and the record's latency. Both are non-trivial state machines.""" + sp = SamplingParams(num_tokens_to_generate=8, termination_id=0) + + # checkpoint() inherits prefix-cache configuration and event_add_engine. req = DynamicInferenceRequest( request_id=1, - prompt_tokens=torch.tensor([1, 2, 3]), + prompt_tokens=torch.tensor([1, 2, 3, 4, 5, 6]), sampling_params=sp, - generated_tokens=[10, 11], + generated_tokens=[7, 8], + block_size_tokens=4, + enable_prefix_caching=True, ) + original_hashes = req.precomputed_block_hashes original_event = req.add_event_add_engine() record = DynamicInferenceRequestRecord.from_request(req) record.checkpoint() assert len(record.requests) == 2 new_req = record.requests[-1] - assert new_req.prompt_tokens.tolist() == [1, 2, 3, 10, 11] - assert new_req.sampling_params.num_tokens_to_generate == 3 + assert new_req.prompt_tokens.tolist() == [1, 2, 3, 4, 5, 6, 7, 8] + assert new_req.sampling_params.num_tokens_to_generate == 6 + assert new_req.block_size_tokens == 4 + assert new_req.enable_prefix_caching + assert new_req.precomputed_block_hashes == compute_block_hashes_batched( + new_req.prompt_tokens, new_req.block_size_tokens + ) + assert new_req.precomputed_block_hashes is not original_hashes + assert len(new_req.precomputed_block_hashes) == 2 assert new_req.event_add_engine is original_event + # A second checkpoint must keep the sticky configuration and extend the hash chain again. + new_req.generated_tokens = [9, 10, 11, 12] + previous_hashes = new_req.precomputed_block_hashes + record.checkpoint() + assert len(record.requests) == 3 + second_new_req = record.requests[-1] + assert second_new_req.prompt_tokens.tolist() == list(range(1, 13)) + assert second_new_req.sampling_params.num_tokens_to_generate == 2 + assert second_new_req.block_size_tokens == 4 + assert second_new_req.enable_prefix_caching + assert second_new_req.precomputed_block_hashes == compute_block_hashes_batched( + second_new_req.prompt_tokens, second_new_req.block_size_tokens + ) + assert second_new_req.precomputed_block_hashes is not previous_hashes + assert len(second_new_req.precomputed_block_hashes) == 3 + assert second_new_req.event_add_engine is original_event + # checkpoint() creates a new event_add_engine when the previous request had none. req2 = DynamicInferenceRequest( request_id=2, diff --git a/tests/unit_tests/inference/test_wandb_logging.py b/tests/unit_tests/inference/test_wandb_logging.py index 02616f96e83..4b7d3588b5e 100644 --- a/tests/unit_tests/inference/test_wandb_logging.py +++ b/tests/unit_tests/inference/test_wandb_logging.py @@ -104,6 +104,7 @@ def test_get_kvcache_utilization_stats_with_requests(self): assert stats['paused_request_count'] == 0 assert stats['active_token_count'] == 0 assert stats['total_request_count'] == 0 + assert stats['block_count_avail'] == dynamic_context.kv_block_allocator.pool_avail # Now add a request and verify stats update correctly context_length = 144 @@ -145,11 +146,12 @@ def test_get_kvcache_utilization_stats_with_requests(self): # Verify block availability decreased after allocation assert stats_after['block_count_avail'] < stats['block_count_avail'] + assert stats_after['block_count_avail'] == dynamic_context.kv_block_allocator.pool_avail - # Verify relationship: allocated_blocks + block_count_avail + 1 (dummy) = total + # Physical occupancy plus raw free blocks and the dummy block equals total. assert ( stats_after['allocated_blocks'] + stats_after['block_count_avail'] + 1 - == dynamic_context.kv_block_allocator.total_count + == dynamic_context.kv_block_allocator.pool_size ) # Verify utilization bounds [0, 1] diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index ce3e8cdb0e3..fbc05f7ff71 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -1689,10 +1689,13 @@ def test_async_sched_no_overlap_pauses_boundary_request(self): context.request_kv_block_counts[active_slice] = 1 context.token_to_input_ids[active_slice] = torch.tensor([80, 81]) - # Leave room in paused storage but no capacity to keep both requests active. - context.kv_block_allocator.active_count = context.kv_block_allocator.get_active_used() - context.kv_block_allocator.paused_count = 2 - context.kv_block_allocator.total_avail = 0 + # Retain one paused request, but exhaust shared-pool capacity with real allocations. + alloc = context.kv_block_allocator + alloc.paused_limit = 1 + filler_blocks = alloc.allocate_memory_blocks(alloc.pool_avail) + assert filler_blocks is not None + filler_blocks = filler_blocks.clone() + assert alloc.get_allocatable_count() == 0 sampled_tokens = torch.tensor([90, 91], dtype=torch.int64) controller._async_sched_logits = AsyncScheduleLogitsState( @@ -1736,6 +1739,7 @@ def initialize_survivor_forward(): controller._run_async_sched_forward.assert_called_once_with( forward_input_ids, forward_position_ids ) + alloc.release_memory_blocks(filler_blocks) def test_sample_from_logits(self): self.setup_model(torch.float32) @@ -2668,7 +2672,7 @@ def test_rewind_kv_cache(self, is_hybrid_model): ) # Initialize allocator and states - ctx.kv_block_allocator.total_avail = 100 + ctx.kv_block_allocator.pool_avail = 100 ctx.request_kv_length_offsets[:2] = torch.tensor([10, 15], device=context_device) ctx.request_kv_block_counts[:2] = torch.tensor([3, 4], device=context_device) @@ -2720,7 +2724,7 @@ def test_rewind_kv_cache(self, is_hybrid_model): # Assert released block is cleared assert ctx.request_to_kv_block_ids[1, 3].item() == -1 - assert ctx.kv_block_allocator.total_avail == 101 # 1 block released + assert ctx.kv_block_allocator.pool_avail == 101 # 1 block released if is_hybrid_model: # Check Mamba state was restored from intermediate cache based on accepted counts @@ -2924,7 +2928,7 @@ def test_rewind_kv_cache_with_prefix_caching_ref_counts(self): ctx.kv_block_allocator.block_ref_counts[20] = 2 ctx.kv_block_allocator.block_ref_counts[10] = 1 - initial_avail = ctx.kv_block_allocator.total_avail + initial_avail = ctx.kv_block_allocator.pool_avail # Req 0 accepts 1 (rewinds 1), Req 1 accepts 0 (rewinds 2, crosses boundary). self.text_generation_controller._init_mtp_sampling_tensors() @@ -2970,7 +2974,7 @@ def test_rewind_kv_cache_does_not_release_shared_prefix_blocks(self): ) # Blocks 10, 20 are shared prefix blocks. Block 30, 40 are exclusive. - ctx.kv_block_allocator.total_avail = 50 + ctx.kv_block_allocator.pool_avail = 50 self.text_generation_controller._init_mtp_sampling_tensors() self.text_generation_controller._accepted_token_counts_per_request = torch.tensor( @@ -2984,7 +2988,7 @@ def test_rewind_kv_cache_does_not_release_shared_prefix_blocks(self): assert ctx.request_kv_block_counts[0].item() == 3 assert ctx.request_last_kv_block_id[0].item() == 30 assert ctx.request_to_kv_block_ids[0, 3].item() == -1 - assert ctx.kv_block_allocator.total_avail == 51 # exactly 1 block released + assert ctx.kv_block_allocator.pool_avail == 51 # exactly 1 block released # Prefix blocks remain in request_to_kv_block_ids. assert ctx.request_to_kv_block_ids[0, 0].item() == 10 From d378bfb88642ed1df43fa19567ff1ca9bb7091f6 Mon Sep 17 00:00:00 2001 From: Zhongbo Zhu <42691305+zhongbozhu@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:05:38 -0700 Subject: [PATCH 174/290] [Main] update moe single weight unit test (#6035) Signed-off-by: Zhongbo Zhu --- .../core/transformer/transformer_config.py | 6 ++++++ .../test_moe_single_grouped_weight_numerics.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 944a3ff244b..af853276819 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -1596,6 +1596,12 @@ def __post_init__(self): "moe_single_grouped_weight is currently supported with high-precision " "primary weights, fp8_recipe='mxfp8', or fp4_recipe='nvfp4'." ) + if self.fp4 and not self.fp4_param: + raise ValueError( + "moe_single_grouped_weight with FP4 compute requires fp4_param=True " + "(--fp4-param-gather). Without FP4 parameter gather, Transformer Engine " + "uses a split-quantize fallback that is being deprecated." + ) if not self.use_transformer_engine_op_fuser: raise ValueError( "moe_single_grouped_weight requires " diff --git a/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py b/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py index f5a4afaeffc..3e1bdb244d4 100644 --- a/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py +++ b/tests/unit_tests/transformer/moe/test_moe_single_grouped_weight_numerics.py @@ -59,6 +59,7 @@ pytestmark = [ pytest.mark.internal, + pytest.mark.launch_on_gb200, pytest.mark.skipif( not is_te_min_version("2.14.0"), reason="moe_single_grouped_weight requires Transformer Engine >= 2.14.0", @@ -696,7 +697,22 @@ def test_single_grouped_weight_parity_with_primary_param_gather( use_transformer_engine_op_fuser=True, ) - @pytest.mark.parametrize("precision", ["bf16", "mxfp8", "nvfp4"]) + @pytest.mark.parametrize( + "precision", + [ + "bf16", + "mxfp8", + pytest.param( + "nvfp4", + marks=pytest.mark.skip( + reason=( + "NVFP4 single grouped weights without FP4 parameter gather use a " + "TransformerEngine split-quantize fallback that is being deprecated; " + ) + ), + ), + ], + ) @pytest.mark.parametrize("gradient_accumulation_fusion", [False, True]) def test_single_grouped_weight_parity_without_primary_param_gather( self, precision, gradient_accumulation_fusion From 801d7e6e36e9ce902028a1033a785f77fd894ec2 Mon Sep 17 00:00:00 2001 From: Dmytro Pykhtar <37850217+dimapihtar@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:58:58 +0300 Subject: [PATCH 175/290] fix ckpt conversion issue (#6151) Signed-off-by: dimapihtar Signed-off-by: Dmytro Pykhtar --- tools/checkpoint/loader_mixtral_hf.py | 1 + tools/checkpoint/saver_base.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/checkpoint/loader_mixtral_hf.py b/tools/checkpoint/loader_mixtral_hf.py index 1515263d531..5c0dad3cc2e 100644 --- a/tools/checkpoint/loader_mixtral_hf.py +++ b/tools/checkpoint/loader_mixtral_hf.py @@ -262,6 +262,7 @@ def check_for_arg(arg_name, default=None): md.position_embedding_type = margs.position_embedding_type md.linear_bias = margs.add_bias_linear md.norm_has_bias = False + md.qkv_bias = False md.swiglu = margs.swiglu md.previous_tensor_parallel_size = margs.tensor_model_parallel_size md.previous_pipeline_parallel_size = margs.pipeline_model_parallel_size diff --git a/tools/checkpoint/saver_base.py b/tools/checkpoint/saver_base.py index b944a3ac0ae..fef281db359 100644 --- a/tools/checkpoint/saver_base.py +++ b/tools/checkpoint/saver_base.py @@ -483,9 +483,14 @@ def pad_weight(orig_word_embed, true_vocab_size): "mlp_norm_weight" : post_norm_weight } if self.margs.num_experts: + num_local_experts = self.margs.num_experts // self.args.target_expert_parallel_size params_dict.update({ - "mlp_fc1_weight" : mlp_l0_weight[ep_rank][tp_rank], - "mlp_fc2_weight" : mlp_l1_weight[ep_rank][tp_rank] + f"mlp_fc1_weight.{i}": mlp_l0_weight[ep_rank][tp_rank][i] + for i in range(num_local_experts) + }) + params_dict.update({ + f"mlp_fc2_weight.{i}": mlp_l1_weight[ep_rank][tp_rank][i] + for i in range(num_local_experts) }) else: params_dict.update({ From 07fdc084735b89f318ec0108ffe0eda894624f73 Mon Sep 17 00:00:00 2001 From: Yan Xu <45385219+Connor-XY@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:03:03 -0700 Subject: [PATCH 176/290] Add determinism developer documentation (#5718) Signed-off-by: Yan Xu --- docs/developer/determinism/README.md | 27 ++++++++ docs/developer/determinism/glossary.md | 85 ++++++++++++++++++++++++ docs/developer/determinism/op-catalog.md | 83 +++++++++++++++++++++++ docs/developer/determinism/status.md | 50 ++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 docs/developer/determinism/README.md create mode 100644 docs/developer/determinism/glossary.md create mode 100644 docs/developer/determinism/op-catalog.md create mode 100644 docs/developer/determinism/status.md diff --git a/docs/developer/determinism/README.md b/docs/developer/determinism/README.md new file mode 100644 index 00000000000..01650a5e347 --- /dev/null +++ b/docs/developer/determinism/README.md @@ -0,0 +1,27 @@ +--- +orphan: true +--- + +# Determinism Developer Reference + +> This reference is for Megatron developers and reviewers working on +> deterministic training. For setup instructions and supported configurations, +> use the [Deterministic Training user guide](../../user-guide/deterministic-training.md). +> This reference does not repeat it. + +Bit-exact determinism means two runs with identical configuration, data, seeds, +software, and hardware produce identical results. + +## Contents + +This reference includes: + +- [`status.md`](./status.md): deterministic-mode enforcement, validation, + performance cost, and a pointer to the live roadmap +- [`op-catalog.md`](./op-catalog.md): operations with a deterministic code path, + operations that deterministic mode does not support, and the goal to shrink + the unsupported set while speeding up the supported set +- [`glossary.md`](./glossary.md): definitions and abbreviations + +The roadmap is tracked dynamically in +[issue #5785](https://github.com/NVIDIA/Megatron-LM/issues/5785). diff --git a/docs/developer/determinism/glossary.md b/docs/developer/determinism/glossary.md new file mode 100644 index 00000000000..6217efca828 --- /dev/null +++ b/docs/developer/determinism/glossary.md @@ -0,0 +1,85 @@ +--- +orphan: true +--- + +# Determinism Glossary + +Definitions for the determinism developer reference. The user guide avoids +these abbreviations where possible. + +## Determinism Contract + +The contract uses these definitions: + +- **Bitwise determinism** + + Given fixed inputs and seeds, a run yields identical numerical results + every time, with everything else held constant. This includes input data + and order, model recipe and configuration, parallelism layout (TP, PP, + DP, EP, CP, VPP), container image and library versions (Megatron-Core, + CUDA, cuDNN, NCCL, Transformer Engine, PyTorch, driver), NCCL settings, + and hardware type and topology. + + The contract is about numerical results, not the computation graph. + Kernel scheduling and execution order may vary as long as every + floating-point reduction happens in the same order. Floating-point + addition is not associative, and reduction order is the root cause of + nearly all training non-determinism. + +- **Verification** + + Verify determinism by tracking training metrics (loss, gradient norm, + params norm, num-zeros) for *bitwise-identical curves* across two + independent runs. The loss works like a checksum of the model state + because it is a reduction over the sampled logits. A mismatch anywhere + propagates into a bitwise difference in the loss and gradient curve + within a few steps. + + Console logs print at limited precision, so for a strict comparison use + the full-precision serialized metrics (for example the TensorBoard event + files) rather than the printed values. + +## Terms + +The following terms appear throughout the reference: + +| Term | Meaning | +| --- | --- | +| Deterministic mode | Execution with `--deterministic-mode`. The argument validation and environment defaults from `megatron/training/determinism.py`, plus `torch.use_deterministic_algorithms(True)`. The library code selects deterministic branches using `config.deterministic_mode` or `torch.are_deterministic_algorithms_enabled()`. | +| Default mode | Execution without `--deterministic-mode`. | +| Bit-exact / bitwise identical | Byte-identical values for the compared tensors or serialized metrics (refer to "Verification"). | +| Reproducible | Same result under the same conditions (allocation, caches, environment). This guarantee is weaker than the contract above. A run can repeat within one allocation and still differ on a different physical topology. | +| Cross-allocation | Comparison of runs on independently assigned resources: a fresh scheduler allocation with generally different physical nodes and network rings. This is the determinism target and the bar a test must meet to count as a determinism certificate. Repeating work inside one allocation exercises less than the contract promises. | +| Collision-free (unique-index) write | An indexed write (`scatter`, `index_put_(accumulate=False)`) whose indices are unique, so no floating-point accumulation happens and ordering cannot change the result. Deterministic without a special kernel. | +| Fail closed | When deterministic mode encounters a feature it cannot vouch for, it rejects the configuration at validation time instead of silently running it. | + +## Parallelism and Infrastructure + +The following abbreviations appear in parallelism and infrastructure discussions: + +| Term | Meaning | +| --- | --- | +| MCore | Megatron Core: the model-parallel training library in this repository. | +| DP | Data parallelism: replicas process different batches and synchronize gradients. | +| TP | Tensor parallelism: one layer is partitioned across devices. | +| PP | Pipeline parallelism: consecutive layer ranges run on different devices. | +| VPP | Virtual pipeline parallelism: interleaved pipeline chunks that reduce pipeline idle time. | +| EP | Expert parallelism: experts are partitioned across devices. | +| CP | Context parallelism: one sequence is partitioned across devices. | +| A2A | All-to-all collective (MoE token dispatch and combine). A rank-indexed permutation, not a floating-point reduction. | +| TE | Transformer Engine, NVIDIA's transformer-kernel library. | +| wgrad / dgrad | Weight gradient / input gradient of a linear layer's backward pass. | + +## Model Abbreviations + +The following abbreviations appear in model discussions: + +| Term | Meaning | +| --- | --- | +| MoE | Mixture of Experts: a layer routes tokens to one or more expert networks. | +| MLA | Multi-Latent Attention: low-rank latent qkv projections (DeepSeek family). | +| DSV3 / DSV4 | DeepSeek-V3- / DeepSeek-V4-style model configurations. DSV3 combines MLA with fine-grained MoE. DSV4 additionally uses DSA sparse attention. | +| DSA | DeepSeek Sparse Attention: a lightning indexer scores tokens and top-k selection sparsifies core attention. | +| MTP | Multi-Token Prediction: auxiliary layers predicting additional future tokens. | +| SSM | State-space model layers (Mamba family). | +| GDN | Gated delta net, an SSM variant (`megatron/core/ssm/gated_delta_net.py`). | diff --git a/docs/developer/determinism/op-catalog.md b/docs/developer/determinism/op-catalog.md new file mode 100644 index 00000000000..c4381c5b5c6 --- /dev/null +++ b/docs/developer/determinism/op-catalog.md @@ -0,0 +1,83 @@ +--- +orphan: true +--- + +# Determinism Operation Catalog + +> This content is for developers reviewing or extending deterministic-mode +> coverage. Terms are defined in the [glossary](./glossary.md). + +The catalog has two buckets: + +- Operations with a deterministic code path +- Operations that deterministic mode cannot support yet + +The project goal is to shrink the second bucket and make the first bucket +faster. + +Most operations in a training step need no entry here. The following are +deterministic as-is: + +- Elementwise ops +- GEMMs under the pinned cuBLAS workspace +- Rank-indexed collectives (all-gather, all-to-all, broadcast) +- Stable sorts +- Unique-index writes + +The tables list only the operations where a choice is made. + +## Deterministic Code Path Operations + +Selected by `torch.are_deterministic_algorithms_enabled()` or +`config.deterministic_mode`. The default-mode path stays in the other branch. + +| Operation | Where | Deterministic Path | Default Path | +| --- | --- | --- | --- | +| MoE token unpermute (combine) | `megatron/core/transformer/moe/moe_utils.py` | `index_add_` — deterministic under torch deterministic algorithms and CUDA-graph safe | `scatter_add_` (atomic accumulation) | +| MoE routing map and probabilities | `megatron/core/transformer/moe/moe_utils.py` | `index_put_(accumulate=False)` row-wise writes | out-of-place `scatter` | +| Vocab-parallel embedding | `megatron/core/tensor_parallel/layers.py` | direct indexing `weight[idx]` (deterministic backward) | `F.embedding` (non-deterministic atomic backward) | +| Gated-delta-net kernel | `megatron/core/ssm/gated_delta_net.py` | torch `chunk_gated_delta_rule` | FLA fused kernel | +| Gated-delta-net causal conv1d | `megatron/core/ssm/gated_delta_net.py` | `F.conv1d` (plus transposes) | FLA `causal_conv1d` | +| Mamba/SSM Triton ops | `megatron/core/ssm/ops/determinism.py` | one fixed autotune config plus a zero-initialized tiled workspace reduced with an ordered `sum` | timing-based autotune, uninitialized workspace | +| Transformer Engine attention | `megatron/core/extensions/transformer_engine.py` | requires `NVTE_ALLOW_NONDETERMINISTIC_ALGO=0`, under which TE selects only backends that support deterministic execution (including deterministic FlashAttention backward) | TE picks freely, including atomic-accumulation attention backward | +| Inference DP scheduling and RL rollout order | `megatron/core/inference/engines/dynamic_engine.py`, `megatron/rl/rl_utils.py` | sort by stable key | completion order | + +The following environment controls make the rest of the step deterministic. +The flag `--deterministic-mode` validates and defaults these settings. For +details, refer to `megatron/training/determinism.py`. + +- `NCCL_ALGO=Ring`. The tree is rejected because its reduction order is not + user-controllable. +- `CUBLAS_WORKSPACE_CONFIG=:4096:8` (or `:16:8`). +- `NVTE_ALLOW_NONDETERMINISTIC_ALGO=0`. +- `MAMBA_DETERMINISTIC` must not be disabled. + +## Operations Without Determinism Support + +Deterministic mode either rejects these at validation (fails closed) or they +are known open gaps. + +| Operation or Feature | Where Enforced or Observed | Status | +| --- | --- | --- | +| Fused cross-entropy loss (`--cross-entropy-loss-fusion`) | rejected by `--deterministic-mode` (`megatron/training/determinism.py`) | The fused kernel is non-deterministic. Whether a deterministic variant is feasible remains an open question. Until then, the framework uses the native vocab-parallel path. | +| TP communication overlap (`--tp-comm-overlap`) | rejected by `--deterministic-mode` | Overlapped collective ordering is not reproducible. | +| Packed sequence (`thd`) in gated-delta-net | assertion in `megatron/core/ssm/gated_delta_net.py` | No deterministic packed-sequence SSM path exists yet. | +| Cross-allocation floating-point collectives (TP all-reduce, DP grad reduce-scatter) | open gap | `NCCL_ALGO=Ring` pins the algorithm but not the physical ring an allocation receives. The environment variable alone does not guarantee bit-exactness across *different* allocations for these reductions. Runs repeated within one allocation, or on allocations with identical topology, remain bit-exact. | + +## Performance Notes + +The deterministic paths above cost roughly 15% of step time compared to +default mode, which varies by model. Models that rely heavily on Mixture of +Experts (MoE) pay more. Measured examples range from approximately four percent +on a large dense model to approximately 17% on a hybrid MoE model. The measured +hotspots are: + +- The deterministic MoE scatter and unpermute path +- The sorted router top-k +- Attention backward +- Grouped-GEMM weight gradient (wgrad) + +Reducing this cost is a tracked workstream in +[issue #5785](https://github.com/NVIDIA/Megatron-LM/issues/5785). A change to +any row above needs a bit-exact test and a comparison of deterministic and +default performance (`tests/performance_tests/shell_test_utils/determinism/`). diff --git a/docs/developer/determinism/status.md b/docs/developer/determinism/status.md new file mode 100644 index 00000000000..6037cb27108 --- /dev/null +++ b/docs/developer/determinism/status.md @@ -0,0 +1,50 @@ +--- +orphan: true +--- + +# Determinism Status + +> Setup and supported configurations are in the +> [user guide](../../user-guide/deterministic-training.md). + +## Deterministic Mode + +`--deterministic-mode` (refer to `megatron/training/determinism.py`) does the +following: + +- Validates the determinism environment variables and fills canonical defaults +- Rejects features that have no deterministic path (cross-entropy fusion and + tensor parallelism (TP) communication overlap) +- Enables `torch.use_deterministic_algorithms(True)` + +The library code then selects the deterministic branches listed in the +[op catalog](./op-catalog.md). Refer to the user guide for exact flags and +environment values. + +## Validation + +- **Module-level bit-exact suite** (`tests/unit_tests/determinism/`): Runs a + model or block twice under restored RNG state and asserts bit-identical + outputs and gradients. Coverage includes: + + - GPTModel, TransformerBlock, and HybridModel + - Tensor parallelism, expert parallelism, fully sharded data parallel, pipeline + parallelism, and virtual pipeline parallelism + - FP8 and FP4 recipes + - Scheduling stressors to surface latent ordering races +- **Performance gate** + (`tests/performance_tests/shell_test_utils/determinism/`): Runs a small + recipe in deterministic and default mode under Nsight Systems, reports a + per-range leaderboard, and fails when the deterministic step time exceeds the + documented threshold. +- **End-to-end verification**: Compares full-precision training metrics across + two independent runs (refer to the glossary's "Verification" note). Extending + checked-in coverage to production-scale architectures is a roadmap item. + +## Performance + +Deterministic mode increases step time by roughly 15%, varying by model and +precision. The goal is under 10%, with a stretch goal near 5%, so you can leave +determinism on in production runs. The hotspot list and optimization progress +live in the [op catalog](./op-catalog.md) and +[issue #5785](https://github.com/NVIDIA/Megatron-LM/issues/5785). From 4d129d878c34ba8a14e532005f9d5a8b0675629b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 1 Aug 2026 00:26:01 +0000 Subject: [PATCH 177/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index fdd98fbd507..666df55c8af 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "Victarry", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From 313f6e95db411010d46b0fd2f391e6b71819bbfa Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 31 Jul 2026 16:15:04 -0700 Subject: [PATCH 178/290] Add MFSDP v2 MCore integration with FullyShardedOptimizer (#5865) Signed-off-by: Jingyue Wu Signed-off-by: svcnvidia-nemo-ci Co-authored-by: svcnvidia-nemo-ci --- .../distributed_data_parallel_config.py | 6 + .../distributed/fsdp/mcore_fsdp_adapter.py | 247 +++++++++++++++++- megatron/core/optimizer/__init__.py | 130 +++++---- .../core/optimizer/fully_sharded_optimizer.py | 126 +++++++++ megatron/core/utils.py | 11 +- megatron/training/arguments.py | 21 +- megatron/training/training.py | 18 +- .../test_mcore_tensor_parallelism_detect.py | 6 +- .../mfsdp_v2/test_mcore_adapter.py | 195 ++++++++++++++ 9 files changed, 690 insertions(+), 70 deletions(-) create mode 100644 megatron/core/optimizer/fully_sharded_optimizer.py create mode 100644 tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py diff --git a/megatron/core/distributed/distributed_data_parallel_config.py b/megatron/core/distributed/distributed_data_parallel_config.py index 96925ce120c..cf8d963f3a1 100644 --- a/megatron/core/distributed/distributed_data_parallel_config.py +++ b/megatron/core/distributed/distributed_data_parallel_config.py @@ -90,6 +90,9 @@ class DistributedDataParallelConfig: use_megatron_fsdp: bool = False """If true, use the FSDP code path for DDP.""" + megatron_fsdp_version: int = 1 + """Megatron-FSDP implementation version. Valid values are 1 and 2.""" + use_custom_fsdp: bool = False """ NOTE: The flag `use_custom_fsdp` is deprecated and will be removed in future versions. @@ -250,6 +253,9 @@ def __post_init__(self): import os """Check the validity of the config.""" + if self.megatron_fsdp_version not in (1, 2): + raise ValueError("megatron_fsdp_version must be either 1 or 2") + if self.reuse_grad_buf_for_mxfp8_param_ag: assert self.fp8_param_gather, "Reuse grad buffer only when keeping params in MXFP8." diff --git a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py index 3ccd9f932c8..d50cb220a24 100644 --- a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py +++ b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py @@ -16,6 +16,8 @@ import random from typing import Dict, List, Optional, Tuple, Type +__all__ = ["FullyShardedDataParallel"] + try: import einops @@ -51,6 +53,11 @@ MegatronFSDP, MixedPrecisionPolicy, ) + from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( + Flat, + Placements, + fully_shard, + ) HAVE_MEGATRON_FSDP = True except ImportError as import_megatron_fsdp_error: @@ -60,7 +67,7 @@ logger = logging.getLogger(__name__) -class FullyShardedDataParallel(_BaseDataParallel): +class FullyShardedDataParallelV1(_BaseDataParallel): """ Fully Sharded Data Parallel (FSDP) wrapper for the Megatron model. """ @@ -112,7 +119,9 @@ def __init__( config: TransformerConfig, ddp_config: DistributedDataParallelConfig, module: torch.nn.Module, - fsdp_unit_modules: Optional[List[torch.nn.Module]] = None, + # This should be named fsdp_unit_module_types; the v1 name is retained for API + # compatibility. + fsdp_unit_modules: Optional[List[Type[torch.nn.Module]]] = None, disable_bucketing: bool = False, device: Optional[torch.device] = None, pg_collection: Optional[ProcessGroupCollection] = None, @@ -483,6 +492,240 @@ def sync_rng_states_across_tp_group(self): _load_rng_state_dict(broadcast_list[0]) +class FullyShardedDataParallelV2(_BaseDataParallel): + """MFSDP v2 wrapper for the Megatron model.""" + + def __init__( + self, + config: TransformerConfig, + ddp_config: DistributedDataParallelConfig, + module: torch.nn.Module, + fsdp_unit_modules: Optional[List[Type[torch.nn.Module]]] = None, + disable_bucketing: bool = False, + device: Optional[torch.device] = None, + pg_collection: Optional[ProcessGroupCollection] = None, + ): + """Initialize the MFSDP v2 wrapper. + + Args: + config: Transformer configuration for the model. + ddp_config: Data-parallel and sharding configuration. + module: Root model module to shard. + fsdp_unit_modules: Module types to shard as child FSDP units. If + unspecified, transformer, MoE transformer, and Mamba layers are used. + disable_bucketing: Compatibility argument that must remain ``False`` for + MFSDP v2. + device: Device whose type is used to construct the data-parallel mesh. + Defaults to CUDA. + pg_collection: Explicit process groups. The ``dp_cp`` group defines the + data-parallel mesh. + + Raises: + ImportError: If the Megatron FSDP implementation is unavailable. + ValueError: If required process groups are missing or the configuration + requests a feature unsupported by MFSDP v2. + """ + if not HAVE_MEGATRON_FSDP: + raise IMPORT_MEGATRON_FSDP_ERROR + if pg_collection is None: + raise ValueError("MFSDP v2 requires an explicit ProcessGroupCollection.") + FullyShardedDataParallelV2._validate_config( + config, ddp_config, module, pg_collection, disable_bucketing + ) + + if has_config_logger_enabled(config): + log_config_to_disk(config, locals(), prefix=type(self).__name__) + + # Optimizer construction reads this attribute; retain the v1 contract for compatibility. + self.ddp_config = ddp_config + + if fsdp_unit_modules is None: + fsdp_unit_modules = [TransformerLayer, MoETransformerLayer, MambaLayer] + + log_single_rank( + logger, logging.INFO, "Setting up FullyShardedDataParallelV2 with config %s", ddp_config + ) + self.mp_policy = MixedPrecisionPolicy( + main_params_dtype=ddp_config.megatron_fsdp_main_params_dtype, + main_grads_dtype=ddp_config.megatron_fsdp_main_grads_dtype, + grad_comm_dtype=ddp_config.megatron_fsdp_grad_comm_dtype, + ) + log_single_rank( + logger, + logging.INFO, + "Setting up Megatron-FSDP MixedPrecisionPolicy with config %s", + self.mp_policy, + ) + + dp_group = pg_collection.dp_cp + device_type = device.type if device is not None else "cuda" + mesh = DeviceMesh.from_group(dp_group, device_type=device_type, mesh_dim_names=("dp",)) + placements = Placements( + dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()] + ) + for submodule in reversed(list(module.modules())): + if submodule is module: + # The root is always sharded after selected child units so it is not + # wrapped twice when its type also appears in fsdp_unit_modules. + continue + if any(isinstance(submodule, module_type) for module_type in fsdp_unit_modules): + fully_shard( + submodule, + mesh=mesh, + placements=placements, + mixed_precision_policy=self.mp_policy, + ) + fully_shard(module, mesh=mesh, placements=placements, mixed_precision_policy=self.mp_policy) + super().__init__(config=config, module=module) + + @staticmethod + def _validate_config( + config: TransformerConfig, + ddp_config: DistributedDataParallelConfig, + module: torch.nn.Module, + pg_collection: ProcessGroupCollection, + disable_bucketing: bool, + ) -> None: + """Validate that the model and configuration are supported by MFSDP v2. + + Args: + config: Transformer configuration describing the requested model topology. + ddp_config: Data-parallel and sharding configuration to validate. + module: Model whose parameters are checked for expert parallelism. + pg_collection: Materialized process groups whose topology must match the + supported MFSDP v2 topology. + disable_bucketing: Whether parameter bucketing is disabled. + + Raises: + ValueError: If a required process group is missing or the model, + topology, or data-parallel configuration uses an unsupported feature. + """ + if disable_bucketing: + raise ValueError("MFSDP v2 does not support disabling bucketing.") + if not hasattr(pg_collection, 'dp_cp'): + raise ValueError("MFSDP v2 requires an explicit dp_cp process group.") + + unsupported_parallelisms = [ + "tensor_model_parallel_size", + "pipeline_model_parallel_size", + "context_parallel_size", + "expert_model_parallel_size", + ] + if any(getattr(config, parallelism) != 1 for parallelism in unsupported_parallelisms): + raise ValueError( + "MFSDP v2 does not currently support: " + + ", ".join( + f"{parallelism}={getattr(config, parallelism)}" + for parallelism in unsupported_parallelisms + ) + ) + + # The config validates the requested topology, while these checks validate the + # materialized topology supplied by the caller's process-group collection. + for group_name in ("tp", "pp", "cp", "ep"): + group = getattr(pg_collection, group_name, None) + if group is not None and group.size() != 1: + raise ValueError( + f"MFSDP v2 currently requires {group_name.upper()} process-group size 1, " + f"got {group.size()}." + ) + + if getattr(config, "num_moe_experts", None) is not None or any( + not getattr(parameter, "allreduce", True) for parameter in module.parameters() + ): + raise ValueError("MFSDP v2 does not currently support expert parameters.") + if ddp_config.data_parallel_sharding_strategy != "optim_grads_params": + raise ValueError( + "MFSDP v2 requires data_parallel_sharding_strategy='optim_grads_params'." + ) + if ddp_config.num_distributed_optimizer_instances != 1: + raise ValueError("MFSDP v2 does not currently support HSDP.") + if ddp_config.outer_dp_sharding_strategy != "no_shard": + raise ValueError("MFSDP v2 does not currently support outer DP sharding.") + if ddp_config.overlap_grad_reduce or ddp_config.overlap_param_gather: + raise ValueError("MFSDP v2 does not currently support communication overlap modes.") + if config.gradient_accumulation_fusion: + raise ValueError("MFSDP v2 does not currently support gradient accumulation fusion.") + if config.calculate_per_token_loss: + raise ValueError("MFSDP v2 does not currently support per-token loss normalization.") + if config.fp8 or config.fp4 or ddp_config.fp8_param_gather or ddp_config.fp4_param_gather: + raise ValueError("MFSDP v2 does not currently support FP8 or FP4.") + if config.cuda_graph_impl != "none" or ddp_config.megatron_fsdp_cuda_graph_mode: + raise ValueError("MFSDP v2 does not currently support CUDA graphs.") + + if ddp_config.fsdp_double_buffer: + raise ValueError("MFSDP v2 does not support fsdp_double_buffer.") + if ddp_config.fsdp_db_use_persist_buf_on_alloc_fail: + raise ValueError("MFSDP v2 does not support fsdp_db_use_persist_buf_on_alloc_fail.") + if ddp_config.fsdp_all_gather_in_start_param_sync: + raise ValueError("MFSDP v2 does not support fsdp_all_gather_in_start_param_sync.") + if ddp_config.nccl_ub: + raise ValueError("MFSDP v2 does not support nccl_ub.") + if ddp_config.disable_symmetric_registration: + raise ValueError("MFSDP v2 does not support disable_symmetric_registration.") + if ddp_config.fsdp_manual_registration: + raise ValueError("MFSDP v2 does not support fsdp_manual_registration.") + if ddp_config.delay_wgrad_compute: + raise ValueError("MFSDP v2 does not support delay_wgrad_compute.") + if ddp_config.suggested_communication_unit_size is not None: + raise ValueError("MFSDP v2 does not support suggested_communication_unit_size.") + if ddp_config.num_buckets is not None: + raise ValueError("MFSDP v2 does not support num_buckets.") + if ddp_config.megatron_fsdp_use_decoupled_grad: + raise ValueError("MFSDP v2 does not support megatron_fsdp_use_decoupled_grad.") + if ddp_config.megatron_fsdp_enable_fine_grained_param_gather: + raise ValueError( + "MFSDP v2 does not support megatron_fsdp_enable_fine_grained_param_gather." + ) + if ddp_config.megatron_fsdp_max_pool_double_buffer: + raise ValueError("MFSDP v2 does not support megatron_fsdp_max_pool_double_buffer.") + + def start_param_sync(self, *unused, **unused_kwargs) -> None: + """MFSDP v2 gathers parameters from its forward pre-hook.""" + + def start_grad_sync(self, *unused, **unused_kwargs) -> None: + """MFSDP v2 reduces gradients during backward.""" + + def finish_grad_sync(self, *unused, **unused_kwargs) -> None: + """MFSDP v2 gradient reduction is complete when backward returns.""" + + def synchronize_param_gather(self, *unused, **unused_kwargs) -> None: + """MFSDP v2 parameter gathers complete inside module hooks.""" + + def broadcast_params(self) -> None: + """Reject parameter broadcast, which is unsupported by MFSDP v2.""" + raise NotImplementedError( + "MFSDP v2 does not support parameter broadcast/data-parallel random initialization." + ) + + def stop_communication(self) -> None: + """MFSDP v2 communication is complete when backward returns.""" + + +def FullyShardedDataParallel( + config: TransformerConfig, + ddp_config: DistributedDataParallelConfig, + module: torch.nn.Module, + fsdp_unit_modules: Optional[List[Type[torch.nn.Module]]] = None, + disable_bucketing: bool = False, + device: Optional[torch.device] = None, + pg_collection: Optional[ProcessGroupCollection] = None, +) -> _BaseDataParallel: + """Construct the configured Megatron-FSDP implementation. + + This is a factory function, not a wrapper type. Use the explicit V1 or V2 + implementation classes for type checks. + """ + fsdp_class = ( + FullyShardedDataParallelV2 + if ddp_config.megatron_fsdp_version == 2 + else FullyShardedDataParallelV1 + ) + return fsdp_class( + config, ddp_config, module, fsdp_unit_modules, disable_bucketing, device, pg_collection + ) + + def _get_hsdp_tp_mesh(outer_fsdp_dp_group, dp_cp_group, tp_group, ep_size=1): assert HAVE_EINOPS, "einops is not installed. Please install it with `pip install einops`." world_size = dist.get_world_size() diff --git a/megatron/core/optimizer/__init__.py b/megatron/core/optimizer/__init__.py index 70f757f2889..f8f5a813b38 100644 --- a/megatron/core/optimizer/__init__.py +++ b/megatron/core/optimizer/__init__.py @@ -56,6 +56,7 @@ from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.transformer.fsdp_dtensor_checkpoint import get_global_unique_param_name +from ..distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallelV2 from ..distributed.param_and_grad_buffer import _ParamAndGradBuffer from ..transformer.module import MegatronModule from ..utils import get_model_config, get_pg_rank, get_pg_size, is_te_min_version, log_single_rank @@ -66,6 +67,7 @@ _create_emerging_optimizer, _get_qkv_split_shapes, ) +from .fully_sharded_optimizer import FullyShardedOptimizer from .grad_scaler import ConstantGradScaler, DynamicGradScaler from .layer_wise_optimizer import LayerWiseDistributedOptimizer, is_managed_by_layer_wise_optimizer from .optimizer import ( @@ -631,56 +633,54 @@ def init_state_fn(opt, config=None): if skip_megatron_wrapping: return optimizer, init_state_fn - # Mixed precision optimizer. - # - Note: both the Float16Optimizer and the DistributedOptimizer inherit - # from the MixedPrecisionOptimizer, which manages any optimizer where - # the model params and main params are distinct. + # Grad scaler: + # if loss-scale is provided, instantiate the constant scaler. + # if we are using fp16 and loss-scale is not present, use a + # dynamic scaler. + # otherwise we are running in bf16 with no loss-scale so + # leave it as None. + grad_scaler = None if config.fp16 or config.bf16 or config.use_distributed_optimizer: - - # Grad scaler: - # if loss-scale is provided, instantiate the constant scaler. - # if we are using fp16 and loss-scale is not present, use a - # dynamic scaler. - # otherwise we are running in bf16 with no loss-scale so - # leave it as None. - grad_scaler = None - - # Constant loss scale. if config.loss_scale: grad_scaler = ConstantGradScaler(config.loss_scale) - - # Dynamic loss scale. - else: - if config.fp16: - grad_scaler = DynamicGradScaler( - initial_scale=config.initial_loss_scale, - min_scale=config.min_loss_scale, - growth_factor=2.0, - backoff_factor=0.5, - growth_interval=config.loss_scale_window, - hysteresis=config.hysteresis, - ) - - optimizer_args = [optimizer, config, grad_scaler, init_state_fn] - if config.use_distributed_optimizer: - optimizer = DistributedOptimizer( - *optimizer_args, - model_chunks=model_chunks, - per_model_buffers=per_model_buffers, - data_parallel_group=data_parallel_group, - data_parallel_group_gloo=data_parallel_group_gloo, - data_parallel_group_idx=data_parallel_group_idx, - distributed_optimizer_instance_id=distributed_optimizer_instance_id, + elif config.fp16: + grad_scaler = DynamicGradScaler( + initial_scale=config.initial_loss_scale, + min_scale=config.min_loss_scale, + growth_factor=2.0, + backoff_factor=0.5, + growth_interval=config.loss_scale_window, + hysteresis=config.hysteresis, ) - # This is needed for case where num_distributed_optimizer_instances > 1. In this case, - # weight gradients are all-reduced across optimizer instances, so each instance has - # the duplicated weight gradients, need to reduce gradient stats inside each instance. - setattr(optimizer, 'grad_stats_parallel_group', intra_dist_opt_group) - else: - optimizer = Float16OptimizerWithFloat16Params(*optimizer_args) - setattr(optimizer, 'grad_stats_parallel_group', model_parallel_group) + + if config.use_distributed_optimizer: + optimizer = DistributedOptimizer( + optimizer, + config, + grad_scaler, + init_state_fn, + model_chunks=model_chunks, + per_model_buffers=per_model_buffers, + data_parallel_group=data_parallel_group, + data_parallel_group_gloo=data_parallel_group_gloo, + data_parallel_group_idx=data_parallel_group_idx, + distributed_optimizer_instance_id=distributed_optimizer_instance_id, + ) + # This is needed for case where num_distributed_optimizer_instances > 1. In this case, + # weight gradients are all-reduced across optimizer instances, so each instance has + # the duplicated weight gradients, need to reduce gradient stats inside each instance. + setattr(optimizer, 'grad_stats_parallel_group', intra_dist_opt_group) + elif isinstance(model_chunks[0], FullyShardedDataParallelV2): + optimizer = FullyShardedOptimizer( + optimizer, config, grad_scaler, init_state_fn, model_chunks=model_chunks + ) + setattr(optimizer, 'grad_stats_parallel_group', data_parallel_group) + elif config.fp16 or config.bf16: + optimizer = Float16OptimizerWithFloat16Params(optimizer, config, grad_scaler, init_state_fn) + setattr(optimizer, 'grad_stats_parallel_group', model_parallel_group) else: # FP32 optimizer. + assert grad_scaler is None optimizer = FP32Optimizer(optimizer, config, init_state_fn) setattr(optimizer, 'grad_stats_parallel_group', model_parallel_group) @@ -753,6 +753,9 @@ def _get_megatron_emerging_optimizer( eopt_name = bare_name use_layer_wise = True + if isinstance(model_chunks[0], FullyShardedDataParallelV2): + raise NotImplementedError("MFSDP v2 with emerging optimizers is not currently validated.") + if not HAVE_EMERGING_OPTIMIZERS: raise ImportError( f"emerging-optimizers package is required for optimizer='{eopt_name}'. " @@ -1033,6 +1036,7 @@ def get_megatron_optimizer( check_config_overrides_consistency(config, config_overrides) + is_mfsdp_v2 = isinstance(model_chunks[0], FullyShardedDataParallelV2) # TODO: the standard and emerging optimizer paths handle pg_collection differently; # unify them so both use a single pg_collection-based flow. if config.optimizer not in ('adam', 'sgd'): @@ -1045,6 +1049,10 @@ def get_megatron_optimizer( log_single_rank(logger, logging.INFO, f'Setting up optimizer with config {config}') + if is_mfsdp_v2: + if config.use_distributed_optimizer: + raise ValueError("MFSDP v2 currently requires use_distributed_optimizer=False.") + # Separate out first model chunk if overlapping param AG with optimizer step. if config.overlap_param_gather_with_optimizer_step: all_dense_model_chunks = [[model_chunks[0]], model_chunks[1:]] @@ -1091,14 +1099,32 @@ def get_megatron_optimizer( for model_chunk, overlap_param_gather_with_optimizer_step in zip( all_dense_model_chunks, overlap_param_gather_with_optimizer_step_flags ): - param_groups, buffers = _get_param_groups_and_buffers( - model_chunk, - model_chunk_offset=model_chunk_offset, - config=config, - config_overrides=config_overrides, - filter_fn=lambda g: True, - buffer_name='buffers', - ) + if is_mfsdp_v2: + param_groups = _get_param_groups(model_chunk, config, config_overrides) + # TE FusedAdam can skip pending updates when a group ends in an empty tensor: + # https://github.com/NVIDIA/TransformerEngine/issues/3207. + # Empty local shards have no optimizer state or data to update, so omit them. + for param_group in param_groups: + param_group['params'] = [ + parameter + for parameter in param_group['params'] + if parameter.to_local().numel() > 0 + ] + param_groups = [ + param_group for param_group in param_groups if param_group['params'] + ] + # MFSDP v2 owns its sharded parameter and gradient storage, so + # FullyShardedOptimizer does not need DDP param-and-grad buffers. + buffers = None + else: + param_groups, buffers = _get_param_groups_and_buffers( + model_chunk, + model_chunk_offset=model_chunk_offset, + config=config, + config_overrides=config_overrides, + filter_fn=lambda g: True, + buffer_name='buffers', + ) optimizer_part = _get_megatron_optimizer_based_on_param_groups( config=config, diff --git a/megatron/core/optimizer/fully_sharded_optimizer.py b/megatron/core/optimizer/fully_sharded_optimizer.py new file mode 100644 index 00000000000..18c2354dcb2 --- /dev/null +++ b/megatron/core/optimizer/fully_sharded_optimizer.py @@ -0,0 +1,126 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""MCore optimizer wrapper for experimental Megatron-FSDP v2.""" + +from typing import Callable, List, Optional + +import torch + +from ..config_logger import has_config_logger_enabled, log_config_to_disk +from ..dist_checkpointing.mapping import ShardedStateDict +from ..transformer.module import MegatronModule +from .grad_scaler import MegatronGradScaler +from .optimizer import MixedPrecisionOptimizer +from .optimizer_config import OptimizerConfig + + +class FullyShardedOptimizer(MixedPrecisionOptimizer): + """MCore optimizer wrapper for MFSDP-owned sharded parameters and gradients. + + MFSDP v2 owns the optimizer-facing parameter and gradient shards directly. + Unlike :class:`DistributedOptimizer`, this wrapper does not build DDP + param-and-grad-buffer range maps or allocate separate main-parameter shards. + It preserves MCore's mixed-precision optimizer step contract while making + MFSDP-specific storage operations explicit. + """ + + def __init__( + self, + optimizer: torch.optim.Optimizer, + config: OptimizerConfig, + grad_scaler: Optional[MegatronGradScaler], + init_state_fn: Callable, + model_chunks: List[MegatronModule], + ) -> None: + """Initialize the MFSDP optimizer wrapper. + + Args: + optimizer: Base optimizer such as Adam or SGD. + config: Optimizer configuration. + grad_scaler: Optional loss scaler. Currently unsupported for MFSDP v2, + but accepted to match the MCore optimizer construction contract. + init_state_fn: Function used to initialize optimizer state. + model_chunks: MFSDP v2 model chunks optimized by this wrapper. + """ + FullyShardedOptimizer._validate_config(config, model_chunks) + if has_config_logger_enabled(config): + log_config_to_disk(config, locals(), prefix=type(self).__name__) + if grad_scaler is not None: + raise ValueError("MFSDP v2 does not currently support loss scaling.") + + super().__init__(optimizer, config, grad_scaler, init_state_fn) + self.model_chunks = model_chunks + self.ddp_config = self.model_chunks[0].ddp_config + for model_chunk in self.model_chunks: + if self.ddp_config != model_chunk.ddp_config: + raise ValueError("All MFSDP v2 model chunks must share the same ddp_config.") + self.is_stub_optimizer = optimizer is None + + @staticmethod + def _validate_config(config: OptimizerConfig, model_chunks: List[MegatronModule]) -> None: + """Validate the MFSDP v2 optimizer support contract.""" + if len(model_chunks) != 1: + raise ValueError("MFSDP v2 currently supports exactly one model chunk.") + if config.use_distributed_optimizer: + raise ValueError("MFSDP v2 currently requires use_distributed_optimizer=False.") + if config.loss_scale is not None: + raise ValueError("MFSDP v2 does not currently support loss scaling.") + if config.fp16: + raise ValueError( + "MFSDP v2 does not currently support FP16 training because FP16 triggers " + "loss unscale." + ) + if config.overlap_param_gather_with_optimizer_step: + raise ValueError("MFSDP v2 does not support optimizer-step parameter-gather overlap.") + if config.optimizer_cpu_offload: + raise ValueError("MFSDP v2 does not currently support optimizer CPU offload.") + if config.use_precision_aware_optimizer: + raise ValueError("MFSDP v2 does not currently support precision-aware optimizer.") + if config.use_layer_wise_distributed_optimizer: + raise ValueError( + "MFSDP v2 does not currently support layer-wise distributed optimizer." + ) + if config.optimizer_cuda_graph: + raise ValueError("MFSDP v2 does not currently support optimizer CUDA graphs.") + + def state_dict(self): + """Return optimizer state. + + MFSDP v2 optimizer checkpointing needs an FSDP-native DTensor state + contract. Keep this intentionally unsupported for the prototype instead + of falling back to DDP-buffer assumptions. + """ + raise NotImplementedError("MFSDP v2 optimizer checkpointing is not yet supported.") + + def load_state_dict(self, state_dict): + """Load optimizer state.""" + raise NotImplementedError("MFSDP v2 optimizer checkpointing is not yet supported.") + + def sharded_state_dict( + self, + model_sharded_state_dict: ShardedStateDict, + is_loading: bool = False, + metadata: Optional[dict] = None, + ) -> ShardedStateDict: + """Build a sharded optimizer state dict.""" + raise NotImplementedError("MFSDP v2 optimizer checkpointing is not yet supported.") + + def zero_grad(self, set_to_none: bool = True) -> None: + """Clear optimizer-visible sharded grads and any grads filtered from local groups.""" + if not self.is_stub_optimizer: + self.optimizer.zero_grad(set_to_none=set_to_none) + + # Empty local DTensor shards are filtered out of optimizer param groups + # as a TE FusedAdam workaround. A rank with no local optimizer params + # can still have stale module grads to clear. + for model_chunk in self.model_chunks: + model_chunk.zero_grad(set_to_none=set_to_none) + + def _copy_model_grads_to_main_grads(self) -> None: + """No-op: MFSDP v2 reduces directly into optimizer-visible sharded grads.""" + + def _copy_main_params_to_model_params(self) -> None: + """No-op: MFSDP v2 currently syncs compute weights in its forward pre-hook.""" + + def _copy_model_params_to_main_params(self, state_dict=None) -> None: + """No-op: model loads already write into MFSDP v2's main weights.""" diff --git a/megatron/core/utils.py b/megatron/core/utils.py index ad9692e8aa5..72373e9ac3b 100644 --- a/megatron/core/utils.py +++ b/megatron/core/utils.py @@ -2820,11 +2820,18 @@ def unwrap_model(model, module_instances=None): from megatron.core.distributed import DistributedDataParallel as DDP from megatron.core.distributed import TorchFullyShardedDataParallel as torch_FSDP from megatron.core.distributed.fsdp.mcore_fsdp_adapter import ( - FullyShardedDataParallel as megatron_FSDP, + FullyShardedDataParallelV1, + FullyShardedDataParallelV2, ) from megatron.core.transformer.module import Float16Module - module_instances = (DDP, torch_FSDP, megatron_FSDP, Float16Module) + module_instances = ( + DDP, + torch_FSDP, + FullyShardedDataParallelV1, + FullyShardedDataParallelV2, + Float16Module, + ) return_list = True if not isinstance(model, list): diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 7137e2bbe5d..623adcad91e 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1103,13 +1103,18 @@ def validate_args(args, defaults={}): # Future updates will drop support for `use_custom_fsdp` to avoid confusion. args.use_custom_fsdp = True - # Megatron-FSDP requires the DistributedOptimizer. - if not args.use_distributed_optimizer: - warn_rank_0( - 'Megatron-FSDP is only compatible with --use-distributed-optimizer. Using DistributedOptimizer...', - args.rank, - ) - args.use_distributed_optimizer = True + if args.megatron_fsdp_version == 2: + assert not args.use_distributed_optimizer, \ + '--megatron-fsdp-version 2 is not compatible with --use-distributed-optimizer' + else: + # Megatron-FSDP v1 requires the DistributedOptimizer. + if not args.use_distributed_optimizer: + warn_rank_0( + 'Megatron-FSDP v1 is only compatible with --use-distributed-optimizer. ' + 'Using DistributedOptimizer...', + args.rank, + ) + args.use_distributed_optimizer = True # Optimizer step MXFP8 buffer operation that is not relevant or supported for Megatron-FSDP. args.reuse_grad_buf_for_mxfp8_param_ag = False if args.moe_single_grouped_weight or args.moe_single_grouped_bias: @@ -3005,6 +3010,8 @@ def _add_distributed_args(parser): dest='align_param_gather') group.add_argument('--use-distributed-optimizer', action='store_true', help='Use distributed optimizer.') + group.add_argument('--megatron-fsdp-version', type=int, default=1, choices=[1, 2], + help='Megatron-FSDP implementation version. Defaults to 1.') group.add_argument('--no-use-layer-wise-param-layout', action='store_false', dest='use_layer_wise_param_layout', diff --git a/megatron/training/training.py b/megatron/training/training.py index 823c0271926..8aab38e6071 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -50,7 +50,9 @@ finalize_model_grads, ) from megatron.core.distributed.fsdp.mcore_fsdp_adapter import ( - FullyShardedDataParallel as megatron_FSDP, + FullyShardedDataParallel, + FullyShardedDataParallelV1, + FullyShardedDataParallelV2, ) from megatron.core.enums import ModelType from megatron.core.fp8_utils import correct_amax_history_if_needed @@ -1858,7 +1860,7 @@ def build_model(): assert HAVE_FSDP2, "Torch FSDP2 requires torch>=2.4.0" DP = torch_FSDP elif args.use_megatron_fsdp: - DP = megatron_FSDP + DP = FullyShardedDataParallel else: DP = DDP @@ -2020,6 +2022,10 @@ def get_megatron_ddp_config(args: argparse.Namespace) -> DistributedDataParallel kwargs["megatron_fsdp_main_grads_dtype"] = args.megatron_fsdp_main_grads_dtype kwargs["megatron_fsdp_grad_comm_dtype"] = args.megatron_fsdp_grad_comm_dtype kwargs["megatron_fsdp_use_decoupled_grad"] = args.use_precision_aware_optimizer + if args.use_megatron_fsdp and args.megatron_fsdp_version == 2: + # MFSDP v2 gathers parameters from module hooks rather than the V1 + # start_param_sync path, so disable the V1-only startup all-gather knob. + kwargs["fsdp_all_gather_in_start_param_sync"] = False if args.use_megatron_fsdp and args.cuda_graph_impl != "none": # Run Megatron-FSDP in CUDA graph-safe mode. Avoids some graph-unsafe host-side # operations (such as pointer dereferencing) that can break CUDA graph replay. @@ -3557,7 +3563,9 @@ def _dp_world_size(): # Setup some training config params. config.grad_scale_func = optimizer.scale_loss if optimizer is not None else None config.timers = timers - if isinstance(model[0], (megatron_FSDP, DDP)) and args.overlap_grad_reduce: + if isinstance( + model[0], (FullyShardedDataParallelV1, FullyShardedDataParallelV2, DDP) + ) and args.overlap_grad_reduce: assert config.no_sync_func is None, ( 'When overlap_grad_reduce is True, config.no_sync_func must be None; ' 'a custom no_sync_func is not supported when overlapping grad-reduce' @@ -3946,7 +3954,9 @@ def trace_handler(p): and iteration == start_iteration + 1 ): for model_chunk in model: - if isinstance(model_chunk, megatron_FSDP) and getattr( + if isinstance( + model_chunk, (FullyShardedDataParallelV1, FullyShardedDataParallelV2) + ) and getattr( model_chunk.ddp_config, "fsdp_manual_registration", False ): param_and_grad_buffer = getattr(model_chunk, "param_and_grad_buffer", None) diff --git a/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py b/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py index 7cdda0d163b..f548609ad94 100644 --- a/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py +++ b/tests/unit_tests/distributed/mfsdp_v1/test_mcore_tensor_parallelism_detect.py @@ -3,7 +3,7 @@ import torch from torch import nn -from megatron.core.distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallel +from megatron.core.distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallelV1 from megatron.core.distributed.fsdp.src.megatron_fsdp.utils import ( get_mcore_tensor_parallel_partition_dim, is_mcore_tensor_parallel_duplicated, @@ -114,10 +114,10 @@ def _make_fsdp_for_unit_tests(): and only setting the attributes that _detect_parallelism_type and _annotate_tensor_parallelism actually use. """ - fsdp = FullyShardedDataParallel.__new__(FullyShardedDataParallel) + fsdp = FullyShardedDataParallelV1.__new__(FullyShardedDataParallelV1) # Copy the registry from the real class. - fsdp._MODULE_TYPE_REGISTRY = FullyShardedDataParallel._MODULE_TYPE_REGISTRY + fsdp._MODULE_TYPE_REGISTRY = FullyShardedDataParallelV1._MODULE_TYPE_REGISTRY return fsdp diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py new file mode 100644 index 00000000000..76d2884a7f1 --- /dev/null +++ b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py @@ -0,0 +1,195 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""MCore adapter and optimizer integration tests for experimental MFSDP v2.""" + +from dataclasses import replace + +import pytest +import torch + +from megatron.core.distributed import DistributedDataParallelConfig +from megatron.core.distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallel +from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental.module import FsdpModule +from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_spec +from megatron.core.optimizer import OptimizerConfig, get_megatron_optimizer +from megatron.core.optimizer.fully_sharded_optimizer import FullyShardedOptimizer +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.transformer_block import TransformerBlock +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.transformer.transformer_layer import TransformerLayer +from tests.unit_tests.test_utilities import Utils + + +def _build_layer(config: TransformerConfig) -> TransformerLayer: + return TransformerLayer( + config=config, + submodules=get_gpt_layer_local_spec().submodules, + layer_number=1, + add_layer_offset=False, + ) + + +def _build_block(config: TransformerConfig) -> TransformerBlock: + return TransformerBlock(config=config, spec=get_gpt_layer_local_spec()).to( + device="cuda", dtype=config.params_dtype + ) + + +class TestMcoreAdapter: + """Exercise a dense MCore transformer block over two data-parallel ranks.""" + + def setup_method(self): + Utils.initialize_model_parallel(1, 1) + if torch.distributed.get_world_size() < 2: + pytest.skip("MFSDP v2 MCore integration test requires at least two ranks.") + self.pg_collection = ProcessGroupCollection.use_mpu_process_groups() + model_parallel_cuda_manual_seed(1234) + + def teardown_method(self): + Utils.destroy_model_parallel() + + def test_wraps_fsdp_unit_modules_before_root(self): + config = TransformerConfig( + num_layers=1, + hidden_size=16, + num_attention_heads=4, + ffn_hidden_size=32, + bf16=True, + params_dtype=torch.bfloat16, + attention_dropout=0.0, + hidden_dropout=0.0, + ) + layer = _build_layer(config) + model = torch.nn.Sequential(layer, torch.nn.Linear(config.hidden_size, config.hidden_size)) + model = model.to(device="cuda", dtype=config.params_dtype) + + wrapped = FullyShardedDataParallel( + config=config, + ddp_config=DistributedDataParallelConfig( + use_megatron_fsdp=True, + megatron_fsdp_version=2, + use_distributed_optimizer=False, + data_parallel_sharding_strategy="optim_grads_params", + megatron_fsdp_main_params_dtype=torch.float32, + megatron_fsdp_main_grads_dtype=torch.float32, + fsdp_all_gather_in_start_param_sync=False, + ), + module=model, + fsdp_unit_modules=[TransformerLayer], + pg_collection=self.pg_collection, + ) + + assert isinstance(wrapped.module, FsdpModule) + assert isinstance(wrapped.module[0], FsdpModule) + + # Post-order wrapping gives the selected TransformerLayer its own parameter group; + # the root FSDP unit should own only the parameters of the remaining Linear module. + child_parameter_names = { + name for group in wrapped.module[0].parameter_groups for name in group.parameter_names + } + root_parameter_names = { + name for group in wrapped.module.parameter_groups for name in group.parameter_names + } + assert child_parameter_names + assert root_parameter_names == {"1.weight", "1.bias"} + + def test_build_train_and_step(self): + config = TransformerConfig( + num_layers=2, + hidden_size=16, + num_attention_heads=4, + ffn_hidden_size=32, + bf16=True, + params_dtype=torch.bfloat16, + attention_dropout=0.0, + hidden_dropout=0.0, + ) + reference_model = _build_block(config) + model = _build_block(config) + model.load_state_dict(reference_model.state_dict()) + # get_megatron_optimizer expects every model chunk to expose ddp_config. The + # reference model remains unwrapped/unsharded, so it cannot use the + # DistributedOptimizer path that expects DDP/FSDP buffer metadata. + reference_model.ddp_config = DistributedDataParallelConfig(use_distributed_optimizer=False) + + model = FullyShardedDataParallel( + config=config, + ddp_config=DistributedDataParallelConfig( + use_megatron_fsdp=True, + megatron_fsdp_version=2, + use_distributed_optimizer=False, + data_parallel_sharding_strategy="optim_grads_params", + megatron_fsdp_main_params_dtype=torch.float32, + megatron_fsdp_main_grads_dtype=torch.bfloat16, + fsdp_all_gather_in_start_param_sync=False, + ), + module=model, + pg_collection=self.pg_collection, + ) + + reference_optimizer_config = OptimizerConfig( + optimizer="adam", + lr=1.0e-3, + weight_decay=0.0, + bf16=True, + params_dtype=torch.bfloat16, + use_distributed_optimizer=False, + clip_grad=0.0, + ) + reference_optimizer = get_megatron_optimizer( + reference_optimizer_config, [reference_model], use_gloo_process_groups=False + ) + optimizer_config = replace(reference_optimizer_config) + with pytest.raises( + ValueError, match="MFSDP v2 currently requires use_distributed_optimizer=False" + ): + get_megatron_optimizer( + replace(reference_optimizer_config, use_distributed_optimizer=True), + [model], + use_gloo_process_groups=False, + ) + optimizer = get_megatron_optimizer(optimizer_config, [model], use_gloo_process_groups=False) + assert isinstance(optimizer, FullyShardedOptimizer) + optimizer.reload_model_params() + + steps = [ + [ + torch.randn(8, 2, config.hidden_size, device="cuda", dtype=torch.bfloat16) + for _ in range(2) + ] + for _ in range(3) + ] + + reference_losses = [] + for microbatches in steps: + reference_optimizer.zero_grad(set_to_none=True) + microbatch_losses = [] + for batch in microbatches: + reference_output = reference_model(hidden_states=batch, attention_mask=None) + reference_loss = reference_output.float().square().mean() + (reference_loss / len(microbatches)).backward() + microbatch_losses.append(reference_loss.detach()) + reference_success, _, _ = reference_optimizer.step() + assert reference_success + reference_losses.append(torch.stack(microbatch_losses).mean()) + + losses = [] + for microbatches in steps: + model.zero_grad_buffer() + optimizer.zero_grad(set_to_none=True) + microbatch_losses = [] + for batch in microbatches: + output = model(hidden_states=batch, attention_mask=None) + loss = output.float().square().mean() + (loss / len(microbatches)).backward() + microbatch_losses.append(loss.detach()) + success, _, _ = optimizer.step() + assert success + losses.append(torch.stack(microbatch_losses).mean()) + + losses = torch.stack(losses) + reference_losses = torch.stack(reference_losses) + assert torch.isfinite(losses).all() + assert torch.isfinite(reference_losses).all() + torch.testing.assert_close(losses, reference_losses, rtol=1e-2, atol=0) From 1addfbd78d534119289777e0e261b132a60072e2 Mon Sep 17 00:00:00 2001 From: nvcsathe Date: Fri, 31 Jul 2026 21:13:53 -0700 Subject: [PATCH 179/290] Add streaming replies for dynamic engine (#5727) Signed-off-by: Chaitra Sathe --- megatron/core/inference/async_stream.py | 44 +++-- .../coordinator.py | 1 + .../handlers.py | 50 ++++- .../core/inference/engines/dynamic_engine.py | 63 ++++++ megatron/core/inference/headers.py | 2 + megatron/core/inference/inference_client.py | 105 +++++++++- megatron/core/inference/sampling_params.py | 15 +- .../endpoints/chat_completions.py | 33 ++++ .../endpoints/completions.py | 40 +++- .../incremental_detokenizer.py | 88 +++++++++ .../openai_streaming.py | 179 ++++++++++++++++++ .../inference/engines/test_dynamic_engine.py | 42 ++++ .../unit_tests/inference/test_async_stream.py | 11 ++ .../inference/test_common_inference_params.py | 19 ++ ...est_data_parallel_inference_coordinator.py | 1 + .../test_inference_client_streaming.py | 153 +++++++++++++++ .../inference/test_openai_streaming.py | 146 ++++++++++++++ 17 files changed, 964 insertions(+), 28 deletions(-) create mode 100644 megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py create mode 100644 megatron/core/inference/text_generation_server/dynamic_text_gen_server/openai_streaming.py create mode 100644 tests/unit_tests/inference/test_inference_client_streaming.py create mode 100644 tests/unit_tests/inference/test_openai_streaming.py diff --git a/megatron/core/inference/async_stream.py b/megatron/core/inference/async_stream.py index 6c3242a13db..f910da93269 100644 --- a/megatron/core/inference/async_stream.py +++ b/megatron/core/inference/async_stream.py @@ -6,17 +6,17 @@ # LICENSE file in the root directory of this source tree. import asyncio -from typing import Any, AsyncGenerator, Callable, Optional, Type, Union +from typing import Any, AsyncGenerator, Callable, Generic, Optional, Type, TypeVar, Union -from megatron.core.inference.inference_request import InferenceRequest from megatron.core.utils import get_asyncio_loop STOP_ITERATION = Exception() +T = TypeVar("T") -class AsyncStream: +class AsyncStream(Generic[T]): """ - Class for encapsulating an asynchronous stream of InferenceRequest outputs. + Class for encapsulating an asynchronous stream of request outputs. Adopted from https://github.com/vllm-project/vllm/blob/eb881ed006ca458b052905e33f0d16dbb428063a/vllm/v1/engine/async_stream.py # pylint: disable=line-too-long """ @@ -24,7 +24,7 @@ class AsyncStream: def __init__( self, request_id: int, - cancel: Callable[[str], None], + cancel: Callable[[], None], loop: Optional[asyncio.AbstractEventLoop] = None, ) -> None: self._request_id = request_id @@ -33,7 +33,12 @@ def __init__( self._finished = False self._loop = get_asyncio_loop(loop) - def put(self, item: Union[InferenceRequest, Exception]) -> None: + @property + def request_id(self) -> int: + """The request associated with this stream.""" + return self._request_id + + def put(self, item: Union[T, Exception]) -> None: """Adds a new value to the stream""" if not self._finished: self._loop.call_soon_threadsafe(self._queue.put_nowait, item) @@ -52,18 +57,29 @@ def finished(self) -> bool: """Whether the stream has finished""" return self._finished - async def generator(self) -> AsyncGenerator[InferenceRequest, None]: + def __aiter__(self): + return self + + async def __anext__(self) -> T: + result = await self._queue.get() + if self._is_raisable(result): + if result == STOP_ITERATION: + raise StopAsyncIteration + raise result + return result + + async def aclose(self) -> None: + """Cancel the request if the stream is still active.""" + if not self._finished: + self._cancel() + + async def generator(self) -> AsyncGenerator[T, None]: """Creates an AsyncGenerator over the stream queue""" try: - while True: - result = await self._queue.get() - if self._is_raisable(result): - if result == STOP_ITERATION: - return - raise result + async for result in self: yield result except GeneratorExit: - self._cancel() + await self.aclose() raise asyncio.CancelledError from None @staticmethod diff --git a/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py b/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py index 6900887bcf0..67eb1bb5829 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py +++ b/megatron/core/inference/data_parallel_inference_coordinator/coordinator.py @@ -189,6 +189,7 @@ def __init__( self.request_id_to_client_request_id = {} self.request_id_to_rank = {} # Maps request_id → rank identity for pending count tracking self.removed_engine_identities = set() + self.client_request_to_request_id = {} self.next_request_id = 0 self.tokenizer = tokenizer diff --git a/megatron/core/inference/data_parallel_inference_coordinator/handlers.py b/megatron/core/inference/data_parallel_inference_coordinator/handlers.py index b2d9a36fffb..2c1712a1de3 100644 --- a/megatron/core/inference/data_parallel_inference_coordinator/handlers.py +++ b/megatron/core/inference/data_parallel_inference_coordinator/handlers.py @@ -87,6 +87,7 @@ def handle_submit_request(coordinator, sender_identity, payload): coordinator.next_request_id += 1 coordinator.request_id_to_client_id[request_id] = sender_identity coordinator.request_id_to_client_request_id[request_id] = client_request_id + coordinator.client_request_to_request_id[(sender_identity, client_request_id)] = request_id # Serialize prompt. if isinstance(prompt, (str, list)): @@ -117,6 +118,7 @@ def handle_submit_request(coordinator, sender_identity, payload): logging.error("Coordinator: no reachable engines for request %d", request_id) del coordinator.request_id_to_client_id[request_id] del coordinator.request_id_to_client_request_id[request_id] + del coordinator.client_request_to_request_id[(sender_identity, client_request_id)] return True coordinator.request_id_to_rank[request_id] = next_identity @@ -196,9 +198,10 @@ def handle_engine_reply(coordinator, sender_identity, payload): coordinator.detokenize(finished_request) fid = finished_request["request_id"] client_identity = coordinator.request_id_to_client_id[fid] - client_request_identity = coordinator.request_id_to_client_request_id[fid] + client_request_id = coordinator.request_id_to_client_request_id[fid] del coordinator.request_id_to_client_id[fid] del coordinator.request_id_to_client_request_id[fid] + del coordinator.client_request_to_request_id[(client_identity, client_request_id)] assigned_rank = coordinator.request_id_to_rank.pop(fid, None) if assigned_rank is not None: idx = coordinator.identity_to_rank_index.get(assigned_rank) @@ -210,13 +213,56 @@ def handle_engine_reply(coordinator, sender_identity, payload): [ client_identity, msgpack.packb( - [Headers.ENGINE_REPLY.value, client_request_identity, finished_request], + [Headers.ENGINE_REPLY.value, client_request_id, finished_request], use_bin_type=True, ), ] ) +@message_handler(Headers.ENGINE_REPLY_PARTIAL) +def handle_engine_reply_partial(coordinator, sender_identity, payload): + """Route incremental engine replies without releasing request routing state.""" + if sender_identity not in coordinator.identities_of_data_parallel_ranks: + assert ( + sender_identity in coordinator.removed_engine_identities + ), f"ENGINE_REPLY_PARTIAL from never-connected sender {sender_identity!r}" + logging.warning("Coordinator: ENGINE_REPLY_PARTIAL from removed engine %r", sender_identity) + return + for partial in payload[1]: + request_id = partial["request_id"] + client_identity = coordinator.request_id_to_client_id[request_id] + client_request_id = coordinator.request_id_to_client_request_id[request_id] + # Partial tokens are detokenized incrementally by the client-facing streaming layer. + coordinator.router_socket.send_multipart( + [ + client_identity, + msgpack.packb( + [Headers.ENGINE_REPLY_PARTIAL.value, client_request_id, partial], + use_bin_type=True, + ), + ] + ) + + +@message_handler(Headers.ABORT_REQUEST) +def handle_abort_request(coordinator, sender_identity, payload): + """Forward a client cancellation to the engine serving that request.""" + if sender_identity not in coordinator.known_clients: + logging.warning("Coordinator: ignoring abort from unknown client.") + return + client_request_id = int(payload[1]) + request_id = coordinator.client_request_to_request_id.get((sender_identity, client_request_id)) + if request_id is None: + return + assigned_rank = coordinator.request_id_to_rank.get(request_id) + if assigned_rank is not None: + coordinator._send_to_engine( + assigned_rank, + msgpack.packb([Headers.ABORT_REQUEST.value, request_id], use_bin_type=True), + ) + + @message_handler(Headers.SHUTDOWN) def handle_shutdown(coordinator, sender_identity, payload): """Stop the coordinator event loop on request from a known client.""" diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index 522ca698899..a1b40bfa459 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -353,6 +353,8 @@ def reset(self) -> None: self.requests: Dict[int, RequestEntry] = {} self.waiting_request_ids = deque() self.failed_request_ids = [] + # Generated token count already streamed for each request. + self._partial_emit_lengths: Dict[int, int] = {} self._generation_epoch: Optional[int] = None # Track requests that should stop due to stop words (detected in post_process_requests) self.stop_word_finished_request_ids: set[int] = set() @@ -2176,6 +2178,44 @@ async def async_forward(self) -> Tuple[Optional[Dict], Dict, float]: return result, context_state, step_time + def _try_send_streaming_partials(self) -> None: + """Send pending token deltas to the inference coordinator.""" + partials: list = [] + emit_lengths: Dict[int, int] = {} + for rid, entry in self.requests.items(): + request = entry.record[-1] + if not getattr(request.sampling_params, "streaming", False): + continue + already = self._partial_emit_lengths.get(rid, 0) + total = len(request.generated_tokens) + stop_word_ids = getattr(request, "stop_word_ids", None) + holdback = 0 + if stop_word_ids and not getattr( + request.sampling_params, "detokenize_stop_sequence", False + ): + holdback = max(0, max(len(ids) for ids in stop_word_ids) - 1) + emit_end = max(already, total - holdback) + streaming_interval = getattr(request.sampling_params, "streaming_interval", 1) + if emit_end - already >= streaming_interval: + new_tokens = list(request.generated_tokens[already:emit_end]) + partial = {"request_id": rid, "new_tokens": new_tokens} + if request.sampling_params.return_log_probs: + partial["new_log_probs"] = list( + (request.generated_log_probs or [])[already:emit_end] + ) + partials.append(partial) + emit_lengths[rid] = emit_end + + if not partials: + return + + payload = msgpack.packb([Headers.ENGINE_REPLY_PARTIAL.value, partials], use_bin_type=True) + nvtx_range_push("coordinator_streaming") + self.socket_for_receiving_requests.send(payload) + nvtx_range_pop("coordinator_streaming") + + self._partial_emit_lengths.update(emit_lengths) + async def async_bookkeep( self, step_result: Optional[Dict], context_state: Dict, step_time: float ): @@ -2283,6 +2323,13 @@ async def async_bookkeep( self.socket_for_receiving_requests.send(payload) nvtx_range_pop("coordinator_communication") + # Stream newly generated tokens for active requests. Finished + # requests were already popped from self.requests above, so their + # emit lengths are dropped here rather than in the loop. + for record in finished_request_records: + self._partial_emit_lengths.pop(record.requests[-1].request_id, None) + self._try_send_streaming_partials() + # Drain prefix cache hit counters from context into engine accumulators. if self.context.enable_prefix_caching: self._prefix_cache_hits += self.context.prefix_cache_hits @@ -2611,6 +2658,22 @@ def schedule_requests(self) -> int: nvtx_range_push("add_request") self.add_request(request_id, prompt, sampling_params) nvtx_range_pop("add_request") + elif header == Headers.ABORT_REQUEST: + request_id = int(data[1]) + entry = self.requests.get(request_id) + if entry is not None: + request = entry.record[-1] + # Force active requests to finish on the next step. + request.sampling_params.num_tokens_to_generate = len(request.generated_tokens) + active_ids = self.context.request_ids[: self.context.total_request_count] + matches = torch.where(active_ids == request_id)[0] + if matches.numel() > 0: + assert matches.numel() == 1 + idx = int(matches[0].item()) + self.context.request_output_lengths[idx] = ( + self.context.request_kv_length_offsets[idx] + + self.context.request_query_lengths[idx] + ) elif header == Headers.SET_GENERATION_EPOCH: new_generation_epoch = data[1] elif header == Headers.START_CUDA_PROFILER: diff --git a/megatron/core/inference/headers.py b/megatron/core/inference/headers.py index 4acef82bc1c..35f9505b5fe 100644 --- a/megatron/core/inference/headers.py +++ b/megatron/core/inference/headers.py @@ -27,6 +27,8 @@ class Request(IntEnum): SUBMIT_REQUEST = 20 ENGINE_REPLY = 21 + ENGINE_REPLY_PARTIAL = 22 + ABORT_REQUEST = 23 class Control(IntEnum): diff --git a/megatron/core/inference/inference_client.py b/megatron/core/inference/inference_client.py index c3017737146..84cd763844b 100644 --- a/megatron/core/inference/inference_client.py +++ b/megatron/core/inference/inference_client.py @@ -1,10 +1,12 @@ # Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import asyncio +import functools import logging import time from typing import List, Optional, Union +from megatron.core.inference.async_stream import AsyncStream from megatron.core.inference.inference_request import DynamicInferenceRequest from megatron.core.inference.sampling_params import SamplingParams from megatron.core.utils import get_asyncio_loop, trace_async_exceptions @@ -83,6 +85,8 @@ def __init__(self, inference_coordinator_address: str, deserialize: bool = False self.completion_futures = {} self.request_submission_times = {} self.next_request_id = 0 + self.streams: dict[int, AsyncStream[dict]] = {} + self.aborted_request_ids: set[int] = set() def add_request( self, prompt: Union[str, List[int]], sampling_params: SamplingParams @@ -115,6 +119,57 @@ def add_request( self.request_submission_times[request_id] = time.perf_counter() return self.completion_futures[request_id] + def abort_request(self, request_id: int) -> None: + """Cancel an in-flight request and close its local response stream.""" + request_id = int(request_id) + self.aborted_request_ids.add(request_id) + stream = self.streams.pop(request_id, None) + if stream is not None: + stream.finish() + future = self.completion_futures.pop(request_id, None) + if future is not None and not future.done(): + future.cancel() + self.request_submission_times.pop(request_id, None) + payload = [Headers.ABORT_REQUEST.value, request_id] + self.socket.send(msgpack.packb(payload, use_bin_type=True)) + + def add_request_streaming( + self, prompt: Union[str, List[int]], sampling_params: SamplingParams + ) -> AsyncStream[dict]: + """Submit a streaming inference request. + + Used by Dynamo directly and by the OpenAI-compatible HTTP frontend. + + Returns an async iterator that yields incremental output dictionaries: + + - ``{"partial": {"request_id": int, "new_tokens": list[int]}}`` whenever + the request's streaming interval is reached, in order. + - ``{"final": }`` exactly once + at the end. The iterator then stops. + + ``sampling_params.streaming`` is forced to True before submission so the + engine knows to emit ENGINE_REPLY_PARTIAL frames for this request. + + Args: + prompt: A string or list of token IDs. + sampling_params: Sampling parameters. ``streaming`` is set to True + in-place. + + Returns: + AsyncStream[dict]: Per-step partial and final reply frames. + """ + sampling_params.streaming = True + request_id = self.next_request_id + self.next_request_id += 1 + payload = [Headers.SUBMIT_REQUEST.value, request_id, prompt, sampling_params.serialize()] + self.socket.send(msgpack.packb(payload, use_bin_type=True)) + stream = AsyncStream( + request_id, functools.partial(self.abort_request, request_id), loop=self._loop + ) + self.streams[request_id] = stream + self.request_submission_times[request_id] = time.perf_counter() + return stream + @trace_async_exceptions async def _recv_task(self): """ @@ -134,9 +189,23 @@ async def _recv_task(self): header = Headers(data[0]) if header == Headers.ENGINE_REPLY: request_id, reply = data[1:] - reply['latency'] = time.perf_counter() - self.request_submission_times.pop( - request_id - ) + if request_id in self.aborted_request_ids: + self.aborted_request_ids.discard(request_id) + continue + submitted = self.request_submission_times.pop(request_id, None) + if submitted is not None: + reply['latency'] = time.perf_counter() - submitted + # Streaming path: deliver final reply + sentinel and stop. + if request_id in self.streams: + stream = self.streams.pop(request_id) + completed_request = ( + DynamicInferenceRequest.deserialize(reply) + if self.deserialize + else reply + ) + stream.put({"final": completed_request}) + stream.finish() + continue completion_future = self.completion_futures.pop(request_id) if completion_future.done(): logging.warning(f"Client: The future for {request_id} has been cancelled!") @@ -145,13 +214,18 @@ async def _recv_task(self): DynamicInferenceRequest.deserialize(reply) if self.deserialize else reply ) completion_future.set_result(completed_request) + elif header == Headers.ENGINE_REPLY_PARTIAL: + request_id, partial = data[1:] + stream = self.streams.get(request_id) + if stream is not None: + stream.put({"partial": partial}) except zmq.Again: await asyncio.sleep(0.005) continue except KeyboardInterrupt: break - def _connect_with_inference_coordinator(self): + def _connect_with_inference_coordinator(self, timeout_seconds: Optional[float] = None): """ Performs the initial handshake with the inference coordinator. @@ -160,10 +234,18 @@ def _connect_with_inference_coordinator(self): """ payload = [Headers.CONNECT.value] self.socket.send(msgpack.packb(payload, use_bin_type=True)) - reply = msgpack.unpackb(self.socket.recv(), raw=False)[0] - assert Headers(reply) == Headers.CONNECT_ACK - - def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): + if timeout_seconds is not None and not self.socket.poll( + timeout=max(0, int(timeout_seconds * 1000)) + ): + raise TimeoutError("Timed out connecting to the Megatron inference coordinator") + reply = msgpack.unpackb(self.socket.recv(), raw=False) + assert Headers(reply[0]) == Headers.CONNECT_ACK + + def start( + self, + loop: Optional[asyncio.AbstractEventLoop] = None, + connect_timeout_seconds: Optional[float] = None, + ): """ Connects to the coordinator and starts the background listener task. @@ -173,7 +255,7 @@ def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): """ logging.info("Client: Connecting to InferenceCoordinator...") self._loop = get_asyncio_loop(loop) - self._connect_with_inference_coordinator() + self._connect_with_inference_coordinator(connect_timeout_seconds) self.listener_task = self._loop.create_task(self._recv_task()) def _send_signal_to_engines(self, signal, *args): @@ -266,5 +348,10 @@ def stop(self): if not future.done(): future.cancel() self.completion_futures.clear() + # Terminate any open streaming iterators. + for stream in self.streams.values(): + stream.finish() + self.streams.clear() + self.aborted_request_ids.clear() self.socket.close(linger=0) self.context.term() diff --git a/megatron/core/inference/sampling_params.py b/megatron/core/inference/sampling_params.py index f7f95060cef..2f7d5bb4551 100644 --- a/megatron/core/inference/sampling_params.py +++ b/megatron/core/inference/sampling_params.py @@ -38,14 +38,17 @@ class SamplingParams: # drops prompt_tokens before serializing the finished request, saving the ZMQ # transmission cost for long prompts. Opt in when the client needs them. return_prompt_tokens: bool = False + streaming: bool = False # Emit incremental ENGINE_REPLY_PARTIAL frames. + streaming_interval: int = 1 # Minimum unsent tokens per ENGINE_REPLY_PARTIAL. def __post_init__(self): - """Ensure backward compatibility for return_prompt_top_n_logprobs. + """Validate parameters and maintain backward compatibility. Sets return_prompt_top_n_logprobs based on skip_prompt_log_probs and top_n_logprobs: - return_prompt_top_n_logprobs = not skip_prompt_log_probs and top_n_logprobs > 0 """ self._sync_prompt_logprobs_fields() + self._validate_streaming_interval() def _sync_prompt_logprobs_fields(self): """Synchronize return_prompt_top_n_logprobs with skip_prompt_log_probs.""" @@ -63,6 +66,15 @@ def _sync_prompt_logprobs_fields(self): else: self.return_prompt_top_n_logprobs = False + def _validate_streaming_interval(self): + """Validate the minimum number of tokens emitted in a streaming delta.""" + if ( + isinstance(self.streaming_interval, bool) + or not isinstance(self.streaming_interval, int) + or self.streaming_interval < 1 + ): + raise ValueError("streaming_interval must be an integer greater than or equal to 1") + def add_attributes(self, attribute_value_pair: dict): """Utility to add more attributes to sampling params @@ -79,6 +91,7 @@ def add_attributes(self, attribute_value_pair: dict): # Synchronize fields after setting attributes self._sync_prompt_logprobs_fields() + self._validate_streaming_interval() def serialize(self) -> dict: """Return a dictionary that is msgpack-serializable.""" diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py index 993499ce02b..e81b5273e84 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/chat_completions.py @@ -12,6 +12,9 @@ from megatron.core.inference.sampling_params import SamplingParams from megatron.core.tokenizers.text.parsers import PARSER_MAPPING +from ..incremental_detokenizer import HuggingFaceFastIncrementalDetokenizer +from ..openai_streaming import openai_stream + logger = logging.getLogger(__name__) # pylint: disable=line-too-long @@ -603,11 +606,41 @@ async def chat_completions(): add_BOS=add_BOS, termination_id=-1 if ignore_eos else None, return_prompt_tokens=return_prompt_tokens, + streaming_interval=int(_get_non_none(req, "streaming_interval", 1)), ) except ValueError as e: return Response(f"Invalid sampling parameter: {e}", status=400) # --- 3. Send Requests to Engine --- + stream_requested = bool(req.get("stream", False)) + if stream_requested: + # Streaming currently supports only Hugging Face fast tokenizers. + try: + incremental_detokenizers = [ + HuggingFaceFastIncrementalDetokenizer(tokenizer, prompt_tokens) + for _ in range(n) + ] + except ValueError as error: + return Response(str(error), status=400) + + streams = [ + client.add_request_streaming(prompt_tokens, sampling_params) for _ in range(n) + ] + include_usage = bool((req.get("stream_options") or {}).get("include_usage", False)) + response = Response( + openai_stream( + streams, + tokenizer, + incremental_detokenizers, + chat=True, + return_log_probs=return_log_probs, + include_usage=include_usage, + ), + content_type="text/event-stream", + ) + response.timeout = None + return response + tasks = [client.add_request(prompt_tokens, sampling_params) for _ in range(n)] if current_app.config['verbose']: diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py index 2e2a57d6fc1..ab217a8e89f 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/endpoints/completions.py @@ -8,11 +8,14 @@ from megatron.core.inference.inference_request import unwrap_serialized_tensors from megatron.core.inference.sampling_params import SamplingParams +from ..incremental_detokenizer import HuggingFaceFastIncrementalDetokenizer +from ..openai_streaming import openai_stream + logger = logging.getLogger(__name__) try: - from quart import Blueprint, current_app, jsonify, request + from quart import Blueprint, Response, current_app, jsonify, request bp = Blueprint('completions_api', __name__) @@ -105,11 +108,24 @@ async def completions(): num_tokens_to_generate=int(req.get("max_tokens", 16)), stop_words=stop, termination_id=-1 if ignore_eos else None, + streaming_interval=int(req.get("streaming_interval", 1)), ) except ValueError as e: return f"Invalid sampling parameter: {e}", 400 # --- 3. Send Requests to Engine --- + stream_requested = bool(req.get("stream", False)) + incremental_detokenizers = [] + if stream_requested: + # Streaming currently supports only Hugging Face fast tokenizers. + try: + incremental_detokenizers = [ + HuggingFaceFastIncrementalDetokenizer(tokenizer, prompt_tokens) + for prompt_tokens in prompts_as_tokens + ] + except ValueError as error: + return str(error), 400 + tasks = [] for prompt_tokens in prompts_as_tokens: per_req_params = SamplingParams( @@ -125,8 +141,28 @@ async def completions(): # This endpoint always echoes prompt_token_ids in its response, so # keep the prompt tokens on the payload (default is now to drop them). return_prompt_tokens=True, + streaming_interval=sampling_params.streaming_interval, + ) + if stream_requested: + tasks.append(client.add_request_streaming(prompt_tokens, per_req_params)) + else: + tasks.append(client.add_request(prompt_tokens, per_req_params)) + + if stream_requested: + include_usage = bool((req.get("stream_options") or {}).get("include_usage", False)) + response = Response( + openai_stream( + tasks, + tokenizer, + incremental_detokenizers, + chat=False, + return_log_probs=return_log_probs, + include_usage=include_usage, + ), + content_type="text/event-stream", ) - tasks.append(client.add_request(prompt_tokens, per_req_params)) + response.timeout = None + return response if current_app.config['verbose']: start_time = time.perf_counter() diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py new file mode 100644 index 00000000000..0238f52fbd6 --- /dev/null +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright 2025 The vLLM authors. + +"""Incremental detokenization for OpenAI-compatible streaming responses.""" + +import logging +from typing import Any + +import tokenizers +from transformers import PreTrainedTokenizerFast + +logger = logging.getLogger(__name__) + +_INVALID_PREFIX_ERROR = "Invalid prefix encountered" + + +class HuggingFaceFastIncrementalDetokenizer: + """Incrementally decode generated tokens with a Hugging Face fast tokenizer. + + This implementation follows vLLM's prompt-prefilled ``DecodeStream`` design. + Streaming support is intentionally limited to Hugging Face fast tokenizers + until equivalent incremental decoders exist for the other tokenizer backends. + """ + + def __init__(self, tokenizer: Any, prompt_token_ids: list[int]) -> None: + tokenizer_wrapper = getattr(tokenizer, "_tokenizer", None) + huggingface_tokenizer = getattr(tokenizer_wrapper, "tokenizer", None) + if not isinstance(huggingface_tokenizer, PreTrainedTokenizerFast): + raise ValueError( + "Streaming is currently supported only for Hugging Face fast tokenizers." + ) + if not hasattr(tokenizers.decoders, "DecodeStream"): + raise ValueError( + "Streaming with Hugging Face fast tokenizers requires tokenizers>=0.22.0." + ) + + self._native_tokenizer = huggingface_tokenizer._tokenizer + self._skip_special_tokens = not getattr(tokenizer_wrapper, "include_special_tokens", True) + self._decode_stream = self._new_decode_stream(prompt_token_ids) + self._text_fragments: list[str] = [] + self._text_length = 0 + + def _new_decode_stream(self, prompt_token_ids: list[int] | None = None): + kwargs = {"skip_special_tokens": self._skip_special_tokens} + if prompt_token_ids is not None: + kwargs["ids"] = list(prompt_token_ids) + return tokenizers.decoders.DecodeStream(**kwargs) + + def update(self, token_ids: list[int]) -> str: + """Decode token IDs and return only newly stable text.""" + fragments = [] + for token_id in token_ids: + fragment = self._decode_next(token_id) + if fragment: + fragments.append(fragment) + + delta = "".join(fragments) + if delta: + self._text_fragments.append(delta) + self._text_length += len(delta) + return delta + + def _decode_next(self, token_id: int) -> str: + try: + fragment = self._decode_stream.step(self._native_tokenizer, token_id) + except (OverflowError, TypeError): + logger.exception("Encountered invalid token ID during streaming: %r", token_id) + return "" + except Exception as exc: + if not str(exc).startswith(_INVALID_PREFIX_ERROR): + raise + logger.warning( + "Resetting the incremental decoder after an invalid prefix for token ID %r.", + token_id, + ) + self._decode_stream = self._new_decode_stream() + fragment = self._decode_stream.step(self._native_tokenizer, token_id) + return fragment or "" + + @property + def text(self) -> str: + """Return all text emitted by the incremental decoder.""" + return "".join(self._text_fragments) + + @property + def text_length(self) -> int: + """Return the number of emitted characters.""" + return self._text_length diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/openai_streaming.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/openai_streaming.py new file mode 100644 index 00000000000..1d3aaaea7e2 --- /dev/null +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/openai_streaming.py @@ -0,0 +1,179 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Shared OpenAI-compatible streaming response formatting.""" + +import asyncio +import json +import time +import uuid + +from megatron.core.inference.inference_request import unwrap_serialized_tensors + + +def _token_logprobs(tokenizer, token_ids, log_probs, chat, start_offset): + entries = [] + offsets = [] + offset = start_offset + for i, token_id in enumerate(token_ids): + token = tokenizer.detokenize([token_id]) + entries.append( + { + "token": token, + "logprob": log_probs[i] if i < len(log_probs) else None, + "bytes": list(token.encode("utf-8")), + "top_logprobs": [], + } + ) + offsets.append(offset) + offset += len(token) + if chat: + return {"content": entries} + return { + "tokens": [entry["token"] for entry in entries], + "token_logprobs": [entry["logprob"] for entry in entries], + "top_logprobs": [None] * len(entries), + "text_offset": offsets, + } + + +def _finish_reason(result): + requested = (result.get("sampling_params") or {}).get("num_tokens_to_generate") + generated = len(result.get("generated_tokens") or []) + return "length" if requested is not None and generated >= requested else "stop" + + +async def openai_stream( + streams, + tokenizer, + incremental_detokenizers, + *, + chat, + return_log_probs=False, + include_usage=False, +): + """Yield SSE records for one or more inference streams.""" + if len(streams) != len(incremental_detokenizers): + raise ValueError("Each inference stream must have an incremental detokenizer.") + + response_id = f"chatcmpl-{uuid.uuid4().hex}" if chat else str(uuid.uuid4()) + created = int(time.time()) + queue = asyncio.Queue() + states = [ + dict(tokens=[], log_probs=[], detokenizer=detokenizer, final=None) + for detokenizer in incremental_detokenizers + ] + + async def pump(index, stream): + try: + async for item in stream: + await queue.put((index, item, None)) + except Exception as exc: # Propagate listener failures through the SSE response. + await queue.put((index, None, exc)) + finally: + await queue.put((index, None, None)) + + tasks = [asyncio.create_task(pump(index, stream)) for index, stream in enumerate(streams)] + + def sse(choices, usage=None): + payload = { + "id": response_id, + "object": "chat.completion.chunk" if chat else "text_completion", + "created": created, + "model": "EMPTY", + "choices": choices, + } + if usage is not None: + payload["usage"] = usage + return f"data: {json.dumps(payload)}\n\n" + + try: + if chat: + for index in range(len(streams)): + yield sse( + [ + { + "index": index, + "delta": {"role": "assistant", "content": ""}, + "logprobs": None, + "finish_reason": None, + } + ] + ) + + remaining = len(streams) + while remaining: + index, item, error = await queue.get() + if error is not None: + yield f"data: {json.dumps({'error': {'message': str(error)}})}\n\n" + continue + if item is None: + remaining -= 1 + continue + + state = states[index] + if "partial" in item: + partial = item["partial"] + new_tokens = partial.get("new_tokens") or [] + new_log_probs = partial.get("new_log_probs") or [] + else: + result = unwrap_serialized_tensors(item["final"]) + state["final"] = result + already = len(state["tokens"]) + new_tokens = (result.get("generated_tokens") or [])[already:] + new_log_probs = (result.get("generated_log_probs") or [])[already:] + + if not new_tokens: + continue + state["tokens"].extend(new_tokens) + state["log_probs"].extend(new_log_probs) + start_offset = state["detokenizer"].text_length + delta = state["detokenizer"].update(new_tokens) + choice = { + "index": index, + "logprobs": ( + _token_logprobs(tokenizer, new_tokens, new_log_probs, chat, start_offset) + if return_log_probs + else None + ), + "finish_reason": None, + } + choice["delta" if chat else "text"] = {"content": delta} if chat else delta + yield sse([choice]) + + prompt_tokens = completion_tokens = cached_token_count = 0 + for index, state in enumerate(states): + result = state["final"] or {} + prompt_len = result.get("prompt_length") + if prompt_len is None: + prompt_len = len(result.get("prompt_tokens") or []) + prompt_tokens = max(prompt_tokens, prompt_len) + completion_tokens += len(result.get("generated_tokens") or []) + cached_token_count = max(cached_token_count, result.get("num_cached_tokens", 0)) + choice = { + "index": index, + "logprobs": None, + "finish_reason": _finish_reason(result), + "generation_token_ids": list(state["tokens"]), + "generation_log_probs": list(state["log_probs"]), + "generated_text": state["detokenizer"].text, + "generated_length": len(state["tokens"]), + } + choice["delta" if chat else "text"] = {} if chat else "" + yield sse([choice]) + + if include_usage: + yield sse( + [], + { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + "prompt_tokens_details": {"cached_tokens": cached_token_count}, + }, + ) + yield "data: [DONE]\n\n" + finally: + for task in tasks: + task.cancel() + for stream in streams: + await stream.aclose() diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine.py b/tests/unit_tests/inference/engines/test_dynamic_engine.py index d8640ad8919..af91409ef7b 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine.py @@ -752,6 +752,48 @@ def test_recompute_suspend_resume_readds_prefix_cached_request_with_fresh_hashes assert engine._add_request.call_args.args[0] is checkpointed +def test_streaming_partials_are_sent(): + engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) + engine._partial_emit_lengths = {} + request = types.SimpleNamespace( + generated_tokens=[11, 12, 13], + generated_log_probs=None, + sampling_params=types.SimpleNamespace(streaming=True, return_log_probs=False), + ) + engine.requests = {7: types.SimpleNamespace(record=[request])} + engine.socket_for_receiving_requests = mock.Mock() + + engine._try_send_streaming_partials() + + engine.socket_for_receiving_requests.send.assert_called_once() + assert engine._partial_emit_lengths == {7: 3} + + +def test_streaming_partials_buffer_until_token_interval(): + engine = DynamicInferenceEngine.__new__(DynamicInferenceEngine) + engine._partial_emit_lengths = {} + request = types.SimpleNamespace( + generated_tokens=[11, 12], + generated_log_probs=None, + sampling_params=SamplingParams( + streaming=True, streaming_interval=3, return_log_probs=False + ), + ) + engine.requests = {7: types.SimpleNamespace(record=[request])} + engine.socket_for_receiving_requests = mock.Mock() + + engine._try_send_streaming_partials() + + engine.socket_for_receiving_requests.send.assert_not_called() + assert engine._partial_emit_lengths == {} + + request.generated_tokens.append(13) + engine._try_send_streaming_partials() + + engine.socket_for_receiving_requests.send.assert_called_once() + assert engine._partial_emit_lengths == {7: 3} + + class TestDynamicInferenceEngine(DynamicInferenceEngineTestBase): @classmethod diff --git a/tests/unit_tests/inference/test_async_stream.py b/tests/unit_tests/inference/test_async_stream.py index b4cf751e35b..2e782e32112 100644 --- a/tests/unit_tests/inference/test_async_stream.py +++ b/tests/unit_tests/inference/test_async_stream.py @@ -66,3 +66,14 @@ async def test_generator_close_invokes_cancel_callback(self): with pytest.raises(asyncio.CancelledError): await gen.aclose() assert called == [True] + + async def test_stream_is_directly_iterable_and_explicitly_closeable(self): + called = [] + s = AsyncStream(request_id=7, cancel=lambda: (called.append(True), s.finish())) + s.put("first") + + assert s.request_id == 7 + assert await s.__anext__() == "first" + await s.aclose() + assert [item async for item in s] == [] + assert called == [True] diff --git a/tests/unit_tests/inference/test_common_inference_params.py b/tests/unit_tests/inference/test_common_inference_params.py index c80cd2ab298..5642b8d9dab 100644 --- a/tests/unit_tests/inference/test_common_inference_params.py +++ b/tests/unit_tests/inference/test_common_inference_params.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + from megatron.core.inference.sampling_params import SamplingParams @@ -9,3 +11,20 @@ def test_sampling_params(self): assert ( sampling_params.min_tokens == 45 ), f"min tokens not set correctly. it is {sampling_params.min_tokens}" + + def test_streaming_interval(self): + sampling_params = SamplingParams(streaming_interval=8) + serialized = sampling_params.serialize() + deserialized = SamplingParams.deserialize(serialized) + + assert SamplingParams().streaming_interval == 1 + assert serialized["streaming_interval"] == 8 + assert deserialized.streaming_interval == 8 + + def test_streaming_interval_must_be_positive(self): + try: + SamplingParams(streaming_interval=0) + except ValueError: + pass + else: + raise AssertionError("streaming_interval=0 should be rejected") diff --git a/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py b/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py index e29d27a8cf0..3d95f158c32 100644 --- a/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py +++ b/tests/unit_tests/inference/test_data_parallel_inference_coordinator.py @@ -878,6 +878,7 @@ def reply(fid): coord.tokenizer = DummyTokenizer() coord.request_id_to_client_id = {11: b"client-A"} coord.request_id_to_client_request_id = {11: 7} + coord.client_request_to_request_id = {(b"client-A", 7): 11} coord.request_id_to_rank = {} coord.router_socket = unittest.mock.MagicMock() diff --git a/tests/unit_tests/inference/test_inference_client_streaming.py b/tests/unit_tests/inference/test_inference_client_streaming.py new file mode 100644 index 00000000000..5d587ffae2f --- /dev/null +++ b/tests/unit_tests/inference/test_inference_client_streaming.py @@ -0,0 +1,153 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for ``InferenceClient.add_request_streaming``.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import msgpack +import pytest +import zmq + +from megatron.core.inference.async_stream import AsyncStream +from megatron.core.inference.headers import Headers +from megatron.core.inference.inference_client import InferenceClient +from megatron.core.inference.sampling_params import SamplingParams + +pytestmark = pytest.mark.asyncio + + +def _make_client(): + fake_socket = MagicMock(name="zmq_socket") + fake_context = MagicMock(name="zmq_context") + fake_context.socket.return_value = fake_socket + with patch("megatron.core.inference.inference_client.zmq.Context", return_value=fake_context): + client = InferenceClient("tcp://127.0.0.1:5555", deserialize=False) + return client, fake_socket + + +async def test_add_request_streaming_emits_partials_then_final(): + """Two ENGINE_REPLY_PARTIAL frames followed by an ENGINE_REPLY terminate the iterator.""" + client, fake_socket = _make_client() + + recv_queue = [ + msgpack.packb([Headers.CONNECT_ACK.value], use_bin_type=True), + msgpack.packb( + [ + Headers.ENGINE_REPLY_PARTIAL.value, + 0, + {"request_id": 0, "new_tokens": [1, 2], "new_log_probs": [-0.1, -0.2]}, + ], + use_bin_type=True, + ), + msgpack.packb( + [ + Headers.ENGINE_REPLY_PARTIAL.value, + 0, + {"request_id": 0, "new_tokens": [3], "new_log_probs": [-0.3]}, + ], + use_bin_type=True, + ), + msgpack.packb( + [Headers.ENGINE_REPLY.value, 0, {"request_id": 0, "generated_tokens": [1, 2, 3]}], + use_bin_type=True, + ), + ] + + def fake_recv(*args, **kwargs): + if recv_queue: + return recv_queue.pop(0) + raise zmq.Again() + + fake_socket.recv.side_effect = fake_recv + client.start() + + params = SamplingParams(temperature=0.7, return_log_probs=True) + assert params.streaming is False # default + + iterator = client.add_request_streaming("hi", params) + + assert isinstance(iterator, AsyncStream) + assert params.streaming is True + assert 0 in client.streams + submit_payload = msgpack.unpackb(fake_socket.send.call_args.args[0], raw=False) + assert submit_payload[0] == Headers.SUBMIT_REQUEST.value + assert submit_payload[3]["streaming"] is True + + items = [] + async for item in iterator: + items.append(item) + + assert len(items) == 3 + assert items[0] == { + "partial": {"request_id": 0, "new_tokens": [1, 2], "new_log_probs": [-0.1, -0.2]} + } + assert items[1] == {"partial": {"request_id": 0, "new_tokens": [3], "new_log_probs": [-0.3]}} + assert "final" in items[2] + assert items[2]["final"]["generated_tokens"] == [1, 2, 3] + + assert 0 not in client.streams + assert 0 not in client.request_submission_times + + client.stop() + + +async def test_streaming_partial_for_unknown_request_is_dropped(): + """ENGINE_REPLY_PARTIAL frames whose request_id has no stream are silently ignored.""" + client, fake_socket = _make_client() + + recv_queue = [ + msgpack.packb([Headers.CONNECT_ACK.value], use_bin_type=True), + msgpack.packb( + [Headers.ENGINE_REPLY_PARTIAL.value, 42, {"request_id": 42, "new_tokens": [9]}], + use_bin_type=True, + ), + ] + + def fake_recv(*args, **kwargs): + if recv_queue: + return recv_queue.pop(0) + raise zmq.Again() + + fake_socket.recv.side_effect = fake_recv + client.start() + + await asyncio.sleep(0.02) + assert client.streams == {} + + client.stop() + + +async def test_client_stop_terminates_open_streams(): + """stop() finishes open streams so awaiters can exit.""" + client, fake_socket = _make_client() + + recv_queue = [msgpack.packb([Headers.CONNECT_ACK.value], use_bin_type=True)] + + def fake_recv(*args, **kwargs): + if recv_queue: + return recv_queue.pop(0) + raise zmq.Again() + + fake_socket.recv.side_effect = fake_recv + client.start() + + iterator = client.add_request_streaming("hi", SamplingParams()) + client.stop() + + items = [item async for item in iterator] + assert items == [] + + +async def test_stream_close_sends_abort_and_terminates_iterator(): + """Closing a live stream sends ABORT_REQUEST and wakes its consumer.""" + client, fake_socket = _make_client() + iterator = client.add_request_streaming("hi", SamplingParams()) + + await iterator.aclose() + + payload = msgpack.unpackb(fake_socket.send.call_args.args[0], raw=False) + assert payload == [Headers.ABORT_REQUEST.value, iterator.request_id] + assert iterator.request_id not in client.streams + assert iterator.request_id not in client.request_submission_times + assert [item async for item in iterator] == [] diff --git a/tests/unit_tests/inference/test_openai_streaming.py b/tests/unit_tests/inference/test_openai_streaming.py new file mode 100644 index 00000000000..048698f40e5 --- /dev/null +++ b/tests/unit_tests/inference/test_openai_streaming.py @@ -0,0 +1,146 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import json +from types import SimpleNamespace + +import pytest +from tokenizers import Tokenizer, decoders, models, pre_tokenizers +from transformers import PreTrainedTokenizerFast + +from megatron.core.inference.async_stream import AsyncStream +from megatron.core.inference.text_generation_server.dynamic_text_gen_server.incremental_detokenizer import ( + HuggingFaceFastIncrementalDetokenizer, +) +from megatron.core.inference.text_generation_server.dynamic_text_gen_server.openai_streaming import ( + openai_stream, +) + + +class _Tokenizer: + def detokenize(self, tokens): + return "".join(chr(ord("a") + token - 1) for token in tokens) + + +class _IncrementalDetokenizer: + def __init__(self): + self._text = "" + + def update(self, tokens): + delta = "".join(chr(ord("a") + token - 1) for token in tokens) + self._text += delta + return delta + + @property + def text(self): + return self._text + + @property + def text_length(self): + return len(self._text) + + +def _make_byte_level_fast_tokenizer(): + alphabet = pre_tokenizers.ByteLevel.alphabet() + backend = Tokenizer(models.BPE(vocab={token: i for i, token in enumerate(alphabet)}, merges=[])) + backend.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False) + backend.decoder = decoders.ByteLevel() + huggingface_tokenizer = PreTrainedTokenizerFast(tokenizer_object=backend) + return SimpleNamespace( + _tokenizer=SimpleNamespace(tokenizer=huggingface_tokenizer, include_special_tokens=True) + ) + + +@pytest.mark.asyncio +async def test_openai_stream_emits_delta_chunks_and_terminal_metadata(): + stream = AsyncStream(request_id=1, cancel=lambda: None) + stream.put({"partial": {"request_id": 1, "new_tokens": [1, 2], "new_log_probs": [-0.1, -0.2]}}) + # Token 3 models a token completed before the engine's final reply and + # therefore absent from its last partial frame. + stream.put( + { + "final": { + "prompt_tokens": [9, 9], + "generated_tokens": [1, 2, 3], + "generated_log_probs": [-0.1, -0.2, -0.3], + "num_cached_tokens": 2, + "sampling_params": {"num_tokens_to_generate": 3}, + } + } + ) + stream.finish() + + records = [ + record + async for record in openai_stream( + [stream], + _Tokenizer(), + [_IncrementalDetokenizer()], + chat=False, + return_log_probs=True, + include_usage=True, + ) + ] + payloads = [json.loads(record.removeprefix("data: ")) for record in records[:-1]] + + first, reconciled, finished, usage = payloads + assert first["choices"][0]["text"] == "ab" + assert "generation_token_ids" not in first["choices"][0] + assert "generation_log_probs" not in first["choices"][0] + assert "generated_text" not in first["choices"][0] + assert "generated_length" not in first["choices"][0] + assert first["choices"][0]["logprobs"]["token_logprobs"] == [-0.1, -0.2] + assert first["choices"][0]["logprobs"]["text_offset"] == [0, 1] + assert reconciled["choices"][0]["text"] == "c" + assert "generation_token_ids" not in reconciled["choices"][0] + assert "generation_log_probs" not in reconciled["choices"][0] + assert "generated_text" not in reconciled["choices"][0] + assert "generated_length" not in reconciled["choices"][0] + assert finished["choices"][0]["finish_reason"] == "length" + assert finished["choices"][0]["generation_token_ids"] == [1, 2, 3] + assert finished["choices"][0]["generation_log_probs"] == [-0.1, -0.2, -0.3] + assert finished["choices"][0]["generated_text"] == "abc" + assert finished["choices"][0]["generated_length"] == 3 + assert usage["usage"] == { + "prompt_tokens": 2, + "completion_tokens": 3, + "total_tokens": 5, + "prompt_tokens_details": {"cached_tokens": 2}, + } + assert records[-1] == "data: [DONE]\n\n" + + +def test_huggingface_fast_incremental_detokenizer_preserves_utf8_boundaries(): + tokenizer = _make_byte_level_fast_tokenizer() + huggingface_tokenizer = tokenizer._tokenizer.tokenizer + token_ids = huggingface_tokenizer.encode("😀 café", add_special_tokens=False) + detokenizer = HuggingFaceFastIncrementalDetokenizer(tokenizer, []) + + streamed_text = "".join(detokenizer.update([token_id]) for token_id in token_ids) + + assert streamed_text == huggingface_tokenizer.decode(token_ids, skip_special_tokens=False) + assert detokenizer.text == streamed_text + assert detokenizer.text_length == len(streamed_text) + + +def test_huggingface_fast_incremental_detokenizer_uses_prompt_context(): + tokenizer = _make_byte_level_fast_tokenizer() + huggingface_tokenizer = tokenizer._tokenizer.tokenizer + prompt_token_ids = huggingface_tokenizer.encode("hello", add_special_tokens=False) + generated_token_ids = huggingface_tokenizer.encode(" world", add_special_tokens=False) + detokenizer = HuggingFaceFastIncrementalDetokenizer(tokenizer, prompt_token_ids) + + streamed_text = "".join(detokenizer.update([token_id]) for token_id in generated_token_ids) + full_text = huggingface_tokenizer.decode( + prompt_token_ids + generated_token_ids, skip_special_tokens=False + ) + prompt_text = huggingface_tokenizer.decode(prompt_token_ids, skip_special_tokens=False) + + assert full_text.startswith(prompt_text) + assert streamed_text == full_text[len(prompt_text) :] + + +def test_incremental_detokenizer_rejects_unsupported_tokenizer(): + with pytest.raises( + ValueError, match="Streaming is currently supported only for Hugging Face fast tokenizers" + ): + HuggingFaceFastIncrementalDetokenizer(_Tokenizer(), []) From 2cbe71d60186705417a025de559bda553025f816 Mon Sep 17 00:00:00 2001 From: Ajay Date: Fri, 31 Jul 2026 22:50:49 -0700 Subject: [PATCH 180/290] deps: Update urllib3 to version 2.7.0 (#6182) Signed-off-by: Ajay Balasa --- pyproject.toml | 2 ++ uv.lock | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 033c9ae002b..cc22c47dbe4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -197,6 +197,8 @@ override-dependencies = [ "torch; sys_platform == 'never'", "torchvision; sys_platform == 'never'", "triton; sys_platform == 'never'", + # TorchX 0.7.0 caps urllib3 below 1.27; require the fix for GHSA-38jv-5279-wg99. + "urllib3>=2.6.3", ] [[tool.uv.dependency-metadata]] diff --git a/uv.lock b/uv.lock index 0f63104f4c9..5bd9add1a3e 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,7 @@ overrides = [ { name = "torch", marker = "sys_platform == 'never'" }, { name = "torchvision", marker = "sys_platform == 'never'" }, { name = "triton", marker = "sys_platform == 'never'" }, + { name = "urllib3", specifier = ">=2.6.3" }, ] [[manifest.dependency-metadata]] @@ -5286,11 +5287,11 @@ wheels = [ [[package]] name = "urllib3" -version = "1.26.20" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From f2d4dfadbbdc85a3545791571d38a11ee2d47058 Mon Sep 17 00:00:00 2001 From: Ajay Date: Fri, 31 Jul 2026 22:51:08 -0700 Subject: [PATCH 181/290] deps: Update black dependency to version 26.3.0 (#6180) Signed-off-by: Ajay Balasa --- pyproject.toml | 4 ++-- uv.lock | 57 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc22c47dbe4..49d014a0489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,7 @@ build = [ ] linting = [ "ruff~=0.9.0", - "black==24.4.2", + "black==26.3.0", "isort==5.13.2", "flake8==7.1.0", "pylint==3.2.6", @@ -255,7 +255,7 @@ line_length = 100 skip_string_normalization = true # recognized by future versions, disallows to reformat code with incompatible versions # Matches NeMO version so people working on both codebases don't need two different version of black installed -required_version = "24" +required_version = "26" skip_magic_trailing_comma = true include = '\.pyi?$' exclude = ''' diff --git a/uv.lock b/uv.lock index 5bd9add1a3e..2853330f83f 100644 --- a/uv.lock +++ b/uv.lock @@ -457,7 +457,7 @@ wheels = [ [[package]] name = "black" -version = "24.4.2" +version = "26.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -465,14 +465,26 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, + { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/47/c9997eb470a7f48f7aaddd3d9a828244a2e4199569e38128715c48059ac1/black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d", size = 642299, upload-time = "2024-04-26T00:32:15.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/5f/25b7b149b8b7d3b958efa4faa56446560408c0f2651108a517526de0320a/black-26.3.0.tar.gz", hash = "sha256:4d438dfdba1c807c6c7c63c4f15794dda0820d2222e7c4105042ac9ddfc5dd0b", size = 664127, upload-time = "2026-03-06T17:42:33.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/75/3a29de3bda4006cc280d833b5d961cf7df3810a21f49e7a63a7e551fb351/black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d", size = 1645176, upload-time = "2024-04-26T00:42:35.606Z" }, - { url = "https://files.pythonhosted.org/packages/be/b8/9c152301774fa62a265b035a8ede4d6280827904ea1af8c3be10a28d3187/black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04", size = 1446227, upload-time = "2024-04-26T00:40:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/25/6d/eb15a1b155f755f43766cc473618c6e1de6555d6a1764965643f486dcf01/black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc", size = 1832011, upload-time = "2024-04-26T00:34:37.825Z" }, - { url = "https://files.pythonhosted.org/packages/43/24/942b22571b0171be7c6f701cdc3e3b7221f5b522ef02cf82503a547a657b/black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0", size = 1428800, upload-time = "2024-04-26T00:35:55.838Z" }, - { url = "https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c", size = 205925, upload-time = "2024-04-26T00:32:12.495Z" }, + { url = "https://files.pythonhosted.org/packages/1d/76/b21711045b7f4c4f1774048d0b34dd10a265c42255658b251ce3303ae3c7/black-26.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2b1e5eec220b419e3591a0aaa6351bd3a9c01fe6291fbaf76d84308eb7a2ede", size = 1895944, upload-time = "2026-03-06T17:46:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c3/8c56e73283326bc92a36101c660228fff09a2403a57a03cacf3f7f84cf62/black-26.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bab64de70bccc992432bee56cdffbe004ceeaa07352127c386faa87e81f9261", size = 1718669, upload-time = "2026-03-06T17:46:26.639Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/712a3ae8f17c1f3cd6f9ac2fffb167a27192f5c7aba68724e8c4ab8474ad/black-26.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b6c5f734290803b7b26493ffd734b02b72e6c90d82d45ac4d5b862b9bdf7720", size = 1794844, upload-time = "2026-03-06T17:46:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5b/ee955040e446df86473287dd24dc69c80dd05e02cc358bca90e22059f7b1/black-26.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7c767396af15b54e1a6aae99ddf241ae97e589f666b1d22c4b6618282a04e4ca", size = 1420461, upload-time = "2026-03-06T17:46:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/12/77/40b8bd44f032bb34c9ebf47ffc5bb47a2520d29e0a4b8a780ab515223b5a/black-26.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:765fd6ddd00f35c55250fdc6b790c272d54ac3f44da719cc42df428269b45980", size = 1229667, upload-time = "2026-03-06T17:46:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/21a834ce3de02c64221243f2adac63fa3c3f441efdb3adbf4136b33dfeb0/black-26.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59754fd8f43ef457be190594c07a52c999e22cb1534dc5344bff1d46fdf1027d", size = 1895195, upload-time = "2026-03-06T17:46:33.12Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/212d9697dd78362dadb778d4616b74c8c2cf7f2e4a55aac2adeb0576f2e9/black-26.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1fd94cfee67b8d336761a0b08629a25938e4a491c440951ce517a7209c99b5ff", size = 1718472, upload-time = "2026-03-06T17:46:34.576Z" }, + { url = "https://files.pythonhosted.org/packages/a2/dd/da980b2f512441375b73cb511f38a2c3db4be83ccaa1302b8d39c9fa2dff/black-26.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b3e653a90ca1ef4e821c20f8edaee80b649c38d2532ed2e9073a9534b14a7", size = 1793741, upload-time = "2026-03-06T17:46:36.261Z" }, + { url = "https://files.pythonhosted.org/packages/93/11/cd69ae8826fe3bc6eaf525c8c557266d522b258154a2968eb46d6d25fac7/black-26.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f8fb9d7c2496adc83614856e1f6e55a9ce4b7ae7fc7f45b46af9189ddb493464", size = 1422522, upload-time = "2026-03-06T17:46:37.607Z" }, + { url = "https://files.pythonhosted.org/packages/75/f5/647cf50255203eb286be197925e86eedc101d5409147505db3e463229228/black-26.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e8618c1d06838f56afbcb3ffa1aa16436cec62b86b38c7b32ca86f53948ffb91", size = 1231807, upload-time = "2026-03-06T17:46:39.072Z" }, + { url = "https://files.pythonhosted.org/packages/ff/77/b197e701f15fd694d20d8ee0001efa2e29eba917aa7c3610ff7b10ae0f88/black-26.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0c6f64ead44f4369c66f1339ecf68e99b40f2e44253c257f7807c5a3ef0ca32", size = 1889209, upload-time = "2026-03-06T17:46:40.453Z" }, + { url = "https://files.pythonhosted.org/packages/93/85/b4d4924ac898adc2e39fc7a923bed99797535bc16dea4bc63944c3903c2b/black-26.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed6f0809134e51ec4a7509e069cdfa42bf996bd0fd1df6d3146b907f36e28893", size = 1720830, upload-time = "2026-03-06T17:46:42.009Z" }, + { url = "https://files.pythonhosted.org/packages/00/b1/5c0bf29fe5b43fcc6f3e8480c6566d21a02d4e702b3846944e7daa06dea9/black-26.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc6ac0ea5dd5fa6311ca82edfa3620cba0ed0426022d10d2d5d39aedbf3e1958", size = 1787676, upload-time = "2026-03-06T17:46:43.382Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/cc8cf14806c144d6a16512272c537d5450f50675d3e8c038705430e90fd9/black-26.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:884bc0aefa96adabcba0b77b10e9775fd52d4b766e88c44dc6f41f7c82787fc8", size = 1445406, upload-time = "2026-03-06T17:46:44.948Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bb/049ea0fad9f8bdec7b647948adcf74bb720bd71dcb213decd553e05b2699/black-26.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:be3bd02aab5c4ab03703172f5530ddc8fc8b5b7bb8786230e84c9e011cee9ca1", size = 1257945, upload-time = "2026-03-06T17:46:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/39/d7/7360654ba4f8b41afcaeb5aca973cfea5591da75aff79b0a8ae0bb8883f6/black-26.3.0-py3-none-any.whl", hash = "sha256:e825d6b121910dff6f04d7691f826d2449327e8e71c26254c030c4f3d2311985", size = 206848, upload-time = "2026-03-06T17:42:31.133Z" }, ] [[package]] @@ -2355,7 +2367,7 @@ docs = [ { name = "sphinx-copybutton" }, ] linting = [ - { name = "black", specifier = "==24.4.2" }, + { name = "black", specifier = "==26.3.0" }, { name = "flake8", specifier = "==7.1.0" }, { name = "isort", specifier = "==5.13.2" }, { name = "pylint", specifier = "==3.2.6" }, @@ -4080,6 +4092,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pytz" version = "2026.2" From 1678db536a95b719f52bab3f677d8d4c0fbbfc1a Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Sat, 1 Aug 2026 10:44:36 -0700 Subject: [PATCH 182/290] Add meta-parameter support (#5369) Signed-off-by: Jingyue Wu Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/dbuffer.py | 10 ++++--- .../experimental/parameter_group.py | 15 +++++++++-- .../distributed/mfsdp_v2/test_fully_shard.py | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py index 8381f9a3a5c..c6344955822 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py @@ -201,15 +201,17 @@ def from_local( def distribute_tensors( cls, tensors: Iterable[torch.Tensor], mesh: DeviceMesh, placements: Iterable[Placement] ) -> "DBuffer": - """Distribute full local tensor values into a DBuffer. + """Distribute full local tensors into a DBuffer. Args: - tensors: Full tensor values available on this rank. + tensors: Full tensors available on this rank. Meta tensors contribute + shape and dtype metadata but no values. mesh: Device mesh whose dimensions correspond to ``placements``. placements: Per-mesh-axis DBuffer placements. Returns: - A DBuffer whose local storage matches ``placements``. + A DBuffer whose real local storage matches ``placements``. Ranges + corresponding to meta tensors are left uninitialized. """ tensors = tuple(tensor.detach().contiguous() for tensor in tensors) if not tensors: @@ -232,7 +234,7 @@ def distribute_tensors( # observable through get_local_tensor() and can remain unspecified. for index, tensor in enumerate(tensors): owned_range = buffer._get_owned_range(index) - if owned_range is None: + if owned_range is None or tensor.is_meta: continue source_slice = tensor.view(-1).narrow( diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 9627f2a7112..6a8d1e7a9bb 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -157,8 +157,19 @@ def __init__( unsharded_parameters: list[nn.Parameter] = [] main_grad_dtype = self.main_grad.dtype if self.main_grad is not None else None for index, parameter in enumerate(parameters.values()): - parameter.data = self._unsharded_model_weight.get_local_tensor(index) - parameter.grad = None + unsharded_tensor = self._unsharded_model_weight.get_local_tensor(index) + if parameter.is_meta: + # A meta Parameter cannot set .data to a real tensor because their + # TensorImpl types are incompatible, so swap in a materialized Parameter. + # This may be problematic if attributes from the original Parameter need + # to be copied to the unsharded Parameter. + materialized_parameter = nn.Parameter( + unsharded_tensor, requires_grad=parameter.requires_grad + ) + torch.utils.swap_tensors(parameter, materialized_parameter) + else: + parameter.data = unsharded_tensor + parameter.grad = None setattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, self) unsharded_parameters.append(parameter) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 510690d09ec..7797fdf02f1 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -838,6 +838,32 @@ def test_cpu_initialized_parameters_shard_to_mesh_device(distributed_setup): torch.testing.assert_close(output, expected_output) +def test_meta_parameters_shard_to_mesh_device(distributed_setup): + """A sharded meta model should support initialization and forward.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + + mesh = init_device_mesh(device.type, (world_size,)) + model = nn.Sequential( + nn.Linear(4, 4, bias=False, device="meta", dtype=torch.bfloat16), + nn.Linear(4, 4, bias=False, device="meta", dtype=torch.bfloat16), + ) + + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + with torch.no_grad(): + model[0].weight.fill_(2.0) + model[1].weight.fill_(3.0) + # The exposed parameters update FP32 main weights, while forward uses separate BF16 + # model weights. This simulates load_checkpoint() until + # https://github.com/NVIDIA/Megatron-LM/pull/6024 lands and syncs after loading. + for parameter_group in model.parameter_groups: + parameter_group.sync_model_weight_from_main_weight() + + output = model(torch.ones(1, 4, device=device, dtype=torch.bfloat16)) + torch.testing.assert_close(output, torch.full_like(output, 96.0)) + + def test_non_leaf_parameter_view_survives_storage_resize(distributed_setup): """A non-leaf parameter view saved for backward should survive full-storage resize.""" world_size = distributed_setup.world_size From 714c24a7e80276be61bae5c4144f66aa47faaa0f Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Sat, 1 Aug 2026 15:09:32 -0700 Subject: [PATCH 183/290] Fix tied parameters (#6164) Signed-off-by: Jingyue Wu Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 21 ++-- .../experimental/parameter_group.py | 106 ++++++++++-------- .../distributed/mfsdp_v2/test_annotation.py | 38 +++++++ .../distributed/mfsdp_v2/test_fully_shard.py | 36 +++++- .../mfsdp_v2/test_mcore_adapter.py | 10 +- 5 files changed, 148 insertions(+), 63 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index f4d30286e14..476c1cb6bc7 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -85,7 +85,7 @@ class FsdpModule: _name: str | None _parameter_groups: tuple[FsdpParameterGroup, ...] _context: FsdpContext | None - _ready_grad_parameters: set[nn.Parameter] + _num_ready_grad_parameters: int _num_trainable_parameters: int # Event recorded after this FsdpModule's full parameters are materialized. # ``None`` lets pre_forward enqueue an all-gather unless an earlier FsdpModule @@ -119,9 +119,9 @@ def __init__( for group_parameters in _group_parameters(owned_parameters) ] self._parameter_groups = tuple(parameter_groups) - self._ready_grad_parameters = set() + self._num_ready_grad_parameters = 0 self._num_trainable_parameters = sum( - len(group.sharded_parameters) for group in self._parameter_groups if group.requires_grad + len(group.fsdp_parameters) for group in self._parameter_groups if group.requires_grad ) self._register_hooks() @@ -210,13 +210,13 @@ def _register_hooks(self) -> None: for group in self._parameter_groups: if not group.requires_grad: continue - for parameter in group.unsharded_parameters: - parameter.register_post_accumulate_grad_hook(self._make_grad_hook(parameter)) + for fsdp_parameter in group.fsdp_parameters: + fsdp_parameter.unsharded.register_post_accumulate_grad_hook(self._make_grad_hook()) - def _make_grad_hook(self, parameter: nn.Parameter) -> Callable[[nn.Parameter], None]: + def _make_grad_hook(self) -> Callable[[nn.Parameter], None]: def grad_hook(_parameter: nn.Parameter) -> None: - self._ready_grad_parameters.add(parameter) - if len(self._ready_grad_parameters) == self._num_trainable_parameters: + self._num_ready_grad_parameters += 1 + if self._num_ready_grad_parameters == self._num_trainable_parameters: self.post_backward() return grad_hook @@ -229,7 +229,7 @@ def pre_forward(self) -> None: """ self._lazy_init_context() torch.cuda.nvtx.range_push(self._nvtx_label("forward")) - self._ready_grad_parameters.clear() + self._num_ready_grad_parameters = 0 context = self.context allgather_stream = context.allgather_stream current_stream = context.current_stream() @@ -315,7 +315,6 @@ def post_backward(self) -> None: """Reduce gradients and return parameters to their sharded resting state.""" self._reduce_gradient_groups() self._reshard_parameter_groups() - self._ready_grad_parameters.clear() torch.cuda.nvtx.range_pop() def _reduce_gradient_groups(self) -> None: @@ -361,7 +360,7 @@ def _collect_owned_parameters(root_module: nn.Module) -> dict[str, nn.Parameter] parameters: dict[str, nn.Parameter] = {} def visit(submodule: nn.Module, submodule_fqn: str) -> None: - direct_parameters = list(submodule.named_parameters(recurse=False)) + direct_parameters = submodule.named_parameters(recurse=False, remove_duplicate=False) for local_parameter_name, parameter in direct_parameters: parameter_fqn = ( diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 6a8d1e7a9bb..7e7592eb9f4 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -15,6 +15,7 @@ """Parameter-group runtime state for the minimal Megatron-FSDP path.""" from contextlib import nullcontext +from dataclasses import dataclass import torch import torch.distributed as dist @@ -34,13 +35,22 @@ def get_containing_parameter_group(parameter: nn.Parameter) -> "FsdpParameterGro return getattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, None) +@dataclass(frozen=True, eq=False) +class FsdpParameter: + """One physical parameter and its FSDP runtime representations.""" + + # Tied weights register one physical parameter under multiple FQNs, all relative + # to the containing group's owning_module. + fqns: tuple[str, ...] + sharded: nn.Parameter + unsharded: nn.Parameter + + class FsdpParameterGroup: """A dtype and requires-grad homogeneous group of FSDP-owned parameters.""" owning_module: nn.Module - parameter_names: tuple[str, ...] - sharded_parameters: tuple[nn.Parameter, ...] - unsharded_parameters: tuple[nn.Parameter, ...] + fsdp_parameters: tuple[FsdpParameter, ...] mesh: DeviceMesh dtype: torch.dtype requires_grad: bool @@ -73,34 +83,37 @@ def __init__( if not parameters: raise ValueError("FsdpParameterGroup requires at least one parameter.") + parameter_to_fqns: dict[nn.Parameter, list[str]] = {} + for fqn, parameter in parameters.items(): + parameter_to_fqns.setdefault(parameter, []).append(fqn) + model_weight_placements = tuple(placements.parameter) main_grad_placements = tuple(placements.gradient) main_weight_placements = tuple(placements.optimizer) - # Python dicts preserve insertion order, so parameter_names and - # parameters.values() define the same stable DBuffer tensor order. + # Python dicts preserve insertion order, so parameter_to_fqns and + # fsdp_parameters define the same stable DBuffer tensor order. self.owning_module = owning_module self.mesh = mesh - self.parameter_names = tuple(parameters) - first_parameter = next(iter(parameters.values())) + first_parameter = next(iter(parameter_to_fqns)) self.dtype = first_parameter.dtype self.requires_grad = first_parameter.requires_grad - for name, parameter in parameters.items(): + for parameter, fqns in parameter_to_fqns.items(): if parameter.dtype != self.dtype: raise ValueError( - f"Expected parameter {name!r} to have dtype {self.dtype}, " + f"Expected parameter {fqns!r} to have dtype {self.dtype}, " f"got {parameter.dtype}." ) if parameter.requires_grad != self.requires_grad: raise ValueError( - f"Expected parameter {name!r} to have requires_grad={self.requires_grad}, " + f"Expected parameter {fqns!r} to have requires_grad={self.requires_grad}, " f"got {parameter.requires_grad}." ) - tensor_shapes = tuple(parameter.shape for parameter in parameters.values()) + tensor_shapes = tuple(parameter.shape for parameter in parameter_to_fqns) main_weight_dtype = mixed_precision_policy.main_params_dtype or torch.float32 self.main_weight = DBuffer.distribute_tensors( - (parameter.to(dtype=main_weight_dtype) for parameter in parameters.values()), + (parameter.to(dtype=main_weight_dtype) for parameter in parameter_to_fqns), mesh=self.mesh, placements=main_weight_placements, ) @@ -153,10 +166,9 @@ def __init__( # main_grad rests here (DP-outer-Partial for HSDP) between microbatches and # is finalized to main_weight's placements after the last microbatch. self._accumulation_placements = main_grad_placements - sharded_parameters: list[nn.Parameter] = [] - unsharded_parameters: list[nn.Parameter] = [] + fsdp_parameters: list[FsdpParameter] = [] main_grad_dtype = self.main_grad.dtype if self.main_grad is not None else None - for index, parameter in enumerate(parameters.values()): + for index, (parameter, fqns) in enumerate(parameter_to_fqns.items()): unsharded_tensor = self._unsharded_model_weight.get_local_tensor(index) if parameter.is_meta: # A meta Parameter cannot set .data to a real tensor because their @@ -171,7 +183,6 @@ def __init__( parameter.data = unsharded_tensor parameter.grad = None setattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, self) - unsharded_parameters.append(parameter) sharded_parameter = nn.Parameter( self.main_weight.get_dtensor(index), requires_grad=parameter.requires_grad @@ -179,9 +190,10 @@ def __init__( if main_grad_dtype: sharded_parameter.grad_dtype = main_grad_dtype setattr(sharded_parameter, _CONTAINING_PARAMETER_GROUP_ATTR, self) - sharded_parameters.append(sharded_parameter) - self.sharded_parameters = tuple(sharded_parameters) - self.unsharded_parameters = tuple(unsharded_parameters) + fsdp_parameters.append( + FsdpParameter(fqns=tuple(fqns), sharded=sharded_parameter, unsharded=parameter) + ) + self.fsdp_parameters = tuple(fsdp_parameters) # Compute weights must be initialized before the first forward; subsequent # refreshes happen from the FSDP optimizer's post-step hook. @@ -194,16 +206,18 @@ def _symmetric_memory_context(self): return nullcontext() return torch.cuda.use_mem_pool(self._symm_mem_pool) - def _set_module_parameters(self, parameters: tuple[nn.Parameter, ...]) -> None: - for name, parameter in zip(self.parameter_names, parameters, strict=True): - module, parameter_name = _get_parameter_owner(self.owning_module, name) + def _set_module_parameter(self, fqns: tuple[str, ...], parameter: nn.Parameter) -> None: + for fqn in fqns: + module, parameter_name = _get_parameter_owner(self.owning_module, fqn) module._parameters[parameter_name] = parameter def _switch_to_sharded_parameters(self) -> None: - self._set_module_parameters(self.sharded_parameters) + for fsdp_parameter in self.fsdp_parameters: + self._set_module_parameter(fsdp_parameter.fqns, fsdp_parameter.sharded) def _switch_to_unsharded_parameters(self) -> None: - self._set_module_parameters(self.unsharded_parameters) + for fsdp_parameter in self.fsdp_parameters: + self._set_module_parameter(fsdp_parameter.fqns, fsdp_parameter.unsharded) def sync_model_weight_from_main_weight(self) -> None: """Refresh compute weights from optimizer weights.""" @@ -269,10 +283,10 @@ def allocate_partial_grad_buffer(self) -> DBuffer: # Preserve AVG semantics by reducing SUM and scaling the output below. partial_op = dist.ReduceOp.AVG if self._symm_mem_pool is None else dist.ReduceOp.SUM grads: list[torch.Tensor] = [] - for name, parameter in zip(self.parameter_names, self.unsharded_parameters, strict=True): - if parameter.grad is None: - raise RuntimeError(f"Missing gradient for FSDP parameter {name!r}.") - grads.append(parameter.grad) + for fsdp_parameter in self.fsdp_parameters: + if fsdp_parameter.unsharded.grad is None: + raise RuntimeError(f"Missing gradient for FSDP parameter {fsdp_parameter.fqns!r}.") + grads.append(fsdp_parameter.unsharded.grad) with self._symmetric_memory_context(): return DBuffer( mesh=self.mesh, @@ -285,9 +299,21 @@ def allocate_partial_grad_buffer(self) -> DBuffer: def copy_gradients_to_partial_buffer(self, partial_grad: DBuffer) -> None: """Pack full local gradients into an existing reduce-scatter input buffer.""" # A future fused-wgrad path can write directly into these buffer views. - for index, parameter in enumerate(self.unsharded_parameters): - partial_grad.get_local_tensor(index).copy_(parameter.grad) - parameter.grad = None + for index, fsdp_parameter in enumerate(self.fsdp_parameters): + partial_grad.get_local_tensor(index).copy_(fsdp_parameter.unsharded.grad) + fsdp_parameter.unsharded.grad = None + + def _has_sharded_grads(self) -> bool: + has_any_grad = False + has_any_missing_grad = False + for fsdp_parameter in self.fsdp_parameters: + if fsdp_parameter.sharded.grad is None: + has_any_missing_grad = True + else: + has_any_grad = True + if has_any_grad and has_any_missing_grad: + raise RuntimeError("FSDP sharded gradients must be either all set or all None.") + return has_any_grad def reduce_partial_gradients( self, partial_grad: DBuffer, is_last_microbatch: bool = True @@ -303,22 +329,10 @@ def reduce_partial_gradients( """ assert self.main_grad is not None - def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: - has_any_grad = False - has_any_missing_grad = False - for parameter in parameters: - if parameter.grad is None: - has_any_missing_grad = True - else: - has_any_grad = True - if has_any_grad and has_any_missing_grad: - raise RuntimeError("FSDP sharded gradients must be either all set or all None.") - return has_any_grad - # zero_grad(set_to_none=True) clears sharded parameter grads, so this # backward can reduce directly into main_grad. zero_grad(set_to_none=False) # leaves sharded grads installed, so this backward accumulates into main_grad. - has_sharded_grads = has_grad(self.sharded_parameters) + has_sharded_grads = self._has_sharded_grads() # A non-accumulation main_grad means the previous step finalized it; this # only happens on the first microbatch. Redistribute it back to the @@ -356,8 +370,8 @@ def has_grad(parameters: tuple[nn.Parameter, ...]) -> bool: self.main_grad = self.main_grad.redistribute(self.main_weight.placements) # Make each sharded parameter's .grad consistent with the final main_grad. - for index, sharded_parameter in enumerate(self.sharded_parameters): - sharded_parameter.grad = self.main_grad.get_dtensor(index) + for index, fsdp_parameter in enumerate(self.fsdp_parameters): + fsdp_parameter.sharded.grad = self.main_grad.get_dtensor(index) def _get_parameter_owner(module: nn.Module, name: str) -> tuple[nn.Module, str]: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py b/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py index cdb6db0f182..f65e0b85724 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py @@ -49,6 +49,19 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return torch.relu(self.layers[1](x + self.bias)) +class TiedLM(nn.Module): + """Tiny language model with shared input and output embedding weights.""" + + def __init__(self) -> None: + super().__init__() + self.embed_tokens = nn.Embedding(8, 4, dtype=torch.bfloat16) + self.lm_head = nn.Linear(4, 8, bias=False, dtype=torch.bfloat16) + self.lm_head.weight = self.embed_tokens.weight + + def forward(self, token_ids: torch.Tensor) -> torch.Tensor: + return self.lm_head(self.embed_tokens(token_ids)).float().sum() + + def _flat_placements() -> Placements: return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) @@ -176,3 +189,28 @@ def test_fsdp_frozen_child_without_grad_inputs_skips_backward_nvtx_range( ("pop", "layers.1", "backward"), ("pop", "", "backward"), ] + + +def test_tied_child_parameters_complete_backward_once_per_cycle(distributed_setup, monkeypatch): + """Tied parameters should complete balanced backward ranges across training cycles.""" + events: list[NvtxEvent] = [] + _setup_nvtx_recording(monkeypatch, events) + model = TiedLM() + mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + token_ids = torch.arange(8, device=distributed_setup.device).reshape(2, 4) + for _ in range(2): + model.zero_grad(set_to_none=True) + model(token_ids).backward() + + assert [(event.kind, event.name, event.phase) for event in events] == [ + ("push", "", "forward"), + ("pop", "", "forward"), + ("push", "", "backward"), + ("pop", "", "backward"), + ("push", "", "forward"), + ("pop", "", "forward"), + ("push", "", "backward"), + ("pop", "", "backward"), + ] diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 7797fdf02f1..e820abe515d 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -73,6 +73,20 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x +class TiedLM(nn.Module): + """Tiny language model with shared input and output embedding weights.""" + + def __init__(self) -> None: + super().__init__() + self.embed_tokens = nn.Embedding(8, 4, dtype=torch.bfloat16) + self.lm_head = nn.Linear(4, 8, bias=False, dtype=torch.bfloat16) + self.lm_head.weight = self.embed_tokens.weight + + def forward(self, token_ids: torch.Tensor) -> torch.Tensor: + """Compute a scalar loss using both aliases of the shared weight.""" + return self.lm_head(self.embed_tokens(token_ids)).float().sum() + + class SaveNonLeafWeightView(torch.autograd.Function): """Autograd function that saves a non-leaf parameter view for backward.""" @@ -342,11 +356,25 @@ def test_nested_fully_shard_excludes_child_owned_parameters(distributed_setup): fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) fully_shard(model, mesh=mesh, placements=_flat_placements()) - inner_names = [name for group in model.inner.parameter_groups for name in group.parameter_names] - outer_names = [name for group in model.parameter_groups for name in group.parameter_names] + (inner_group,) = model.inner.parameter_groups + (outer_group,) = model.parameter_groups + + assert [parameter.fqns for parameter in inner_group.fsdp_parameters] == [("weight",)] + assert [parameter.fqns for parameter in outer_group.fsdp_parameters] == [("bias",)] + + +def test_tied_child_parameters_allocate_one_physical_weight(distributed_setup): + """Tied registrations should allocate one DBuffer entry and optimizer parameter.""" + model = TiedLM() + mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) + fully_shard(model, mesh=mesh, placements=_flat_placements()) - assert inner_names == ["weight"] - assert outer_names == ["bias"] + (parameter_group,) = model.parameter_groups + (parameter,) = parameter_group.fsdp_parameters + assert parameter.fqns == ("embed_tokens.weight", "lm_head.weight") + assert parameter_group.main_weight.layout.size == 8 * 4 + # Both aliases must expose the same optimizer-visible sharded parameter. + assert len(list(model.parameters())) == 1 def test_forward_peak_memory_bounds_in_flight_child_all_gathers(distributed_setup): diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py index 76d2884a7f1..a132ff88139 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py @@ -86,10 +86,16 @@ def test_wraps_fsdp_unit_modules_before_root(self): # Post-order wrapping gives the selected TransformerLayer its own parameter group; # the root FSDP unit should own only the parameters of the remaining Linear module. child_parameter_names = { - name for group in wrapped.module[0].parameter_groups for name in group.parameter_names + name + for group in wrapped.module[0].parameter_groups + for parameter in group.fsdp_parameters + for name in parameter.fqns } root_parameter_names = { - name for group in wrapped.module.parameter_groups for name in group.parameter_names + name + for group in wrapped.module.parameter_groups + for parameter in group.fsdp_parameters + for name in parameter.fqns } assert child_parameter_names assert root_parameter_names == {"1.weight", "1.bias"} From 9829b3f1dd16f5233ff7bf50e67f6443c527c2bc Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sun, 2 Aug 2026 11:21:44 +0200 Subject: [PATCH 184/290] chore(ci): AUT-1231 bump preflight workflow template (#6198) Signed-off-by: svcnemo-autobot --- .github/workflows/cicd-main.yml | 2 +- .github/workflows/copyright-check.yml | 2 +- .github/workflows/install-test.yml | 2 +- .github/workflows/multi-approval-bot.yml | 2 +- .github/workflows/release.yaml | 2 +- .../incremental_detokenizer.py | 16 +++++++++++----- .../inference/test_openai_streaming.py | 15 +++++++++++++++ 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index 8c7d329cd1c..20750eb55f5 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -133,7 +133,7 @@ jobs: pre-flight: needs: [is-not-external-contributor] if: github.repository == 'NVIDIA/Megatron-LM' - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@c1a0837f362a1a696e647238ab1cf916b2a7cf4a # v1.8.6 configure: runs-on: ubuntu-latest diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml index c5a20f9c066..22da94b83e1 100644 --- a/.github/workflows/copyright-check.yml +++ b/.github/workflows/copyright-check.yml @@ -24,7 +24,7 @@ on: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@c1a0837f362a1a696e647238ab1cf916b2a7cf4a # v1.8.6 if: github.repository == 'NVIDIA/Megatron-LM' copyright-check: diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 1a1ae490bc9..6eced656113 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -29,7 +29,7 @@ on: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@c1a0837f362a1a696e647238ab1cf916b2a7cf4a # v1.8.6 if: github.repository == 'NVIDIA/Megatron-LM' pip-test-pytorch: diff --git a/.github/workflows/multi-approval-bot.yml b/.github/workflows/multi-approval-bot.yml index f55b60842da..00dd3d5a3f2 100644 --- a/.github/workflows/multi-approval-bot.yml +++ b/.github/workflows/multi-approval-bot.yml @@ -9,7 +9,7 @@ on: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@6a2f81195fd910ae91d3c001d2e32ceb6d82e975 # v1.0.0 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@c1a0837f362a1a696e647238ab1cf916b2a7cf4a # v1.8.6 if: github.repository == 'NVIDIA/Megatron-LM' codeowners-approval: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1b3d2af292a..3e3b5ed2506 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -72,7 +72,7 @@ concurrency: jobs: pre-flight: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@211c302d648552cecccec610fe796cc22a091f37 # v0.94.1 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_cicd_preflight.yml@c1a0837f362a1a696e647238ab1cf916b2a7cf4a # v1.8.6 if: github.repository == 'NVIDIA/Megatron-LM' && github.event_name != 'workflow_dispatch' bump: diff --git a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py index 0238f52fbd6..47b50e29399 100644 --- a/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py +++ b/megatron/core/inference/text_generation_server/dynamic_text_gen_server/incremental_detokenizer.py @@ -6,9 +6,6 @@ import logging from typing import Any -import tokenizers -from transformers import PreTrainedTokenizerFast - logger = logging.getLogger(__name__) _INVALID_PREFIX_ERROR = "Invalid prefix encountered" @@ -23,13 +20,22 @@ class HuggingFaceFastIncrementalDetokenizer: """ def __init__(self, tokenizer: Any, prompt_token_ids: list[int]) -> None: + try: + import tokenizers + from transformers import PreTrainedTokenizerFast + except ImportError as exc: + raise ImportError( + "Incremental detokenization requires the tokenizers and transformers packages." + ) from exc + + self._tokenizers = tokenizers tokenizer_wrapper = getattr(tokenizer, "_tokenizer", None) huggingface_tokenizer = getattr(tokenizer_wrapper, "tokenizer", None) if not isinstance(huggingface_tokenizer, PreTrainedTokenizerFast): raise ValueError( "Streaming is currently supported only for Hugging Face fast tokenizers." ) - if not hasattr(tokenizers.decoders, "DecodeStream"): + if not hasattr(self._tokenizers.decoders, "DecodeStream"): raise ValueError( "Streaming with Hugging Face fast tokenizers requires tokenizers>=0.22.0." ) @@ -44,7 +50,7 @@ def _new_decode_stream(self, prompt_token_ids: list[int] | None = None): kwargs = {"skip_special_tokens": self._skip_special_tokens} if prompt_token_ids is not None: kwargs["ids"] = list(prompt_token_ids) - return tokenizers.decoders.DecodeStream(**kwargs) + return self._tokenizers.decoders.DecodeStream(**kwargs) def update(self, token_ids: list[int]) -> str: """Decode token IDs and return only newly stable text.""" diff --git a/tests/unit_tests/inference/test_openai_streaming.py b/tests/unit_tests/inference/test_openai_streaming.py index 048698f40e5..c690ad5478e 100644 --- a/tests/unit_tests/inference/test_openai_streaming.py +++ b/tests/unit_tests/inference/test_openai_streaming.py @@ -1,5 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import builtins import json from types import SimpleNamespace @@ -109,6 +110,20 @@ async def test_openai_stream_emits_delta_chunks_and_terminal_metadata(): assert records[-1] == "data: [DONE]\n\n" +def test_huggingface_fast_incremental_detokenizer_requires_optional_dependencies(monkeypatch): + original_import = builtins.__import__ + + def import_without_transformers(name, *args, **kwargs): + if name == "transformers": + raise ImportError("transformers is unavailable") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_transformers) + + with pytest.raises(ImportError, match="requires the tokenizers and transformers packages"): + HuggingFaceFastIncrementalDetokenizer(_Tokenizer(), []) + + def test_huggingface_fast_incremental_detokenizer_preserves_utf8_boundaries(): tokenizer = _make_byte_level_fast_tokenizer() huggingface_tokenizer = tokenizer._tokenizer.tokenizer From 42460a7af821366e7115a162cb4410106bea93f0 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Sun, 2 Aug 2026 19:11:45 -0700 Subject: [PATCH 185/290] Clarify single communication stream benefits (#6177) Signed-off-by: Jingyue Wu --- megatron/core/distributed/fsdp/src/docs/runtime_schedule.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/docs/runtime_schedule.md b/megatron/core/distributed/fsdp/src/docs/runtime_schedule.md index a43e0d9a2a5..ae2ecb58360 100644 --- a/megatron/core/distributed/fsdp/src/docs/runtime_schedule.md +++ b/megatron/core/distributed/fsdp/src/docs/runtime_schedule.md @@ -92,13 +92,14 @@ figure. ## Single Communication Stream -Prototype: [PR #5416](https://github.com/NVIDIA/Megatron-LM/pull/5416) - PyTorch’s CUDA caching allocator maintains memory pools on a per-stream basis. Consolidating communication onto a single stream, rather than using separate streams for AllGather and ReduceScatter, can reduce allocator fragmentation by allowing allocations to be reused from the same stream-local pool. +Using a single communication stream may also improve determinism by imposing a consistent +ordering on communication operations that could otherwise make independent progress. + A potential drawback is the introduction of artificial dependencies between AllGather (AG) and ReduceScatter (RS) operations. When AG and RS use separate NCCL communicators and CUDA streams, they can make independent progress and potentially overlap. Anecdotal measurements From df2da78b39442a7642d15cc8c678d11353f20501 Mon Sep 17 00:00:00 2001 From: Xuanteng Huang <44627253+xuantengh@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:52:47 +0800 Subject: [PATCH 186/290] Bump black version in pre-commit config to match pyproject.toml (#6201) Signed-off-by: Xuanteng Huang --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 851efa0e303..3b289e1368a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 'refs/tags/24.4.2:refs/tags/24.4.2' + rev: 'refs/tags/26.3.0:refs/tags/26.3.0' hooks: - id: black files: ^megatron/core/.*|^tests/unit_tests/.* @@ -14,4 +14,4 @@ repos: rev: 5.13.2 hooks: - id: isort - files: ^megatron/core/.* \ No newline at end of file + files: ^megatron/core/.* From dac95f342a95ed30ef9bfa311c2068e486e9763e Mon Sep 17 00:00:00 2001 From: Dmytro Pykhtar <37850217+dimapihtar@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:02:50 +0300 Subject: [PATCH 187/290] set weights_only=True (#6150) Signed-off-by: dimapihtar Signed-off-by: Dmytro Pykhtar --- megatron/core/dist_checkpointing/serialization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index cc08aa26dbf..0f09a3262a2 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -233,7 +233,7 @@ def load_common_state_dict(checkpoint_dir: Union[str, Path]) -> StateDict: loaded = pyt_state_dict[unique_key] if isinstance(loaded, io.BytesIO): loaded.seek(0) - loaded = torch.load(loaded, weights_only=False) + loaded = torch.load(loaded, weights_only=True) return loaded[0] From c2fd8275d9fa13cd7d2e51c9d9370fbb6cdae02d Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Tue, 4 Aug 2026 01:26:23 +0800 Subject: [PATCH 188/290] [fix] Give same-key GTP chain neighbours distinct gather buffers (#6207) Signed-off-by: Shiqing Fan Co-authored-by: Jiangfei Duan --- .../core/generalized_tensor_parallel.md | 5 +- .../generalized_tensor_parallelism.py | 34 +++++++++ .../test_gtp_basics.py | 69 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index dbba305e791..a14475cd869 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -342,7 +342,7 @@ The figure visualizes the per-class split from the list above: green = resolves Two distinct pools with explicit lifecycle rules: -- **`GTPWeightCache`** (AG/RS output buffers) — ticket-based, keyed on `(shape, dtype, fwd, expert_idx, reduce_scatter)`. Same-shape buffers across layers are shared. Tickets persistent; buffer allocated lazily on first `get()`; addresses stable across iterations for CG replay. +- **`GTPWeightCache`** (AG/RS output buffers) — ticket-based, keyed on `(shape, dtype, fwd, expert_idx, reduce_scatter)`. Same-shape buffers across layers are shared, **except between chain neighbours** — one-step-ahead keeps `prev_w` and the current weight live at once, so `_ensure_distinct_buffer_from_prev` folds a parity bit into the key when the two would collide, at the cost of one extra buffer for the second of the pair. Normally inert (neighbours are different weight roles, hence different shapes); it fires when CG capture leaves two same-shaped weights adjacent — embedding + output_layer alone in the `UNGRAPHED` chain. Tickets persistent; buffer allocated lazily on first `get()`; addresses stable across iterations for CG replay. - **`_wgrad_buf_pool`** (wgrad-GEMM output recycling) — holds the **full, unsharded** wgrad-GEMM output buffer (shape `_unsharded_shape`, dtype `main_grad.dtype` — fp32 when `grad_reduce_in_fp32`, else bf16). The TE backward writes the wgrad into it via `main_grad_func = weight.grad_buffer` (a `DistributedWeight` protocol method backed by `get_wgrad_tensor`; it is a *scratch*, distinct from the sharded `param.main_grad`); the protocol's `finalize_group_grads` (backed by `wgrad_reduce_scatter`) then reduce-scatters it down to the shard and the buffer is returned here. This is a full-weight-shaped fp32/bf16 transient — one of the larger per-weight buffers — and is **precision-independent** (wgrad is always computed in high precision), so it is identical in BF16 vs MXFP8 runs. Buffers are tagged `_from_gtp_wgrad_pool=True` at `_wgrad_pool_get`; `_wgrad_pool_put` no-ops on foreign buffers (fresh allocs from Megatron `layers.py` or aten F.embedding bwd) → caching allocator handles those, so the pool never accumulates untagged buffers. #### Overlap design summary @@ -527,7 +527,8 @@ Three consequences: - the weight cache keys **one buffer per `(shape, dtype, expert_idx)`**, which assumes at most one same-key weight is live; - one-block-ahead makes block *N* and block *N+1* weights **live at the same time** — same key, two tensors in flight; - fix: a chain-position **parity (0,1,0,1…)** is folded into the cache key, so consecutive blocks alternate between **exactly two** buffers (counter cleared by `reset_gtp_state()`); - - without it the prefetch would **overwrite the weight the running GEMM is still reading** — a silent-correctness bug, not a crash. + - without it the prefetch would **overwrite the weight the running GEMM is still reading** — a silent-correctness bug, not a crash; + - the hazard is not exclusive to grouped chains — *any* chain whose neighbours share a key has it. Grouped chains are same-key throughout, so they take the blanket counter; others take the narrower `_ensure_distinct_buffer_from_prev` check, which allocates only where the collision is real (see *Buffer / memory management*). - **Eager only** — the optimization disables itself under CUDA-graph capture: - `_classify_param_chain` evaluates `graphed = _FULL_ITERATION or ("moe" in cuda_graph_modules)` **before** the split, and returns the plain `GRAPHED` chain when it is true; - so with `--cuda-graph-impl full_iteration` **every** param is `GRAPHED` — expert weights included — and they keep the ordinary one-step-ahead prefetch; diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index c72cf313dc2..a8edc016c80 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -1008,6 +1008,33 @@ def _double_buffer_parity(self) -> int: self._buf_parity = p return p + def _gather_buffer_identity(self, dtype) -> tuple: + """The part of the cache key that decides which weights share a gather buffer.""" + return (self._unsharded_shape_padded, dtype, self.expert_idx) + + def _ensure_distinct_buffer_from_prev(self, dtype): + """Move self to a second buffer if its chain predecessor would share one. + + One-step-ahead prefetch keeps prev_w and self live at once, so sharing a buffer lets + self's gather clobber the weight prev_w's GEMM is still reading. Neighbours normally + differ in shape; a CUDA-graph-partitioned chain can leave two same-shaped weights + adjacent (embedding + output_layer alone in the UNGRAPHED chain). + + Grouped chains use their own counter (``_GTP_GROUPED_BUF_PARITY_COUNTER``). + """ + prev = self.prev_w + if prev is None or _chain_is_grouped(self.chain_id): + return + if self.is_routed_expert or prev.is_routed_expert: + return + if prev._cached_dtypes is None: # never gathered — no buffer to collide with + return + if prev._gather_buffer_identity(prev._cached_dtypes[0]) != self._gather_buffer_identity( + dtype + ): + return + self._buf_parity = 1 - (getattr(prev, "_buf_parity", None) or 0) + def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: """Build cache key from output shape + dtype. @@ -1034,6 +1061,10 @@ def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: # chain_id keeps fc1/fc2 apart (both can be in flight at once, even if same-shaped); # parity alternates consecutive blocks between two buffers. key = key + (self.chain_id, self._double_buffer_parity()) + elif getattr(self, "_buf_parity", None): + # Set by _ensure_distinct_buffer_from_prev. Parity 0 keeps the shared buffer, so + # only the second weight of an adjacent same-key pair costs an extra allocation. + key = key + (self._buf_parity,) return key def _strip_padding(self, tensor): @@ -1432,6 +1463,9 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): dtypes = [ q.dtype if q is not None else w.dtype for q, w in zip(quantizers, self._weights) ] + # Must run before the reserve below — it decides which buffer the ticket gets. + self._ensure_distinct_buffer_from_prev(dtypes[0]) + for w, dt in zip(self._weights, dtypes): w._ag_ticket_fwd = cache.reserve(w, dt, fwd=True) cache.get(w._ag_ticket_fwd) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py index 09f09d5d61a..0b44576a8c3 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py @@ -518,6 +518,75 @@ def test_parity_cached_and_stable(self): assert fwd[-1] == 0 and bwd[-1] == 0 and rs[-1] == 0 +def _worker_same_key_neighbours_dont_share_a_buffer(rank, world_size, port): + """Two same-shaped weights adjacent in a plain (non-grouped) chain need two buffers. + + This is the GTP_ungraphed chain under CUDA graphs: every layer weight is captured, leaving + only embedding + output_layer behind — same shape, same dtype, same cache key. One-step-ahead + prefetch keeps both live (w0's consume issues w1's gather), so one buffer means w1's gather + lands on the weight w0's GEMM is still reading. + """ + torch.manual_seed(0) + in_f, out_f = 32, 64 + dtype = torch.bfloat16 + gtp_remat_group = dist.new_group(list(range(world_size))) + + l0 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + l1 = _make_gtp_linear(in_f, out_f, gtp_remat_group, dtype) + # Distinct values so a clobber shows up in the gathered data, not just in the address. + with torch.no_grad(): + l0.weight.fill_(1.0) + l1.weight.fill_(-1.0) + + inp = torch.randn(4, in_f, dtype=dtype, device="cuda") + dist.broadcast(inp, src=0) + + # First pass wires the chain; the second is the one that prefetches. + for _ in range(2): + l0(inp, is_first_microbatch=True) + l1(inp, is_first_microbatch=True) + + w0, w1 = l0.weight, l1.weight + assert w0.next_w is w1 and w1.prev_w is w0, "w0 and w1 must be adjacent in one chain" + assert w0._gather_buffer_identity(dtype) == w1._gather_buffer_identity( + dtype + ), "nothing is being tested unless both weights want the same buffer" + + cache = gtp_module.get_global_GTP_cache() + b0, b1 = cache.get(w0._ag_ticket_fwd), cache.get(w1._ag_ticket_fwd) + torch.cuda.synchronize() + assert b0.data_ptr() != b1.data_ptr(), ( + "same-key chain neighbours share one gather buffer — w1's prefetch can clobber " + "the weight w0's GEMM is still reading" + ) + assert (b0 == 1).all(), "w0's gathered weight was clobbered by w1's prefetch" + assert (b1 == -1).all(), "w1's buffer does not hold w1's weight" + + +class TestSameKeyNeighbourTiebreak: + """A non-grouped chain buys a second buffer only where two adjacent weights would actually + share one — unlike the grouped chains above, which alternate unconditionally.""" + + _Fake = TestGroupedDoubleBuffer._Fake + + def _key(self, parity=None): + f = self._Fake("GTP_ungraphed") + if parity is not None: + f._buf_parity = parity + return f._get_cache_key(torch.bfloat16, fwd=True, reduce_scatter=False) + + def test_parity_zero_keeps_the_shared_buffer(self): + # Unset and 0 must give the same key: only the second weight of a pair pays. + assert self._key(0) == self._key() == ((128, 256), torch.bfloat16, 0, False) + + def test_parity_one_gets_its_own_buffer(self): + assert self._key(1) != self._key() + + def test_adjacent_same_shape_weights_get_distinct_buffers(self): + _requires_multi_gpu(4) + _run_distributed(_worker_same_key_neighbours_dont_share_a_buffer, 4) + + # --------------------------------------------------------------------------- # Wgrad reduce-scatter: shape and deferred async path # --------------------------------------------------------------------------- From abf04f46e39a22f8e6f2597ba820a7712aef36c2 Mon Sep 17 00:00:00 2001 From: Philip Monk Date: Mon, 3 Aug 2026 11:29:56 -0700 Subject: [PATCH 189/290] Fix gradient reduction issue when EP=1, EP=TP, and EGTP != GTP (#6080) Signed-off-by: Philip Monk --- .../core/distributed/param_and_grad_buffer.py | 5 +- .../core/extensions/transformer_engine.py | 8 +- .../core/post_training/modelopt/layers.py | 1 + .../generalized_tensor_parallelism.py | 15 ++- megatron/core/tensor_parallel/layers.py | 4 +- .../test_grad_sync_with_expert_parallel.py | 44 +++++-- tests/unit_tests/training/test_param_norm.py | 118 +++++++++++++++++- 7 files changed, 174 insertions(+), 21 deletions(-) diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index 4439f123852..1b13cdbe3cc 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -894,8 +894,9 @@ def group_params_for_buffers( - param_dtype: storage dtype (torch.uint8 for FP8/NVFP4 parameters, else param.dtype). - grad_dtype: gradient reduction dtype (torch.float if grad_reduce_in_fp32, else param.dtype). - is_expert_parallel: whether the parameter uses the expert topology (param.allreduce == False), - which requires a separate buffer for the expert data-parallel group. This is true for experts - when expert-parallelism > 1 or expert-tensor-parallelism != tensor-parallelism. + which requires a separate buffer for the expert data-parallel group. This is true for experts + when expert-parallelism > 1, expert-tensor-parallelism != tensor-parallelism, or expert-GTP + != GTP. The param_indices track each parameter's position among same-dtype params (using the "fake" high-precision dtype for FP8/NVFP4 params), needed for loading non-native-fp8 diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 8f0117c68ec..39a314625ef 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -93,7 +93,7 @@ def _set_expert_parameter_attributes( ) -> None: """Set process-group and tensor-partition metadata on an expert TE module. - ``allreduce=False`` selects EDP for gradient reduction. + ``allreduce=False`` selects the expert topology, including EDP for gradient reduction. Weights and biases, including TEGroupedLinear's numbered parameters, are also marked as TP-partitioned according to ``parallel_mode``; row-parallel biases remain replicated. @@ -104,7 +104,7 @@ def _set_expert_parameter_attributes( Args: module: Transformer Engine module whose direct parameters should be marked. parallel_mode: Tensor-parallel mode used by the module (``"column"``, ``"row"``, or None). - use_expert_pgs: Whether to use EP/ETP/EDP process groups instead of TP/CP/DP. + use_expert_pgs: Whether to use EP/ETP/EGTP/EDP process groups instead of TP/GTP/CP/DP. """ for name, param in module.named_parameters(recurse=False): param.allreduce = not use_expert_pgs @@ -982,6 +982,7 @@ def __init__( use_expert_pgs = is_expert and ( self.expert_parallel or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + or self.config.expert_gtp_weight_remat_size != self.config.gtp_weight_remat_size ) if is_expert: rng_tracker_name = get_expert_parallel_rng_tracker_name() @@ -1475,6 +1476,7 @@ def __init__( use_expert_pgs = ( config.expert_model_parallel_size > 1 or config.expert_tensor_parallel_size != config.tensor_model_parallel_size + or config.expert_gtp_weight_remat_size != config.gtp_weight_remat_size ) _set_expert_parameter_attributes(self, "column", use_expert_pgs) @@ -1731,6 +1733,7 @@ def __init__( use_expert_pgs = ( config.expert_model_parallel_size > 1 or config.expert_tensor_parallel_size != config.tensor_model_parallel_size + or config.expert_gtp_weight_remat_size != config.gtp_weight_remat_size ) _set_expert_parameter_attributes(self, "row", use_expert_pgs) @@ -2163,6 +2166,7 @@ def __init__( use_expert_pgs = is_expert and ( self.expert_parallel or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + or self.config.expert_gtp_weight_remat_size != self.config.gtp_weight_remat_size ) if is_expert: extra_kwargs["rng_tracker_name"] = get_expert_parallel_rng_tracker_name() diff --git a/megatron/core/post_training/modelopt/layers.py b/megatron/core/post_training/modelopt/layers.py index 5f1a746e95b..cd8919e50e7 100644 --- a/megatron/core/post_training/modelopt/layers.py +++ b/megatron/core/post_training/modelopt/layers.py @@ -163,6 +163,7 @@ def __init__( self.config.expert_model_parallel_size > 1 or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + or self.config.expert_gtp_weight_remat_size != self.config.gtp_weight_remat_size ) setattr(param, "allreduce", not use_expert_groups) else: diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index a8edc016c80..9099cd114b9 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -507,11 +507,14 @@ def _gtp_slice_one_param(param, gtp_remat_group, *, name=""): shard = tensor[gtp_rank * shard_size : (gtp_rank + 1) * shard_size] gtp_shard = GTPShardedParam(shard.clone()) gtp_shard.pad_length = pad_length - # Preserve the source weight's TP attributes (dropped when wrapping into GTPShardedParam), - # so param_is_not_tensor_parallel_duplicate still classifies it without GTP-specific code. - from megatron.core.tensor_parallel import copy_tensor_model_parallel_attributes + # Preserve duplicate-filtering metadata dropped when wrapping into GTPShardedParam. + from megatron.core.tensor_parallel import ( + copy_gtp_attributes, + copy_tensor_model_parallel_attributes, + ) copy_tensor_model_parallel_attributes(gtp_shard, param) + copy_gtp_attributes(gtp_shard, param) return gtp_shard @@ -545,10 +548,14 @@ def _gtp_wrap_bf16_shard(module, name, param): :func:`_gtp_slice_one_param`, which slices a full weight — this only wraps it, no slicing. Returns the new param (also swapped into the module). """ - from megatron.core.tensor_parallel import copy_tensor_model_parallel_attributes + from megatron.core.tensor_parallel import ( + copy_gtp_attributes, + copy_tensor_model_parallel_attributes, + ) gtp_shard = GTPShardedParam(param.data) copy_tensor_model_parallel_attributes(gtp_shard, param) + copy_gtp_attributes(gtp_shard, param) delattr(module, name) module._parameters[name] = gtp_shard return gtp_shard diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index 3ff635b362b..a42cbd05841 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -100,7 +100,7 @@ def param_is_not_tensor_parallel_duplicate(param, tp_group=None, expert_tp_group """ if hasattr(param, "tensor_model_parallel") and param.tensor_model_parallel: return True - # allreduce=False marks parameters reduced over expert DP, so filter their duplicates over ETP. + # allreduce=False marks parameters using the expert topology, so filter duplicates over ETP. if not getattr(param, "allreduce", True) and expert_tp_group is not None: tp_group = expert_tp_group # Prefer provided tp_group when available (new explicit path). @@ -961,6 +961,7 @@ def __init__( use_expert_pgs = self.is_expert and ( self.expert_parallel or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + or self.config.expert_gtp_weight_remat_size != self.config.gtp_weight_remat_size ) self.output_size_per_partition = divide(output_size, world_size) @@ -1379,6 +1380,7 @@ def __init__( use_expert_pgs = self.is_expert and ( self.expert_parallel or self.config.expert_tensor_parallel_size != self.config.tensor_model_parallel_size + or self.config.expert_gtp_weight_remat_size != self.config.gtp_weight_remat_size ) setattr(self.weight, "allreduce", not use_expert_pgs) diff --git a/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py b/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py index fe56317f39a..a088016d52b 100644 --- a/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py +++ b/tests/unit_tests/distributed/test_grad_sync_with_expert_parallel.py @@ -142,26 +142,44 @@ def _build_expert_linear(implementation: str, config: TransformerConfig) -> torc @pytest.mark.parametrize( - ("tensor_model_parallel_size", "expert_tensor_parallel_size"), [(2, 1), (1, 2)] + ( + "tensor_model_parallel_size", + "expert_tensor_parallel_size", + "gtp_weight_remat_size", + "expert_gtp_weight_remat_size", + ), + [(2, 1, 1, 1), (1, 2, 1, 1), (1, 1, 1, 2)], ) @pytest.mark.parametrize( "implementation", ["native", "transformer_engine", "transformer_engine_grouped"] ) def test_expert_grad_sync_uses_expert_data_parallel_group( - implementation: str, tensor_model_parallel_size: int, expert_tensor_parallel_size: int + implementation: str, + tensor_model_parallel_size: int, + expert_tensor_parallel_size: int, + gtp_weight_remat_size: int, + expert_gtp_weight_remat_size: int, ): - """Expert gradients must not be reduced over ordinary DP when ETP differs from TP.""" + """Expert gradients must use expert DP when the expert and dense topologies differ.""" + if expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19") if Utils.world_size < 4 or Utils.world_size % 4 != 0: pytest.skip("Test requires a world size divisible by four") if Utils.world_size > 16: pytest.skip("Rank-encoded gradients are intended for small unit-test world sizes") - Utils.initialize_model_parallel( - tensor_model_parallel_size=tensor_model_parallel_size, - expert_model_parallel_size=1, - expert_tensor_parallel_size=expert_tensor_parallel_size, - ) try: + Utils.initialize_model_parallel( + tensor_model_parallel_size=tensor_model_parallel_size, + expert_model_parallel_size=1, + expert_tensor_parallel_size=expert_tensor_parallel_size, + gtp_remat_size=gtp_weight_remat_size, + expert_gtp_remat_size=expert_gtp_weight_remat_size, + ) + # Per-token loss leaves DDP's pre-collective gradient scaling at one. config = TransformerConfig( num_layers=1, @@ -174,6 +192,10 @@ def test_expert_grad_sync_uses_expert_data_parallel_group( tensor_model_parallel_size=tensor_model_parallel_size, expert_model_parallel_size=1, expert_tensor_parallel_size=expert_tensor_parallel_size, + tensor_parallel_num_weight_shards=(tensor_model_parallel_size * gtp_weight_remat_size), + expert_tensor_parallel_num_weight_shards=( + expert_tensor_parallel_size * expert_gtp_weight_remat_size + ), calculate_per_token_loss=True, gradient_accumulation_fusion=False, perform_initialization=False, @@ -194,7 +216,7 @@ def test_expert_grad_sync_uses_expert_data_parallel_group( ) expert_dp_group = parallel_state.get_expert_data_parallel_group( - partial_expert_data_parallel=True + with_gtp_remat=False, partial_expert_data_parallel=True ) ordinary_dp_group = parallel_state.get_data_parallel_group( with_context_parallel=True, partial_data_parallel=True @@ -222,6 +244,10 @@ def test_expert_grad_sync_uses_expert_data_parallel_group( assert len(model.expert_parallel_buffers) == 1 assert all(param.allreduce is False for param in model.parameters()) finally: + if expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.generalized_tensor_parallelism import reset_gtp_state + + reset_gtp_state() Utils.destroy_model_parallel() diff --git a/tests/unit_tests/training/test_param_norm.py b/tests/unit_tests/training/test_param_norm.py index 27193ebf827..12dbfba6992 100644 --- a/tests/unit_tests/training/test_param_norm.py +++ b/tests/unit_tests/training/test_param_norm.py @@ -19,6 +19,8 @@ def _build_tiny_moe_gpt( tensor_parallel_size: int, expert_parallel_size: int, expert_tensor_parallel_size: int, + tensor_parallel_num_weight_shards: int | None = None, + expert_tensor_parallel_num_weight_shards: int | None = None, bf16: bool = False, add_bias_linear: bool = False, ) -> GPTModel: @@ -36,6 +38,8 @@ def _build_tiny_moe_gpt( tensor_model_parallel_size=tensor_parallel_size, expert_model_parallel_size=expert_parallel_size, expert_tensor_parallel_size=expert_tensor_parallel_size, + tensor_parallel_num_weight_shards=tensor_parallel_num_weight_shards, + expert_tensor_parallel_num_weight_shards=expert_tensor_parallel_num_weight_shards, sequence_parallel=tensor_parallel_size > 1, use_cpu_initialization=True, add_bias_linear=add_bias_linear, @@ -119,22 +123,116 @@ def test_moe_param_norm_counts_each_logical_parameter_once( Utils.destroy_model_parallel() +def test_moe_param_norm_uses_expert_gtp_topology_when_it_differs_from_dense_gtp(monkeypatch): + """Expert parameters must use EGTP even when EP, TP, and ETP alone do not distinguish them.""" + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTP_CONFIG, + GTPShardedParam, + reset_gtp_state, + update_gtp_config, + ) + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19") + if Utils.world_size < 2 or Utils.world_size % 2 != 0: + pytest.skip("test requires an even world size") + + monkeypatch.setattr( + common_utils, "get_args", lambda: SimpleNamespace(use_megatron_fsdp=False, bf16=False) + ) + # Keep the all-ones assertion focused on topology rather than physical GTP padding. + original_pad_for_alignment = GTP_CONFIG.pad_for_alignment + update_gtp_config(pad_for_alignment=0) + + try: + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + expert_model_parallel_size=1, + expert_tensor_parallel_size=1, + ) + reference_model = _build_tiny_moe_gpt( + tensor_parallel_size=1, expert_parallel_size=1, expert_tensor_parallel_size=1 + ) + _fill_parameters_with_ones(reference_model) + expected_numel = sum(param.numel() for param in reference_model.parameters()) + expected_norm = math.sqrt(expected_numel) + del reference_model + + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, + expert_model_parallel_size=1, + expert_tensor_parallel_size=1, + gtp_remat_size=1, + expert_gtp_remat_size=2, + ) + model = _build_tiny_moe_gpt( + tensor_parallel_size=1, + expert_parallel_size=1, + expert_tensor_parallel_size=1, + tensor_parallel_num_weight_shards=1, + expert_tensor_parallel_num_weight_shards=2, + ) + _fill_parameters_with_ones(model) + + expert_params = [param for name, param in model.named_parameters() if ".experts." in name] + assert any(isinstance(param, GTPShardedParam) for param in expert_params) + + actual_norm = common_utils.calc_params_l2_norm(model) + + assert actual_norm == pytest.approx(expected_norm) + finally: + update_gtp_config(pad_for_alignment=original_pad_for_alignment) + reset_gtp_state() + Utils.destroy_model_parallel() + + @pytest.mark.parametrize("use_distributed_optimizer", (False, True), ids=("optimizer", "distopt")) @pytest.mark.parametrize( - ("tensor_parallel_size", "expert_parallel_size", "expert_tensor_parallel_size"), - ((2, 2, 1), (2, 1, 2), (4, 1, 2), (2, 1, 4)), - ids=("expert-parallel", "expert-tensor-parallel", "tp-larger-than-etp", "etp-larger-than-tp"), + ( + "tensor_parallel_size", + "expert_parallel_size", + "expert_tensor_parallel_size", + "gtp_weight_remat_size", + "expert_gtp_weight_remat_size", + ), + ((2, 2, 1, 1, 1), (2, 1, 2, 1, 1), (4, 1, 2, 1, 1), (2, 1, 4, 1, 1), (1, 1, 1, 1, 2)), + ids=( + "expert-parallel", + "expert-tensor-parallel", + "tp-larger-than-etp", + "etp-larger-than-tp", + "expert-gtp-differs-from-dense-gtp", + ), ) def test_moe_gradient_stats_and_clipping_count_each_logical_gradient_once( tensor_parallel_size: int, expert_parallel_size: int, expert_tensor_parallel_size: int, + gtp_weight_remat_size: int, + expert_gtp_weight_remat_size: int, use_distributed_optimizer: bool, ): """Gradient norm, clipping, and zero count should include each logical gradient once.""" + if expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + + if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19") if Utils.world_size < 4 or Utils.world_size % 4 != 0: pytest.skip("test requires a world size divisible by four") + original_pad_for_alignment = None + if expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTP_CONFIG, + update_gtp_config, + ) + + # Keep the all-ones assertion focused on topology rather than physical GTP padding. + original_pad_for_alignment = GTP_CONFIG.pad_for_alignment + update_gtp_config(pad_for_alignment=0) + try: Utils.initialize_model_parallel( tensor_model_parallel_size=1, @@ -152,11 +250,17 @@ def test_moe_gradient_stats_and_clipping_count_each_logical_gradient_once( tensor_model_parallel_size=tensor_parallel_size, expert_model_parallel_size=expert_parallel_size, expert_tensor_parallel_size=expert_tensor_parallel_size, + gtp_remat_size=gtp_weight_remat_size, + expert_gtp_remat_size=expert_gtp_weight_remat_size, ) model = _build_tiny_moe_gpt( tensor_parallel_size=tensor_parallel_size, expert_parallel_size=expert_parallel_size, expert_tensor_parallel_size=expert_tensor_parallel_size, + tensor_parallel_num_weight_shards=(tensor_parallel_size * gtp_weight_remat_size), + expert_tensor_parallel_num_weight_shards=( + expert_tensor_parallel_size * expert_gtp_weight_remat_size + ), bf16=True, ) ddp_config = DistributedDataParallelConfig( @@ -211,6 +315,14 @@ def test_moe_gradient_stats_and_clipping_count_each_logical_gradient_once( grads_checked += 1 assert grads_checked > 0 finally: + if expert_gtp_weight_remat_size > 1: + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + reset_gtp_state, + update_gtp_config, + ) + + update_gtp_config(pad_for_alignment=original_pad_for_alignment) + reset_gtp_state() Utils.destroy_model_parallel() From b4587a30e6198b1c76d038ec8bfdb946a638da56 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Mon, 3 Aug 2026 13:03:27 -0700 Subject: [PATCH 190/290] Apply black 26.3.0 formatting to four files it now reformats (#6210) Signed-off-by: Deepak Narayanan --- megatron/core/models/vision/radio.py | 8 ++++---- megatron/core/transformer/attention.py | 12 ++++++------ megatron/core/transformer/mlp.py | 8 ++------ megatron/core/transformer/transformer_layer.py | 4 +--- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/megatron/core/models/vision/radio.py b/megatron/core/models/vision/radio.py index 277a33671bd..d6633d51f38 100644 --- a/megatron/core/models/vision/radio.py +++ b/megatron/core/models/vision/radio.py @@ -610,11 +610,11 @@ def window_select(pos_embed): return pos_embed def aspect_ratio_select(pos_embed): - (pos_H, pos_W) = pos_embed.shape[-2:] - (input_H, input_W) = input_dims + pos_H, pos_W = pos_embed.shape[-2:] + input_H, input_W = input_dims if input_H == input_W: return pos_embed - (crop_H, crop_W) = (pos_H, pos_W) + crop_H, crop_W = (pos_H, pos_W) if input_W < input_H: crop_W = min(pos_W, math.ceil(pos_W * (input_W / input_H))) else: @@ -676,7 +676,7 @@ def aspect_ratio_select(pos_embed): else: max_dim = max(input_dims) - (B, C, _H, _W) = pos_embed.shape + B, C, _H, _W = pos_embed.shape aspect_ratio_select_required = B * C * max_dim**2 >= torch.iinfo(torch.int32).max if aspect_ratio_select_required or self.cpe_aspect_ratio_select: pos_embed = aspect_ratio_select(pos_embed) diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py index 3a5876bc0fe..4ce5babb9c0 100644 --- a/megatron/core/transformer/attention.py +++ b/megatron/core/transformer/attention.py @@ -468,7 +468,7 @@ def _checkpointed_attention_forward( checkpoint_inputs.append(kwarg_value) def custom_forward(*inputs): - (query, key, value, attention_mask, _, attn_mask_type, *tensor_kwarg_values) = inputs + query, key, value, attention_mask, _, attn_mask_type, *tensor_kwarg_values = inputs attn_mask_type = AttnMaskType(attn_mask_type.item()) extra_kwargs = dict(core_attention_extra_kwargs) for name, kwarg_value in zip(tensor_kwarg_names, tensor_kwarg_values): @@ -1881,9 +1881,9 @@ def get_query_key_value_tensors( ] if SplitAlongDim is not None: - (query, gate, key, value) = SplitAlongDim(mixed_qkv, 3, split_arg_list) + query, gate, key, value = SplitAlongDim(mixed_qkv, 3, split_arg_list) else: - (query, gate, key, value) = torch.split(mixed_qkv, split_arg_list, dim=3) + query, gate, key, value = torch.split(mixed_qkv, split_arg_list, dim=3) else: # If no output gate: [sq, b, ng, (np/ng + 2) * hn] # --> [sq, b, ng, np/ng * hn], None, [sq, b, ng, hn], [sq, b, ng, hn] @@ -1898,9 +1898,9 @@ def get_query_key_value_tensors( return mixed_qkv, split_arg_list if SplitAlongDim is not None: - (query, key, value) = SplitAlongDim(mixed_qkv, 3, split_arg_list) + query, key, value = SplitAlongDim(mixed_qkv, 3, split_arg_list) else: - (query, key, value) = torch.split(mixed_qkv, split_arg_list, dim=3) + query, key, value = torch.split(mixed_qkv, split_arg_list, dim=3) # Query [sq, b, ng, np/ng * hn] -> [sq, b, np, hn] query = query.reshape(query.size(0), query.size(1), -1, self.hidden_size_per_attention_head) @@ -2144,7 +2144,7 @@ def get_query_key_value_tensors( mixed_kv = mixed_kv.view(*new_tensor_shape) # [sk, b, np, 2 * hn] --> 2 [sk, b, np, hn] - (key, value) = tensor_parallel.split_tensor_along_last_dim(mixed_kv, 2) + key, value = tensor_parallel.split_tensor_along_last_dim(mixed_kv, 2) # Attention head [sq, b, h] --> [sq, b, hp] query, _ = apply_module(self.linear_q)(hidden_states) diff --git a/megatron/core/transformer/mlp.py b/megatron/core/transformer/mlp.py index f5659d21a6e..ae0f8171fd4 100644 --- a/megatron/core/transformer/mlp.py +++ b/megatron/core/transformer/mlp.py @@ -193,12 +193,8 @@ def __init__( if ffn_hidden_size is None: if is_expert: raise ValueError("MoE MLP requires `ffn_hidden_size`, but it was not provided.") - warnings.warn( - "MLP requires ffn_hidden_size, but it was not provided. Using \ - config.ffn_hidden_size by default.", - DeprecationWarning, - stacklevel=2, - ) + warnings.warn("MLP requires ffn_hidden_size, but it was not provided. Using \ + config.ffn_hidden_size by default.", DeprecationWarning, stacklevel=2) ffn_hidden_size = not_none(self.config.ffn_hidden_size) # If this is a gated linear unit we double the output width diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index 3fa91068769..5c55f2abe6c 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -1246,9 +1246,7 @@ def _te_cuda_graph_replay_impl(self, args, kwargs, context): # CUDA Graph captures the whole MLP/MoE part. CUDA Graph output is the layer output. assert len(cuda_graph_output) == 1, "CUDA Graph output should be the layer output." output = cuda_graph_output.pop() - assert ( - not self.config.overlap_moe_expert_parallel_comm - ), "EP overlap must be \ + assert not self.config.overlap_moe_expert_parallel_comm, "EP overlap must be \ disabled when CUDA graph captures the whole MLP/MoE part." elif self.is_moe_layer and CudaGraphModule.moe_router in self.config.cuda_graph_modules: # CUDA Graph partially captures the MoE. From 708f0a5eaa7b71b89997b8d1dd2222bcbea187a4 Mon Sep 17 00:00:00 2001 From: Haoran Zhang Date: Mon, 3 Aug 2026 13:32:48 -0700 Subject: [PATCH 191/290] Add context-parallel causal conv1d support (#5917) Signed-off-by: Haoran Zhang --- megatron/core/ssm/causal_conv1d.py | 90 ++++++++++++++++++++++ tests/unit_tests/ssm/test_causal_conv1d.py | 73 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 megatron/core/ssm/causal_conv1d.py create mode 100644 tests/unit_tests/ssm/test_causal_conv1d.py diff --git a/megatron/core/ssm/causal_conv1d.py b/megatron/core/ssm/causal_conv1d.py new file mode 100644 index 00000000000..f70f455687d --- /dev/null +++ b/megatron/core/ssm/causal_conv1d.py @@ -0,0 +1,90 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Causal convolution over contiguous context-parallel sequence shards.""" + +import torch + +from megatron.core.tensor_parallel.mappings import all_to_all + +try: + from causal_conv1d import causal_conv1d_fn +except ImportError: + causal_conv1d_fn = None + + +def _exchange_initial_states( + x: torch.Tensor, state_len: int, cp_group: torch.distributed.ProcessGroup +) -> torch.Tensor | None: + """Exchange the preceding rank's tail as the local convolution state. + + All ranks participate in a differentiable ring exchange. Rank 0 zeros the + wrapped tail to preserve the global causal boundary. + """ + if state_len < 0: + raise ValueError(f"state_len must be non-negative, got {state_len}") + if state_len == 0 or cp_group.size() == 1: + return None + if x.shape[1] < state_len: + raise ValueError( + "Each local sequence shard must contain at least " + f"{state_len} tokens for causal convolution, got {x.shape[1]}" + ) + + cp_size = cp_group.size() + cp_rank = cp_group.rank() + batch_size, _, channels = x.shape + split_size = batch_size * state_len + + # Pack only the boundary tokens; x remains a strided sequence shard. + tail = x[:, -state_len:, :].reshape(split_size, channels) + input_splits = [0] * cp_size + output_splits = [0] * cp_size + input_splits[(cp_rank + 1) % cp_size] = split_size + output_splits[(cp_rank - 1) % cp_size] = split_size + previous_tail = all_to_all( + cp_group, tail, output_split_sizes_=output_splits, input_split_sizes=input_splits + ).view(batch_size, state_len, channels) + + if cp_rank == 0: + # Preserve the autograd path while enforcing the global left boundary. + previous_tail = previous_tail.clone() + previous_tail.zero_() + return previous_tail.transpose(1, 2) + + +def causal_conv1d_cp( + x: torch.Tensor, + weight: torch.Tensor, + bias: torch.Tensor | None, + activation: str | None, + cp_group: torch.distributed.ProcessGroup, +) -> torch.Tensor: + """Apply causal Conv1d to a contiguous context-parallel shard. + + Args: + x: Input tensor of shape ``[B, T, D]``. + weight: Depthwise weights of shape ``[D, W]``. + bias: Optional channel-wise bias. + activation: Optional activation passed to ``causal_conv1d_fn``. + cp_group: Context-parallel process group ordered by sequence shard. + + Returns: + Output tensor of shape ``[B, T, D]``. + + Raises: + ImportError: If the optional ``causal-conv1d`` dependency is unavailable. + """ + if causal_conv1d_fn is None: + raise ImportError("causal_conv1d_cp requires the optional causal-conv1d dependency") + + initial_states = _exchange_initial_states( + x=x, state_len=weight.shape[-1] - 1, cp_group=cp_group + ) + output = causal_conv1d_fn( + x=x.transpose(1, 2), + weight=weight, + bias=bias, + initial_states=initial_states, + activation=activation, + ) + return output.transpose(1, 2) diff --git a/tests/unit_tests/ssm/test_causal_conv1d.py b/tests/unit_tests/ssm/test_causal_conv1d.py new file mode 100644 index 00000000000..6818a4e2026 --- /dev/null +++ b/tests/unit_tests/ssm/test_causal_conv1d.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +import pytest +import torch +import torch.distributed as dist + +from megatron.core import parallel_state +from megatron.core.ssm import causal_conv1d as causal_conv1d_module +from tests.unit_tests.test_utilities import Utils + +try: + from causal_conv1d import causal_conv1d_fn + + HAVE_CAUSAL_CONV1D = True +except ImportError: + HAVE_CAUSAL_CONV1D = False + + +def _contiguous_slice(tensor, cp_rank, local_seq_len): + return tensor[:, cp_rank * local_seq_len : (cp_rank + 1) * local_seq_len].contiguous() + + +@pytest.mark.internal +@pytest.mark.skipif( + not HAVE_CAUSAL_CONV1D or not torch.cuda.is_available() or Utils.world_size < 2, + reason="CP causal convolution parity requires causal-conv1d and at least two GPUs", +) +def test_causal_conv1d_cp_matches_full_sequence(): + Utils.initialize_model_parallel(context_parallel_size=Utils.world_size) + try: + cp_group = parallel_state.get_context_parallel_group() + cp_size = dist.get_world_size(group=cp_group) + cp_rank = dist.get_rank(group=cp_group) + device = torch.device("cuda", torch.cuda.current_device()) + dtype = torch.float32 + rtol, atol = 3e-4, 1e-3 + local_seq_len = 64 + global_seq_len = cp_size * local_seq_len + + torch.manual_seed(1234) + + channels, width = 16, 4 + x_global = torch.randn(1, global_seq_len, channels, device=device, dtype=dtype) + weight_global = torch.randn(channels, width, device=device, dtype=dtype) + bias_global = torch.randn(channels, device=device, dtype=dtype) + dy_global = torch.randn_like(x_global) + + x_ref = x_global.detach().clone().requires_grad_(True) + weight_ref = weight_global.detach().clone().requires_grad_(True) + bias_ref = bias_global.detach().clone().requires_grad_(True) + output_ref = causal_conv1d_fn( + x=x_ref.transpose(1, 2), weight=weight_ref, bias=bias_ref, activation="silu" + ).transpose(1, 2) + output_ref.backward(dy_global) + + x_local = _contiguous_slice(x_global, cp_rank, local_seq_len).detach().requires_grad_(True) + weight_local = weight_global.detach().clone().requires_grad_(True) + bias_local = bias_global.detach().clone().requires_grad_(True) + output_local = causal_conv1d_module.causal_conv1d_cp( + x=x_local, weight=weight_local, bias=bias_local, activation="silu", cp_group=cp_group + ) + output_local.backward(_contiguous_slice(dy_global, cp_rank, local_seq_len)) + dist.all_reduce(weight_local.grad, group=cp_group) + dist.all_reduce(bias_local.grad, group=cp_group) + + expected_output = _contiguous_slice(output_ref, cp_rank, local_seq_len) + expected_dx = _contiguous_slice(x_ref.grad, cp_rank, local_seq_len) + torch.testing.assert_close(output_local, expected_output, rtol=rtol, atol=atol) + torch.testing.assert_close(x_local.grad, expected_dx, rtol=rtol, atol=atol) + torch.testing.assert_close(weight_local.grad, weight_ref.grad, rtol=rtol, atol=atol) + torch.testing.assert_close(bias_local.grad, bias_ref.grad, rtol=rtol, atol=atol) + finally: + Utils.destroy_model_parallel() From 43b1fc8ea1e92d8cd1c7128c0277c7b95f945cbc Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 3 Aug 2026 23:53:14 +0200 Subject: [PATCH 192/290] Refactor TE fused ops integration into mixin (#4630) Signed-off-by: CarlosGomes98 --- .../core/extensions/transformer_engine.py | 609 +++++++++--------- tests/unit_tests/fusions/test_te_fused_ops.py | 101 +++ 2 files changed, 423 insertions(+), 287 deletions(-) create mode 100644 tests/unit_tests/fusions/test_te_fused_ops.py diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 39a314625ef..1251c85dcee 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -11,7 +11,7 @@ import re import warnings from contextlib import contextmanager, nullcontext -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast import torch import torch.nn.functional as F @@ -623,68 +623,79 @@ def __new__(cls, config: TransformerConfig): if HAVE_TE and is_te_min_version("1.13.0"): - class TEFusedResidualRMSNorm(te.pytorch.RMSNorm): + class TEFusedOpsMixin: + """Shared lifecycle helpers for modules backed by TE fused ops. + + Requirements and assumptions: + - The mixin must appear before the concrete module class in the MRO. + - Subclasses must call ``super().__init__`` so the mixin can initialize + the unregistered fused-ops cache. + - Subclasses must implement ``_make_fused_impl`` and return a + ``te.pytorch.ops.Sequential`` whose parameters alias parameters owned + by the registered MCore/TE modules. + - The fused implementation is built lazily on first forward, after the + concrete module has created its submodules and parameters. + - The fused implementation must not be registered as a PyTorch + submodule, because checkpointing and optimizer state should continue + to use the original module hierarchy as the source of truth. + - The fused implementation is an unregistered execution view over + registered source modules and parameters. + - Forward hooks on source modules are best-effort emulated on the fused + implementation. Hooks that modify tensors are unsupported because TE + fused ops do not expose intermediate tensors. + - Hooks, config, and source module replacement after first forward are + not reflected in the cached fused implementation. Call + ``_reset_fused_impl`` before the next forward after such changes. """ - RMSNorm with fused residual output for Megatron Core. - - Inherits from te.pytorch.RMSNorm to maintain all parameter management, - checkpoint compatibility, and Megatron-specific features. Creates a fused - implementation using TE's ops API that shares the base class parameters. - - The fused implementation uses: - - MakeExtraOutput: Forks the residual connection - - RMSNorm: Normalizes the main path - Forward pass returns: (normalized_output, residual) - """ + _fused_impl: Optional[Tuple[te.pytorch.ops.Sequential]] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Fused implementation (stored in tuple to avoid submodule registration) - self._fused_impl: Optional[Tuple[te.pytorch.ops.Sequential]] = None + self._fused_impl = None def _make_fused_impl(self) -> te.pytorch.ops.Sequential: - """ - Construct fused ops pipeline that shares parameters with base RMSNorm. + """Construct the fused implementation.""" + raise NotImplementedError - Creates MakeExtraOutput + RMSNorm ops, where the RMSNorm op shares - the weight parameter with self.weight from the base class. - """ + def _get_fused_impl(self) -> te.pytorch.ops.Sequential: + """Return the lazily-built fused implementation. - fused_impl = te.pytorch.ops.Sequential() - - # Op 1: MakeExtraOutput - forks the residual - fused_impl.append(te.pytorch.ops.MakeExtraOutput()) - - # Op 2: RMSNorm - shares weight parameter with self - kwargs = { - "eps": self.eps, - "device": "meta", # Already initialized - "dtype": self.weight.dtype, - "zero_centered_gamma": self.zero_centered_gamma, - } - - # Add sm_margin if available (TE 2.5+) - if hasattr(self, '_sm_margins'): - kwargs["sm_margin"] = self._sm_margins - - rmsnorm_op = te.pytorch.ops.RMSNorm(self.weight.shape, **kwargs) - - rmsnorm_op.weight = self.weight - - fused_impl.append(rmsnorm_op) + This owns cache initialization and hook registration for all fused + wrappers. The fused implementation is stored in a tuple so PyTorch + does not register the TE ops graph as a submodule with duplicate + parameters. + """ + if self._fused_impl is None: + fused_impl = self._make_fused_impl() + self._register_hooks_on_fused_impl(fused_impl) + self._fused_impl = (fused_impl,) + return self._fused_impl[0] - self._register_hooks_on_fused_impl(fused_impl) + def _reset_fused_impl(self) -> None: + """Discard the cached unregistered fused implementation. - return fused_impl + This is intended for tests and for internal use after replacing + source modules, changing config that affects TE ops, or changing + hooks after the first forward. + """ + self._fused_impl = None def _register_hooks_on_fused_impl(self, fused_impl: torch.nn.Module) -> None: + """Attempt to emulate submodule callback hooks. + This is not always possible because Transformer Engine's + op fuser does not expose intermediate tensors. Depending + on what kernel fusions the op fuser chooses, the + intermediate tensors may not even exist. Hooks that modify + tensors will result in incorrect behavior. + """ + + # Get submodule hooks forward_pre_hooks = [] forward_post_hooks = [] backward_pre_hooks = [] backward_post_hooks = [] - for submodule in self.modules(): for hook_id, hook in submodule._forward_pre_hooks.items(): with_kwargs = hook_id in submodule._forward_pre_hooks_with_kwargs @@ -697,6 +708,8 @@ def _register_hooks_on_fused_impl(self, fused_impl: torch.nn.Module) -> None: for hook in submodule._backward_hooks.values(): backward_post_hooks.append((submodule, hook)) + module_name = self.__class__.__name__ + # Pre-forward hooks # Note: DDP pre-forward hooks are safe since they do not # interact with input tensor. @@ -708,8 +721,8 @@ def _register_hooks_on_fused_impl(self, fused_impl: torch.nn.Module) -> None: for _, hook, _ in forward_pre_hooks ): warnings.warn( - "TEFusedResidualRMSNorm module has a submodule with a pre-forward hook. " - "TEFusedResidualRMSNorm module does not expose intermediate tensors, " + f"{module_name} module has a submodule with a pre-forward hook. " + f"{module_name} module does not expose intermediate tensors, " "so the hook may have incorrect behavior if it attempts to " "access the input tensor." ) @@ -722,9 +735,8 @@ def forward_pre_hook(module, *_) -> None: ret = hook(submodule, ()) if ret is not None: raise RuntimeError( - "TEFusedResidualRMSNorm module does not expose " - "intermediate tensors, but submodule has " - "pre-forward hook that modifies input tensor." + f"{module_name} module does not expose intermediate tensors, " + "but submodule has pre-forward hook that modifies input tensor." ) fused_impl.register_forward_pre_hook(forward_pre_hook) @@ -732,8 +744,8 @@ def forward_pre_hook(module, *_) -> None: # Post-forward hooks if forward_post_hooks: warnings.warn( - "TEFusedResidualRMSNorm module has a submodule with a post-forward hook. " - "TEFusedResidualRMSNorm module does not expose intermediate tensors, " + f"{module_name} module has a submodule with a post-forward hook. " + f"{module_name} module does not expose intermediate tensors, " "so the hook may have incorrect behavior if it attempts to " "access the input or output tensors." ) @@ -746,9 +758,8 @@ def forward_post_hook(module, *_) -> None: ret = hook(submodule, (), None) if ret is not None: raise RuntimeError( - "TEFusedResidualRMSNorm module does not expose " - "intermediate tensors, but submodule has " - "post-forward hook that modifies output tensor." + f"{module_name} module does not expose intermediate tensors, " + "but submodule has post-forward hook that modifies output tensor." ) fused_impl.register_forward_hook(forward_post_hook) @@ -756,15 +767,231 @@ def forward_post_hook(module, *_) -> None: # Backward hooks if backward_pre_hooks: raise RuntimeError( - "TEFusedResidualRMSNorm module does not support " - "submodules with pre-backward hooks" + f"{module_name} module does not support submodules with pre-backward hooks" ) if backward_post_hooks: raise RuntimeError( - "TEFusedResidualRMSNorm module does not support " - "submodules with post-backward hooks" + f"{module_name} module does not support submodules with post-backward hooks" ) + def _te_ops_has_nested_attr(module: torch.nn.Module, attr_name: str) -> bool: + """Return whether an adapter input exposes a possibly-nested attribute.""" + current: Any = module + for part in attr_name.split("."): + if not hasattr(current, part): + return False + current = getattr(current, part) + return True + + def _validate_te_ops_adapter_module( + module: torch.nn.Module, module_name: str, required_attrs: Sequence[str] + ) -> None: + """Validate that a module has the MCore/TE wrapper attributes needed by an adapter.""" + missing_attrs = [ + attr for attr in required_attrs if not _te_ops_has_nested_attr(module, attr) + ] + if missing_attrs: + raise ValueError( + f"{module_name} must be a Megatron Core Transformer Engine wrapper, " + f"but got {module.__class__.__name__} with missing required attributes: " + f"{', '.join(missing_attrs)}." + ) + + def _get_te_ops_tensor_parallel_context() -> ( + Tuple[int, Optional[torch.distributed.ProcessGroup]] + ): + """Return tensor-parallel world size and group for TE ops.""" + tp_world_size = get_tensor_model_parallel_world_size() + tp_group = None + if tp_world_size > 1: + tp_group = get_tensor_model_parallel_group() + return tp_world_size, tp_group + + def _get_te_ops_rng_state_tracker_function() -> Optional[Callable]: + """Return the CUDA RNG tracker function if it is initialized.""" + if get_cuda_rng_tracker().is_initialized(): + return get_cuda_rng_tracker + return None + + def _make_te_ops_rmsnorm_from_te_rmsnorm( + module: torch.nn.Module, module_name: str + ) -> te.pytorch.ops.RMSNorm: + """Construct a TE RMSNorm op that aliases an existing TE RMSNorm module.""" + _validate_te_ops_adapter_module( + module, module_name, ("eps", "weight", "zero_centered_gamma") + ) + + kwargs = { + "eps": module.eps, + "device": "meta", + "dtype": module.weight.dtype, + "zero_centered_gamma": module.zero_centered_gamma, + } + if hasattr(module, '_sm_margins'): + kwargs["sm_margin"] = module._sm_margins + + op = te.pytorch.ops.RMSNorm(module.weight.shape, **kwargs) + op.weight = module.weight + return op + + def _make_te_ops_norm_from_mcore_te_layernorm_linear( + module: torch.nn.Module, module_name: str + ) -> te.pytorch.ops.FusibleOperation: + """Construct a TE norm op that aliases a Megatron TE LayerNormLinear wrapper.""" + _validate_te_ops_adapter_module( + module, + module_name, + ( + "eps", + "layer_norm_bias", + "layer_norm_weight", + "normalization", + "weight", + "zero_centered_gamma", + ), + ) + + norm_type = module.normalization + norm_shape = module.weight.size(1) + kwargs = { + "eps": module.eps, + "device": "meta", + "dtype": module.layer_norm_weight.dtype, + "zero_centered_gamma": module.zero_centered_gamma, + } + + if norm_type == "LayerNorm": + op = te.pytorch.ops.LayerNorm(norm_shape, **kwargs) + op.weight = module.layer_norm_weight + op.bias = module.layer_norm_bias + elif norm_type == "RMSNorm": + op = te.pytorch.ops.RMSNorm(norm_shape, **kwargs) + op.weight = module.layer_norm_weight + else: + raise ValueError(f"Unsupported normalization ({norm_type})") + return op + + def _make_te_ops_basic_linear_from_mcore_te_linear( + module: torch.nn.Module, + *, + module_name: str, + output_features: Optional[int] = None, + tensor_parallel_mode: Optional[str] = None, + tensor_parallel_group: Optional[torch.distributed.ProcessGroup] = None, + sequence_parallel: Optional[bool] = None, + rng_state_tracker_function: Optional[Callable] = None, + ) -> te.pytorch.ops.BasicLinear: + """Construct a TE BasicLinear op that aliases a Megatron TE linear wrapper.""" + _validate_te_ops_adapter_module( + module, + module_name, + ("config.tp_comm_overlap", "fuse_wgrad_accumulation", "ub_name", "weight"), + ) + + weight = module.weight + userbuffers_options = None + if module.config.tp_comm_overlap and module.ub_name is not None: + userbuffers_options = {"comm_name": module.ub_name} + if output_features is None: + output_features = weight.size(0) + if sequence_parallel is None: + sequence_parallel = False + + op = te.pytorch.ops.BasicLinear( + weight.size(1), + output_features, + device="meta", + dtype=weight.dtype, + tensor_parallel_mode=tensor_parallel_mode, + tensor_parallel_group=tensor_parallel_group, + sequence_parallel=sequence_parallel, + rng_state_tracker_function=rng_state_tracker_function, + accumulate_into_main_grad=module.fuse_wgrad_accumulation, + userbuffers_options=userbuffers_options, + ) + op.weight = weight + return op + + def _make_te_ops_bias_from_tensor( + bias: Optional[torch.Tensor], + ) -> Optional[te.pytorch.ops.Bias]: + """Construct a TE Bias op that aliases an existing bias tensor.""" + if isinstance(bias, torch.Tensor) and bias.numel() == 0: + bias = None + if bias is None: + return None + + op = te.pytorch.ops.Bias(bias.numel(), device="meta", dtype=bias.dtype) + op.bias = bias + return op + + def _make_te_ops_activation( + activation_func: Callable, gated_linear_unit: bool, cache_quantized_input: bool + ) -> te.pytorch.ops.FusibleOperation: + """Construct a TE activation op.""" + op_type = None + if (activation_func, gated_linear_unit) == (F.gelu, False): + op_type = te.pytorch.ops.GELU + elif (activation_func, gated_linear_unit) == (F.gelu, True): + op_type = te.pytorch.ops.GEGLU + elif (activation_func, gated_linear_unit) == (F.silu, False): + if not is_te_min_version("2.8.0"): + raise NotImplementedError("SiLU activation requires Transformer Engine 2.8+") + op_type = te.pytorch.ops.SiLU + elif (activation_func, gated_linear_unit) == (F.silu, True): + op_type = te.pytorch.ops.SwiGLU + elif (activation_func, gated_linear_unit) == (F.relu, False): + op_type = te.pytorch.ops.ReLU + elif (activation_func, gated_linear_unit) == (F.relu, True): + op_type = te.pytorch.ops.ReGLU + + if op_type is None: + raise NotImplementedError( + "Transformer Engine operation-based API does not support " + f"activation_func={activation_func}, " + f"gated_linear_unit={gated_linear_unit}" + ) + + kwargs = {} + if is_te_min_version("2.3"): + kwargs["cache_quantized_input"] = cache_quantized_input + return op_type(**kwargs) + + class TEFusedResidualRMSNorm(TEFusedOpsMixin, te.pytorch.RMSNorm): + """ + RMSNorm with fused residual output for Megatron Core. + + Inherits from te.pytorch.RMSNorm to maintain all parameter management, + checkpoint compatibility, and Megatron-specific features. Creates a fused + implementation using TE's ops API that shares the base class parameters. + + The fused implementation uses: + - MakeExtraOutput: Forks the residual connection + - RMSNorm: Normalizes the main path + + Forward pass returns: (normalized_output, residual) + """ + + def _make_fused_impl(self) -> te.pytorch.ops.Sequential: + """ + Construct fused ops pipeline that shares parameters with base RMSNorm. + + Creates MakeExtraOutput + RMSNorm ops, where the RMSNorm op shares + the weight parameter with self.weight from the base class. + """ + + fused_impl = te.pytorch.ops.Sequential() + + # Op 1: MakeExtraOutput - forks the residual + fused_impl.append(te.pytorch.ops.MakeExtraOutput()) + + # Op 2: RMSNorm - shares weight parameter with self + fused_impl.append( + _make_te_ops_rmsnorm_from_te_rmsnorm(self, module_name=self.__class__.__name__) + ) + + return fused_impl + def forward(self, hidden_states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ Forward pass with fused residual output. @@ -780,14 +1007,9 @@ def forward(self, hidden_states: torch.Tensor) -> Tuple[torch.Tensor, torch.Tens when MakeExtraOutput is present, so we don't need manual unpacking. """ - # Construct fused impl lazily on first forward - # (in case parameters are modified after __init__) - if self._fused_impl is None: - self._fused_impl = (self._make_fused_impl(),) - # Apply fused implementation # Sequential returns (normalized_output, residual) automatically - return self._fused_impl[0](hidden_states) + return self._get_fused_impl()(hidden_states) else: TEFusedResidualRMSNorm = None # type: ignore[assignment, misc] @@ -2723,99 +2945,63 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): if HAVE_TE and is_te_min_version("1.13.0"): - class TEFusedMLP(MLP): + class TEFusedMLP(TEFusedOpsMixin, MLP): """MLP wrapper using Transformer Engine's operation-based API.""" @copy_signature(MLP.__init__) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Fused implementation - self._fused_impl: Optional[Tuple[te.pytorch.ops.Sequential]] = None - def _make_fused_impl(self) -> te.pytorch.ops.Sequential: """Construct fused module matching MLP.""" # Container for fusible ops fused_impl = te.pytorch.ops.Sequential() - # Tensor parallelism configuration - tp_world_size = get_tensor_model_parallel_world_size() - tp_group = None - if tp_world_size > 1: - tp_group = get_tensor_model_parallel_group() - - # RNG state - rng_state_tracker_function = None - if get_cuda_rng_tracker().is_initialized(): - rng_state_tracker_function = get_cuda_rng_tracker + tp_world_size, tp_group = _get_te_ops_tensor_parallel_context() + rng_state_tracker_function = _get_te_ops_rng_state_tracker_function() # Check submodule types - if not isinstance(self.linear_fc1, te.pytorch.LayerNormLinear): + if not isinstance(self.linear_fc1, TELayerNormColumnParallelLinear): raise ValueError( f"{self.__class__.__name__} expects FC1 to be " - "Transformer Engine LayerNormLinear, but found " + "Megatron Core TELayerNormColumnParallelLinear, but found " f"{self.linear_fc1.__class__.__name__}." ) - if not isinstance(self.linear_fc2, te.pytorch.Linear): + if not isinstance(self.linear_fc2, TERowParallelLinear): raise ValueError( - f"{self.__class__.__name__} expects FC1 to be " - "Transformer Engine Linear, but found " + f"{self.__class__.__name__} expects FC2 to be " + "Megatron Core TERowParallelLinear, but found " f"{self.linear_fc2.__class__.__name__}." ) - # Norm op - norm_type = self.linear_fc1.normalization - norm_shape = self.linear_fc1.weight.size(1) - kwargs = { - "eps": self.linear_fc1.eps, - "device": "meta", - "dtype": self.linear_fc1.layer_norm_weight.dtype, - "zero_centered_gamma": self.linear_fc1.zero_centered_gamma, - } - op = None - if norm_type == "LayerNorm": - op = te.pytorch.ops.LayerNorm(norm_shape, **kwargs) - op.weight = self.linear_fc1.layer_norm_weight - op.bias = self.linear_fc1.layer_norm_bias - elif norm_type == "RMSNorm": - op = te.pytorch.ops.RMSNorm(norm_shape, **kwargs) - op.weight = self.linear_fc1.layer_norm_weight - else: - raise ValueError(f"Unsupported normalization ({norm_type})") - fused_impl.append(op) + fused_impl.append( + _make_te_ops_norm_from_mcore_te_layernorm_linear( + self.linear_fc1, module_name="linear_fc1" + ) + ) # FC1 linear op weight = self.linear_fc1.weight - userbuffers_options = None - if self.linear_fc1.config.tp_comm_overlap and self.linear_fc1.ub_name is not None: - userbuffers_options = {"comm_name": self.linear_fc1.ub_name} - op = te.pytorch.ops.BasicLinear( - weight.size(1), - weight.size(0) * tp_world_size, - device="meta", - dtype=weight.dtype, - tensor_parallel_mode="column" if tp_world_size > 1 else None, - tensor_parallel_group=tp_group, - sequence_parallel=self.linear_fc1.sequence_parallel, - rng_state_tracker_function=rng_state_tracker_function, - accumulate_into_main_grad=self.linear_fc1.fuse_wgrad_accumulation, - userbuffers_options=userbuffers_options, + fused_impl.append( + _make_te_ops_basic_linear_from_mcore_te_linear( + self.linear_fc1, + module_name="linear_fc1", + output_features=weight.size(0) * tp_world_size, + tensor_parallel_mode="column" if tp_world_size > 1 else None, + tensor_parallel_group=tp_group, + sequence_parallel=self.linear_fc1.sequence_parallel, + rng_state_tracker_function=rng_state_tracker_function, + ) ) - op.weight = weight - fused_impl.append(op) # FC1 bias op - bias = self.linear_fc1.bias - if isinstance(bias, torch.Tensor) and bias.numel() == 0: - bias = None - if bias is not None: - op = te.pytorch.ops.Bias(bias.numel(), device="meta", dtype=bias.dtype) - op.bias = bias + op = _make_te_ops_bias_from_tensor(self.linear_fc1.bias) + if op is not None: fused_impl.append(op) # Activation op - op = self._make_activation_op( + op = _make_te_ops_activation( self.activation_func, self.config.gated_linear_unit, self.config.activation_func_fp8_input_store, @@ -2823,21 +3009,13 @@ def _make_fused_impl(self) -> te.pytorch.ops.Sequential: fused_impl.append(op) # FC2 linear op - weight = self.linear_fc2.weight - userbuffers_options = None - if self.linear_fc2.config.tp_comm_overlap and self.linear_fc2.ub_name is not None: - userbuffers_options = {"comm_name": self.linear_fc2.ub_name} - op = te.pytorch.ops.BasicLinear( - weight.size(1), - weight.size(0), - device="meta", - dtype=weight.dtype, - rng_state_tracker_function=rng_state_tracker_function, - accumulate_into_main_grad=self.linear_fc2.fuse_wgrad_accumulation, - userbuffers_options=userbuffers_options, + fused_impl.append( + _make_te_ops_basic_linear_from_mcore_te_linear( + self.linear_fc2, + module_name="linear_fc2", + rng_state_tracker_function=rng_state_tracker_function, + ) ) - op.weight = weight - fused_impl.append(op) if tp_world_size > 1: if self.linear_fc2.sequence_parallel: fused_impl.append(te.pytorch.ops.ReduceScatter(tp_group)) @@ -2846,160 +3024,17 @@ def _make_fused_impl(self) -> te.pytorch.ops.Sequential: # FC2 bias op if not self.linear_fc2.te_return_bias: - bias = self.linear_fc2.bias - if isinstance(bias, torch.Tensor) and bias.numel() == 0: - bias = None - if bias is not None: - op = te.pytorch.ops.Bias(bias.numel(), device="meta", dtype=bias.dtype) - op.bias = bias + op = _make_te_ops_bias_from_tensor(self.linear_fc2.bias) + if op is not None: fused_impl.append(op) - # Emulate submodule forward hooks if needed - self._register_hooks_on_fused_impl(fused_impl) - return fused_impl - def _make_activation_op( - self, activation_func: Callable, gated_linear_unit: bool, cache_quantized_input: bool - ) -> te.pytorch.ops.FusibleOperation: - """Construct activation op.""" - - # Get op type - op_type = None - if (activation_func, gated_linear_unit) == (F.gelu, False): - op_type = te.pytorch.ops.GELU - elif (activation_func, gated_linear_unit) == (F.gelu, True): - op_type = te.pytorch.ops.GEGLU - elif (activation_func, gated_linear_unit) == (F.silu, False): - if not is_te_min_version("2.8.0"): - raise NotImplementedError("SiLU activation requires Transformer Engine 2.8+") - op_type = te.pytorch.ops.SiLU - elif (activation_func, gated_linear_unit) == (F.silu, True): - op_type = te.pytorch.ops.SwiGLU - elif (activation_func, gated_linear_unit) == (F.relu, False): - op_type = te.pytorch.ops.ReLU - elif (activation_func, gated_linear_unit) == (F.relu, True): - op_type = te.pytorch.ops.ReGLU - - # Could not find corresponding activation op - if op_type is None: - raise NotImplementedError( - "Transformer Engine operation-based API does not support " - f"activation_func={activation_func}, " - f"gated_linear_unit={gated_linear_unit}" - ) - - # Construct op - kwargs = {} - if is_te_min_version("2.3"): - kwargs["cache_quantized_input"] = cache_quantized_input - return op_type(**kwargs) - - def _register_hooks_on_fused_impl(self, fused_impl: torch.nn.Module) -> None: - """Attempt to emulate submodule callback hooks. - - This is not always possible because Transformer Engine's - op fuser does not expose intermediate tensors. Depending - on what kernel fusions the op fuser chooses, the - intermediate tensors may not even exist. Hooks that modify - tensors will result in incorrect behavior. - - """ - - # Get submodule hooks - forward_pre_hooks = [] - forward_post_hooks = [] - backward_pre_hooks = [] - backward_post_hooks = [] - for submodule in self.modules(): - for hook_id, hook in submodule._forward_pre_hooks.items(): - with_kwargs = hook_id in submodule._forward_pre_hooks_with_kwargs - forward_pre_hooks.append((submodule, hook, with_kwargs)) - for hook_id, hook in submodule._forward_hooks.items(): - with_kwargs = hook_id in submodule._forward_hooks_with_kwargs - forward_post_hooks.append((submodule, hook, with_kwargs)) - for hook in submodule._backward_pre_hooks.values(): - backward_pre_hooks.append((submodule, hook)) - for hook in submodule._backward_hooks.values(): - backward_post_hooks.append((submodule, hook)) - - # Pre-forward hooks - # Note: DDP pre-forward hooks are safe since they do not - # interact with input tensor. - if forward_pre_hooks: - from megatron.core.distributed import distributed_data_parallel - - if any( - inspect.getmodule(hook) != distributed_data_parallel - for _, hook, _ in forward_pre_hooks - ): - warnings.warn( - "TEFusedMLP module has a submodule with a pre-forward hook. " - "TEFusedMLP module does not expose intermediate tensors, " - "so the hook may have incorrect behavior if it attempts to " - "access the input tensor." - ) - - def forward_pre_hook(module, *_) -> None: - for submodule, hook, with_kwargs in forward_pre_hooks: - if with_kwargs: - ret = hook(submodule, (), {}) - else: - ret = hook(submodule, ()) - if ret is not None: - raise RuntimeError( - "TEFusedMLP module does not expose intermediate tensors, but " - "submodule has pre-forward hook that modifies input tensor." - ) - - fused_impl.register_forward_pre_hook(forward_pre_hook) - - # Post-forward hooks - if forward_post_hooks: - warnings.warn( - "TEFusedMLP module has a submodule with a post-forward hook. " - "TEFusedMLP module does not expose intermediate tensors, " - "so the hook may have incorrect behavior if it attempts to " - "access the input or output tensors." - ) - - def forward_post_hook(module, *_) -> None: - for submodule, hook, with_kwargs in forward_post_hooks: - if with_kwargs: - ret = hook(submodule, (), {}, None) - else: - ret = hook(submodule, (), None) - if ret is not None: - raise RuntimeError( - "TEFusedMLP module does not expose intermediate tensors, but " - "submodule has post-forward hook that modifies output tensor." - ) - - fused_impl.register_forward_hook(forward_post_hook) - - # Backward hooks - if backward_pre_hooks: - raise RuntimeError( - "TEFusedMLP module does not support submodules with pre-backward hooks" - ) - if backward_post_hooks: - raise RuntimeError( - "TEFusedMLP module does not support submodules with post-backward hooks" - ) - def forward(self, hidden_states: torch.Tensor, **kwargs) -> Tuple[Tensor, Optional[Tensor]]: """Forward.""" - # Construct fused impl if needed - # Note: We initialize during the first forward pass in - # case the params are modified after the constructor. - # Note: The fused impl is stored in a tuple to avoid - # registering as a submodule. - if self._fused_impl is None: - self._fused_impl = (self._make_fused_impl(),) - # Apply fused impl - out = self._fused_impl[0](hidden_states) + out = self._get_fused_impl()(hidden_states) # Return bias tensor if requested bias = None diff --git a/tests/unit_tests/fusions/test_te_fused_ops.py b/tests/unit_tests/fusions/test_te_fused_ops.py new file mode 100644 index 00000000000..f3ebeb084a6 --- /dev/null +++ b/tests/unit_tests/fusions/test_te_fused_ops.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import pytest +import torch + +from megatron.core.extensions import transformer_engine as te_ext +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.utils import is_te_min_version + +pytestmark = [ + pytest.mark.skipif(not te_ext.HAVE_TE, reason="Transformer Engine is not available"), + pytest.mark.skipif( + not is_te_min_version("1.13.0"), + reason="TE fused ops wrappers require Transformer Engine >= 1.13.0", + ), +] + + +def _make_rmsnorm() -> torch.nn.Module: + return te_ext.TEFusedResidualRMSNorm(normalized_shape=16, dtype=torch.float32, device="cpu") + + +def test_rmsnorm_fused_impl_aliases_source_weight(): + module = _make_rmsnorm() + + fused_impl = module._get_fused_impl() + + assert fused_impl[1].weight is module.weight + + +def test_fused_impl_is_cached_and_resettable(): + module = _make_rmsnorm() + + first_impl = module._get_fused_impl() + + assert module._get_fused_impl() is first_impl + + module._reset_fused_impl() + + assert module._fused_impl is None + + second_impl = module._get_fused_impl() + + assert second_impl is not first_impl + assert second_impl[1].weight is module.weight + + +def test_fused_impl_is_not_registered_as_module_or_state_dict_source(): + module = _make_rmsnorm() + expected_state_keys = set(module.state_dict().keys()) + expected_module_keys = tuple(module._modules.keys()) + + fused_impl = module._get_fused_impl() + + assert set(module.state_dict().keys()) == expected_state_keys + assert "weight" in expected_state_keys + assert tuple(module._modules.keys()) == expected_module_keys + assert "_fused_impl" not in module._modules + assert all(child is not fused_impl for child in module.modules()) + + +def test_mcore_te_linear_adapter_rejects_plain_te_linear(): + plain_linear = te_ext.te.pytorch.Linear(16, 16, device="meta") + + with pytest.raises(ValueError) as exc_info: + te_ext._make_te_ops_basic_linear_from_mcore_te_linear( + plain_linear, module_name="plain_linear" + ) + + message = str(exc_info.value) + assert "plain_linear" in message + assert plain_linear.__class__.__name__ in message + assert "config.tp_comm_overlap" in message + + +def test_mcore_te_linear_adapter_aliases_source_weight(): + config = TransformerConfig( + num_layers=1, + hidden_size=16, + num_attention_heads=1, + use_cpu_initialization=True, + params_dtype=torch.float32, + ) + linear = te_ext.TEColumnParallelLinear( + 16, + 32, + config=config, + init_method=torch.nn.init.zeros_, + gather_output=False, + bias=True, + skip_bias_add=True, + is_expert=False, + tp_comm_buffer_name="fc1", + ) + + op = te_ext._make_te_ops_basic_linear_from_mcore_te_linear( + linear, module_name="linear", output_features=linear.weight.size(0) + ) + + assert isinstance(op, te_ext.te.pytorch.ops.BasicLinear) + assert op.weight is linear.weight From 23b2ff22eea5a150fec2b41b4d035afa216c8040 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Tue, 4 Aug 2026 09:12:46 +0800 Subject: [PATCH 193/290] [feat] Add reduce-scatter-with-fp32-accumulation support for GTP (#6200) Signed-off-by: Shiqing Fan --- .../core/generalized_tensor_parallel.md | 46 +- .../reduce_scatter_with_fp32_accumulation.py | 17 +- .../generalized_tensor_parallelism.py | 120 +++++- megatron/training/arguments.py | 6 + megatron/training/training.py | 3 + ...t_reduce_scatter_with_fp32_accumulation.py | 51 +++ .../test_gtp_grad_correctness.py | 404 +++++++++++++++++- 7 files changed, 623 insertions(+), 24 deletions(-) diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index a14475cd869..f12306f9170 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -274,7 +274,8 @@ At iter-0 you'll see one rank-0 log line confirming the active config: ``` GTP_remat enabled. GTPRematConfig(pad_for_alignment=16, check_param_states=False, - weight_prefetch=True, async_reduction=True, calculate_per_token_loss=False) + weight_prefetch=True, async_reduction=True, calculate_per_token_loss=False, + reduce_scatter_with_fp32_accumulation=False) ``` ### 2.4 Tuning knobs @@ -287,6 +288,7 @@ update_gtp_config( weight_prefetch=True, # Disable to debug the cold-start path async_reduction=True, # Whether to perform GTP_remat gradient reduction asynchronously calculate_per_token_loss=False, # Mirror config.calculate_per_token_loss (SUM vs MEAN RS) + reduce_scatter_with_fp32_accumulation=False, # wgrad RS: BF16 all-to-all + FP32 sum (§2.5) ) ``` @@ -294,6 +296,42 @@ update_gtp_config( > **CUDA-graph warmup under GTP_remat.** When CUDA graphs are enabled, GTP_remat forces a minimum of **2** per-graph warmup steps regardless of `--cuda-graph-warmup-steps` (e.g. a user-set `0` is bumped to `2`): the first warmup builds the weight-prefetch chain and the second exercises the prefetch path before capture. +### 2.5 FP32-accumulation wgrad reduce-scatter (optional) + +```bash +--gtp-remat-reduce-scatter-with-fp32-accumulation # default: off +``` + +**A ring reduce-scatter rounds the partial sum at every one of its `N-1` hops, so BF16 gradient error compounds with the axis size (≈`√N` for gradient-like data, worse when contributions share a sign). This flag replaces it with an all-to-all plus one local FP32 sum, eliminating that accumulation error for the same bytes on the wire.** + +| | | +|---|---| +| **Use when** | wgrads are BF16 (the default) **and** the gtp_remat axis is ≥ 4 | +| **Skip when** | `--accumulate-allreduce-grads-in-fp32` is set, which already makes the wire and the accumulation FP32; or the axis is ≤ 2, where it is auto-bypassed | +| **Gain** | the `N-1` intermediate roundings disappear, leaving only the final downcast — so the error stops growing with the axis, and the benefit grows with it | +| **Cost** | one unsharded-wgrad-sized scratch buffer per in-flight reduce-scatter, plus a local FP32 sum and downcast at `wait()` time | + +Implemented in `megatron/core/distributed/reduce_scatter_with_fp32_accumulation.py`. This is the +gtp_remat-axis analogue of `--ddp-reduce-scatter-with-fp32-accumulation` and **independent of +it** — a different collective over a different process group, so enable either, both, or neither. + +**Behaviour notes** + +- **The mean stays a pre-scale.** Both paths apply `1/gtp_remat` to the wgrad before the + collective (§3.2 table); under `calculate_per_token_loss` the axis SUMs and no factor + applies either way. +- **Auto-bypass at axis size ≤ 2.** The gate reads the per-chain group, so each axis decides + independently: a `GTP_remat=8 × EGTP_remat=2` run gets FP32 accumulation on the dense weights + and the plain reduce-scatter on the experts. +- **Scratch lifetime.** The buffer comes from GTP's wgrad pool rather than a fresh `empty_like`, + and is returned only once the handle is waited — it is the *input* to the deferred FP32 sum. +- **Batched (grouped / routed-expert) path.** The all-to-alls share one `ncclGroupStart/End` via + `_coalescing_manager`, but the manager cannot serve as the handle: it waits only the NCCL work + it collects, while each fp32-accum handle still owes a local FP32 sum. The sums are deferred + behind it in one composite handle — which is why the all-to-alls are issued with + `async_op=True`: for this primitive that flag defers the sum, it does not merely return a + handle. (DDP's own flag sidesteps all this by asserting a single bucket.) + --- ## 3. Implementation details @@ -430,6 +468,8 @@ The DP collective only covers the replicate axis; the gtp_remat axis is complete | final normalization | net grad = full `(replicate × gtp_remat)` **mean** | grads summed over all axes, then `÷ total_global_tokens` in `finalize_model_grads` | - **Default (mean) path** decouples gradient scaling from the gtp_remat degree: the DP `1/replicate` mean × the reduce-scatter `1/gtp_remat` mean (sharded weights) — or × the finalize AVG (replicated params) — equals the exact full mean, independent of the gtp_remat axis size. +- **`--gtp-remat-reduce-scatter-with-fp32-accumulation` swaps the collective, not the scaling** + — this table applies unchanged (§2.5). - **Per-token-loss path** must SUM over gtp_remat (like the DP axis): `total_global_tokens` already counts the gtp_remat peers' distinct tokens, so the single `÷ total_global_tokens` does all normalization. A `1/gtp_remat` mean here would shrink every gtp_remat gradient by `1/gtp_remat` (grad-norm mismatch + divergence), so the reduce-scatter mean and finalize AVG are both gated on `not calculate_per_token_loss`. > **`average_in_collective` must be off (the default).** The default-path scaling is a *pre-scale* applied before a SUM collective. `average_in_collective=True` instead uses NCCL AVG over the collective's own (replicate) group, which interacts incorrectly with the gtp_remat completion. Asserted via `ProcessGroupCollection.is_gtp_remat_active` in both `arguments.py` (training) and `DistributedDataParallel.__init__` (direct megatron-core users). (Independently, `calculate_per_token_loss` already forbids `average_in_collective`.) @@ -552,10 +592,12 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_tp_gtp.py` | GTP_remat composed with tensor parallelism (`tp_group × gtp_remat_group`). | | `test_moe_egtp.py` | EGTP_remat on MoE routed-expert weights. | | `test_gtp_loss_correctness.py` | End-to-end: GTP_remat per-step loss trajectory matches a no-GTP_remat baseline. | -| `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. | +| `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. Also the fp32-accumulation reduce-scatter (§2.5): gtp_remat-axis and DDP-axis parity, plus the size-2 bypass. | | `test_gtp_cudagraph_grad.py` | Capture-step grad-norm guard (§1.2): `_backup_grads_before_capture`/`_restore_grads_after_capture` keep a graph capture from clobbering finalized `main_grad` (own params + cross-graph `next_w`, incl. routed-expert `weight_list`). | | `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | | `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | | `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. | +The fp32-accumulation primitive itself is covered outside this suite, by `tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py`, which does not require GTP_remat. + All tests require ≥ 4 GPUs and TransformerEngine >= 2.19; they self-skip when those are unavailable. A green run (skips for unmet hardware/config are acceptable) is the minimum bar for any GTP_remat change. diff --git a/megatron/core/distributed/reduce_scatter_with_fp32_accumulation.py b/megatron/core/distributed/reduce_scatter_with_fp32_accumulation.py index 9a249561660..3fd1e5671a3 100644 --- a/megatron/core/distributed/reduce_scatter_with_fp32_accumulation.py +++ b/megatron/core/distributed/reduce_scatter_with_fp32_accumulation.py @@ -1,7 +1,7 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. -from typing import Any +from typing import Any, Optional import torch @@ -36,7 +36,7 @@ def wait(self): assert output_tensor_in_fp32.dtype == torch.float32 # Copy downcasted sum into output_tensor. - self.output_tensor.copy_(output_tensor_in_fp32) + self.output_tensor.copy_(output_tensor_in_fp32.view(self.output_tensor.shape)) def reduce_scatter_with_fp32_accumulation( @@ -45,6 +45,7 @@ def reduce_scatter_with_fp32_accumulation( op: torch.distributed.ReduceOp, group: torch.distributed.ProcessGroup, async_op: bool, + all_to_all_output_tensor: Optional[torch.Tensor] = None, ): """Reduce-scatter with FP32 accumulation. @@ -58,6 +59,9 @@ def reduce_scatter_with_fp32_accumulation( op (torch.distributed.ReduceOp): Only torch.distributed.ReduceOp.SUM is supported. group (torch.distributed.ProcessGroup): Process group to use for reduce-scatter. async_op (bool): Only False is supported right now. + all_to_all_output_tensor (torch.Tensor, optional): Caller-provided scratch matching + input_tensor's shape and dtype, for callers with their own buffer pool. Allocated + internally when omitted; must stay alive until .wait() returns. """ # Make sure arguments conform to the implementation. assert op == torch.distributed.ReduceOp.SUM @@ -74,7 +78,14 @@ def reduce_scatter_with_fp32_accumulation( # Call all_to_all (every rank should have their respective gradient shards collected from # all ranks). We also create a tensor for the all-to-all output (the all-to-all collective # cannot be performed in-place). - all_to_all_output_tensor = torch.empty_like(input_tensor) + if all_to_all_output_tensor is None: + all_to_all_output_tensor = torch.empty_like(input_tensor) + else: + assert all_to_all_output_tensor.shape == input_tensor.shape, ( + f"all_to_all_output_tensor shape {tuple(all_to_all_output_tensor.shape)} does not " + f"match input_tensor shape {tuple(input_tensor.shape)}" + ) + assert all_to_all_output_tensor.dtype == input_tensor.dtype all_to_all_handle = torch.distributed.all_to_all_single( output=all_to_all_output_tensor, input=input_tensor, group=group, async_op=async_op ) diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index 9099cd114b9..3ff33914cda 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -316,6 +316,18 @@ def _wgrad_pool_put(buf: torch.Tensor): _wgrad_buf_pool[key].append(buf) +class _GTPCompositeWorkHandle: + """Waits on several collective handles as one, so GTPShardHandle still sees one handle.""" + + def __init__(self, handles): + self.handles = [h for h in handles if h is not None] + + def wait(self): + """Wait on every underlying handle, in issue order.""" + for handle in self.handles: + handle.wait() + + def _stream_key(chain_id: str, group) -> tuple: """Key for the per-(chain, group) AG/RS stream dicts. @@ -389,6 +401,10 @@ class GTPRematConfig: # normalization. When False, the gtp_remat reduce-scatter takes the MEAN so it composes with # DDP's 1/replicate scaling to yield the full (replicate x gtp) mean. calculate_per_token_loss: bool = False + # Run the gtp_remat wgrad reduce-scatter as all-to-all + local FP32 sum: same bytes on the + # wire, but accumulation no longer loses precision as the axis grows. Bypassed at axis size + # <= 2. Independent of the DDP-axis --ddp-reduce-scatter-with-fp32-accumulation. + reduce_scatter_with_fp32_accumulation: bool = False GTP_CONFIG = GTPRematConfig() @@ -414,7 +430,12 @@ def tag_gtp_params_with_names(model): def configure_gtp_remat_from_recipe( - *, fp4=False, fp8_recipe=None, fp8=False, calculate_per_token_loss=False + *, + fp4=False, + fp8_recipe=None, + fp8=False, + calculate_per_token_loss=False, + reduce_scatter_with_fp32_accumulation=False, ): """ Configure GTP weight-remat (padding + loss reduction) from the quantization recipe. @@ -423,7 +444,11 @@ def configure_gtp_remat_from_recipe( # gtp_remat grad reduction SUMs (not means) the gtp_remat axis under per-token-loss. # check_param_states=False: GTP buffer reuse (notably under CUDA-graph capture) trips the # param-state debug asserts, so keep them off for GTP runs. - update_gtp_config(calculate_per_token_loss=calculate_per_token_loss, check_param_states=False) + update_gtp_config( + calculate_per_token_loss=calculate_per_token_loss, + check_param_states=False, + reduce_scatter_with_fp32_accumulation=reduce_scatter_with_fp32_accumulation, + ) if fp4: update_gtp_config(pad_for_alignment=16) elif fp8_recipe == "mxfp8": @@ -836,6 +861,7 @@ def _init_gtp_runtime_attrs(obj): obj._wgrad_rs_handle = None obj.rs_event = torch.cuda.Event(external=True) obj._rs_ticket = None + obj._rs_a2a_bufs = None # all-to-all scratch held by an in-flight fp32-accum RS # Padding obj.pad_length = 0 # Debug @@ -1560,13 +1586,23 @@ def _wait_reduce_scatter(self, finalize_grad=False): for w in self._weights: self._handle_megatron_grad_accum(w) self._already_finalized = True - # Release stashed wgrad inputs: UNGRAPHED buffers go back to the pool; - # GRAPHED just drops Python refs (addresses must stay stable for CG). - if getattr(self, "_wgrad_input_bufs", None) is not None: + self._release_comm_scratch() + + def _release_comm_scratch(self, attrs=("_wgrad_input_bufs", "_rs_a2a_bufs")): + """Release the buffers a finished RS was reading. + + Its wgrad inputs, and the fp32-accum all-to-all scratch (input to the deferred FP32 sum, + so only free once the handle has been waited on). UNGRAPHED buffers go back to the pool; + GRAPHED just drops Python refs (addresses must stay stable for CG). + """ + for attr in attrs: + bufs = getattr(self, attr, None) + if bufs is None: + continue if not _chain_is_graphed(self.chain_id): - for buf in self._wgrad_input_bufs: + for buf in bufs: _wgrad_pool_put(buf) - self._wgrad_input_bufs = None + setattr(self, attr, None) def _prescale_wgrads_for_mean_rs(self, wgrads): """Pre-scale wgrad by 1/gtp_remat so the SUM reduce-scatter yields the gtp_remat mean. @@ -1581,6 +1617,40 @@ def _prescale_wgrads_for_mean_rs(self, wgrads): if gtp_remat_size > 1 and not GTP_CONFIG.calculate_per_token_loss: torch._foreach_mul_(list(wgrads), 1.0 / gtp_remat_size) + def _reduce_scatter_fp32_accum(self, tensor, out_buffer): + """Issue one fp32-accum reduce-scatter (all-to-all now, FP32 sum at wait) -> (out, handle). + + Always issued async, even for a sync RS: under a coalescing manager the all-to-all is + only enqueued at context exit, so the handle's FP32 sum must never run inline here. The + caller waits the handle (immediately when sync) and then releases the scratch. + + The all-to-all scratch is unsharded-sized, so it comes from the wgrad pool: GTP keeps + several RS in flight and an empty_like each would add that much peak memory. + """ + # Local import: tensor_parallel has no top-level dependency on core.distributed. + from megatron.core.distributed.reduce_scatter_with_fp32_accumulation import ( + reduce_scatter_with_fp32_accumulation, + ) + + tensor = tensor.contiguous() + if out_buffer is None: + out_shape = [tensor.shape[0] // self.group.size(), *tensor.shape[1:]] + out_buffer = torch.empty(out_shape, dtype=tensor.dtype, device=tensor.device) + + a2a_buf = _wgrad_pool_get(tuple(tensor.shape), tensor.dtype, tensor.device) + handle = reduce_scatter_with_fp32_accumulation( + out_buffer, + tensor, + op=torch.distributed.ReduceOp.SUM, + group=self.group, + async_op=True, + all_to_all_output_tensor=a2a_buf, + ) + # Input to the deferred FP32 sum: held until the handle is waited on, then released by + # _release_comm_scratch (from _wait_reduce_scatter, or inline on the sync path). + self._rs_a2a_bufs = (self._rs_a2a_bufs or []) + [a2a_buf] + return out_buffer, handle + def _reduce_scatter(self, wgrads, async_op, nvtx_label=None): """Reduce-scatter one or more wgrads → (outputs, handle). Single tensor: plain RS; multiple: coalesced RS.""" @@ -1627,6 +1697,42 @@ def _reduce_scatter(self, wgrads, async_op, nvtx_label=None): rs_ctx = nullcontext() with rs_ctx: + # Size <= 2 gains nothing (one addition) and still costs the scratch, so it bypasses. + # self.group is per chain, so each axis decides on its own. + if GTP_CONFIG.reduce_scatter_with_fp32_accumulation and self.group.size() > 2: + nvtx_range_push(f"{nvtx_label}.gtp_rs_fp32accum") + outputs, sum_handles = [], [] + if len(wgrads) > 1: + # Batched: group the all-to-alls into one ncclGroupStart/End, exactly like + # the plain batched RS below. Unlike that path we cannot hand the manager + # back as the handle: the manager waits only on the underlying NCCL collective + # to complete, and each fp32-accum handle still owes a local FP32 sum. So the + # sums are deferred behind the manager's work in one composite handle. + with torch.distributed._coalescing_manager( + group=self.group, device=wgrads[0].device, async_ops=True + ) as cm: + for out_buffer, tensor in zip(out_buffers, wgrads): + out, h = self._reduce_scatter_fp32_accum(tensor, out_buffer) + outputs.append(out) + sum_handles.append(h) + # The grouped work from _end_coalescing is the real completion; the per-op + # handles returned inside the region are not, so drop them and let cm wait. + for h in sum_handles: + h.all_to_all_handle = None + handle = _GTPCompositeWorkHandle([cm, *sum_handles]) + else: + out, handle = self._reduce_scatter_fp32_accum(wgrads[0], out_buffers[0]) + outputs.append(out) + nvtx_range_pop(f"{nvtx_label}.gtp_rs_fp32accum") + if async_op: + return outputs, handle + # Sync RS: the FP32 sums were deferred out of the issue loop, so finish here. + # Only the a2a scratch is ours to release — the wgrad inputs belong to the + # caller on this path (recycled in wgrad_reduce_scatter). + handle.wait() + self._release_comm_scratch(("_rs_a2a_bufs",)) + return outputs, None + if len(wgrads) == 1: nvtx_range_push(f"{nvtx_label}.gtp_rs") out, handle = reduce_scatter_along_first_dim( diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 623adcad91e..e63944ecfb0 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2994,6 +2994,12 @@ def _add_distributed_args(parser): default=False, help='If set, use a reduce-scatter implementation which sends lower-precision ' 'values over the wire (using an all-to-all to keep total communication overhead in line ' 'with the standard ring implementation) but performs accumulation locally in FP32.') + group.add_argument('--gtp-remat-reduce-scatter-with-fp32-accumulation', action='store_true', + default=False, help='Same trade as --ddp-reduce-scatter-with-fp32-accumulation, but for ' + 'the wgrad reduce-scatter GTP weight-remat performs over the gtp_remat axis: send ' + 'low-precision values over the wire via an all-to-all and accumulate locally in FP32. ' + 'Independent of the DDP flag (different collective, different process group). Costs one ' + 'extra unsharded-wgrad-sized scratch buffer per in-flight reduce-scatter.') group.add_argument('--ddp-param-name-patterns-for-fp32-local-accumulation', nargs='+', default=[], help='List of param_name patterns (in Python\'s fnmatch format) to match against ' 'to do local gradient accumulation in FP32. The special pattern \'all\' matches ' diff --git a/megatron/training/training.py b/megatron/training/training.py index 8aab38e6071..a8378a91ced 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -2103,6 +2103,9 @@ def _build_model_wrapper(wrap_with_ddp: bool): fp8_recipe=getattr(args, 'fp8_recipe', None), fp8=getattr(args, 'fp8', None) is not None, calculate_per_token_loss=getattr(args, 'calculate_per_token_loss', False), + reduce_scatter_with_fp32_accumulation=getattr( + args, 'gtp_remat_reduce_scatter_with_fp32_accumulation', False + ), ) model = _build_model_wrapper(wrap_with_ddp) diff --git a/tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py b/tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py index dedc8d54c68..801cfd1c1c6 100644 --- a/tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py +++ b/tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py @@ -76,3 +76,54 @@ def test_reduce_scatter_with_fp32_accumulation( assert ( torch.allclose(tensor1_shard, tensor2_shard) == baseline_reduce_scatter_in_fp32 ), f"{get_non_matching_values(tensor1_shard, tensor2_shard)}" + + @pytest.mark.parametrize("axis_size", [2, 4, 8, 16]) + def test_power_of_two_prescale_equals_scaling_the_fp32_sum(self, axis_size: int): + """Pre-scaling BF16 by 1/2^k is exact, which is why GTP pre-scales its 1/gtp_remat mean + onto the wgrad instead of accumulating it here. Local arithmetic only, no collective.""" + scale = 1.0 / axis_size + contributions = (torch.randn(axis_size, 100000, device='cuda') * 1e-3).bfloat16() + + prescaled = (contributions * scale).sum(dim=0, dtype=torch.float32).bfloat16() + scaled_sum = (contributions.sum(dim=0, dtype=torch.float32) * scale).bfloat16() + + assert torch.equal(prescaled, scaled_sum), ( + f"1/{axis_size} pre-scale is not exact: " + f"{(prescaled != scaled_sum).sum().item()} elements differ" + ) + + def test_caller_provided_all_to_all_output_tensor(self): + """A caller-supplied scratch (GTP passes one from its wgrad pool) must be honored. + + A mis-sized one must be rejected before the all-to-all: bailing out mid-flight + desynchronizes the group. + """ + rank, world_size = Utils.rank, Utils.world_size + kwargs = {"op": torch.distributed.ReduceOp.SUM, "group": None, "async_op": False} + tensor = torch.rand(100000, device='cuda', dtype=torch.bfloat16) + + internal = tensor.clone() + reduce_scatter_with_fp32_accumulation( + shard_buffer(internal, world_size)[rank], internal, **kwargs + ) + provided = tensor.clone() + reduce_scatter_with_fp32_accumulation( + shard_buffer(provided, world_size)[rank], + provided, + all_to_all_output_tensor=torch.empty_like(tensor), + **kwargs, + ) + torch.testing.assert_close( + shard_buffer(provided, world_size)[rank], + shard_buffer(internal, world_size)[rank], + rtol=0, + atol=0, + ) + + with pytest.raises(AssertionError): + reduce_scatter_with_fp32_accumulation( + shard_buffer(tensor, world_size)[rank], + tensor, + all_to_all_output_tensor=torch.empty_like(tensor)[:-world_size], + **kwargs, + ) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py index b969e5e5bf8..a563bcd3f3d 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py @@ -21,6 +21,8 @@ weights and grad-norm -- must match. """ +import contextlib + import pytest import torch import torch.distributed as dist @@ -122,8 +124,9 @@ def _run_one_backward(ddp_model, rank, calculate_per_token_loss=False): def _full_main_grads(stack): """Reconstruct full (unsharded) reduced gradients keyed by param name. - GTPShardedParam.main_grad is the local gtp_remat shard -> all-gather over the gtp_remat - group. Non-GTP_remat params are replicated -> take the local (already gtp_remat-summed) copy. + GTPShardedParam.main_grad is the local shard -> all-gather over its own axis (expert params + shard over egtp_remat, dense over gtp_remat). Non-GTP_remat params are replicated -> take + the local (already gtp_remat-summed) copy. """ from megatron.core import parallel_state as ps @@ -133,7 +136,11 @@ def _full_main_grads(stack): g_attr = 'main_grad' if hasattr(p, 'main_grad') else 'grad' mg = getattr(p, g_attr) if isinstance(p, GTPShardedParam): - g = ps.get_gtp_weight_remat_group() + g = ( + ps.get_expert_gtp_weight_remat_group() + if _is_expert_param(name, p) + else ps.get_gtp_weight_remat_group() + ) shards = [torch.empty_like(mg) for _ in range(g.size())] dist.all_gather(shards, mg.contiguous(), group=g) out[name] = torch.cat(shards, dim=0).float().cpu() @@ -142,6 +149,22 @@ def _full_main_grads(stack): return out +def _load_gtp_shards(stack, saved, moe=False): + """Copy each param's own shard of the unsharded `saved` weights into `stack`.""" + from megatron.core import parallel_state as ps + + gtp_rank = ps.get_gtp_weight_remat_group().rank() + egtp_rank = ps.get_expert_gtp_weight_remat_group().rank() if moe else 0 + for name, p in stack.named_parameters(): + full = saved[name] + if isinstance(p, GTPShardedParam): + r = egtp_rank if _is_expert_param(name, p) else gtp_rank + ss = p.shape[0] + p.data.copy_(full[r * ss : (r + 1) * ss]) + else: + p.data.copy_(full) + + def _worker(rank, world_size, port, calculate_per_token_loss=False): from megatron.core import parallel_state as ps from megatron.core.process_groups_config import ProcessGroupCollection @@ -231,14 +254,24 @@ def _worker(rank, world_size, port, calculate_per_token_loss=False): # --------------------------------------------------------------------------- -def _build_ddp_distopt_and_optim(stack): +def _build_ddp_distopt_and_optim( + stack, + overlap_grad_reduce=False, + bucket_size=None, + reduce_scatter_with_fp32_accumulation=False, + grad_reduce_in_fp32=True, +): """Real distributed-optimizer setup (Adam), matching the 64-GPU production path.""" from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig from megatron.core.optimizer import OptimizerConfig, get_megatron_optimizer config = _make_config() ddp_config = DistributedDataParallelConfig( - use_distributed_optimizer=True, overlap_grad_reduce=False + use_distributed_optimizer=True, + overlap_grad_reduce=overlap_grad_reduce, + bucket_size=bucket_size, + grad_reduce_in_fp32=grad_reduce_in_fp32, + reduce_scatter_with_fp32_accumulation=reduce_scatter_with_fp32_accumulation, ) module = torch.nn.Sequential() for i, layer in enumerate(stack): @@ -397,7 +430,11 @@ def _worker_moe_distopt(rank, world_size, port): from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed - pgs = ['tp', 'cp', 'gtp_remat', 'ep'] + # None => pull every group from the MPU globals. An explicit list is a trap here: the MoE + # token dispatcher reads pg_collection.tp_ep, and an omitted group comes back as None rather + # than raising, so the dispatcher silently gathers over a size-1 group and blows up in + # preprocess() with "shape '[ep,tp,num_experts]' is invalid". + pgs = None # ---------- Phase A: baseline GTP1/EGTP1, EP2 (DP2 dense / expert_dp2) ---------- ps.destroy_model_parallel() @@ -476,6 +513,326 @@ def _worker_moe_distopt(rank, world_size, port): torch.testing.assert_close(torch.tensor(moe_gn), torch.tensor(base_gn), atol=0, rtol=3e-2) +# --------------------------------------------------------------------------- +# GTP_remat + reduce_scatter_with_fp32_accumulation +# --------------------------------------------------------------------------- + +# Small enough to split the stack into several bucket groups: with one big bucket every wgrad +# has landed before the single dispatch, hiding a grad-ready ordering bug. +FP32ACCUM_BUCKET_SIZE = 20000 +FP32ACCUM_STEPS = 3 + + +def _reset_dist_reduce_scatter_func(): + """Undo param_and_grad_buffer's sticky module-level ``dist_reduce_scatter_func``. + + It is set (never reset) by the first fp32-accum DDP, so without this a later plain-RS phase + keeps using fp32-accum and the comparison passes vacuously. + """ + import megatron.core.distributed.param_and_grad_buffer as pgb + + pgb.dist_reduce_scatter_func = torch.distributed._reduce_scatter_base + + +def _run_gtp2_phase(rank, saved, fp32_accum): + """GTP_remat=2 dist-opt run with overlapped grad reduce; returns per-step grad-norms.""" + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + stack = _make_stack(_make_config(), pgc) + for layer in stack: + layer.cuda() + gtp_rank = ps.get_gtp_weight_remat_group().rank() + for name, p in stack.named_parameters(): + full = saved[name] + if isinstance(p, GTPShardedParam): + ss = p.shape[0] + p.data.copy_(full[gtp_rank * ss : (gtp_rank + 1) * ss]) + else: + p.data.copy_(full) + + _reset_dist_reduce_scatter_func() + ddp_model, optim = _build_ddp_distopt_and_optim( + stack, + overlap_grad_reduce=True, + bucket_size=FP32ACCUM_BUCKET_SIZE, + reduce_scatter_with_fp32_accumulation=fp32_accum, + grad_reduce_in_fp32=False, # bf16 on the wire: the regime fp32-accum exists for + ) + assert len(ddp_model.bucket_groups) > 1, ( + f"expected >1 bucket group at bucket_size={FP32ACCUM_BUCKET_SIZE}, " + f"got {len(ddp_model.bucket_groups)} -- test would not cover the dispatch-ordering hazard" + ) + grad_norms = [ + _run_step_distopt(ddp_model, optim, rank + 17 * it) for it in range(FP32ACCUM_STEPS) + ] + + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + return grad_norms + + +def _worker_fp32accum(rank, world_size, port): + """GTP_remat=2 grad-norms must be identical with and without fp32-accumulation RS. + + fp32-accum reads grad_data through an all-to-all at dispatch time, while GTP defers its + wgrad ``main_grad.add_`` to a later backward node. If grad-ready fires from autograd instead + of GTP's manual hook, the all-to-all reads grad_data before the add lands. Both phases share + weights and data, so any difference is that staleness. + """ + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + # Common (unsharded) init weights from a GTP_remat=1 build. + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'gtp_remat']) + ref_stack = _make_stack(_make_config(), pgc) + for layer in ref_stack: + layer.cuda() + for p in ref_stack.parameters(): + dist.broadcast(p.data, src=0) + saved = {n: p.data.clone() for n, p in ref_stack.named_parameters()} + del ref_stack + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + + plain_gns = _run_gtp2_phase(rank, saved, fp32_accum=False) + fp32_gns = _run_gtp2_phase(rank, saved, fp32_accum=True) + _reset_dist_reduce_scatter_func() # don't leak fp32-accum into later tests + + if rank == 0: + for step, (pg, fg) in enumerate(zip(plain_gns, fp32_gns)): + print( + f"[fp32accum] step {step}: grad_norm plainRS={pg:.6f} fp32accum={fg:.6f}", + flush=True, + ) + torch.testing.assert_close( + torch.tensor(fp32_gns), torch.tensor(plain_gns), atol=0, rtol=1e-3 + ) + + +# --------------------------------------------------------------------------- +# GTP_remat wgrad reduce-scatter via FP32 accumulation +# (--gtp-remat-reduce-scatter-with-fp32-accumulation) +# --------------------------------------------------------------------------- + + +def _reset_gtp_global_state(): + """Drop GTP process-globals sized against the current layout. + + The weight cache and wgrad pool are keyed by shape and outlive `destroy_model_parallel()`, + so a phase that changes the gtp_remat degree would be handed a stale-sized buffer. + """ + import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module + + GTPShardedParam._chain_state = {} + gtp_module.get_global_GTP_cache().clear() + gtp_module._wgrad_buf_pool.clear() + gtp_module._inflight_comm_params.clear() + + +def _gtp_rs_phase(rank, saved, fp32_accum, moe=False, gtp_size=4): + """One GTP_remat backward; returns the full (gathered) reduced gradients per param. + + `saved` may be None when only the dispatch counts matter. gtp_size defaults to 4 because + size <= 2 is bypassed -- which the bypass test asserts and which would otherwise make the + gradient comparison vacuous. + """ + import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + ps.destroy_model_parallel() + init_kwargs = dict( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=gtp_size + ) + if moe: + # EP=1 (not 2) so all 4 ranks form ONE expert-GTP group: EP=2 would cap EGTP_remat at 2 + # on a 4-GPU run, which the size-<=2 bypass turns back into a plain reduce-scatter. + init_kwargs.update(expert_model_parallel_size=1, expert_gtp_remat_size=gtp_size) + ps.initialize_model_parallel(**init_kwargs) + model_parallel_cuda_manual_seed(42) + # None => every group; the MoE dispatcher needs tp_ep (see _worker_moe_distopt). + pgc = ProcessGroupCollection.use_mpu_process_groups() + stack = (_make_moe_stack if moe else _make_stack)( + _make_moe_config() if moe else _make_config(), pgc + ) + for layer in stack: + layer.cuda() + assert ps.get_gtp_weight_remat_group().size() == gtp_size + if saved is not None: + _load_gtp_shards(stack, saved, moe) + + gtp_module.update_gtp_config(reduce_scatter_with_fp32_accumulation=fp32_accum) + try: + # Non-dist-opt DDP so main_grad holds the full reduced gradient and can be compared + # directly (the dist-opt buffer only has this rank's reduce-scattered slice). + _run_one_backward(_build_ddp(stack), rank) + grads = _full_main_grads(stack) + finally: + gtp_module.update_gtp_config(reduce_scatter_with_fp32_accumulation=False) + + ps.destroy_model_parallel() + _reset_gtp_global_state() + return grads + + +@contextlib.contextmanager +def _fp32accum_probe(): + """Count fp32-accum dispatches and coalesced batched composites; yields the counter dict. + + Both counters guard against a vacuous pass -- a flag that never switched the collective, or + a batched path that regressed to one launch per weight. Patching the module attribute works + because ``_reduce_scatter_fp32_accum`` imports the primitive at call time. + """ + import megatron.core.distributed.reduce_scatter_with_fp32_accumulation as rs_fp32_module + import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module + + counts = {"dispatches": 0, "coalesced_composites": 0} + orig_rs = rs_fp32_module.reduce_scatter_with_fp32_accumulation + orig_init = gtp_module._GTPCompositeWorkHandle.__init__ + + def _counting_rs(*args, **kwargs): + counts["dispatches"] += 1 + return orig_rs(*args, **kwargs) + + def _counting_init(self, handles): + # The batched path builds _GTPCompositeWorkHandle([cm, *sum_handles]): a coalescing + # manager first, then the deferred FP32 sums. + if handles and isinstance(handles[0], dist.distributed_c10d._CoalescingManager): + counts["coalesced_composites"] += 1 + orig_init(self, handles) + + rs_fp32_module.reduce_scatter_with_fp32_accumulation = _counting_rs + gtp_module._GTPCompositeWorkHandle.__init__ = _counting_init + try: + yield counts + finally: + rs_fp32_module.reduce_scatter_with_fp32_accumulation = orig_rs + gtp_module._GTPCompositeWorkHandle.__init__ = orig_init + + +def _saved_unsharded_weights(moe=False): + """Unsharded init weights from a GTP_remat=1 build, so every phase starts identically. + + EP=1, so expert weights are DP replicas rather than EP-local and every param can be + broadcast. + """ + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + ps.destroy_model_parallel() + init_kwargs = dict( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=1 + ) + if moe: + init_kwargs.update(expert_model_parallel_size=1, expert_gtp_remat_size=1) + ps.initialize_model_parallel(**init_kwargs) + model_parallel_cuda_manual_seed(42) + pgc = ProcessGroupCollection.use_mpu_process_groups() + ref_stack = (_make_moe_stack if moe else _make_stack)( + _make_moe_config() if moe else _make_config(), pgc + ) + for layer in ref_stack: + layer.cuda() + for _name, p in ref_stack.named_parameters(): + dist.broadcast(p.data, src=0) + saved = {n: p.data.clone() for n, p in ref_stack.named_parameters()} + del ref_stack + ps.destroy_model_parallel() + _reset_gtp_global_state() + return saved + + +def _max_rel_grad_diff(reference, other): + """Largest per-param relative gradient difference -> (value, param name). + + Relative, not absolute: max|grad| is ~1e-4 on this micro-model, so an absolute bound would + read benign rounding as a huge error. All-zero params are skipped. + """ + worst, worst_name = 0.0, None + for name, ref_grad in reference.items(): + denom = ref_grad.abs().max().item() + if denom < 1e-30: + continue + rel = (ref_grad - other[name]).abs().max().item() / denom + if rel > worst: + worst, worst_name = rel, name + return worst, worst_name + + +def _worker_gtp_rs_fp32accum(rank, world_size, port, moe): + """FP32 accumulation must not change the reduced gradients. + + It alters only the summation precision of the gtp_remat reduce-scatter, never its math. + Guards the integration: the pooled all-to-all scratch, the deferred FP32 sums, and + (moe=True) the batched grouped path. + """ + saved = _saved_unsharded_weights(moe) + + with _fp32accum_probe() as counts: + plain = _gtp_rs_phase(rank, saved, fp32_accum=False, moe=moe) + n_plain = counts["dispatches"] + fp32 = _gtp_rs_phase(rank, saved, fp32_accum=True, moe=moe) + n_fp32 = counts["dispatches"] - n_plain + n_coalesced = counts["coalesced_composites"] + + assert n_plain == 0, f"FP32-accum RS ran with the flag off ({n_plain} dispatches)" + assert n_fp32 > 0, "FP32-accum RS never ran with the flag on -- test would be vacuous" + if moe: + # Only grouped expert chains carry several wgrads per RS, so only they build a composite. + assert n_coalesced > 0, ( + "batched grouped RS did not coalesce its all-to-alls -- regressed to one " + "ncclGroupStart/End per weight" + ) + + if rank == 0: + worst, worst_name = _max_rel_grad_diff(plain, fp32) + print( + f"[gtp-rs-fp32accum moe={moe}] dispatches={n_fp32} " + f"max rel grad diff={worst:.3e} ({worst_name})", + flush=True, + ) + # Tolerance, not equality: the paths legitimately differ by the rounding fp32-accum + # removes. Structural breakage (dropped mean, unwaited handle) lands near 1.0. + assert worst < 2e-2, ( + f"GTP FP32-accumulation reduce-scatter changed the reduced gradient " + f"(max rel diff {worst:.3e} on {worst_name})" + ) + + +def _worker_gtp_rs_fp32accum_bypassed_at_size_2(rank, world_size, port): + """A gtp_remat axis of size 2 must fall back to the plain reduce-scatter, flag or not. + + One addition rounds the same either way, so fp32-accum cannot change the result at size 2 + while still costing the all-to-all scratch. Enabling the flag there must issue zero + fp32-accum collectives. + """ + with _fp32accum_probe() as counts: + # saved=None: only the dispatch count matters here, not the gradient values. + _gtp_rs_phase(rank, saved=None, fp32_accum=True, gtp_size=2) + + assert counts["dispatches"] == 0, ( + f"gtp_remat=2 issued {counts['dispatches']} FP32-accumulation reduce-scatters; the " + "size-<=2 bypass is not firing, so the run pays the extra all-to-all buffer for no " + "precision gain" + ) + + def _worker_idog_span(rank, world_size, port): """Dist-opt grad-stats group (intra_dist_opt) must span the FULL world for both dense-only and MoE(EP2/EGTP2) configs. A naive build collapses the MoE case to a sub-world @@ -540,18 +897,41 @@ def test_gtp2_dp2_grad_matches_dp4_baseline(self, per_token_loss): finally: update_gtp_config(calculate_per_token_loss=False) + @pytest.mark.parametrize("moe", [False, True]) + def test_gtp_remat_rs_fp32_accumulation_preserves_grads(self, moe): + """--gtp-remat-reduce-scatter-with-fp32-accumulation must not change the gradients. + + moe=False covers the single-tensor RS; moe=True covers the batched grouped/expert RS, + whose coalesced all-to-alls and deferred FP32 sums are joined under one composite + handle -- asserted, not just exercised, so a regression to per-weight launches fails. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_gtp_rs_fp32accum, 4, moe) + + def test_gtp_remat_rs_fp32_accumulation_bypassed_at_size_2(self): + """A size-2 gtp_remat axis must ignore the flag (no gain, real memory cost).""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_gtp_rs_fp32accum_bypassed_at_size_2, 4) + + def test_gtp2_fp32accum_rs_grad_norm_matches_plain_rs(self): + """GTP_remat + --ddp-reduce-scatter-with-fp32-accumulation must match plain RS. + + Regression guard for the dispatch-ordering hazard: the fp32-accum all-to-all reads + grad_data at dispatch, so DDP grad-ready has to fire from GTP's manual post-add hook, + not from autograd. Needs >1 bucket group (small bucket_size) to be sensitive. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_fp32accum, 4) + def test_gtp2_dp2_distopt_grad_norm_matches_dp4_baseline(self): """GTP2xDP2 dist-opt grad-norm must match no-GTP_remat DP4 (the 64-GPU path).""" if torch.cuda.device_count() < 4: pytest.skip("Requires 4 CUDA devices") _run_distributed(_worker_distopt, 4) - @pytest.mark.skip( - reason="EP=2 (engages EGTP_remat) but the minimal test dims (SEQ16 BATCH1 hidden256) hit a " - "token-dispatcher shape error in the alltoall path (RuntimeError shape [2,1,4]). Needs a " - "larger MoE config to run; left as a stub. The real EGTP_remat path is validated at scale " - "(loss matches the GTP1/EGTP1 baseline after the is_gtp/allreduce master-param fix)." - ) def test_moe_egtp_distopt_grad_norm_matches_baseline(self): """GTP2/EGTP2 MoE dist-opt grad-norm must match GTP1/EGTP1 baseline (EP=2 both).""" if torch.cuda.device_count() < 4: From 5adbee205cf0b5ec62087cf8b82ea891873ea837 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Tue, 4 Aug 2026 13:53:07 +0200 Subject: [PATCH 194/290] chore(ci): AUT-1298 bump community workflow to v1.8.7 (#6219) Signed-off-by: svcnemo-autobot --- .github/workflows/community-bot.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/community-bot.yml b/.github/workflows/community-bot.yml index 47a54ec9264..59623c30157 100644 --- a/.github/workflows/community-bot.yml +++ b/.github/workflows/community-bot.yml @@ -21,9 +21,10 @@ on: jobs: community-bot: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@f0dadfd1b2d5c3f48a24ded127abd50afbf8ce11 # v0.65.10 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@98ea77e930f3e1b0f97a1ce5255e12c407e9d0d4 # v1.8.7 with: community_project_id: ${{ vars.COMMUNITY_PROJECT_ID }} + app-id: ${{ vars.BOT_ID }} if: github.repository == 'NVIDIA/Megatron-LM' secrets: - GH_TOKEN: ${{ secrets.PAT }} + BOT_KEY: ${{ secrets.BOT_KEY }} From 0896be0f5735e910a906dc4a8b5ed0363e4a7be6 Mon Sep 17 00:00:00 2001 From: Paul Wambergue Date: Tue, 4 Aug 2026 17:32:07 +0200 Subject: [PATCH 195/290] fix: pretrain_hybrid.py --help (#6211) Signed-off-by: Paul Wambergue Co-authored-by: Tom Long --- megatron/core/transformer/transformer_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index af853276819..66cfaded213 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -1275,7 +1275,7 @@ class TransformerConfig(ModelParallelConfig): """Scale factor for paged stash CUDA buffer allocation. Sign selects sizing: positive = avg-based, negative = actual-max. Magnitude is headroom - (e.g. 1.10 = 10%).""" + (e.g. 1.10 = 10%%).""" moe_paged_stash_buffer_size_factor_cpu: float = 0.0 """Scale factor for paged stash host buffer. 0 disables host buffer. From 22dfc28846715a338c0c631511e03abb4daafd1e Mon Sep 17 00:00:00 2001 From: Dong Hyuk Chang <9426164+thomasdhc@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:37:47 -0400 Subject: [PATCH 196/290] refactor(ci): migrate triage consumer to Cerno (#6250) Signed-off-by: Dong Hyuk Chang <9426164+thomasdhc@users.noreply.github.com> Co-authored-by: OpenAI Codex --- .gitlab-ci.yml | 2 +- .gitlab/{nemo-ci-triage.yml => cerno.yml} | 2 +- .gitlab/scripts/build.sh | 2 +- .gitlab/stages/06.triage.yml | 16 +++++----- docker/Dockerfile.linting | 8 ++--- tests/test_utils/python_scripts/linear_ci.py | 8 ++--- tests/test_utils/python_scripts/notify.py | 12 ++++---- tests/test_utils/test_ci_triage.py | 31 +++++++++++++++----- 8 files changed, 49 insertions(+), 32 deletions(-) rename .gitlab/{nemo-ci-triage.yml => cerno.yml} (87%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ae27dc6d2f4..5c541e8549e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -283,7 +283,7 @@ variables: description: Apply proposed Linear issue opens, updates, and closes # CI wide variables - NEMO_CI_TRIAGE_CONFIG: .gitlab/nemo-ci-triage.yml + CERNO_CONFIG: .gitlab/cerno.yml CI_MCORE_LTS_IMAGE: ${GITLAB_ENDPOINT}:5005/adlr/megatron-lm/mcore_ci_lts CI_MCORE_DEV_IMAGE: ${GITLAB_ENDPOINT}:5005/adlr/megatron-lm/mcore_ci_dev CI_NEMO_IMAGE: ${GITLAB_ENDPOINT}:5005/adlr/megatron-lm/nemo_ci diff --git a/.gitlab/nemo-ci-triage.yml b/.gitlab/cerno.yml similarity index 87% rename from .gitlab/nemo-ci-triage.yml rename to .gitlab/cerno.yml index 4922d63dac1..6f723daa166 100644 --- a/.gitlab/nemo-ci-triage.yml +++ b/.gitlab/cerno.yml @@ -1,4 +1,4 @@ -# Megatron-LM configuration for nemo-ci-triage. +# Megatron-LM configuration for Cerno. gitlab: project_id: 19378 diff --git a/.gitlab/scripts/build.sh b/.gitlab/scripts/build.sh index c72bd38a581..e6c7e5c38c5 100644 --- a/.gitlab/scripts/build.sh +++ b/.gitlab/scripts/build.sh @@ -50,7 +50,7 @@ fi if [[ "$FILE" == "Dockerfile.linting" ]]; then ADDITIONAL_PARAMS+=("--build-arg CI_SERVER_URL=${CI_SERVER_URL}") - ADDITIONAL_PARAMS+=("--secret id=NEMO_CI_TRIAGE_TOKEN,env=PAT") + ADDITIONAL_PARAMS+=("--secret id=CERNO_TOKEN,env=PAT") fi echo $(git rev-parse HEAD) diff --git a/.gitlab/stages/06.triage.yml b/.gitlab/stages/06.triage.yml index b80010540ae..91f8a4e703c 100644 --- a/.gitlab/stages/06.triage.yml +++ b/.gitlab/stages/06.triage.yml @@ -25,12 +25,12 @@ triage:linear_reconcile: artifacts: true script: - >- - nemo-ci-linear status - --config "${NEMO_CI_TRIAGE_CONFIG}" + cerno-linear status + --config "${CERNO_CONFIG}" --build-module-regex '^megatron-lm$' --output linear_status_report.json - >- - nemo-ci-linear reconcile + cerno-linear reconcile --failure-buckets failure_buckets.json --linear-report linear_status_report.json --pipeline-summaries pipeline_summaries.json @@ -50,8 +50,8 @@ triage:linear_write: allow_failure: true script: - >- - nemo-ci-linear write - --config "${NEMO_CI_TRIAGE_CONFIG}" + cerno-linear write + --config "${CERNO_CONFIG}" --plan linear_action_plan.json --output linear_action_plan_post.json artifacts: @@ -79,7 +79,7 @@ triage:slack_linear_followup: allow_failure: true script: - >- - nemo-ci-notify + cerno-notify --pipeline-summary slack_notification.json --linear-plan linear_action_plan_post.json --slack-bot-token "${MCORE_SLACK_BOT_TOKEN:-${ALERTMANAGER_TOKEN}}" @@ -89,8 +89,8 @@ triage:slack_linear_followup: if [[ -z "${THREAD_TIMESTAMP}" ]]; then echo "No Slack thread timestamp; skipping detailed triage follow-ups." else - nemo-ci-notify \ - --config "${NEMO_CI_TRIAGE_CONFIG}" \ + cerno-notify \ + --config "${CERNO_CONFIG}" \ --module megatron_lm \ --only-followup \ --thread-ts "${THREAD_TIMESTAMP}" \ diff --git a/docker/Dockerfile.linting b/docker/Dockerfile.linting index 737b8cefac8..e9a4de80455 100644 --- a/docker/Dockerfile.linting +++ b/docker/Dockerfile.linting @@ -24,10 +24,10 @@ RUN --mount=type=secret,id=JET_INDEX_URLS \ # Keep this in the internal-only stage so public CI has no internal service dependency. ARG CI_SERVER_URL -ARG NEMO_CI_TRIAGE_COMMIT=6e24e567ae2855e8acad5e2f780c7c26c195ab46 -RUN --mount=type=secret,id=NEMO_CI_TRIAGE_TOKEN \ +ARG CERNO_COMMIT=5a5fb5360e67f8f09d189871bbc0d768c09c43fa +RUN --mount=type=secret,id=CERNO_TOKEN \ GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.extraHeader \ - GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'oauth2:%s' "$(cat /run/secrets/NEMO_CI_TRIAGE_TOKEN)" | base64 -w0)" \ + GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'oauth2:%s' "$(cat /run/secrets/CERNO_TOKEN)" | base64 -w0)" \ uv pip install --no-cache-dir \ - "nemo-ci-triage @ git+${CI_SERVER_URL}/dl/nemo/nemo-ci-triage.git@${NEMO_CI_TRIAGE_COMMIT}" + "cerno @ git+${CI_SERVER_URL}/dl/nemo/cerno.git@${CERNO_COMMIT}" diff --git a/tests/test_utils/python_scripts/linear_ci.py b/tests/test_utils/python_scripts/linear_ci.py index 9bd9549b164..71f1afb1396 100644 --- a/tests/test_utils/python_scripts/linear_ci.py +++ b/tests/test_utils/python_scripts/linear_ci.py @@ -1,8 +1,8 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Megatron-LM adapters for nemo-ci-triage's failure-reporting workflow. +"""Megatron-LM adapters for Cerno's failure-reporting workflow. -The triage package owns LLM summarization, Linear reconciliation, and Slack +Cerno owns LLM summarization, Linear reconciliation, and Slack follow-up logic. This module only converts Megatron-LM's direct child-pipeline jobs into the generic failure records consumed by the package summarizer. """ @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any, Callable -from nemo_ci_triage.agent import summarize_pipeline_failures as summarizer +from cerno.agent import summarize_pipeline_failures as summarizer LINEAR_MODULE = "megatron_lm" _FUNCTIONAL_PREFIX = "functional:run_" @@ -94,7 +94,7 @@ def build_pipeline_reports( load_error_report: Callable[[int], dict | None], project_url: str, ) -> tuple[dict, dict]: - """Build the two JSON contracts consumed by nemo-ci-triage reconciliation. + """Build the two JSON contracts consumed by Cerno reconciliation. Each recipe is qualified by its child-pipeline variant. A recipe is only included in ``passed_tests`` when that exact variant completed successfully; diff --git a/tests/test_utils/python_scripts/notify.py b/tests/test_utils/python_scripts/notify.py index 71852bc617b..12dbe62331e 100644 --- a/tests/test_utils/python_scripts/notify.py +++ b/tests/test_utils/python_scripts/notify.py @@ -8,13 +8,13 @@ import click import gitlab -from nemo_ci_triage.slack_notification import notification -from nemo_ci_triage.slack_notification.utils import repository_settings +from cerno.slack_notification import notification +from cerno.slack_notification.utils import repository_settings from tests.test_utils.python_scripts import linear_ci -TRIAGE_CONFIG = Path(os.getenv("NEMO_CI_TRIAGE_CONFIG", ".gitlab/nemo-ci-triage.yml")) -PROJECT_ID, REPO_NAME = repository_settings(TRIAGE_CONFIG) +CERNO_CONFIG = Path(os.getenv("CERNO_CONFIG", ".gitlab/cerno.yml")) +PROJECT_ID, REPO_NAME = repository_settings(CERNO_CONFIG) WEBHOOK_URL = os.getenv("WEBHOOK_URL", "") SLACK_BOT_TOKEN = os.getenv("MCORE_SLACK_BOT_TOKEN") or os.getenv("ALERTMANAGER_TOKEN", "") SLACK_CHANNEL_ID = os.getenv("MCORE_SLACK_CHANNEL_ID", "") @@ -57,7 +57,7 @@ def _bridge_gpu(bridge_name: str) -> str: def get_pipeline_jobs( pipeline_id: int, job_prefix: str | tuple[str, ...], project: Any | None = None ) -> list[tuple[str, int, list[dict]]]: - """Collect Megatron-LM's direct child pipelines using nemo-ci-triage-2.""" + """Collect Megatron-LM's direct child pipelines using Cerno.""" project = project or get_project() root_pipeline = project.pipelines.get(pipeline_id) pipeline_jobs = [] @@ -153,7 +153,7 @@ def main( webhook_url=WEBHOOK_URL or None, slack_bot_token=SLACK_BOT_TOKEN if use_bot else None, slack_channel_id=SLACK_CHANNEL_ID if use_bot else None, - config=TRIAGE_CONFIG, + config=CERNO_CONFIG, ) write_slack_context(slack_output, thread_timestamp) diff --git a/tests/test_utils/test_ci_triage.py b/tests/test_utils/test_ci_triage.py index b6494c66ea0..626013c73ef 100644 --- a/tests/test_utils/test_ci_triage.py +++ b/tests/test_utils/test_ci_triage.py @@ -43,7 +43,7 @@ def group_failures(failures): @pytest.fixture def notify_module(monkeypatch): - pytest.importorskip("nemo_ci_triage.slack_notification") + pytest.importorskip("cerno.slack_notification") monkeypatch.setenv("GITLAB_ENDPOINT", "ci.example.com") from tests.test_utils.python_scripts import notify @@ -119,6 +119,23 @@ def test_error_extraction_is_opt_in_for_generated_jobs( assert job["artifacts"]["paths"] == ["results/"] +def test_cerno_hard_cutover_contract(): + pipeline = yaml.safe_load(Path(".gitlab-ci.yml").read_text()) + triage = Path(".gitlab/stages/06.triage.yml").read_text() + dockerfile = Path("docker/Dockerfile.linting").read_text() + build_script = Path(".gitlab/scripts/build.sh").read_text() + + assert pipeline["variables"]["CERNO_CONFIG"] == ".gitlab/cerno.yml" + assert triage.count("cerno-linear") == 3 + assert triage.count("cerno-notify") == 2 + assert triage.count('--config "${CERNO_CONFIG}"') == 3 + assert "ARG CERNO_COMMIT=5a5fb5360e67f8f09d189871bbc0d768c09c43fa" in dockerfile + assert '"cerno @ git+${CI_SERVER_URL}/dl/nemo/cerno.git@${CERNO_COMMIT}"' in dockerfile + assert "id=CERNO_TOKEN" in dockerfile + assert "/run/secrets/CERNO_TOKEN" in dockerfile + assert "--secret id=CERNO_TOKEN,env=PAT" in build_script + + def test_notification_rules_use_expected_pipeline_sources(): unit = yaml.safe_load(Path(".gitlab/stages/02.test.yml").read_text()) functional = yaml.safe_load(Path(".gitlab/stages/04.functional-tests.yml").read_text()) @@ -366,9 +383,9 @@ def test_failed_job_without_report_still_creates_a_safe_bucket(monkeypatch): def test_triage_config_selects_megatron_and_enables_write_actions(): - linear_status = pytest.importorskip("nemo_ci_triage.linear.linear_status") - linear_write = pytest.importorskip("nemo_ci_triage.linear.linear_write") - config = Path(".gitlab/nemo-ci-triage.yml") + linear_status = pytest.importorskip("cerno.linear.linear_status") + linear_write = pytest.importorskip("cerno.linear.linear_write") + config = Path(".gitlab/cerno.yml") assert linear_status.modules_for_regex("^megatron-lm$", config) == [ ( @@ -409,7 +426,7 @@ def test_slack_followup_uses_upstream_detailed_and_execution_summaries(): @pytest.mark.parametrize("pipeline_context", ["mr", "nightly", "weekly", "release"]) -def test_notification_delegates_to_triage_package(monkeypatch, notify_module, pipeline_context): +def test_notification_delegates_to_cerno(monkeypatch, notify_module, pipeline_context): notify = notify_module project = Mock() pipeline_jobs = [("functional:run_dev_dgx_h100", 101, [{"status": "failed"}])] @@ -447,7 +464,7 @@ def test_notification_delegates_to_triage_package(monkeypatch, notify_module, pi webhook_url="https://slack.invalid/webhook", slack_bot_token=None, slack_channel_id=None, - config=notify.TRIAGE_CONFIG, + config=notify.CERNO_CONFIG, ) collector.assert_called_once_with(123, notify.JOB_PREFIXES["functional-tests"], project=project) @@ -531,7 +548,7 @@ def test_notification_records_bot_thread_context(monkeypatch, tmp_path, notify_m webhook_url=None, slack_bot_token="xoxb-test", slack_channel_id="C0123456789", - config=notify.TRIAGE_CONFIG, + config=notify.CERNO_CONFIG, ) assert json.loads(slack_output.read_text()) == { "channel_id": "C0123456789", From 46fe6d2221fcd83737c0cec7ec603fe33ccd3745 Mon Sep 17 00:00:00 2001 From: wdykas Date: Tue, 4 Aug 2026 17:19:43 -0400 Subject: [PATCH 197/290] Validate NIXL CUDA transports and share transfer agents (#6221) Signed-off-by: William Dykas --- .../disaggregation/transfer_backends/nixl.py | 172 +++++++++++++++--- .../inference/test_kv_transfer_backends.py | 153 +++++++++++++++- 2 files changed, 300 insertions(+), 25 deletions(-) diff --git a/megatron/core/inference/disaggregation/transfer_backends/nixl.py b/megatron/core/inference/disaggregation/transfer_backends/nixl.py index af355e25045..2f2bdd6b6a7 100644 --- a/megatron/core/inference/disaggregation/transfer_backends/nixl.py +++ b/megatron/core/inference/disaggregation/transfer_backends/nixl.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 +import importlib.metadata as importlib_metadata import logging import os import time @@ -27,12 +28,29 @@ logger = logging.getLogger(__name__) + +def _detect_nixl_variant(api_module_name: str) -> Optional[str]: + """Return the installed distribution that provides the active NIXL API module.""" + package_name = api_module_name.split(".", maxsplit=1)[0] + for distribution_name in importlib_metadata.packages_distributions().get(package_name, []): + normalized = distribution_name.lower().replace("-", "_") + if normalized.startswith("nixl_cu"): + return normalized + return None + + try: - from nixl._api import nixl_agent # type: ignore[import-not-found] + from nixl import _api as _nixl_api # type: ignore[import-not-found] + + nixl_agent = _nixl_api.nixl_agent + nixl_agent_config = _nixl_api.nixl_agent_config + _NIXL_VARIANT = _detect_nixl_variant(_nixl_api.__name__) _HAVE_NIXL = True except ImportError: nixl_agent = None # type: ignore[assignment] + nixl_agent_config = None # type: ignore[assignment] + _NIXL_VARIANT = None _HAVE_NIXL = False @@ -42,6 +60,68 @@ _POLL_TIMEOUT_S = 30.0 +def _validate_ucx_transport_config(memory_buffer: torch.Tensor) -> None: + """Configure safe UCX memory detection and reject host-only transports.""" + # Explicit registration makes the UCX memtype cache unnecessary and avoids + # stale host classifications for reused CUDA virtual addresses. + os.environ.setdefault("UCX_MEMTYPE_CACHE", "n") + + if not memory_buffer.is_cuda: + return + + configured_tls = os.environ.get("UCX_TLS") + if not configured_tls: + return + + tokens = { + token.strip().lower().lstrip("\\") + for token in configured_tls.lstrip("^").split(",") + if token.strip() + } + cuda_transports = {"cuda_copy", "cuda_ipc", "gdr_copy"} + if configured_tls.startswith("^"): + excludes_all_cuda = "cuda" in tokens or cuda_transports.issubset(tokens) + if not excludes_all_cuda: + return + elif "all" in tokens or "cuda" in tokens or tokens & cuda_transports: + return + + raise RuntimeError( + f"UCX_TLS={configured_tls!r} does not enable a CUDA transport for NIXL GPU " + "buffers. Remove the override to let UCX select transports automatically, or " + "include a CUDA transport such as cuda_copy or cuda_ipc." + ) + + +def _validate_nixl_cuda_support(agent: Any, memory_buffer: torch.Tensor) -> None: + """Reject a NIXL/UCX runtime that cannot safely register CUDA memory.""" + if not memory_buffer.is_cuda: + return + + cuda_version = torch.version.cuda + if cuda_version and _NIXL_VARIANT: + expected_variant = f"nixl_cu{cuda_version.split('.', maxsplit=1)[0]}" + if _NIXL_VARIANT.startswith("nixl_cu") and _NIXL_VARIANT != expected_variant: + expected_package = expected_variant.replace("_", "-") + raise RuntimeError( + f"PyTorch uses CUDA {cuda_version}, but NIXL selected {_NIXL_VARIANT}. " + f"Install the matching backend with `pip install {expected_package}` " + "before starting a disaggregated worker. A mismatched NIXL backend can make " + "UCX classify GPU buffers as host memory." + ) + + get_memory_types = getattr(agent, "get_backend_mem_types", None) + if get_memory_types is None: + return + ucx_memory_types = {str(mem_type).lower() for mem_type in get_memory_types("UCX")} + if not any("cuda" in mem_type or "vram" in mem_type for mem_type in ucx_memory_types): + raise RuntimeError( + "The NIXL UCX backend does not report CUDA/VRAM memory support " + f"(reported memory types: {sorted(ucx_memory_types)}). Install a NIXL backend " + "built for this CUDA major version, or rebuild UCX with `--with-cuda=`." + ) + + @dataclass class NixlPullHandle: """Pollable handle for one logical pull made of one or more NIXL transfers.""" @@ -94,8 +174,38 @@ def wait(self) -> None: time.sleep(_POLL_INTERVAL_S) +class _NixlAgentContext: + """NIXL resources shared by the state buffers on one rank.""" + + def __init__(self, agent_name: str): + # One-sided reads require the passive peer to make transport progress. + # A single shared progress thread avoids contention with model execution + # while still progressing KV, convolution-state, and SSM-state transfers. + agent_config = nixl_agent_config(enable_prog_thread=True) + self.agent_name = agent_name + self.agent = nixl_agent(agent_name, agent_config) + self.known_peers: Dict[str, Any] = {} + self.ref_count = 0 + + def acquire(self) -> Any: + """Retain the shared agent for one buffer registration.""" + if self.agent is None: + raise RuntimeError("NIXL agent context is closed") + self.ref_count += 1 + return self.agent + + def release(self) -> None: + """Release one registration and drop the agent after the last user closes.""" + if self.ref_count <= 0: + raise RuntimeError("NIXL agent context released without an owner") + self.ref_count -= 1 + if self.ref_count == 0: + self.known_peers.clear() + self.agent = None + + class NixlTransferBackend: - """Per-rank NIXL agent owning a registration over the paged KV buffer. + """Per-buffer registration on a rank's NIXL agent. Per-block transfers are descriptor ranges over that registration. Peer metadata is exchanged by the control plane and registered lazily on first @@ -123,6 +233,7 @@ def __init__( layer_end: Optional[int] = None, ssm_layout: Optional[SSMShardLayout] = None, ssm_state_kind: Optional[str] = None, + _shared_context: Optional[_NixlAgentContext] = None, ): if not _HAVE_NIXL: raise RuntimeError( @@ -130,7 +241,6 @@ def __init__( "NIXL runtime and `pip install nixl` before launching " "disaggregated workers." ) - self.agent_name = agent_name self._memory_buffer = memory_buffer # Addressing geometry shared with the other backends. @@ -170,21 +280,22 @@ def __init__( self._ssm_layout = ssm_layout self._ssm_state_kind = ssm_state_kind - # Configure UCX before agent construction. Avoid TCP for VRAM addresses; - # operators may override this by setting UCX_TLS before launch. - os.environ.setdefault("UCX_TLS", "cuda_ipc,cuda_copy,cma,shm,self") - # Explicit registration makes the UCX memtype cache unnecessary and - # avoids stale VRAM/host classifications. - os.environ.setdefault("UCX_MEMTYPE_CACHE", "n") - - self._agent = nixl_agent(agent_name) - self._reg_handle = self._agent.register_memory(memory_buffer) - - # Base64 keeps NIXL metadata safe for msgpack/json control messages. - self._agent_metadata = self._agent.get_agent_metadata() + _validate_ucx_transport_config(memory_buffer) + if _shared_context is None: + _shared_context = _NixlAgentContext(agent_name) + self._agent_context = _shared_context + self._agent = _shared_context.acquire() + try: + _validate_nixl_cuda_support(self._agent, memory_buffer) + self._reg_handle = self._agent.register_memory(memory_buffer) + except Exception: + self._agent = None + self._agent_context = None + _shared_context.release() + raise # Peer agent_name -> id returned by add_remote_agent. - self._known_peers: Dict[str, Any] = {} + self._known_peers = _shared_context.known_peers logger.info( "NixlTransferBackend[%s] registered %d-block buffer " @@ -200,15 +311,23 @@ def __init__( shape, ) + def new_registered_buffer(self, **kwargs) -> "NixlTransferBackend": + """Register another state buffer on this backend's NIXL agent.""" + + if self._agent_context is None: + raise RuntimeError("cannot register a buffer on a closed NIXL backend") + return type(self)(_shared_context=self._agent_context, **kwargs) + def export_meta(self) -> Dict[str, Any]: """Return JSON/msgpack-safe metadata for shipping to a decode peer. Layout fields describe the scatter-gather address ranges needed to pull source blocks into decode-owned blocks. """ + agent_metadata = self._agent.get_agent_metadata() meta = { - "agent_name": self.agent_name, - "agent_metadata_b64": base64.b64encode(self._agent_metadata).decode("ascii"), + "agent_name": self._agent_context.agent_name, + "agent_metadata_b64": base64.b64encode(agent_metadata).decode("ascii"), "base_addr": self._buf_ptr, "outer_stride_bytes": self._outer_stride_bytes, "device_id": self._device_id, @@ -252,7 +371,9 @@ def _ensure_peer_registered(self, peer_meta: Dict[str, Any]) -> str: peer_id = self._agent.add_remote_agent(base64.b64decode(metadata_b64)) resolved = peer_id if peer_id else peer_name self._known_peers[peer_name] = resolved - logger.info("NixlTransferBackend[%s] registered peer %s", self.agent_name, peer_name) + logger.info( + "NixlTransferBackend[%s] registered peer %s", self._agent_context.agent_name, peer_name + ) return resolved def _validate_peer( @@ -615,11 +736,18 @@ def _begin_transfer( return xfer, ctx def close(self) -> None: - """Release the registration and agent.""" + """Release this buffer registration and its reference to the shared agent.""" if self._agent is None: return + agent = self._agent + agent_context = self._agent_context try: - self._agent.deregister_memory(self._reg_handle) + agent.deregister_memory(self._reg_handle) except Exception: # noqa: BLE001 - shutdown path logger.exception("NixlTransferBackend: deregister_memory failed") - self._agent = None + finally: + self._agent = None + self._agent_context = None + self._known_peers = {} + self._reg_handle = None + agent_context.release() diff --git a/tests/unit_tests/inference/test_kv_transfer_backends.py b/tests/unit_tests/inference/test_kv_transfer_backends.py index 0f32bc2e5c9..c3bbfa60825 100644 --- a/tests/unit_tests/inference/test_kv_transfer_backends.py +++ b/tests/unit_tests/inference/test_kv_transfer_backends.py @@ -7,6 +7,89 @@ from megatron.core.inference.disaggregation.transfer_backends import base +class _FakeCudaBuffer: + is_cuda = True + + +@pytest.mark.parametrize( + ("package_name", "distribution_name"), [("nixl_cu12", "nixl-cu12"), ("nixl_cu13", "nixl-cu13")] +) +def test_nixl_detects_active_cuda_distribution(monkeypatch, package_name, distribution_name): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + monkeypatch.setattr( + nixl_mod.importlib_metadata, + "packages_distributions", + lambda: {"nixl": ["nixl"], package_name: [distribution_name]}, + ) + + assert nixl_mod._detect_nixl_variant(f"{package_name}._api") == package_name + + +@pytest.mark.parametrize("configured_tls", ["tcp", "^cuda", "^cuda_copy,cuda_ipc,gdr_copy"]) +def test_nixl_rejects_ucx_config_without_cuda_transport(monkeypatch, configured_tls): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + monkeypatch.setenv("UCX_TLS", configured_tls) + + with pytest.raises(RuntimeError, match="does not enable a CUDA transport"): + nixl_mod._validate_ucx_transport_config(_FakeCudaBuffer()) + + +@pytest.mark.parametrize("configured_tls", [None, "all", "rc,cuda_copy", "^tcp"]) +def test_nixl_accepts_ucx_config_with_cuda_transport(monkeypatch, configured_tls): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + monkeypatch.delenv("UCX_MEMTYPE_CACHE", raising=False) + if configured_tls is None: + monkeypatch.delenv("UCX_TLS", raising=False) + else: + monkeypatch.setenv("UCX_TLS", configured_tls) + + nixl_mod._validate_ucx_transport_config(_FakeCudaBuffer()) + + assert nixl_mod.os.environ["UCX_MEMTYPE_CACHE"] == "n" + + +def test_nixl_rejects_cuda_major_variant_mismatch(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + monkeypatch.setattr(nixl_mod, "_NIXL_VARIANT", "nixl_cu12") + monkeypatch.setattr(nixl_mod.torch.version, "cuda", "13.2") + + with pytest.raises(RuntimeError, match="NIXL selected nixl_cu12"): + nixl_mod._validate_nixl_cuda_support(object(), _FakeCudaBuffer()) + + +def test_nixl_rejects_ucx_without_vram_support(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + class FakeAgent: + def get_backend_mem_types(self, backend): + assert backend == "UCX" + return ["DRAM"] + + monkeypatch.setattr(nixl_mod, "_NIXL_VARIANT", "nixl_cu13") + monkeypatch.setattr(nixl_mod.torch.version, "cuda", "13.2") + + with pytest.raises(RuntimeError, match="does not report CUDA/VRAM"): + nixl_mod._validate_nixl_cuda_support(FakeAgent(), _FakeCudaBuffer()) + + +def test_nixl_accepts_matching_variant_with_vram_support(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + class FakeAgent: + def get_backend_mem_types(self, backend): + assert backend == "UCX" + return ["DRAM", "VRAM"] + + monkeypatch.setattr(nixl_mod, "_NIXL_VARIANT", "nixl_cu13") + monkeypatch.setattr(nixl_mod.torch.version, "cuda", "13.2") + + nixl_mod._validate_nixl_cuda_support(FakeAgent(), _FakeCudaBuffer()) + + def test_backend_registry_selects_by_explicit_name(): assert base.construct_kv_transfer_backend_class("nixl").name == "nixl" @@ -55,9 +138,14 @@ def test_ssm_geometry_uses_conv_and_recurrent_state_names(): def test_nixl_direct_backend_exports_metadata_with_fake_agent(monkeypatch): from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + class FakeAgentConfig: + def __init__(self, *, enable_prog_thread): + self.enable_prog_thread = enable_prog_thread + class FakeAgent: - def __init__(self, name): + def __init__(self, name, config): self.name = name + self.config = config def get_agent_metadata(self): return b"agent-meta" @@ -67,6 +155,7 @@ def register_memory(self, tensor): monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + monkeypatch.setattr(nixl_mod, "nixl_agent_config", FakeAgentConfig) backend = nixl_mod.NixlTransferBackend( "prefill", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 @@ -78,13 +167,69 @@ def register_memory(self, tensor): assert metadata["num_outer"] == 2 assert metadata["num_blocks"] == 3 assert metadata["blocks_axis"] == 1 + assert backend._agent.config.enable_prog_thread is True + + +def test_nixl_registered_buffers_share_agent_and_peer_cache(monkeypatch): + from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod + + agents = [] + + class FakeAgent: + def __init__(self, name, config): + self.registrations = [] + self.deregistrations = [] + agents.append(self) + + def get_agent_metadata(self): + return f"registrations={len(self.registrations)}".encode() + + def register_memory(self, tensor): + self.registrations.append(tensor) + return ("reg", len(self.registrations)) + + def deregister_memory(self, registration): + self.deregistrations.append(registration) + + monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) + monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + monkeypatch.setattr(nixl_mod, "nixl_agent_config", lambda **_: object()) + + backend = nixl_mod.NixlTransferBackend( + "prefill", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 + ) + sibling = backend.new_registered_buffer( + agent_name="prefill-mamba-conv", + memory_buffer=torch.zeros(2, 4, 5, dtype=torch.float32), + expected_num_blocks=4, + ) + + assert len(agents) == 1 + assert sibling._agent is backend._agent + assert sibling._known_peers is backend._known_peers + assert sibling.export_meta()["agent_name"] == "prefill" + assert backend.export_meta()["agent_metadata_b64"] == "cmVnaXN0cmF0aW9ucz0y" + + agent = backend._agent + agent_context = backend._agent_context + assert agent_context.ref_count == 2 + + backend.close() + assert agent_context.ref_count == 1 + assert agent_context.agent is agent + assert sibling._agent is agent + + sibling.close() + assert agent_context.ref_count == 0 + assert agent_context.agent is None + assert agent.deregistrations == [("reg", 1), ("reg", 2)] def test_nixl_begin_pull_blocks_uses_remote_metadata_with_fake_agent(monkeypatch): from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod class FakeAgent: - def __init__(self, name): + def __init__(self, name, config): self.name = name self.transferred = False @@ -116,6 +261,7 @@ def check_xfer_state(self, xfer): monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + monkeypatch.setattr(nixl_mod, "nixl_agent_config", lambda **_: object()) backend = nixl_mod.NixlTransferBackend( "decode", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 @@ -140,7 +286,7 @@ def test_nixl_begin_pull_blocks_returns_pollable_handle(monkeypatch): from megatron.core.inference.disaggregation.transfer_backends import nixl as nixl_mod class FakeAgent: - def __init__(self, name): + def __init__(self, name, config): self.name = name self.transfers = 0 self.polls = 0 @@ -173,6 +319,7 @@ def check_xfer_state(self, xfer): monkeypatch.setattr(nixl_mod, "_HAVE_NIXL", True) monkeypatch.setattr(nixl_mod, "nixl_agent", FakeAgent) + monkeypatch.setattr(nixl_mod, "nixl_agent_config", lambda **_: object()) backend = nixl_mod.NixlTransferBackend( "decode", torch.zeros(2, 3, 5, dtype=torch.float32), expected_num_blocks=3 From 3fe15193cf95c1c552b6a288fa0cb07c6b5aca24 Mon Sep 17 00:00:00 2001 From: nvcsathe Date: Tue, 4 Aug 2026 14:56:00 -0700 Subject: [PATCH 198/290] Add prefix-cache lifecycle hooks (#5860) Signed-off-by: Chaitra Sathe --- .../inference/contexts/dynamic_context.py | 95 +++++++- .../inference/contexts/kv_block_allocator.py | 25 ++- .../core/inference/engines/dynamic_engine.py | 27 ++- .../contexts/test_kv_event_publication.py | 207 ++++++++++++++++++ .../test_dynamic_engine_async_sched.py | 2 + .../inference/test_kv_allocator_observers.py | 69 ++++++ 6 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 tests/unit_tests/inference/contexts/test_kv_event_publication.py create mode 100644 tests/unit_tests/inference/test_kv_allocator_observers.py diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py index 5735b003ebf..0b50f50d6de 100644 --- a/megatron/core/inference/contexts/dynamic_context.py +++ b/megatron/core/inference/contexts/dynamic_context.py @@ -1,11 +1,11 @@ -# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import logging import math import operator import warnings from contextlib import nullcontext -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple import torch # type: ignore import torch.nn.functional as F # type: ignore @@ -54,6 +54,9 @@ from .mamba_slot_allocator import MAX_INTERMEDIATE_OFFSETS_PER_REQUEST, MambaSlotAllocator from .routing_metadata import RoutingMetadata +# These callbacks are currently consumed only by the Dynamo frontend. +KVEventListener = Callable[[str, dict[str, Any]], None] + try: from .fused_kv_append_kernel import triton_append_key_value_cache except ImportError: @@ -226,6 +229,69 @@ def get_mem_size_str(n_bytes: int) -> str: raise Exception(f"something went wrong, n_bytes={n_bytes}.") +class DynamoHelper: + """Manage KV-cache lifecycle events consumed by the Dynamo frontend.""" + + def __init__(self) -> None: + self._kv_event_listeners: list[KVEventListener] = [] + self._pending_kv_stored_events: list[dict[str, Any]] = [] + + @property + def has_kv_event_listeners(self) -> bool: + """Return whether any KV-event listeners are registered.""" + return bool(self._kv_event_listeners) + + def add_kv_event_listener(self, listener: KVEventListener) -> None: + """Register a KV-cache lifecycle listener. + + Args: + listener: Callback invoked with the event kind and payload. + """ + self._kv_event_listeners.append(listener) + + def queue_kv_stored_event(self, payload: dict[str, Any]) -> None: + """Queue a stored event for publication after a successful forward pass. + + Args: + payload: Stored-event payload. + """ + self._pending_kv_stored_events.append(payload) + + def publish_pending_kv_stored_events(self) -> None: + """Publish blocks whose KV contents were produced by a successful forward pass.""" + pending, self._pending_kv_stored_events = self._pending_kv_stored_events, [] + for payload in pending: + self._emit_kv_event("stored", payload) + + def discard_pending_kv_stored_events(self) -> None: + """Discard registrations left by an interrupted or failed forward pass.""" + self._pending_kv_stored_events.clear() + + def on_kv_blocks_deregistered(self, _block_ids: list[int], hashes: set[int]) -> None: + """Publish removal events for deregistered KV blocks. + + Args: + _block_ids: Deregistered block IDs, unused by Dynamo. + hashes: Hashes of the deregistered blocks. + """ + if hashes: + self._emit_kv_event("removed", {"block_hashes": list(hashes)}) + + def notify_kv_cache_cleared(self) -> None: + """Notify listeners that no previously advertised block is routable.""" + self.discard_pending_kv_stored_events() + if self._kv_event_listeners: + self._emit_kv_event("cleared", {}) + + def _emit_kv_event(self, kind: str, payload: dict[str, Any]) -> None: + """Notify Dynamo listeners without allowing frontend failures to stop inference.""" + for listener in tuple(self._kv_event_listeners): + try: + listener(kind, payload) + except Exception: # pylint: disable=broad-exception-caught + logging.exception("KV-event listener failed while handling %r", kind) + + @internal_api # pylint: disable=line-too-long class DynamicInferenceContext(BaseInferenceContext): @@ -409,7 +475,7 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC model_config, vp_stage=None, pp_rank=pp_rank ) self.num_mamba_layers = 0 - (self.mamba_conv_states_shape, self.mamba_ssm_states_shape) = (None, None) + self.mamba_conv_states_shape, self.mamba_ssm_states_shape = (None, None) self.layer_map = {i: i for i in range(self.num_attention_layers)} if self.num_attention_layers == 0: @@ -573,6 +639,10 @@ def __init__(self, model_config: TransformerConfig, inference_config: InferenceC enable_prefix_caching=self.enable_prefix_caching, prefix_caching_eviction_policy=self.prefix_caching_eviction_policy, ) + self.dynamo_helper = DynamoHelper() + self.kv_block_allocator.add_blocks_deregistered_observer( + self.dynamo_helper.on_kv_blocks_deregistered + ) # Track request metadata. request_metadata_types = inference_config.request_metadata_types @@ -2759,6 +2829,7 @@ def reset( """ # There is no prefix-cache state to preserve when caching is disabled. preserve_prefix_cache = preserve_prefix_cache and self.enable_prefix_caching + self.dynamo_helper.discard_pending_kv_stored_events() self.reset_tensors() self.reset_metadata( preserve_prefix_cache=preserve_prefix_cache, preserve_counters=preserve_counters @@ -2771,6 +2842,8 @@ def reset( # Reset Mamba cache state. if not preserve_prefix_cache and self.mamba_slot_allocator is not None: self.mamba_slot_allocator.reset() + if not preserve_prefix_cache: + self.dynamo_helper.notify_kv_cache_cleared() def current_input_and_position_ids( self, *, num_warmup_tokens: Optional[int] = None @@ -2997,7 +3070,7 @@ def check_availability(self, req: DynamicInferenceRequest) -> Tuple[bool, bool, self.total_request_count < self.max_requests and self.paused_request_count == 0 ) - (matched_block_ids, num_blocks_from_pool, _, _, _, effective_prefill_chunk_length) = ( + matched_block_ids, num_blocks_from_pool, _, _, _, effective_prefill_chunk_length = ( self._compute_prefix_match(req, req.remaining_prompt_length) ) @@ -3253,6 +3326,20 @@ def _register_range(start: int, end: int): self.kv_block_allocator.register_kv_block_hashes( block_ids_to_hash, block_hashes_slice, parent_hashes_slice ) + if self.dynamo_helper.has_kv_event_listeners: + token_start = start * self.block_size_tokens + token_end = end * self.block_size_tokens + token_ids = req.prompt_tokens[token_start:token_end].tolist() + self.dynamo_helper.queue_kv_stored_event( + { + "block_hashes": list(block_hashes_slice), + "token_ids": token_ids, + "num_block_tokens": [self.block_size_tokens] * (end - start), + "parent_hash": ( + int(req.precomputed_block_hashes[start - 1]) if start > 0 else None + ), + } + ) # Range 1: prior-chunk partial block that this chunk just completed _register_range(previously_complete, min(already_allocated_blocks, num_complete_blocks)) diff --git a/megatron/core/inference/contexts/kv_block_allocator.py b/megatron/core/inference/contexts/kv_block_allocator.py index 5a7c736c5b3..90043c9bad4 100644 --- a/megatron/core/inference/contexts/kv_block_allocator.py +++ b/megatron/core/inference/contexts/kv_block_allocator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import heapq from collections import deque @@ -10,6 +10,9 @@ from megatron.core.inference.config import PrefixCachingEvictionPolicy +# Block deregistration observers are currently registered only by DynamoHelper. +BlocksDeregisteredObserver = Callable[[list[int], set[int]], None] + class KVBlockAllocator: """Allocator that manages blocks of memory for the KV cache. @@ -41,6 +44,7 @@ def __init__( self.enable_prefix_caching = enable_prefix_caching self.prefix_caching_eviction_policy = prefix_caching_eviction_policy self.on_blocks_deregistered: Optional[Callable] = None + self._blocks_deregistered_observers: list[BlocksDeregisteredObserver] = [] assert ( 0 <= paused_limit <= pool_size - 2 @@ -388,6 +392,13 @@ def register_kv_block_hashes( torch.ones(int(has_parent.sum()), dtype=torch.int64), ) + def add_blocks_deregistered_observer(self, observer: BlocksDeregisteredObserver) -> None: + """Register a callback invoked when cached blocks are deregistered. + + Currently used only by DynamoHelper. + """ + self._blocks_deregistered_observers.append(observer) + def _deregister_blocks(self, block_ids: Tensor) -> None: """Remove blocks from prefix caching state and return to free pool. @@ -402,6 +413,7 @@ def _deregister_blocks(self, block_ids: Tensor) -> None: # Gather hashes via batched tensor indexing block_ids_i64 = block_ids.to(torch.int64) + block_ids_list = block_ids.tolist() hashes = self.block_hashes[block_ids_i64].tolist() # Remove from kv_hash_to_block_id dict (set ops + C-level map, no Python loop) @@ -411,10 +423,6 @@ def _deregister_blocks(self, block_ids: Tensor) -> None: maxlen=0, ) - # Notify Mamba slot allocator (if wired) to clean up its state - if self.on_blocks_deregistered is not None: - self.on_blocks_deregistered(block_ids.tolist(), keys_to_delete) - # Reset block state (batched tensor ops) if self.prefix_caching_eviction_policy == PrefixCachingEvictionPolicy.LRU: # Drop these blocks from their parents' child counts before clearing @@ -438,6 +446,13 @@ def _deregister_blocks(self, block_ids: Tensor) -> None: self.block_bag[self.pool_avail : self.pool_avail + num_blocks] = block_ids self.pool_avail += num_blocks + # Notify dependent allocators and external observers only after KV allocator + # bookkeeping commits, so callback failures cannot leave this allocator partial. + if self.on_blocks_deregistered is not None: + self.on_blocks_deregistered(block_ids_list, keys_to_delete) + for observer in tuple(self._blocks_deregistered_observers): + observer(block_ids_list, keys_to_delete) + def update_timestamps(self, block_ids: Tensor) -> None: """Update LRU timestamps for accessed blocks. No-op in RZ mode. diff --git a/megatron/core/inference/engines/dynamic_engine.py b/megatron/core/inference/engines/dynamic_engine.py index a1b40bfa459..63b4176bf37 100644 --- a/megatron/core/inference/engines/dynamic_engine.py +++ b/megatron/core/inference/engines/dynamic_engine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. import asyncio import concurrent.futures @@ -851,6 +851,9 @@ def suspend(self): return InferenceMode.unset_active() + dynamo_helper = getattr(self.context, "dynamo_helper", None) + if dynamo_helper is not None: + dynamo_helper.discard_pending_kv_stored_events() # Deallocate context tensors. with self.__class__.suspend_resume_ctx( @@ -858,6 +861,14 @@ def suspend(self): ): self.context.deallocate_inference_state_buffers() + if ( + dynamo_helper is not None + and self.context.kv_cache_management_mode == KVCacheManagementMode.RECOMPUTE + ): + # PERSIST and OFFLOAD restore the same cache contents on resume; only + # RECOMPUTE invalidates the blocks previously advertised to Dynamo. + dynamo_helper.notify_kv_cache_cleared() + if ( self.context.kv_cache_management_mode != KVCacheManagementMode.PERSIST and not self.context.static_kv_memory_pointers @@ -1945,7 +1956,7 @@ def schedule_chunked_prefill(self): # add_request() only computes `effective = span - skip` tokens. prefix_skip = 0 if prefix_caching_enabled and not is_continuing_chunked_prefill: - (_, _, _, _, prefix_skip, _) = self.context._compute_prefix_match( + _, _, _, _, prefix_skip, _ = self.context._compute_prefix_match( req, remaining_len ) prefix_skip = min(prefix_skip, remaining_len - 1) # keep >=1 token to run @@ -2025,7 +2036,7 @@ def schedule_chunked_prefill(self): # admits the request). For >= 2 computed tokens add_request computes # exactly this chunk, which already fits the budget. if prefix_skip > 0 and (prefill_chunk_length - prefix_skip) < 2: - (_, _, _, _, _, actual_effective) = self.context._compute_prefix_match( + _, _, _, _, _, actual_effective = self.context._compute_prefix_match( req, prefill_chunk_length ) if self.context.active_token_count + actual_effective > self.context.max_tokens: @@ -2084,6 +2095,12 @@ async def async_forward(self) -> Tuple[Optional[Dict], Dict, float]: if self.state in (EngineState.SUSPENDED, EngineState.SUSPENDING): raise EngineSuspendedError(self.context.step_count) + # Discard registrations left by an interrupted prior step before this + # step's scheduling queues new registrations. + dynamo_helper = getattr(self.context, "dynamo_helper", None) + if dynamo_helper is not None: + dynamo_helper.discard_pending_kv_stored_events() + mode = self.context.config.async_sched_mode if mode == AsyncScheduleMode.LEGACY: self.schedule_waiting_requests() @@ -2141,6 +2158,8 @@ async def async_forward(self) -> Tuple[Optional[Dict], Dict, float]: self.decode_only = controller_result.decode_only pre_step_context_state["decode_only"] = self.decode_only result = controller_result.output + if dynamo_helper is not None: + dynamo_helper.publish_pending_kv_stored_events() if will_log_this_step: self.step_end_event.record() self.step_end_event.synchronize() @@ -2256,7 +2275,7 @@ async def async_bookkeep( [self.get_request(i).add_event_pause() for i in newly_paused_request_ids] # Process finished requests (adds FINISH events and returns records). - (active_request_ids, finished_request_records) = self.post_process_requests( + active_request_ids, finished_request_records = self.post_process_requests( active_request_ids, finished_request_ids, evict_request_ids, diff --git a/tests/unit_tests/inference/contexts/test_kv_event_publication.py b/tests/unit_tests/inference/contexts/test_kv_event_publication.py new file mode 100644 index 00000000000..a660997d0d3 --- /dev/null +++ b/tests/unit_tests/inference/contexts/test_kv_event_publication.py @@ -0,0 +1,207 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from collections import deque +from contextlib import nullcontext +from types import SimpleNamespace +from unittest import mock +from unittest.mock import Mock + +import pytest + +from megatron.core.inference.config import AsyncScheduleMode, KVCacheManagementMode +from megatron.core.inference.contexts.dynamic_context import DynamicInferenceContext, DynamoHelper +from megatron.core.inference.engines.dynamic_engine import DynamicInferenceEngine, EngineState + + +def _context_with_listener(): + context = DynamicInferenceContext.__new__(DynamicInferenceContext) + context.dynamo_helper = DynamoHelper() + listener = Mock() + context.dynamo_helper.add_kv_event_listener(listener) + return context, listener + + +def test_stored_event_is_published_only_after_forward_completion(): + context, listener = _context_with_listener() + payload = {"block_hashes": [101], "token_ids": [1, 2]} + + context.dynamo_helper.queue_kv_stored_event(payload) + + listener.assert_not_called() + context.dynamo_helper.publish_pending_kv_stored_events() + listener.assert_called_once_with("stored", payload) + context.dynamo_helper.publish_pending_kv_stored_events() + listener.assert_called_once_with("stored", payload) + + +def test_cache_clear_discards_unpublished_stored_events(): + context, listener = _context_with_listener() + + context.dynamo_helper.queue_kv_stored_event({"block_hashes": [101]}) + context.dynamo_helper.notify_kv_cache_cleared() + + listener.assert_called_once_with("cleared", {}) + context.dynamo_helper.publish_pending_kv_stored_events() + listener.assert_called_once_with("cleared", {}) + + +def test_dummy_reset_preserves_prefix_cache_without_publishing_clear(): + context, listener = _context_with_listener() + context.enable_prefix_caching = True + context.reset_tensors = Mock() + context.reset_metadata = Mock() + context.step_count = 17 + context.prefix_cache_lru_clock = 11 + context.mamba_slot_allocator = Mock() + context.dynamo_helper.queue_kv_stored_event({"block_hashes": [101]}) + + context.reset(preserve_prefix_cache=True, preserve_counters=True) + context.dynamo_helper.publish_pending_kv_stored_events() + + listener.assert_not_called() + context.reset_tensors.assert_called_once_with() + context.reset_metadata.assert_called_once_with( + preserve_prefix_cache=True, preserve_counters=True + ) + context.mamba_slot_allocator.reset.assert_not_called() + assert context.step_count == 17 + assert context.prefix_cache_lru_clock == 11 + + +def test_full_reset_publishes_clear_after_cache_reset(): + context, listener = _context_with_listener() + order = [] + context.enable_prefix_caching = True + context.reset_tensors = Mock(side_effect=lambda: order.append("reset_tensors")) + context.reset_metadata = Mock(side_effect=lambda **_kwargs: order.append("reset_metadata")) + context.step_count = 17 + context.prefix_cache_lru_clock = 11 + context.mamba_slot_allocator = Mock() + context.mamba_slot_allocator.reset.side_effect = lambda: order.append("reset_mamba") + listener.side_effect = lambda kind, _payload: order.append(kind) + + context.reset() + + assert order == ["reset_tensors", "reset_metadata", "reset_mamba", "cleared"] + + +@pytest.mark.parametrize( + ("cache_mode", "expect_clear"), + [ + (KVCacheManagementMode.PERSIST, False), + (KVCacheManagementMode.OFFLOAD, False), + (KVCacheManagementMode.RECOMPUTE, True), + ], +) +def test_suspend_clears_only_recomputed_cache(cache_mode, expect_clear): + context, listener = _context_with_listener() + order = [] + context.kv_cache_management_mode = cache_mode + context.static_kv_memory_pointers = True + context.deallocate_inference_state_buffers = Mock( + side_effect=lambda: order.append("deallocate") + ) + listener.side_effect = lambda kind, _payload: order.append(kind) + context.dynamo_helper.queue_kv_stored_event({"block_hashes": [101]}) + + engine = object.__new__(DynamicInferenceEngine) + engine.state = EngineState.RUNNING + engine.context = context + engine.unified_memory_level = 0 + engine.requests = {} + engine.waiting_request_ids = deque() + engine.use_coordinator = False + + with ( + mock.patch.object(DynamicInferenceEngine, "suspend_resume_ctx", return_value=nullcontext()), + mock.patch("megatron.core.inference.engines.dynamic_engine.InferenceMode.unset_active"), + ): + engine.suspend() + context.dynamo_helper.publish_pending_kv_stored_events() + + expected_order = ["deallocate", "cleared"] if expect_clear else ["deallocate"] + assert order == expected_order + + +def test_reset_metadata_can_preserve_prefix_allocator(): + context = DynamicInferenceContext.__new__(DynamicInferenceContext) + context.enable_prefix_caching = True + context.reset_attention_state = Mock() + context.reset_mamba_state = Mock() + context.kv_block_allocator = Mock() + context.request_to_kv_block_ids = Mock() + + context.reset_metadata(preserve_prefix_cache=True) + + context.reset_attention_state.assert_called_once_with() + context.reset_mamba_state.assert_called_once_with() + context.kv_block_allocator.reset.assert_not_called() + context.request_to_kv_block_ids.fill_.assert_called_once_with(-1) + + +def test_next_forward_can_discard_events_left_by_a_failed_forward(): + context, listener = _context_with_listener() + + context.dynamo_helper.queue_kv_stored_event({"block_hashes": [101]}) + context.dynamo_helper.discard_pending_kv_stored_events() + context.dynamo_helper.publish_pending_kv_stored_events() + + listener.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_forward_discards_before_scheduling_and_publishes_after_forward(monkeypatch): + context, listener = _context_with_listener() + context.step_count = 0 + context.prefix_cache_lru_clock = 0 + context.active_token_count = 0 + context.chunked_prefill_request_id = -1 + context.num_prefill_requests = 0 + context.config = SimpleNamespace(async_sched_mode=AsyncScheduleMode.LEGACY) + context.dynamo_helper.queue_kv_stored_event({"block_hashes": [7]}) + + order = [] + payload = {"block_hashes": [101], "token_ids": [1, 2]} + discard = context.dynamo_helper.discard_pending_kv_stored_events + publish = context.dynamo_helper.publish_pending_kv_stored_events + + def discard_pending(): + order.append("discard") + discard() + + def schedule(): + order.append("schedule") + context.dynamo_helper.queue_kv_stored_event(payload) + + async def forward(**kwargs): + order.append("forward") + assert kwargs == {} + listener.assert_not_called() + return SimpleNamespace(decode_only=False, output={"output": True}) + + def publish_pending(): + order.append("publish") + publish() + + context.dynamo_helper.discard_pending_kv_stored_events = discard_pending + context.dynamo_helper.publish_pending_kv_stored_events = publish_pending + + engine = object.__new__(DynamicInferenceEngine) + engine.state = EngineState.RUNNING + engine.context = context + engine.logging_step_interval = 0 + engine.schedule_waiting_requests = schedule + engine.controller = SimpleNamespace(async_generate_output_tokens_dynamic_batch=forward) + + monkeypatch.setattr( + "megatron.core.inference.engines.dynamic_engine.nvtx_range_push", lambda *_: None + ) + monkeypatch.setattr( + "megatron.core.inference.engines.dynamic_engine.nvtx_range_pop", lambda *_: None + ) + + result, _, _ = await DynamicInferenceEngine.async_forward(engine) + + assert result == {"output": True} + assert order == ["discard", "schedule", "forward", "publish"] + listener.assert_called_once_with("stored", payload) diff --git a/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py b/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py index b8eb78b2403..d72e59275d0 100644 --- a/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py +++ b/tests/unit_tests/inference/engines/test_dynamic_engine_async_sched.py @@ -8,6 +8,7 @@ import pytest from megatron.core.inference.config import AsyncScheduleMode +from megatron.core.inference.contexts.dynamic_context import DynamoHelper from megatron.core.inference.engines import DynamicInferenceEngine from megatron.core.inference.engines.dynamic_engine import EngineState, _get_decode_only_log_state from megatron.core.inference.sampling_params import SamplingParams @@ -246,6 +247,7 @@ def test_async_forward_routes_one_controller_iteration( num_prefill_requests=1 if expected_nvtx_range == "Prefill" else 0, chunked_prefill_request_id=17, is_decode_only=mock.Mock(return_value=decode_only.launched), + dynamo_helper=DynamoHelper(), ) output = None if primer_only else {"sample": "tokens"} engine.controller = SimpleNamespace( diff --git a/tests/unit_tests/inference/test_kv_allocator_observers.py b/tests/unit_tests/inference/test_kv_allocator_observers.py new file mode 100644 index 00000000000..eced425db4f --- /dev/null +++ b/tests/unit_tests/inference/test_kv_allocator_observers.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from unittest.mock import Mock + +import torch + +from megatron.core.inference.config import PrefixCachingEvictionPolicy +from megatron.core.inference.contexts.dynamic_context import DynamoHelper +from megatron.core.inference.contexts.kv_block_allocator import KVBlockAllocator + + +def test_allocator_notifies_observer_without_replacing_legacy_callback(): + context = Mock() + allocator = KVBlockAllocator( + context, + 8, + 0, + enable_prefix_caching=True, + prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO, + ) + removed = Mock() + legacy = Mock() + allocator.add_blocks_deregistered_observer(removed) + allocator.on_blocks_deregistered = legacy + + blocks = allocator.allocate_memory_blocks(2) + allocator.register_kv_block_hashes(blocks.tolist(), [101, 202]) + allocator.release_memory_blocks(blocks) + + legacy.assert_called_once() + removed.assert_called_once() + + +def test_listener_failure_does_not_interrupt_block_deregistration(): + context = Mock() + allocator = KVBlockAllocator( + context, + 8, + 0, + enable_prefix_caching=True, + prefix_caching_eviction_policy=PrefixCachingEvictionPolicy.REF_ZERO, + ) + helper = DynamoHelper() + failing_listener = Mock(side_effect=RuntimeError("publisher unavailable")) + healthy_listener = Mock() + helper.add_kv_event_listener(failing_listener) + helper.add_kv_event_listener(healthy_listener) + allocator.add_blocks_deregistered_observer(helper.on_kv_blocks_deregistered) + + blocks = allocator.allocate_memory_blocks(2) + + def assert_allocator_committed(_kind, _payload): + block_ids = blocks.to(torch.int64) + assert torch.all(allocator.block_hashes[block_ids] == -1) + assert torch.all(allocator.block_ref_counts[block_ids] == 0) + + healthy_listener.side_effect = assert_allocator_committed + allocator.register_kv_block_hashes(blocks.tolist(), [101, 202]) + allocator.release_memory_blocks(blocks) + + assert not allocator.kv_hash_to_block_id + assert torch.all(allocator.block_hashes[blocks.to(torch.int64)] == -1) + assert torch.all(allocator.block_ref_counts[blocks.to(torch.int64)] == 0) + failing_listener.assert_called_once() + healthy_listener.assert_called_once() + assert failing_listener.call_args.args[0] == "removed" + assert healthy_listener.call_args.args[0] == "removed" + assert set(failing_listener.call_args.args[1]["block_hashes"]) == {101, 202} + assert set(healthy_listener.call_args.args[1]["block_hashes"]) == {101, 202} From 9a3c40b25408f9c5f4ed87500989ebe299f88fea Mon Sep 17 00:00:00 2001 From: Yashaswi Karnati <144376261+yashaswikarnati@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:37:41 -0700 Subject: [PATCH 199/290] Make Bridge Communicator aware of GTP (#6263) Signed-off-by: ykarnati --- .../pipeline_parallel/bridge_communicator.py | 10 ++++++---- .../test_bridge_communicator.py | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/megatron/core/pipeline_parallel/bridge_communicator.py b/megatron/core/pipeline_parallel/bridge_communicator.py index fc234cac8ae..b7f85e36c56 100644 --- a/megatron/core/pipeline_parallel/bridge_communicator.py +++ b/megatron/core/pipeline_parallel/bridge_communicator.py @@ -212,15 +212,17 @@ def _get_or_create_bridge_pg(cls, ranks: List[int]): def get_leader_rank(self, grid: HyperCommGrid, is_src: bool) -> List[int]: """Get the leader rank for a given grid and direction. - We elect leader rank for each dp replica, the first tp-cp rank in the group + We elect a leader for each DP and GTP data lane, the first tp-cp rank in the group in the last pp stage (for src grid) or first pp stage (for dest grid) is the leader. """ leader_ranks = [] local_leader_rank = None - # grid.gen_rank_enum(["tp", "cp", "pp"]) # vary tp & cp, but same dp + # grid.gen_rank_enum(["tp", "cp", "pp"]) # vary tp & cp, same dp and gtp_remat # returns a list of sublists, each sublist is a group of ranks - # that have different tp & cp & pp, same dp - per_dp_replica_ranks = grid._gen_rank_enum([x for x in grid.dim_names if x != "dp"]) + # that have different tp & cp & pp, same dp and gtp_remat + per_dp_replica_ranks = grid._gen_rank_enum( + [x for x in grid.dim_names if x not in ("dp", "gtp_remat")] + ) if is_src: # Add rank from last pp stage ranks = [] diff --git a/tests/unit_tests/pipeline_parallel/test_bridge_communicator.py b/tests/unit_tests/pipeline_parallel/test_bridge_communicator.py index e4801ad8939..4eaa11b8c24 100644 --- a/tests/unit_tests/pipeline_parallel/test_bridge_communicator.py +++ b/tests/unit_tests/pipeline_parallel/test_bridge_communicator.py @@ -112,7 +112,7 @@ def _shard_and_copy_( _active_grids: list = [] -def create_hypercomm_grid(offset=0, tp=1, cp=1, pp=1, dp=1): +def create_hypercomm_grid(offset=0, tp=1, cp=1, pp=1, dp=1, gtp_remat=1): """Create a HyperCommGrid with tensor parallelism=2, context parallelism=2, and data parallelism=2.""" # Set up environment for world size 8 if not already set if not dist.is_initialized(): @@ -123,12 +123,13 @@ def create_hypercomm_grid(offset=0, tp=1, cp=1, pp=1, dp=1): os.environ["WORLD_SIZE"] = "8" grid = HyperCommGrid( - shape=[tp, cp, pp, dp], - dim_names=["tp", "cp", "pp", "dp"], + shape=[tp, gtp_remat, cp, pp, dp], + dim_names=["tp", "gtp_remat", "cp", "pp", "dp"], rank_offset=offset, backend="nccl", ) _ = grid.create_pg(["tp"]) + _ = grid.create_pg(["gtp_remat"]) _ = grid.create_pg(["cp"]) _ = grid.create_pg(["pp"]) _ = grid.create_pg(["dp"]) @@ -326,6 +327,17 @@ def test_bridge_pg_membership(self, grid1_tp, grid1_dp, grid2_tp, grid2_dp): ] assert all(rank not in expected for rank in member_ranks) + def test_gtp_is_an_independent_bridge_data_lane(self): + src_grid = create_hypercomm_grid(offset=0, tp=2, dp=2) + dest_grid = create_hypercomm_grid(offset=4, tp=2, dp=1, gtp_remat=2) + bridge = BridgeCommunicator(src_grid, dest_grid) + + assert len(bridge.src_tp_leaders) == 2 + assert len(bridge.dest_tp_leaders) == 2 + assert sorted(set(bridge.src_tp_leaders) | set(bridge.dest_tp_leaders)) == list( + dist.get_process_group_ranks(bridge.bridge_pg) + ) + def test_send_forward_recv_forward(self): """Test send_forward and recv_forward operations.""" From 9fa816bfd9dd994dc5acf6012fb4e841a56cfa4a Mon Sep 17 00:00:00 2001 From: Jiangfei Duan <30710061+JF-D@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:43:06 -0700 Subject: [PATCH 200/290] Fix GTP reduce-scatter overlap across local CUDA graphs (#6060) Signed-off-by: Jiangfei Duan --- .../core/generalized_tensor_parallel.md | 84 ++++- .../generalized_tensor_parallelism.py | 173 ++++++--- megatron/core/tensor_parallel/gtp_api.py | 21 +- .../core/tensor_parallel/gtp_cuda_graphs.py | 233 ++++++++++++ megatron/core/transformer/cuda_graphs.py | 64 ++-- .../test_gtp_basics.py | 160 ++++++++- .../test_gtp_partial_cg.py | 331 ++++++++++++++++++ 7 files changed, 979 insertions(+), 87 deletions(-) create mode 100644 megatron/core/tensor_parallel/gtp_cuda_graphs.py create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index f12306f9170..6e03046e01f 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -24,7 +24,7 @@ Both `GTP_remat` collectives are prefetched one step ahead, so they overlap the **Scope of this document**: a high-level summary of GTP_remat — design intent, public CLI surface, and Megatron-LM ↔ TransformerEngine integration touchpoints. -**Source**: core implementation in `megatron/core/tensor_parallel/generalized_tensor_parallelism.py`, public surface re-exported from `megatron/core/tensor_parallel/gtp_api.py`. Low-precision tensor primitives (FP8 / MXFP8 / NVFP4) stay in TransformerEngine and are imported by the implementation module. +**Source**: core sharding and collective implementation in `megatron/core/tensor_parallel/generalized_tensor_parallelism.py`, CUDA-graph lifecycle support in `megatron/core/tensor_parallel/gtp_cuda_graphs.py`, and the public surface re-exported from `megatron/core/tensor_parallel/gtp_api.py`. Low-precision tensor primitives (FP8 / MXFP8 / NVFP4) stay in TransformerEngine and are imported by the implementation module. **Outline:** @@ -58,6 +58,8 @@ Both `GTP_remat` collectives are prefetched one step ahead, so they overlap the - [3.3 Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) - [3.4 Prefetch-chain construction and its design assumptions](#34-prefetch-chain-construction-and-its-design-assumptions) - [Grouped-expert chains (one-block-ahead)](#grouped-expert-chains-one-block-ahead) + - [3.5 CUDA graph integration](#35-cuda-graph-integration) + - [Cross-graph backward reduce-scatter overlap](#cross-graph-backward-reduce-scatter-overlap) - [4. Testing](#4-testing) --- @@ -84,11 +86,11 @@ CG compatibility is designed-in from day one, not retrofitted. The entire sync / - **Chains never cross-link across the capture axis** (`GTPChain.GRAPHED` / `GTPChain.UNGRAPHED`, plus the eager-only grouped-expert chains of §3.4). `prev_w` / `next_w` only connect same-chain params, so a captured traversal never reaches into eager Python and vice-versa. - **`torch.cuda.Event(external=True)`** for `ag_event` / `rs_event` — the events survive CG capture boundaries and can be waited on from replay-time streams. - **Idempotent ticket cache**: `GTPWeightCache.get(ticket)` keeps `slot.buf` set even after `release()`, so replays read the same buffer address as capture. `clear()` drops buffers while keeping tickets valid → supports CG re-capture with lazy re-allocation. -- **Allocate-in-pool at creation** (`set_cuda_graph_mempool` + `_graphed_alloc`): GRAPHED-chain AG/RS buffers and quantized weight storage are allocated **directly into the CG memory pool** at first creation (during warmup, before capture), so no CUDA allocations happen inside the captured graph — and no post-hoc reallocation/clone is needed. UNGRAPHED buffers stay in regular allocator memory. +- **Allocate-in-pool at creation** (`set_cuda_graph_mempool` + `cuda_graph_pool_allocation`): GRAPHED-chain AG/RS buffers and quantized weight storage are allocated **directly into the CG memory pool** at first creation (during warmup, before capture), so no CUDA allocations happen inside the captured graph and no post-hoc reallocation/clone is needed. UNGRAPHED buffers stay in regular allocator memory. - **Lazy, one-shot chain linking**: `prefetch_initialized` is flipped during the first fwd (warmup), so the chain-construction Python side-effects never execute inside a captured graph. The link table is buffered and flushed atomically at the second forward. - **DDP hook manual triggering**: `register_grad_accum_hook` stores the DDP hook on the param; `_CudagraphReplayNode.backward` calls it manually after replay (since `AccumulateGrad` hooks are silenced by replay). This is also how the `assert self.grad_reduce_handle is not None` failure from partial-CG + overlap-grad-reduce is resolved. - **Warmup is side-effect-free on `main_grad`**: GTP_remat accumulates wgrad into `main_grad` *inside* the backward (the fusion path returns wgrads as graph outputs instead). Graph capture only *records* ops; it never runs them. But `create_fwd_graph` runs an **eager** warmup fwd+bwd before capturing. That warmup backward executes GTP_remat's `main_grad.add_`. Its deferred cascade adds into a cross-graph `next_w` (another module) from a **stale RS ticket** — the prior backward's wgrad. And `create_cudagraphs()` runs *after* `finalize_model_grads`. So this overwrites the finalized (reduced + per-token-scaled) grads and spikes the step's grad norm. **Fix**: `create_fwd_graph` snapshots the grads its warmup touches — own params + cross-graph `next_w` — via `_backup_grads_before_capture`, then restores them after capture. The bwd graph has no warmup, so it needs none. Bounded to one module's grads. -- **Drains at CG / eager boundary**: `_drain_gtp_side_streams()` before eager MoE expert compute. Inside bwd capture, two-phase drain: Phase 1 joins the within-graph cascade and records `bwd_completion_event` (next runner unblocks); Phase 2 calls `wait_async_comms(GRAPHED)` to drain the chain-tail handle and re-joins side streams (queued after the event so it doesn't delay the next runner). +- **Graph-owned two-stage backward drain**: Stage 1 drains only the all-gathers issued by the current graph and records `bwd_completion_event`, allowing the next backward graph to start. Stage 2 drains that graph's reduce-scatters, accumulates the result into `main_grad`, and releases its persistent wgrad-ring slots. See [§3.5](#cross-graph-backward-reduce-scatter-overlap). - **Side-stream registration**: the `(GRAPHED, gtp_remat_group)` ag/rs streams are materialized at runner init (`_register_gtp_side_streams`) so they are captured before the first forward. ### 1.3 Low-precision gather (native FP8 / NVFP4 param) @@ -275,7 +277,7 @@ At iter-0 you'll see one rank-0 log line confirming the active config: ``` GTP_remat enabled. GTPRematConfig(pad_for_alignment=16, check_param_states=False, weight_prefetch=True, async_reduction=True, calculate_per_token_loss=False, - reduce_scatter_with_fp32_accumulation=False) + reduce_scatter_with_fp32_accumulation=False, graph_wgrad_ring_size=2) ``` ### 2.4 Tuning knobs @@ -289,11 +291,14 @@ update_gtp_config( async_reduction=True, # Whether to perform GTP_remat gradient reduction asynchronously calculate_per_token_loss=False, # Mirror config.calculate_per_token_loss (SUM vs MEAN RS) reduce_scatter_with_fp32_accumulation=False, # wgrad RS: BF16 all-to-all + FP32 sum (§2.5) + graph_wgrad_ring_size=2, # Persistent wgrad slots per graph scheduling domain ) ``` `training.py` auto-tunes `pad_for_alignment` based on the quantization recipe (`--fp4`, `--fp8-recipe=mxfp8`, etc.) before model construction. The other knobs are usually left at defaults. +GTP backward reduce-scatter overlap across local CUDA-graph boundaries is enabled automatically. The ownership and ordering protocol is described in [§3.5](#cross-graph-backward-reduce-scatter-overlap). + > **CUDA-graph warmup under GTP_remat.** When CUDA graphs are enabled, GTP_remat forces a minimum of **2** per-graph warmup steps regardless of `--cuda-graph-warmup-steps` (e.g. a user-set `0` is bumped to `2`): the first warmup builds the weight-prefetch chain and the second exercises the prefetch path before capture. ### 2.5 FP32-accumulation wgrad reduce-scatter (optional) @@ -575,6 +580,74 @@ Three consequences: - why it must: `cuda_graphs.py` drains with `wait_async_comms(GTPChain.GRAPHED.value)`, matching the id **literally**, so a weight in `GTP_remat_grouped_fc1_ungraphed` would never be joined at the graph boundary — a **correctness** hazard, not just a lost overlap; - lifting it would mean draining by chain-id *prefix* (`_chain_is_grouped`) or registering the grouped streams before capture — neither is done today. +### 3.5 CUDA graph integration + +GTP supports both **full-iteration CUDA graphs** and **local/partial CUDA graphs**. The common integration keeps graph and eager chains separate, builds lazy prefetch links during warmup, materializes side streams before capture, and preserves stable addresses for captured communication buffers. Full-iteration capture has no boundary between individual layer graphs. Local capture divides the model into independently replayed graph runners, so communication at a runner boundary requires an explicit completion protocol. The features below describe CUDA-graph-specific GTP optimizations and the ownership rules required to make them safe. + +#### Cross-graph backward reduce-scatter overlap + +*Problem.* A local backward graph may launch GTP all-gathers and wgrad reduce-scatters on side streams. The conservative completion boundary drains both kinds of communication before releasing the next graph. This is correct, but it serializes the current graph's RS tail with otherwise independent compute in the next graph. Releasing the next graph earlier introduces two ownership requirements: each graph must drain only its own communication, and an RS input must remain alive until NCCL has stopped reading it even if another graph has started. + +*Without cross-graph overlap.* The conservative backward schedule drains communication in two stages: + +```text +Stage 1: graph-owned AG handles -> wait graph-owned AG streams +Stage 2: graph-owned RS handles -> finalize main_grad -> wait graph-owned RS streams +``` + +`bwd_completion_event` is recorded after Stage 2. The next graph therefore starts after the current graph's AG, RS, and `main_grad` finalization have completed. Because the RS input lifetime cannot extend into the next graph, no persistent cross-graph wgrad ring is required. + +```text +time --------------------------------------------------------------------------------> + +runner i wgrad GEMM -> launch RS_i -> Stage 1: drain AG -> Stage 2: wait RS_i +RS stream +---------------- RS_i -----------------> add main_grad_i +runner i stream -> completion_i +main stream wait completion_i + -> runner i-1 +``` + +*Design.* Cross-graph overlap keeps the same two drain stages but moves `bwd_completion_event` between them. Stage 1 establishes that the graph's all-gathers are complete, which is sufficient to release the next graph. Stage 2 remains ordered after the event and drains RS before finalizing `main_grad`. This creates a compute window for the RS tail without changing the collective or gradient math. + +*With cross-graph overlap.* The main stream may launch the next backward graph while the current graph's RS and `main_grad` finalization continue. Fixed-address ring slots and replay-time events protect each RS input for the longer lifetime. + +```text +time ------------------------------------------------------------------------------------> + +runner i wait ready[S0] -> wgrad_i writes S0 -> Stage 1 -> completion_i +RS stream +------ RS_i(S0) ------> ready[S0] -> add_i +main stream +-> launch runner i-1 +runner i-1 wait ready[S1] + wgrad_i-1 writes S1 +RS stream +--- RS_i-1(S1) ---> ready[S1] +main stream +-> runner i-2 +runner i-2 wait ready[S0] + + S0 and S1 are allocated before capture, outside the shared graph pool. +``` + +The two modes differ only in release timing and the storage required to make early release safe: + +| Property | Without cross-graph overlap | With cross-graph overlap | +|---|---|---| +| `bwd_completion_event` | After Stage 2 | Between Stage 1 and Stage 2 | +| RS overlap with the next graph | No | Yes | +| Persistent wgrad ring | Not required | Required | +| Additional persistent memory | None for the ring | Bounded by `graph_wgrad_ring_size` | + +*Implementation.* Cross-graph overlap is implemented by the following cooperating mechanisms: + +1. **Capture-local communication ownership.** `track_gtp_capture_comms()` creates one `GTPCaptureCommState` per backward capture. `register_capture_comm()` records the exact params, AG streams, and RS streams touched by that graph. Both drain stages pass `capture_comms.params` to `wait_async_comms()`, so a graph drains only communication it owns. +2. **Two-stage completion protocol.** Stage 1 calls `wait_async_comms(..., skip_rs=True)` and joins graph-owned AG streams before recording `bwd_completion_event`. Stage 2 drains graph-owned RS handles, accumulates reduced wgrads into `main_grad`, and joins the RS streams. +3. **Persistent wgrad-ring allocation.** `initialize_graph_wgrad_rings()` runs after DDP creates `main_grad` and before graph capture. `allocate_graph_wgrad_rings()` allocates fixed-address tensors outside the shared graph pool. Slots are keyed by communication domain, unsharded shape, padded shape, dtype, and expert index. The default ring size is two. +4. **Actual RS-input ownership.** `_prepare_wgrad_reduce_scatter_inputs()` registers the ring slot selected as the actual NCCL input. If one graph maps multiple parameters to the same slot, capture fails with a request to increase `graph_wgrad_ring_size`. +5. **Replay fencing.** Before replay writes a slot, the graph runner waits for its `ready_event`. The RS stream publishes that event only after NCCL has stopped reading the slot. Different slots may remain live concurrently; reuse of an occupied slot waits. +6. **Final gradient fence.** `wait_for_gtp_grad_reduction_on_current_stream()` joins GTP side streams and graph-runner streams before DDP or the optimizer consumes `main_grad`. + +*Result and cost.* The ring owns the padded RS input. The wgrad GEMM writes the logical prefix, the alignment tail remains zero, and a non-ring producer is copied into the logical view before reduce-scatter. The bounded memory cost is up to `graph_wgrad_ring_size` full unsharded wgrad buffers for each matching scheduling/shape domain, rather than one buffer per layer. The default ring size of two is sufficient when each graph has one same-key writer: one slot may remain an in-flight RS input while the next graph writes the other, and reuse waits on the older slot's `ready_event`. A larger ring is needed only when one graph contains multiple same-key writers whose reduce-scatter inputs can be live together. Capture rejects unsafe same-slot reuse instead of silently aliasing it. + +The feature applies only to **local/partial CUDA graphs** and is enabled automatically. Full-iteration CUDA graphs do not use this feature because their backward execution has no local graph boundary. + ## 4. Testing **Whenever you add or change a GTP_remat/EGTP_remat feature, run the GTP_remat unit-test suite below as a sanity check before opening a PR.** These tests exercise the full TE↔Mcore path (weight gather/RS, DDP, distributed optimizer, finalize, grad-norm) and catch silent-correctness regressions that don't surface as crashes. @@ -586,7 +659,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | Test file | What it guards | |-----------|----------------| -| `test_gtp_basics.py` | Core GTP_remat shard/gather + DDP bucket alignment. | +| `test_gtp_basics.py` | Core GTP_remat shard/gather, cache ownership, wgrad ring, and DDP bucket alignment. | | `test_attention_gtp.py` | GTP_remat on attention linears, loss parity vs no-GTP_remat. | | `test_mamba_gtp.py` | GTP_remat on Mamba projection weights. | | `test_tp_gtp.py` | GTP_remat composed with tensor parallelism (`tp_group × gtp_remat_group`). | @@ -594,6 +667,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_gtp_loss_correctness.py` | End-to-end: GTP_remat per-step loss trajectory matches a no-GTP_remat baseline. | | `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. Also the fp32-accumulation reduce-scatter (§2.5): gtp_remat-axis and DDP-axis parity, plus the size-2 bypass. | | `test_gtp_cudagraph_grad.py` | Capture-step grad-norm guard (§1.2): `_backup_grads_before_capture`/`_restore_grads_after_capture` keep a graph capture from clobbering finalized `main_grad` (own params + cross-graph `next_w`, incl. routed-expert `weight_list`). | +| `test_gtp_partial_cg.py` | Four-layer partial-CG loss and eager-vs-replay grad-norm parity with two-slot ring reuse across independently replayed graphs (§3.5). | | `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | | `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | | `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. | diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index 3ff33914cda..c9c890c4bd2 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -36,6 +36,12 @@ import torch from packaging.version import Version +from megatron.core.tensor_parallel.gtp_cuda_graphs import ( + allocate_graph_wgrad_rings, + cuda_graph_pool_allocation, + register_capture_comm, + register_capture_wgrad_ring_slot, +) from megatron.core.utils import log_single_rank logger = logging.getLogger(__name__) @@ -275,6 +281,7 @@ class GTPWeightState(Enum): _AG_STREAMS: Dict[str, torch.cuda.Stream] = {} _RS_STREAMS: Dict[str, torch.cuda.Stream] = {} + # Wgrad input buffer pool, keyed by (shape, dtype). UNGRAPHED-only: GRAPHED # wgrad bufs need address stability for CG replay and are not pool-recycled. _wgrad_buf_pool: Dict[tuple, list] = {} @@ -358,6 +365,18 @@ def get_rs_stream(chain_id: str = GTPChain.GRAPHED.value, group=None) -> torch.c return _RS_STREAMS[key] +def initialize_graph_wgrad_rings() -> None: + """Allocate persistent wgrad inputs before local CUDA-graph capture.""" + allocate_graph_wgrad_rings( + _GTP_PARAMS, + full_iteration=_FULL_ITERATION, + async_reduction=GTP_CONFIG.async_reduction, + ring_size=GTP_CONFIG.graph_wgrad_ring_size, + graphed_chain_id=GTPChain.GRAPHED.value, + stream_key=_stream_key, + ) + + def wait_for_gtp_grad_reduction_on_current_stream() -> None: """Fence the current stream against all GTP backward grad work before the DP gradient sync. @@ -405,6 +424,11 @@ class GTPRematConfig: # wire, but accumulation no longer loses precision as the axis grows. Bypassed at axis size # <= 2. Independent of the DDP-axis --ddp-reduce-scatter-with-fp32-accumulation. reduce_scatter_with_fp32_accumulation: bool = False + # Persistent wgrad slots per scheduling/shape domain for partial-CG asynchronous reduce-scatter. + # Two slots cover the usual case of one same-key writer per graph. A graph containing multiple + # same-key writers may need more slots to keep all in-flight RS inputs distinct. + # TODO: Infer each domain's ring size automatically. + graph_wgrad_ring_size: int = 2 GTP_CONFIG = GTPRematConfig() @@ -786,6 +810,13 @@ def __init__(self, handle, gtp_shards, reduce_scatter=False): self.gtp_shards = gtp_shards self.reduce_scatter = reduce_scatter _inflight_comm_params.add(gtp_shards[0]) + param = gtp_shards[0] + stream = ( + get_rs_stream(param.chain_id, param.group) + if reduce_scatter + else get_ag_stream(param.chain_id, param.group) + ) + register_capture_comm(param, stream, reduce_scatter=reduce_scatter) def wait(self): """Wait on the underlying NCCL work and update the shards' state.""" @@ -1069,18 +1100,28 @@ def _ensure_distinct_buffer_from_prev(self, dtype): self._buf_parity = 1 - (getattr(prev, "_buf_parity", None) or 0) def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: - """Build cache key from output shape + dtype. + """Build a cache key that includes the communication scheduling domain. + + ``GTPWeightCache.release`` retains a ticket's buffer pointer while returning the storage to + its key's pool. Reuse is therefore safe only for operations serialized on the same GTP chain + and process group. GRAPHED and UNGRAPHED chains use independent streams, as do collectives + on different process groups, so sharing across either boundary can race even when shape and + dtype match. - Weights with matching gathered shape and dtype share a buffer. For experts gathered - in parallel, self.expert_idx keeps each distinct; same-indexed experts across layers share. + Within one scheduling domain, weights with matching gathered shape and dtype share a + buffer. Expert weights gathered in parallel use self.expert_idx to remain distinct, while + the same expert index across layers shares a buffer. - Grouped one-block-ahead chains additionally fold in a double-buffer parity so a prefetched - layer N+1 weight never lands in the buffer that layer N is still consuming (see - ``_GTP_GROUPED_BUF_PARITY_COUNTER``). + Grouped one-block-ahead chains additionally fold in the logical fc1/fc2 chain and a + double-buffer parity. This keeps simultaneously live fc1/fc2 weights distinct and prevents + a prefetched layer N+1 weight from overwriting the buffer still consumed by layer N. """ + scheduling_domain = _stream_key(self.chain_id, self.group) + if not isinstance(dtype, torch.dtype): key = ( + scheduling_domain, self._unsharded_shape_padded, dtype, fwd, @@ -1089,7 +1130,13 @@ def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: reduce_scatter, ) else: - key = (self._unsharded_shape_padded, dtype, self.expert_idx, reduce_scatter) + key = ( + scheduling_domain, + self._unsharded_shape_padded, + dtype, + self.expert_idx, + reduce_scatter, + ) if _chain_is_grouped(self.chain_id): # chain_id keeps fc1/fc2 apart (both can be in flight at once, even if same-shaped); # parity alternates consecutive blocks between two buffers. @@ -1519,7 +1566,13 @@ def batched_all_gather_and_prefetch(self, **kwargs): return self.all_gather_and_prefetch(**kwargs) def get_wgrad_tensor(self): - """Pool-allocate a wgrad scratch tensor of unsharded shape for the bwd GEMM.""" + """Return a logical-shape view of stable ring storage or ordinary scratch. + + Capture ownership is registered later, when the final RS input is selected. + """ + ring_slot = getattr(self, "_gtp_graph_wgrad_ring_slot", None) + if ring_slot is not None: + return self._gtp_graph_wgrad_ring_view return _wgrad_pool_get(self._unsharded_shape, self.main_grad.dtype, self.device) def register_grad_accum_hook( @@ -1571,6 +1624,7 @@ def _wait_reduce_scatter(self, finalize_grad=False): with torch.cuda.stream(rs_stream): if self._wgrad_rs_handle is not None: self._wgrad_rs_handle.wait() + self._record_graph_wgrad_ring_slots_ready() self._wgrad_rs_handle = None self.rs_event.record() if finalize_grad: @@ -1604,6 +1658,16 @@ def _release_comm_scratch(self, attrs=("_wgrad_input_bufs", "_rs_a2a_bufs")): _wgrad_pool_put(buf) setattr(self, attr, None) + def _record_graph_wgrad_ring_slots_ready(self) -> None: + """Publish that this RS has finished reading its persistent input slots.""" + seen = set() + for weight in self._weights: + slot = getattr(weight, "_gtp_graph_wgrad_ring_slot", None) + if slot is None or id(slot) in seen: + continue + seen.add(id(slot)) + slot.ready_event.record() + def _prescale_wgrads_for_mean_rs(self, wgrads): """Pre-scale wgrad by 1/gtp_remat so the SUM reduce-scatter yields the gtp_remat mean. @@ -1665,8 +1729,7 @@ def _reduce_scatter(self, wgrads, async_op, nvtx_label=None): for w in self._weights: w._set_rs_state(new_rs_state) - if self.pad_length > 0: - wgrads = [torch.nn.functional.pad(w, (0, 0, 0, self.pad_length)) for w in wgrads] + wgrads = self._prepare_wgrad_reduce_scatter_inputs(wgrads) if async_op: dtypes = [w.dtype for w in wgrads] @@ -1753,6 +1816,35 @@ def _reduce_scatter(self, wgrads, async_op, nvtx_label=None): return outputs, cm if async_op else None + def _prepare_wgrad_reduce_scatter_inputs(self, wgrads): + """Return alignment-padded RS inputs with stable ownership when ring-backed. + + A ring slot owns the full padded tensor. The wgrad GEMM writes only its logical prefix, + while the zero tail remains untouched. If a caller did not produce wgrad directly into + that prefix, copy it there before RS. + """ + prepared = [] + for weight, wgrad in zip(self._weights, wgrads): + slot = getattr(weight, "_gtp_graph_wgrad_ring_slot", None) + if slot is None: + if weight.pad_length > 0: + wgrad = torch.nn.functional.pad(wgrad, (0, 0, 0, weight.pad_length)) + prepared.append(wgrad) + continue + + register_capture_wgrad_ring_slot(slot, weight) + logical_view = weight._gtp_graph_wgrad_ring_view + if tuple(wgrad.shape) != tuple(logical_view.shape): + raise RuntimeError( + f"GTP wgrad shape {tuple(wgrad.shape)} does not match ring view " + f"{tuple(logical_view.shape)} for {weight._debug_name}" + ) + if wgrad.data_ptr() != logical_view.data_ptr(): + logical_view.copy_(wgrad) + prepared.append(slot.tensor) + + return prepared + def wgrad_reduce_scatter(self, wgrad, nvtx_label=None): """Reduce-scatter wgrad(s): sync for the last weight, async+deferred for others. Accepts a single tensor (non-routed) or a list (routed experts). @@ -1886,35 +1978,6 @@ class _TicketSlot: buf: Optional[torch.Tensor] = field(default=None) # None when released or after clear() -# CUDA-graph memory pool: routes GRAPHED-chain allocations (AG/RS buffers, quantized weight -# storage) into the capture pool at creation time, avoiding post-hoc reallocation. Registered -# via set_cuda_graph_mempool before the first graphed forward; stays None when CG is off, where -# _graphed_alloc is a no-op (regular allocator). -_CG_MEMPOOL_DEVICE = None -_CG_MEMPOOL = None - - -def set_cuda_graph_mempool(device, mempool): - """Register the CUDA-graph memory pool for GRAPHED-chain GTP allocations.""" - global _CG_MEMPOOL_DEVICE, _CG_MEMPOOL - _CG_MEMPOOL_DEVICE = device - _CG_MEMPOOL = mempool - - -@contextmanager -def _graphed_alloc(chain_id): - """Route allocations in this block into the registered CG mempool when ``chain_id`` - is GRAPHED and a pool is registered; otherwise a no-op (regular allocator).""" - if _CG_MEMPOOL is not None and _chain_is_graphed(chain_id): - torch._C._cuda_beginAllocateCurrentThreadToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) - try: - yield - finally: - torch._C._cuda_endAllocateToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) - else: - yield - - class GTPWeightCache: """Ticket-based buffer pool for GTP all-gather / reduce-scatter buffers. @@ -1964,8 +2027,8 @@ def _allocate_buffer( else: out_shape = param._unsharded_shape_padded - # Route GRAPHED-chain buffers into the CG mempool at creation (see _graphed_alloc). - with _graphed_alloc(getattr(param, "chain_id", GTPChain.UNGRAPHED.value)): + chain_id = getattr(param, "chain_id", GTPChain.UNGRAPHED.value) + with cuda_graph_pool_allocation(_chain_is_graphed(chain_id)): if not isinstance(dtype, torch.dtype): # Use the gather quantizer copy: mutating the param's own quantizer usage # would corrupt the optimizer's quantize_ update direction (frozen weights). @@ -2037,14 +2100,18 @@ def get(self, ticket: int) -> torch.Tensor: return slot.buf def release(self, ticket: int): - """Return the buffer to the pool (ticket stays valid). + """Release a reusable buffer to the pool while keeping its ticket valid. - slot.buf is intentionally NOT cleared: get() must stay idempotent so CUDA-graph-captured - buffers keep their fixed address across replays. + Captured reduce-scatter tickets keep exclusive ownership. ``slot.buf`` is never cleared, + so every CUDA-graph ticket retains a stable address across replays. """ slot = self._slots[ticket] if slot.buf is None: return + # Independently replayed graphs may overlap, so a captured RS output must not be recycled + # into another fixed-address ticket. + if slot.chain_id == GTPChain.GRAPHED.value and slot.reduce_scatter: + return # Use identity check — tensor == tensor returns a multi-element bool tensor # which crashes in a boolean context ("Boolean value of Tensor is ambiguous"). if not any(b is slot.buf for b in self._pool.get(slot.key, [])): @@ -2067,7 +2134,10 @@ def get_global_GTP_cache() -> GTPWeightCache: def wait_async_comms( - chain_id: str = None, skip_rs: bool = False, finalize_after_drain: bool = False + chain_id: str = None, + skip_rs: bool = False, + finalize_after_drain: bool = False, + params: Optional[List[GTPShardedParam]] = None, ): """Drain in-flight GTP async AG / RS handles. @@ -2084,12 +2154,15 @@ def wait_async_comms( NCCL RS) so it starts during AG drain rather than after, avoiding SM-saturation that blocks cross-graph overlap. Falls back to caller-stream accumulation if no RS handle. + params: If specified, drain only async work issued by the owning CUDA graph. Outside graph + capture, the process-global in-flight set remains the default. Per-param side effects: * _already_ag_drained = True (if an AG handle was drained) * _already_finalized = True (if finalize_after_drain=True) """ - for param in list(_inflight_comm_params): + comm_params = list(_inflight_comm_params) if params is None else params + for param in comm_params: if ( chain_id is not None and getattr(param, "chain_id", GTPChain.UNGRAPHED.value) != chain_id @@ -2105,14 +2178,20 @@ def wait_async_comms( param._wait_recompute_param_gather() param._recompute_already_drained = True if not skip_rs: + had_rs = param._wgrad_rs_handle is not None + rs_was_in_scope = ( + had_rs + if params is not None + else any(w._rs_ticket is not None for w in param._weights) + ) param._wait_reduce_scatter(finalize_grad=finalize_after_drain) # Fallback inline-accumulation: only when finalize is requested, _wait_reduce_scatter # didn't already finalize, and an RS actually ran (rs_ticket set). Skips pure-AG # prefetches in _inflight_comm_params (no wgrad). need_fallback_accumulation = ( finalize_after_drain + and rs_was_in_scope and not getattr(param, "_already_finalized", False) - and any(w._rs_ticket is not None for w in param._weights) ) if need_fallback_accumulation: cache = get_global_GTP_cache() diff --git a/megatron/core/tensor_parallel/gtp_api.py b/megatron/core/tensor_parallel/gtp_api.py index b49a5c02ded..06555ebe719 100644 --- a/megatron/core/tensor_parallel/gtp_api.py +++ b/megatron/core/tensor_parallel/gtp_api.py @@ -2,16 +2,16 @@ """Generalized Tensor Parallelism (GTP) public API. -Thin re-export of the implementation in -``megatron.core.tensor_parallel.generalized_tensor_parallelism`` (see that module -for the design). GTP depends on TransformerEngine: if TE is missing or too old the -inner module imports cleanly but reports ``HAVE_TE = False``, mirrored here as -``HAVE_GTP = False``. Consumers gate every GTP code path behind ``if HAVE_GTP:``, -so no core module uses GTP symbols without TE. +Thin facade over the core implementation in ``generalized_tensor_parallelism`` and CUDA-graph +lifecycle support in ``gtp_cuda_graphs``. GTP depends on TransformerEngine: if TE is missing or +too old the core module imports cleanly but reports ``HAVE_TE = False``, mirrored here as +``HAVE_GTP = False``. Consumers gate every GTP code path behind ``if HAVE_GTP:``, so no core +module uses GTP symbols without TE. """ try: from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTP_CONFIG, HAVE_TE, GTPChain, GTPEmbeddingWeight, @@ -23,13 +23,17 @@ get_rs_stream, gtp_native_fp8_load_context, gtp_remat_shard_dim0, + initialize_graph_wgrad_rings, is_gtp_param, make_sharded_tensors_for_checkpoint_with_gtp_remat, - set_cuda_graph_mempool, wait_async_comms, wait_for_gtp_grad_reduction_on_current_stream, wrap_module_params_gtp, ) + from megatron.core.tensor_parallel.gtp_cuda_graphs import ( + set_cuda_graph_mempool, + track_gtp_capture_comms, + ) HAVE_GTP = HAVE_TE except ImportError: @@ -40,6 +44,7 @@ __all__ = [ "HAVE_GTP", + "GTP_CONFIG", "GTPChain", "GTPEmbeddingWeight", "attach_gtp_to_presharded_module", @@ -51,8 +56,10 @@ "gtp_native_fp8_load_context", "gtp_remat_shard_dim0", "is_gtp_param", + "initialize_graph_wgrad_rings", "make_sharded_tensors_for_checkpoint_with_gtp_remat", "set_cuda_graph_mempool", + "track_gtp_capture_comms", "wait_async_comms", "wait_for_gtp_grad_reduction_on_current_stream", "wrap_module_params_gtp", diff --git a/megatron/core/tensor_parallel/gtp_cuda_graphs.py b/megatron/core/tensor_parallel/gtp_cuda_graphs.py new file mode 100644 index 00000000000..94df4695bc8 --- /dev/null +++ b/megatron/core/tensor_parallel/gtp_cuda_graphs.py @@ -0,0 +1,233 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""CUDA-graph lifecycle support for Generalized Tensor Parallelism (GTP). + +This module owns state that exists only for local CUDA-graph capture and replay: + +* capture-local ownership of asynchronous GTP communication; +* persistent wgrad ring buffers whose lifetime may cross graph boundaries; +* routing graph-owned allocations into the shared CUDA-graph memory pool. +""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Callable, Iterable, Optional + +import torch + +from megatron.core.utils import log_single_rank + +logger = logging.getLogger(__name__) + + +@dataclass +class GraphWgradRingSlot: + """One persistent wgrad slot guarded by its reduce-scatter completion event.""" + + tensor: torch.Tensor + ready_event: torch.cuda.Event + key: tuple + index: int + + +@dataclass +class GTPCaptureCommState: + """Asynchronous GTP work issued while capturing one CUDA graph.""" + + params: list = field(default_factory=list) + ag_streams: list = field(default_factory=list) + rs_streams: list = field(default_factory=list) + wgrad_ring_slots: list = field(default_factory=list) + _param_ids: set = field(default_factory=set) + _ag_stream_ids: set = field(default_factory=set) + _rs_stream_ids: set = field(default_factory=set) + _wgrad_ring_slot_params: dict = field(default_factory=dict) + + def register_comm(self, param, stream: torch.cuda.Stream, *, reduce_scatter: bool) -> None: + """Record a parameter and side stream owned by this graph capture.""" + param_id = id(param) + if param_id not in self._param_ids: + self._param_ids.add(param_id) + self.params.append(param) + + stream_id = id(stream) + streams = self.rs_streams if reduce_scatter else self.ag_streams + stream_ids = self._rs_stream_ids if reduce_scatter else self._ag_stream_ids + if stream_id not in stream_ids: + stream_ids.add(stream_id) + streams.append(stream) + + def register_wgrad_ring_slot(self, slot: GraphWgradRingSlot, param) -> None: + """Track slots used by this graph and reject unsafe intra-graph aliasing.""" + slot_id = id(slot) + param_id = id(param) + prior_param_id = self._wgrad_ring_slot_params.get(slot_id) + if prior_param_id is not None and prior_param_id != param_id: + raise RuntimeError( + "One CUDA graph writes the same GTP wgrad ring slot for multiple " + "parameters; increase GTP_CONFIG.graph_wgrad_ring_size" + ) + if prior_param_id is None: + self._wgrad_ring_slot_params[slot_id] = param_id + self.wgrad_ring_slots.append(slot) + + +_ACTIVE_CAPTURE_COMM_STATE: Optional[GTPCaptureCommState] = None + + +def register_capture_comm(param, stream: torch.cuda.Stream, *, reduce_scatter: bool) -> None: + """Register communication with the active capture, if one exists.""" + if _ACTIVE_CAPTURE_COMM_STATE is not None: + _ACTIVE_CAPTURE_COMM_STATE.register_comm(param, stream, reduce_scatter=reduce_scatter) + + +def register_capture_wgrad_ring_slot(slot: GraphWgradRingSlot, param) -> None: + """Register a ring slot with the active capture, if one exists.""" + if _ACTIVE_CAPTURE_COMM_STATE is not None: + _ACTIVE_CAPTURE_COMM_STATE.register_wgrad_ring_slot(slot, param) + + +@contextmanager +def track_gtp_capture_comms(): + """Track asynchronous GTP work owned by one CUDA-graph capture.""" + global _ACTIVE_CAPTURE_COMM_STATE + + if _ACTIVE_CAPTURE_COMM_STATE is not None: + raise RuntimeError("Nested GTP CUDA-graph communication tracking is unsupported") + + state = GTPCaptureCommState() + _ACTIVE_CAPTURE_COMM_STATE = state + try: + yield state + finally: + _ACTIVE_CAPTURE_COMM_STATE = None + + +# Slots live outside the shared graph pool so independently replayed graphs cannot reuse an +# in-flight reduce-scatter input as temporary workspace. +_GRAPH_WGRAD_RINGS: dict[tuple, list[GraphWgradRingSlot]] = {} + + +def allocate_graph_wgrad_rings( + params: Iterable, + *, + full_iteration: bool, + async_reduction: bool, + ring_size: int, + graphed_chain_id: str, + stream_key: Callable[[str, object], tuple], +) -> None: + """Allocate bounded persistent inputs for cross-graph asynchronous reduce-scatter. + + Slots are shared across layers only within one communication scheduling domain. A two-slot + ring retains one graph of overlap without allocating one full unsharded wgrad per layer. + """ + if full_iteration or not async_reduction or _GRAPH_WGRAD_RINGS: + return + if ring_size < 1: + raise ValueError("GTP_CONFIG.graph_wgrad_ring_size must be at least 1") + + params_by_key = defaultdict(list) + seen_params = set() + for chain_param in params: + if not getattr(chain_param, "is_gtp_weight_remat", False): + continue + if chain_param.chain_id != graphed_chain_id or chain_param.prev_w is None: + continue + for param in chain_param._weights: + if id(param) in seen_params: + continue + seen_params.add(id(param)) + if not hasattr(param, "main_grad"): + raise RuntimeError( + "GTP wgrad rings must be initialized after DDP creates param.main_grad" + ) + key = ( + stream_key(param.chain_id, param.group), + param._unsharded_shape, + param._unsharded_shape_padded, + param.main_grad.dtype, + param.expert_idx, + ) + params_by_key[key].append(param) + + total_bytes = 0 + buffer_count = 0 + new_slots = [] + for key, matching_params in params_by_key.items(): + slot_count = min(ring_size, len(matching_params)) + slots = [] + exemplar = matching_params[0] + for slot_index in range(slot_count): + tensor = torch.empty( + exemplar._unsharded_shape_padded, + dtype=exemplar.main_grad.dtype, + device=exemplar.device, + memory_format=torch.contiguous_format, + ) + if exemplar.pad_length > 0: + tensor.narrow(0, exemplar._unsharded_shape[0], exemplar.pad_length).zero_() + slot = GraphWgradRingSlot( + tensor=tensor, + ready_event=torch.cuda.Event(external=True), + key=key, + index=slot_index, + ) + slots.append(slot) + new_slots.append(slot) + total_bytes += tensor.numel() * tensor.element_size() + buffer_count += 1 + + _GRAPH_WGRAD_RINGS[key] = slots + for param_index, param in enumerate(matching_params): + slot = slots[param_index % slot_count] + param._gtp_graph_wgrad_ring_slot = slot + if param.pad_length > 0: + param._gtp_graph_wgrad_ring_view = slot.tensor.narrow( + 0, 0, param._unsharded_shape[0] + ) + else: + param._gtp_graph_wgrad_ring_view = slot.tensor + + # Initially every slot is available. Later generations are recorded on the RS stream after NCCL + # has finished reading the slot. + for slot in new_slots: + slot.ready_event.record() + if new_slots: + torch.cuda.current_stream().synchronize() + + log_single_rank( + logger, + logging.INFO, + f"[GTP Wgrad Ring] allocated {buffer_count} buffers " + f"({total_bytes / 1024**2:.1f} MB), ring_size={ring_size}", + ) + + +_CG_MEMPOOL_DEVICE = None +_CG_MEMPOOL = None + + +def set_cuda_graph_mempool(device, mempool) -> None: + """Register the shared memory pool used for graph-owned GTP allocations.""" + global _CG_MEMPOOL_DEVICE, _CG_MEMPOOL + _CG_MEMPOOL_DEVICE = device + _CG_MEMPOOL = mempool + + +@contextmanager +def cuda_graph_pool_allocation(enabled: bool): + """Route allocations in this context into the registered CUDA-graph pool.""" + if _CG_MEMPOOL is None or not enabled: + yield + return + + torch._C._cuda_beginAllocateCurrentThreadToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) + try: + yield + finally: + torch._C._cuda_endAllocateToPool(_CG_MEMPOOL_DEVICE, _CG_MEMPOOL) diff --git a/megatron/core/transformer/cuda_graphs.py b/megatron/core/transformer/cuda_graphs.py index 782fd6bf14f..df7a1dc2c9a 100644 --- a/megatron/core/transformer/cuda_graphs.py +++ b/megatron/core/transformer/cuda_graphs.py @@ -66,10 +66,13 @@ if HAVE_GTP: from megatron.core.tensor_parallel.gtp_api import ( + GTP_CONFIG, GTPChain, get_ag_stream, get_rs_stream, + initialize_graph_wgrad_rings, set_cuda_graph_mempool, + track_gtp_capture_comms, wait_async_comms, ) else: @@ -77,9 +80,12 @@ # possibly-used-before-assignment; every use site is guarded by HAVE_GTP / # gtp_remat at runtime. GTPChain = None + GTP_CONFIG = None get_ag_stream = None get_rs_stream = None + initialize_graph_wgrad_rings = None set_cuda_graph_mempool = None + track_gtp_capture_comms = None wait_async_comms = None try: @@ -584,6 +590,12 @@ def _create_cudagraphs(cls): "https://github.com/NVIDIA/TransformerEngine/blob/v2.10/transformer_engine/pytorch/utils.py#L759" # pylint: disable=line-too-long ) + gtp_active = any(r[0].gtp_remat for r in cls.cudagraph_record) + if gtp_active: + # GTP buffer reuse during capture trips the param-state debug asserts; disable them. + GTP_CONFIG.check_param_states = False + initialize_graph_wgrad_rings() + _set_capture_start() if has_te_modules: te_set_capture_start() @@ -837,8 +849,14 @@ def backward(ctx, *grads): if runner.use_stream: runner.stream.wait_stream(torch.cuda.current_stream()) + if runner.gtp_remat: + for slot in runner._gtp_wgrad_ring_slots: + runner.stream.wait_event(slot.ready_event) with torch.cuda.stream(runner.stream): runner.bwd_graph.replay() + if runner.gtp_remat: + for slot in runner._gtp_wgrad_ring_slots: + slot.ready_event.record(runner.stream) torch.cuda.current_stream().wait_event(runner.bwd_completion_event) else: runner.bwd_graph.replay() @@ -925,6 +943,9 @@ def __init__( self.finalized_during_bwd_capture = [] # (rs_stream, params) DDP grad-ready hook plan; built in create_bwd_graph. self._gtp_finalize_hook_plan = [] + # Persistent wgrad slots written by this graph. Replay waits for each slot's previous RS + # reader before launching the graph. + self._gtp_wgrad_ring_slots = [] self.grad_enabled = need_backward and torch.is_grad_enabled() self.func = super(MegatronModule, self.base_module).__call__ if func is None else func @@ -984,8 +1005,10 @@ def __init__( _set_skip_fp8_weight_update_tensor(False) def _register_gtp_side_streams(self, group): - """Register a GTP (chain, group)'s GRAPHED AG/RS side streams for capture/replay sync: the - AG stream on both fwd and bwd, the RS stream on bwd only.""" + """Register static streams used by forward capture and GTP warmup. + + Backward capture dynamically discovers exact owned streams via track_gtp_capture_comms(). + """ ag = get_ag_stream(GTPChain.GRAPHED.value, group) rs = get_rs_stream(GTPChain.GRAPHED.value, group) self.fwd_side_streams.append(ag) @@ -1425,9 +1448,11 @@ def create_bwd_graph(self): if FREEZE_GC: gc.freeze() - with torch.cuda.graph(self.bwd_graph, pool=self.mempool): - - self._sync_against_side_streams(self.bwd_side_streams) + capture_comm_context = track_gtp_capture_comms() if self.gtp_remat else nullcontext(None) + with ( + capture_comm_context as capture_comms, + torch.cuda.graph(self.bwd_graph, pool=self.mempool), + ): grad_inputs = torch.autograd.grad( outputs=tuple(o for o in self.fwd_graph_output_surface if o.requires_grad), @@ -1454,30 +1479,18 @@ def create_bwd_graph(self): # consumer's cascade; for within-graph tails both # happen here (see wait_async_comms). if self.gtp_remat: - # Phase 1: drain AG; fence runner_stream past dense + EGTP AG - # so bwd_completion_event records AFTER NCCL_AG completion. - wait_async_comms(GTPChain.GRAPHED.value, skip_rs=True) - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = pg_collection.gtp_remat - graphed_ag = get_ag_stream(GTPChain.GRAPHED.value, gtp_remat_group) - torch.cuda.current_stream().wait_stream(graphed_ag) - egtp_remat_group = pg_collection.expt_gtp_remat - if egtp_remat_group is not None and egtp_remat_group.size() > 1: - egtp_graphed_ag = get_ag_stream(GTPChain.GRAPHED.value, egtp_remat_group) - torch.cuda.current_stream().wait_stream(egtp_graphed_ag) + # Phase 1: drain AG + wait_async_comms(GTPChain.GRAPHED.value, skip_rs=True, params=capture_comms.params) + self._wait_side_streams(capture_comms.ag_streams) - # Record completion AFTER AG drain + fence but BEFORE RS drain, - # so main_stream can trigger the next runner while RS is still - # in flight on rs_stream. + # Release the next runner after AG drain but before RS drain. self.bwd_completion_event.record() # Phase 2: in-graph RS drain + finalize. - wait_async_comms(GTPChain.GRAPHED.value, finalize_after_drain=True) - - if self.bwd_side_streams: - self._wait_side_streams(self.bwd_side_streams) + wait_async_comms( + GTPChain.GRAPHED.value, finalize_after_drain=True, params=capture_comms.params + ) + self._wait_side_streams(capture_comms.rs_streams) if self.use_stream and not self.gtp_remat: # Non-GTP path: record after the side-stream join. @@ -1491,6 +1504,7 @@ def create_bwd_graph(self): self.finalized_during_bwd_capture = ( self._compute_finalized_during_bwd_capture() if self.gtp_remat else [] ) + self._gtp_wgrad_ring_slots = list(capture_comms.wgrad_ring_slots) if self.gtp_remat else [] # Precompute the (rs_stream, params) DDP grad-ready hook plan once — it's # replay-invariant — so Graphed.backward avoids per-replay group lookups. diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py index 0b44576a8c3..297c89578f1 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py @@ -23,6 +23,8 @@ - TestWaitAsyncCommsFallback - inline-accumulation fallback when _wgrad_rs_handle is None - TestGTPDDPBucketAlignment - GTP/regular DDP bucket ends padded for dist-opt alignment - TestGTPDDPGradReadyWiring - GTP params drive DDP grad-ready via the manual hook, not autograd +- TestGTPWeightCacheSchedulingDomain - cache reuse stays within one chain/process-group domain +- TestGTPGraphWgradRing - partial-CG wgrad ring ownership and RS-input correctness Multi-GPU tests skip when ``torch.distributed.get_world_size()`` != the required world size (4). """ @@ -42,10 +44,13 @@ from transformer_engine.pytorch.quantized_tensor import QuantizedTensor import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module +import megatron.core.tensor_parallel.gtp_cuda_graphs as gtp_cuda_graphs from megatron.core import parallel_state from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTPChain, GTPShardedParam, + GTPWeightCache, wrap_module_params_gtp, ) from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( @@ -74,6 +79,38 @@ def rank(self): return self._rank +class TestGTPWeightCacheSchedulingDomain: + @staticmethod + def _make_param(group, chain_id): + param = GTPShardedParam(torch.zeros(4, 4, device="cuda")) + param.group = group + param.chain_id = chain_id + return param + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA event test") + def test_cache_reuse_isolated_by_chain_and_process_group(self): + cache = GTPWeightCache() + group = _FakeGroup(size=2) + other_group = _FakeGroup(size=2) + + first = self._make_param(group, GTPChain.UNGRAPHED.value) + second = self._make_param(group, GTPChain.UNGRAPHED.value) + first_ticket = cache.reserve(first, torch.bfloat16, fwd=True) + first_buffer = cache.get(first_ticket) + cache.release(first_ticket) + second_ticket = cache.reserve(second, torch.bfloat16, fwd=True) + assert cache.get(second_ticket) is first_buffer + cache.release(second_ticket) + + graphed = self._make_param(group, GTPChain.GRAPHED.value) + graphed_ticket = cache.reserve(graphed, torch.bfloat16, fwd=True) + assert cache.get(graphed_ticket) is not first_buffer + + other = self._make_param(other_group, GTPChain.UNGRAPHED.value) + other_ticket = cache.reserve(other, torch.bfloat16, fwd=True) + assert cache.get(other_ticket) is not first_buffer + + def _worker_sharding_aligned(rank, world_size, port): K, M = world_size * 32, 16 # K divisible by 16*world_size → no padding full_weight = torch.arange(K * M, dtype=torch.float32).reshape(K, M).cuda() @@ -476,6 +513,7 @@ class _Fake: def __init__(self, chain_id): self.chain_id = chain_id + self.group = None _double_buffer_parity = gtp_module.GTPShardedParam._double_buffer_parity _get_cache_key = gtp_module.GTPShardedParam._get_cache_key @@ -505,8 +543,14 @@ def test_fc1_fc2_never_share_a_buffer(self): "GTP_remat_grouped_fc2_ungraphed" ) - def test_non_grouped_key_unchanged(self): - assert self._key("GTP_ungraphed") == ((128, 256), torch.bfloat16, 0, False) + def test_non_grouped_key_includes_scheduling_domain(self): + assert self._key("GTP_ungraphed") == ( + ("GTP_ungraphed", 0), + (128, 256), + torch.bfloat16, + 0, + False, + ) def test_parity_cached_and_stable(self): f = self._Fake("GTP_remat_grouped_fc1_ungraphed") @@ -577,7 +621,11 @@ def _key(self, parity=None): def test_parity_zero_keeps_the_shared_buffer(self): # Unset and 0 must give the same key: only the second weight of a pair pays. - assert self._key(0) == self._key() == ((128, 256), torch.bfloat16, 0, False) + assert ( + self._key(0) + == self._key() + == (("GTP_ungraphed", 0), (128, 256), torch.bfloat16, 0, False) + ) def test_parity_one_gets_its_own_buffer(self): assert self._key(1) != self._key() @@ -1285,3 +1333,109 @@ def test_gtp_params_use_manual_grad_ready_hook(self): """GTP params route DDP grad-ready through register_grad_accum_hook, not autograd.""" _requires_multi_gpu(4) _run_distributed(_worker_gtp_ddp_grad_ready_wiring, 4) + + +class TestGTPGraphWgradRing: + @staticmethod + def _make_padded_chain(count=4): + group = _FakeGroup(size=2) + weights = [GTPShardedParam(torch.randn(3, 4, device="cuda")) for _ in range(count)] + for weight in weights: + weight.group = group + weight.chain_id = GTPChain.GRAPHED.value + weight.pad_length = 2 + weight.main_grad = torch.empty_like(weight) + for previous, current in zip(weights, weights[1:]): + previous.next_w = current + current.prev_w = previous + return weights + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA event test") + def test_partial_cg_wgrad_ring_ownership(self, monkeypatch): + monkeypatch.setattr(gtp_module, "_FULL_ITERATION", False) + monkeypatch.setattr(gtp_module.GTP_CONFIG, "async_reduction", True) + monkeypatch.setattr(gtp_module.GTP_CONFIG, "graph_wgrad_ring_size", 2) + monkeypatch.setattr(gtp_cuda_graphs, "_GRAPH_WGRAD_RINGS", {}) + + weights = self._make_padded_chain() + monkeypatch.setattr(gtp_module, "_GTP_PARAMS", weights) + gtp_module.initialize_graph_wgrad_rings() + + slot_1 = weights[1]._gtp_graph_wgrad_ring_slot + slot_2 = weights[2]._gtp_graph_wgrad_ring_slot + slot_3 = weights[3]._gtp_graph_wgrad_ring_slot + assert slot_1 is not slot_2 + assert slot_1 is slot_3 + assert len(gtp_cuda_graphs._GRAPH_WGRAD_RINGS) == 1 + assert slot_1.ready_event.query() + + capture_state = gtp_cuda_graphs.GTPCaptureCommState() + monkeypatch.setattr(gtp_cuda_graphs, "_ACTIVE_CAPTURE_COMM_STATE", capture_state) + logical_view = weights[1].get_wgrad_tensor() + assert not capture_state.wgrad_ring_slots + assert slot_1.tensor.shape == (6, 4) + assert logical_view.shape == (4, 4) + assert logical_view.data_ptr() == slot_1.tensor.data_ptr() + + logical_view.fill_(7) + prepared = weights[1]._prepare_wgrad_reduce_scatter_inputs([logical_view]) + assert capture_state.wgrad_ring_slots == [slot_1] + assert prepared[0] is slot_1.tensor + assert torch.count_nonzero(slot_1.tensor[4:]) == 0 + + with pytest.raises(RuntimeError, match="increase GTP_CONFIG.graph_wgrad_ring_size"): + weights[3]._prepare_wgrad_reduce_scatter_inputs([logical_view]) + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA event test") + def test_native_fp8_style_param_uses_wgrad_ring(self, monkeypatch): + """GTP-tagged native-FP8 params are not GTPShardedParam instances.""" + + class NativeFP8StyleParam: + def __init__(self, group): + self.is_gtp_weight_remat = True + self.group = group + self.chain_id = GTPChain.GRAPHED.value + self.pad_length = 2 + self.expert_idx = 0 + self.device = torch.device("cuda") + self.main_grad = torch.empty(3, 4, device=self.device) + self._unsharded_shape = (4, 4) + self._unsharded_shape_padded = (6, 4) + self.prev_w = None + self.next_w = None + self._weights = [self] + + monkeypatch.setattr(gtp_module, "_FULL_ITERATION", False) + monkeypatch.setattr(gtp_module.GTP_CONFIG, "async_reduction", True) + monkeypatch.setattr(gtp_module.GTP_CONFIG, "graph_wgrad_ring_size", 2) + monkeypatch.setattr(gtp_cuda_graphs, "_GRAPH_WGRAD_RINGS", {}) + + group = _FakeGroup(size=2) + first = NativeFP8StyleParam(group) + second = NativeFP8StyleParam(group) + first.next_w = second + second.prev_w = first + monkeypatch.setattr(gtp_module, "_GTP_PARAMS", [first, second]) + + gtp_module.initialize_graph_wgrad_rings() + + assert second._gtp_graph_wgrad_ring_slot.tensor.shape == (6, 4) + assert len(gtp_cuda_graphs._GRAPH_WGRAD_RINGS) == 1 + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA event test") + def test_rs_input_copies_into_ring(self, monkeypatch): + monkeypatch.setattr(gtp_module, "_FULL_ITERATION", False) + monkeypatch.setattr(gtp_module.GTP_CONFIG, "async_reduction", True) + monkeypatch.setattr(gtp_module.GTP_CONFIG, "graph_wgrad_ring_size", 2) + monkeypatch.setattr(gtp_cuda_graphs, "_GRAPH_WGRAD_RINGS", {}) + + weights = self._make_padded_chain(count=2) + monkeypatch.setattr(gtp_module, "_GTP_PARAMS", weights) + gtp_module.initialize_graph_wgrad_rings() + + wgrad = torch.arange(16, dtype=torch.float32, device="cuda").reshape(4, 4) + rs_input = weights[1]._prepare_wgrad_reduce_scatter_inputs([wgrad])[0] + + assert rs_input is weights[1]._gtp_graph_wgrad_ring_slot.tensor + torch.testing.assert_close(rs_input[:4], wgrad) + assert torch.count_nonzero(rs_input[4:]) == 0 diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py new file mode 100644 index 00000000000..addcb9cb657 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py @@ -0,0 +1,331 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Integration test for GTP correctness with local partial CUDA graphs. + +This is the local-CUDA-graph counterpart of ``test_gtp_loss_correctness.py``. It compares eager +execution with attention-only local CUDA graphs under the same GTP2 x DP2 topology, with and +without cross-graph RS overlap. It verifies the complete loss trajectory and global gradient norm, +including repeated replays of one backward. +""" + +import copy +import gc + +import pytest +import torch + +from megatron.core.tensor_parallel.gtp_api import ( + HAVE_GTP, + GTPChain, + classify_gtp_remat_chains, + wait_for_gtp_grad_reduction_on_current_stream, +) + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.17", allow_module_level=True) + +from transformer_engine.pytorch import fp8_autocast + +import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( # noqa: F401 + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + + +def _worker_gtp_partial_cg_correctness(rank, world_size, port): + """Compare eager and local attention CUDA graphs with GTP2 x DP2.""" + del port + + from megatron.core import parallel_state as ps + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + from megatron.core.optimizer.clip_grads import get_grad_norm_fp32 + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel import param_is_not_gtp_duplicate + from megatron.core.tensor_parallel.random import ( + initialize_rng_tracker, + model_parallel_cuda_manual_seed, + ) + from megatron.core.transformer.cuda_graphs import ( + _CudagraphGlobalRecord, + create_cudagraphs, + delete_cuda_graphs, + ) + from megatron.core.transformer.identity_op import IdentityFuncOp, IdentityOp + from megatron.core.transformer.transformer_config import TransformerConfig + + hidden = 4096 + num_heads = 32 + ffn_hidden = 16384 + # Four layers force parameters with matching scheduling domains/shapes to reuse the two-slot + # wgrad ring across independently replayed graphs. + num_layers = 4 + sequence_length = 32 + batch_size = 1 + learning_rate = 0.01 + steps = 10 + dtype = torch.bfloat16 + gtp_degree = 2 + dp_degree = 2 + assert world_size == gtp_degree * dp_degree + + def make_config(*, partial_cg=False): + return TransformerConfig( + num_attention_heads=num_heads, + num_layers=num_layers, + hidden_size=hidden, + ffn_hidden_size=ffn_hidden, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + gtp_weight_remat_size=gtp_degree, + cuda_graph_impl="local" if partial_cg else "none", + cuda_graph_modules=["attn"] if partial_cg else [], + cuda_graph_warmup_steps=2, + ) + + def make_attention_stack(config, pg_collection): + spec = copy.deepcopy(get_gpt_layer_with_transformer_engine_spec()) + spec.submodules.pre_mlp_layernorm = IdentityOp + spec.submodules.mlp = IdentityOp + spec.submodules.mlp_bda = IdentityFuncOp + return torch.nn.ModuleList( + [ + spec.module( + config, spec.submodules, layer_number=i + 1, pg_collection=pg_collection + ) + for i in range(num_layers) + ] + ) + + def run_step(layers, x): + with fp8_autocast(enabled=False): + for layer in layers: + x, _ = layer(x, attention_mask=None) + return x.mean() + + def reset_grad_state(layers): + for param in layers.parameters(): + if hasattr(param, "main_grad"): + param.main_grad.zero_() + param.grad = None + # DDP resets this before every local-CG training iteration. + param.grad_added_to_main_grad = False + + def initialize_main_grads(layers): + for param in layers.parameters(): + if not hasattr(param, "main_grad"): + param.main_grad = torch.zeros_like(param) + param.grad_added_to_main_grad = False + + def make_replica_input(seed, replica_rank): + # This focused test does not instantiate DDP. Keep each GTP group on one microbatch so + # replicated parameters stay synchronized, while the two DP replicas exercise different + # trajectories. + torch.manual_seed(seed + replica_rank) + return torch.randn(sequence_length, batch_size, hidden, dtype=dtype, device="cuda") + + def global_grad_norm(layers, grad_stats_group): + """Mirror Megatron's GTP duplicate filtering and global L2-norm reduction.""" + grads = [] + for param in layers.parameters(): + if not param_is_not_gtp_duplicate(param): + continue + if isinstance(param, GTPShardedParam): + grad = param.main_grad + else: + grad = param.grad if param.grad is not None else param.main_grad + assert grad is not None + grads.append(grad) + return float(get_grad_norm_fp32(grads, grad_stats_parallel_group=grad_stats_group)) + + def apply_sgd_step(layers, gtp_size): + with torch.no_grad(): + for param in layers.parameters(): + if isinstance(param, GTPShardedParam): + param.data.sub_((learning_rate / gtp_size) * param.main_grad) + else: + grad = param.grad if param.grad is not None else param.main_grad + param.data.sub_(learning_rate * grad) + param.grad = None + + # Eager reference: GTP2 x DP2. + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=gtp_degree + ) + model_parallel_cuda_manual_seed(42) + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["tp", "cp", "gtp_remat"] + ) + eager_config = make_config() + eager = make_attention_stack(eager_config, pg_collection).cuda() + eager_gtp_group = ps.get_gtp_weight_remat_group() + eager_dp_group = ps.get_data_parallel_group(with_gtp_remat=False) + eager_dp_rank = eager_dp_group.rank() + assert eager_gtp_group.size() == gtp_degree + assert eager_dp_group.size() == dp_degree + assert any(isinstance(param, GTPShardedParam) for param in eager.parameters()) + initialize_main_grads(eager) + saved_local_weights = {name: param.data.clone() for name, param in eager.named_parameters()} + + eager_losses = [] + eager_grad_norms = [] + for step in range(steps): + reset_grad_state(eager) + x = make_replica_input(step * world_size, eager_dp_rank) + x.requires_grad_() + loss = run_step(eager, x) + eager_losses.append(loss.item()) + loss.backward() + wait_for_gtp_grad_reduction_on_current_stream() + eager_grad_norms.append(global_grad_norm(eager, eager_gtp_group)) + apply_sgd_step(eager, eager_gtp_group.size()) + + del eager, loss, x + ps.destroy_model_parallel() + gtp_module.reset_gtp_state() + + # Optimized path: the same GTP2 x DP2 topology with attention-only local CUDA graphs. + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=gtp_degree + ) + initialize_rng_tracker(use_te_rng_tracker=True, force_reset=True) + model_parallel_cuda_manual_seed(42) + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["tp", "cp", "gtp_remat"] + ) + partial_cg_config = make_config(partial_cg=True) + partial_cg = make_attention_stack(partial_cg_config, pg_collection).cuda() + classify_gtp_remat_chains( + partial_cg, + cuda_graph_modules=partial_cg_config.cuda_graph_modules, + cuda_graph_impl=partial_cg_config.cuda_graph_impl, + ) + + gtp_group = ps.get_gtp_weight_remat_group() + dp_group = ps.get_data_parallel_group(with_gtp_remat=False) + gtp_size = gtp_group.size() + gtp_rank = gtp_group.rank() + dp_rank = dp_group.rank() + assert gtp_size == gtp_degree + assert dp_group.size() == dp_degree + assert dp_rank == eager_dp_rank + gtp_params = [param for param in partial_cg.parameters() if isinstance(param, GTPShardedParam)] + assert gtp_params, "GTP not active: no GTPShardedParam found" + assert all(param.chain_id == GTPChain.GRAPHED.value for param in gtp_params) + for name, param in partial_cg.named_parameters(): + param.data.copy_(saved_local_weights[name]) + # Production captures after DDP maps every parameter into a main-grad buffer and initializes + # the fused-accumulation marker. Mirror those invariants without pulling the full DDP stack into + # this focused GTP/CUDA-graph test. + initialize_main_grads(partial_cg) + + partial_cg_losses = [] + partial_cg_grad_norms = [] + try: + # Record one eager backward, then replay the same input and weights. This isolates the + # graph execution path: model state, GTP topology, and reduction order are unchanged. + reset_grad_state(partial_cg) + eager_probe_x = make_replica_input(1234, dp_rank) + eager_probe_x.requires_grad_() + eager_probe_loss = run_step(partial_cg, eager_probe_x) + eager_probe_loss.backward() + wait_for_gtp_grad_reduction_on_current_stream() + eager_grad_norm = global_grad_norm(partial_cg, gtp_group) + eager_probe_loss_value = eager_probe_loss.item() + + create_cudagraphs() + assert _CudagraphGlobalRecord.cudagraph_created + runners = [layer.cudagraph_manager.cudagraph_runners[0] for layer in partial_cg] + assert all(runner.gtp_remat for runner in runners) + assert any(runner._gtp_wgrad_ring_slots for runner in runners) + + replay_grad_norms = [] + replay_losses = [] + for _ in range(3): + reset_grad_state(partial_cg) + replay_x = eager_probe_x.detach().clone().requires_grad_() + replay_loss = run_step(partial_cg, replay_x) + replay_loss.backward() + wait_for_gtp_grad_reduction_on_current_stream() + replay_losses.append(replay_loss.item()) + replay_grad_norms.append(global_grad_norm(partial_cg, gtp_group)) + + replay_grad_norms_tensor = torch.tensor(replay_grad_norms) + assert torch.isfinite(replay_grad_norms_tensor).all() + torch.testing.assert_close( + replay_grad_norms_tensor, + torch.full_like(replay_grad_norms_tensor, eager_grad_norm), + atol=1e-6, + rtol=5e-3, + ) + torch.testing.assert_close( + torch.tensor(replay_losses), + torch.full((len(replay_losses),), eager_probe_loss_value), + atol=1e-6, + rtol=5e-3, + ) + if gtp_rank == 0: + print( + f"[partial-CG grad norm, DP replica {dp_rank}] " + f"eager={eager_grad_norm:.6f} replays={replay_grad_norms}", + flush=True, + ) + + del eager_probe_loss, eager_probe_x, replay_loss, replay_x + + for step in range(steps): + reset_grad_state(partial_cg) + x = make_replica_input(step * world_size, dp_rank) + x.requires_grad_() + loss = run_step(partial_cg, x) + partial_cg_losses.append(loss.item()) + loss.backward() + wait_for_gtp_grad_reduction_on_current_stream() + partial_cg_grad_norms.append(global_grad_norm(partial_cg, gtp_group)) + apply_sgd_step(partial_cg, gtp_size) + del loss, x + finally: + torch.cuda.synchronize() + for layer in partial_cg: + for runner in layer.cudagraph_manager.cudagraph_runners: + if runner.fwd_graph is not None: + runner.fwd_graph.reset() + if runner.bwd_graph is not None: + runner.bwd_graph.reset() + delete_cuda_graphs() + for layer in partial_cg: + layer.cudagraph_manager.cudagraph_runners.clear() + gc.collect() + ps.destroy_model_parallel() + ps.initialize_model_parallel() + gtp_module.reset_gtp_state() + + if rank == 0: + for step, (eager_loss, partial_cg_loss) in enumerate(zip(eager_losses, partial_cg_losses)): + print( + f"Step {step:2d}: eager={eager_loss:.6f} partial_cg={partial_cg_loss:.6f}", + flush=True, + ) + torch.testing.assert_close( + torch.tensor(partial_cg_losses), torch.tensor(eager_losses), atol=1e-6, rtol=5e-3 + ) + torch.testing.assert_close( + torch.tensor(partial_cg_grad_norms), torch.tensor(eager_grad_norms), atol=1e-6, rtol=5e-3 + ) + + +class TestGTPPartialCGCorrectness: + def test_gtp_partial_cg_loss_and_grad_norm_match_eager(self): + """Local-CG loss trajectory and global grad norm must match eager execution.""" + if torch.cuda.device_count() < 4: + pytest.skip("Requires at least 4 CUDA devices") + _run_distributed(_worker_gtp_partial_cg_correctness, 4) From 66995092445d800b6df487a505d8c739a70e3aa2 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Wed, 5 Aug 2026 10:45:03 +0200 Subject: [PATCH 201/290] revert(dist_checkpointing): AUT-1325 remove streaming checkpoint dequantization (#6275) Signed-off-by: svcnemo-autobot Co-authored-by: svcnemo-autobot --- .../core/dist_checkpointing/serialization.py | 10 +- .../strategies/fully_parallel.py | 11 +- .../dist_checkpointing/strategies/torch.py | 136 ++---- megatron/training/arguments.py | 17 - megatron/training/checkpointing.py | 5 +- megatron/training/config/training_config.py | 7 - .../test_pipeline_parallel_layout.py | 1 - .../test_stream_ckpt_dequant.py | 388 ------------------ tests/unit_tests/dist_checkpointing/utils.py | 1 - .../pipeline_parallel/test_pipeline_layout.py | 1 - tests/unit_tests/test_checkpointing.py | 1 - 11 files changed, 30 insertions(+), 548 deletions(-) delete mode 100644 tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py diff --git a/megatron/core/dist_checkpointing/serialization.py b/megatron/core/dist_checkpointing/serialization.py index 0f09a3262a2..9ce9268d574 100644 --- a/megatron/core/dist_checkpointing/serialization.py +++ b/megatron/core/dist_checkpointing/serialization.py @@ -132,15 +132,7 @@ def load( # params with a high-precision state dict; # 2. When using delayed scaling, this loading process writes an extra value into the global # amax_history buffer of Transformer Engine, which is undesirable. - # - # When the sharded strategy supports per-tensor streaming dequantize - # (``stream_ckpt_dequant``), both concerns are handled inside the - # LoadPlanner on a per-tensor basis, which avoids peaking GPU memory - # with N simultaneous high-precision scratch tensors before the load - # begins. Covers FP8/MXFP8/blockwise-FP8/NVFP4 via the common - # ``QuantizedTensor`` base class. - if not getattr(sharded_strategy, "stream_ckpt_dequant", False): - force_all_tensors_to_non_fp8(sharded_state_dict) + force_all_tensors_to_non_fp8(sharded_state_dict) sharded_state_dict, nonpersistent_state_dict, sh_ten_factories = load_preprocess( sharded_state_dict diff --git a/megatron/core/dist_checkpointing/strategies/fully_parallel.py b/megatron/core/dist_checkpointing/strategies/fully_parallel.py index c201224efe9..b1217cece0d 100644 --- a/megatron/core/dist_checkpointing/strategies/fully_parallel.py +++ b/megatron/core/dist_checkpointing/strategies/fully_parallel.py @@ -184,13 +184,6 @@ def __init__( self.cached_distribution: Optional[ShardDistribution] = None self.cached_global_metadata: Optional[Metadata] = None - @property - def stream_ckpt_dequant(self) -> bool: - """Forward the streaming dequantize flag from the wrapped strategy so that - ``serialization.load`` can skip the upfront ``force_all_tensors_to_non_fp8`` pass - when streaming is enabled.""" - return getattr(self.base_strategy, "stream_ckpt_dequant", False) - @debug_time("FullyParallelLoadStrategyWrapper.load", logger) def load( self, @@ -245,11 +238,11 @@ def load( # Step 3: load part of the checkpoint. # Load only sharded objects first. ShardedTensors will be loaded separately # so that we can keep track of sharded tensors loaded by this rank - (sharded_tensors, sharded_state_dict, to_load_shards, unloaded_shards) = ( + sharded_tensors, sharded_state_dict, to_load_shards, unloaded_shards = ( self._defer_loading_sharded_tensors(sharded_state_dict) ) - (sharded_objects, sharded_state_dict, to_load_objects, unloaded_objects) = ( + sharded_objects, sharded_state_dict, to_load_objects, unloaded_objects = ( self._defer_loading_sharded_objects(sharded_state_dict) ) diff --git a/megatron/core/dist_checkpointing/strategies/torch.py b/megatron/core/dist_checkpointing/strategies/torch.py index d8487c4bb43..2055912e46e 100644 --- a/megatron/core/dist_checkpointing/strategies/torch.py +++ b/megatron/core/dist_checkpointing/strategies/torch.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -""" Strategies using PyTorch distributed.checkpoint as an underlying format. """ +"""Strategies using PyTorch distributed.checkpoint as an underlying format.""" + import inspect import io import os @@ -332,7 +333,7 @@ def _mcore_to_torch_sharded_object(sh_objs: List[ShardedObject]) -> io.BytesIO: def _unwrap_pyt_sharded_tensor( - sh_ten: Union[TorchShardedTensor, CheckpointableShardedTensor, LocalShardsContainer, Any] + sh_ten: Union[TorchShardedTensor, CheckpointableShardedTensor, LocalShardsContainer, Any], ) -> Union[List[torch.Tensor], Any]: """Unwrap tensor from PyT ShardedTensor instance. @@ -349,20 +350,9 @@ def _unwrap_pyt_sharded_tensor( ret_tensors = [] for sh in sh_ten.local_shards(): ten = sh.tensor - if mcore_sh_ten.prepend_axis_num > 0: - # NOTE: use ``view`` to strip the prepended singleton axes. Indexing - # (``ten[0]``) and ``squeeze`` both dispatch through - # ``aten.select.int`` / ``aten.squeeze`` which are not implemented - # by ``MXFP8Tensor`` (nor by the blockwise tensor class) — they - # fall back to ``QuantizedTensor.__torch_dispatch__`` which - # dequantizes the entire tensor to BF16 before applying the op. - # For large FP8/MXFP8 checkpoints this materializes a full-size - # BF16 copy per shard and OOMs right after a successful streaming - # load. ``view`` is handled natively by all TE quantized tensor - # classes without any dequantize. - for i in range(mcore_sh_ten.prepend_axis_num): - assert ten.size(i) == 1 - ten = ten.view(ten.shape[mcore_sh_ten.prepend_axis_num :]) + for _ in range(mcore_sh_ten.prepend_axis_num): + assert ten.size(0) == 1 + ten = ten[0] # NOTE: ten.squeeze(0) uses more memory for FP8 tensors ret_tensors.append(ten) return ret_tensors @@ -502,19 +492,12 @@ def __init__( *args, shapes_validation_sharded_tensors: Iterable[ShardedTensor] = (), allow_shape_mismatch_sharded_tensors: Optional[Dict[str, ShardedTensor]] = None, - stream_ckpt_dequant: bool = True, **kwargs, ) -> None: super().__init__(*args, **kwargs) self.shapes_validation_sharded_tensors = shapes_validation_sharded_tensors self.allow_shape_mismatch_sharded_tensors = allow_shape_mismatch_sharded_tensors - self.stream_ckpt_dequant = stream_ckpt_dequant - # Maps id(read_item) -> (read_item, target_tensor, amax_snapshot_or_None, kind) - # kind is "stream" for the streaming per-tensor dequant path and "noncontig" - # for the existing contiguity-fix path. - self._intermediate_read_items: Dict[ - int, Tuple[ReadItem, torch.Tensor, Optional[torch.Tensor], str] - ] = {} + self._intermediate_read_item_and_target: Optional[Tuple[ReadItem, torch.Tensor]] = None def _validate_global_shapes(self, metadata, sharded_tensors): for sh_ten in sharded_tensors: @@ -568,92 +551,37 @@ def create_local_plan(self) -> LoadPlan: return local_plan def resolve_tensor(self, read_item: ReadItem): - """Override to add quantized-tensor support. - - Two paths are handled here: - - 1. Streaming per-tensor dequantize (when ``stream_ckpt_dequant`` is True - and the destination is a TE ``QuantizedTensor`` — covers Float8, - MXFP8, blockwise FP8, and NVFP4 via the common base class). We - allocate a per-tensor high-precision scratch buffer, return it as - the load destination, and quantize-copy it back into the original - tensor in ``commit_tensor``. This replaces the upfront bulk - dequantize done by ``force_all_tensors_to_non_fp8`` and keeps at - most one scratch tensor live at a time. - - 2. Non-contiguous Float8 fix: narrowing a Float8Tensor can produce a - non-contiguous view for which no ``copy_`` kernel exists. We fall - back to a contiguous Float8 clone and copy it back in - ``commit_tensor``. - - Both cases stash state in ``self._intermediate_read_items``, keyed by - ``id(read_item)``, so ``commit_tensor`` can undo them. + """Override to add FP8 support. + + Narrowing the Float8Tensor can create incontiguous tensors and there are + no `copy` kernels for such cases. This method creates a contiguous FP8 + tensors so that the subsequent `copy_` in FileSystemReader succeeds. + Note that this requires tracking the original tensor + (as `self._intermediate_read_item_and_target` attribute) + and restoring it in `commit_tensor` method. """ target_tensor = super().resolve_tensor(read_item) - - # Lazy import to avoid circular imports (fp8_utils pulls in core.tensor_parallel). - from ...fp8_utils import is_float8tensor as _is_quantized_tensor - - if ( - self.stream_ckpt_dequant - and HAVE_TE - and _is_quantized_tensor(target_tensor) - and target_tensor.is_cuda - ): - # Snapshot amax for delayed-scaling quantizers so the subsequent - # BF16->FP8 quantize-copy does not pollute amax_history. For - # current-scaling / MXFP8 / blockwise / NVFP4 quantizers, amax is - # None or absent on the quantizer and the snapshot is a no-op. - amax_snapshot: Optional[torch.Tensor] = None - quantizer = getattr(target_tensor, "_quantizer", None) - amax = getattr(quantizer, "amax", None) if quantizer is not None else None - if isinstance(amax, torch.Tensor): - amax_snapshot = amax.detach().clone() - - scratch = torch.empty( - target_tensor.shape, dtype=target_tensor.dtype, device=target_tensor.device - ) - self._intermediate_read_items[id(read_item)] = ( - read_item, - target_tensor, - amax_snapshot, - "stream", - ) - return scratch - if ( not target_tensor.is_contiguous() and HAVE_TE and isinstance(target_tensor, Float8Tensor) ): - self._intermediate_read_items[id(read_item)] = ( - read_item, - target_tensor, - None, - "noncontig", - ) + self._intermediate_read_item_and_target = (read_item, target_tensor) target_tensor = Float8Tensor.make_like( target_tensor, data=target_tensor._data.contiguous() ) return target_tensor def commit_tensor(self, read_item: ReadItem, tensor: torch.Tensor) -> None: - """Undo the detours stashed in ``resolve_tensor``. - - - Streaming case: copy the high-precision scratch back into the - original quantized tensor (quantize-on-copy), then restore the - pre-load ``amax`` for delayed-scaling quantizers. - - Non-contiguous case: copy the contiguous clone back into the - original narrowed Float8Tensor view. - """ - entry = self._intermediate_read_items.pop(id(read_item), None) - if entry is not None: - _, target_tensor, amax_snapshot, kind = entry + """Restores the original FP8 tensor saved in `resolve_tensor`.""" + if self._intermediate_read_item_and_target is not None: + interm_read_item, target_tensor = self._intermediate_read_item_and_target + assert ( + interm_read_item is read_item + ), '`commit_tensor` method should be called right after `resolve_tensor`' target_tensor.copy_(tensor) - if kind == "stream" and amax_snapshot is not None: - # quantizer was non-None when we took the snapshot - target_tensor._quantizer.amax.copy_(amax_snapshot) tensor = target_tensor + self._intermediate_read_item_and_target = None return super().commit_tensor(read_item, tensor) @@ -752,7 +680,7 @@ def async_save( _logged_mcore_async_deprecation = True # Translate the state dict - (sharded_state_dict, flat_mapping, rename_mapping) = ( + sharded_state_dict, flat_mapping, rename_mapping = ( _replace_state_dict_keys_with_sharded_keys( sharded_state_dict, self.keep_only_main_replica ) @@ -924,20 +852,9 @@ def _get_filesystem_reader( class TorchDistLoadShardedStrategy: """Basic load strategy for the PyT Distributed format.""" - def __init__( - self, - cache_metadata: bool = False, - stream_ckpt_dequant: bool = True, - checkpoint_name: str = None, - ): + def __init__(self, cache_metadata: bool = False, checkpoint_name: str = None): self.cached_global_metadata: Optional[Metadata] = None self.cache_metadata = cache_metadata - # When True, quantized destinations (FP8/MXFP8/blockwise FP8/NVFP4) are - # dequantized per-tensor inside the LoadPlanner rather than all at once - # before the load starts. This trades a small planner overhead for a - # large reduction in peak GPU memory during load. See - # serialization.load() and MCoreLoadPlanner.resolve_tensor for details. - self.stream_ckpt_dequant = stream_ckpt_dequant self.checkpoint_name = checkpoint_name def load( @@ -968,7 +885,7 @@ def load( orig_sharded_state_dict = sharded_state_dict # MCore state dict to PyT Distributed compatible - (sharded_state_dict, flat_mapping, rename_mapping) = ( + sharded_state_dict, flat_mapping, rename_mapping = ( _replace_state_dict_keys_with_sharded_keys(sharded_state_dict) ) pyt_state_dict = mcore_to_pyt_state_dict(sharded_state_dict, True) @@ -982,7 +899,6 @@ def load( planner=MCoreLoadPlanner( shapes_validation_sharded_tensors=flexible_shape_sharded_tensors, allow_shape_mismatch_sharded_tensors=allow_shape_mismatch_sharded_tensors, - stream_ckpt_dequant=self.stream_ckpt_dequant, flatten_state_dict=False, flatten_sharded_tensors=False, ), diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index e63944ecfb0..d4f9eb9c0de 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1080,23 +1080,6 @@ def validate_args(args, defaults={}): ): raise ValueError("MXFP8 with inference optimized layers requires FlashInfer >= 0.6.4") - # Streaming dequantize is unsafe with tensorwise (current) FP8 scaling. - # The streaming planner does a BF16->FP8 ``copy_`` per slice; tensorwise - # recomputes the per-tensor scale from each slice's amax, so multi-shard - # destinations (e.g. resharded loads) end up with inconsistent scales - # across slices and the loaded weights are corrupted. Block-scaled - # recipes (mxfp8, blockwise, nvfp4) carry per-block scales and are - # unaffected. Force the upfront ``force_all_tensors_to_non_fp8`` path - # for tensorwise. - if args.fp8 and args.fp8_recipe == "tensorwise" and args.stream_ckpt_dequant: - warn_rank_0( - "--fp8-recipe=tensorwise is incompatible with the streaming " - "checkpoint dequantize path; falling back to the upfront " - "dequantize pass. Pass --no-stream-ckpt-dequant to silence " - "this warning." - ) - args.stream_ckpt_dequant = False - if args.use_megatron_fsdp: # NOTE: The flag `use_custom_fsdp` is deprecated and will be removed in future versions. # Please use `use_megatron_fsdp` instead, as all functionality will be migrated there. diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 26650a7d559..8131645f09c 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -1624,10 +1624,7 @@ def _load_global_dist_base_checkpoint( ) checkpoint_name = get_checkpoint_name(load_dir, iteration, release, return_base_dir=True) - load_strategy = TorchDistLoadShardedStrategy( - cache_metadata=args.ckpt_assume_constant_structure, - stream_ckpt_dequant=args.stream_ckpt_dequant, - ) + load_strategy = TorchDistLoadShardedStrategy(cache_metadata=args.ckpt_assume_constant_structure) # NOTE: `args.ckpt_fully_parallel_load` applies to both persistent and non-persistent checkpoints. if args.ckpt_fully_parallel_load: if args.ckpt_fully_parallel_load_process_group == 'dp': diff --git a/megatron/training/config/training_config.py b/megatron/training/config/training_config.py index 1cb71f21a08..fb5598b8d42 100644 --- a/megatron/training/config/training_config.py +++ b/megatron/training/config/training_config.py @@ -616,13 +616,6 @@ class CheckpointConfig: verify_integrity: bool = False """Whether to hash checkpointing files during save and validate their integrity during load.""" - stream_ckpt_dequant: bool = True - """Per-tensor streaming dequantize when loading checkpoints with quantized model params - (FP8, MXFP8, blockwise FP8, NVFP4). The LoadPlanner dequantizes one destination at a time, - instead of dequantizing the entire state dict to high precision before the load starts - (which allocates N simultaneous scratch tensors and can OOM on large models). On by - default; pass --no-stream-ckpt-dequant to fall back to the legacy upfront pass.""" - def __post_init__(self): from megatron.training.utils import has_nvrx_checkpointing_async_support diff --git a/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py b/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py index aa1e682b39b..4ed91aa2cb6 100644 --- a/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py +++ b/tests/unit_tests/dist_checkpointing/test_pipeline_parallel_layout.py @@ -143,7 +143,6 @@ def create_args(): args.vocab_file = None args.add_position_embedding = False args.ckpt_assume_constant_structure = True - args.stream_ckpt_dequant = True args.ckpt_load_validate_sharding_integrity = True args.dist_ckpt_strictness = "assume_ok_unexpected" args.fp16 = False diff --git a/tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py b/tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py deleted file mode 100644 index bc940668469..00000000000 --- a/tests/unit_tests/dist_checkpointing/test_stream_ckpt_dequant.py +++ /dev/null @@ -1,388 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. - -"""Tests for the streaming per-tensor dequantize path used when loading -distributed checkpoints with quantized (FP8 / MXFP8 / blockwise / NVFP4) -model parameters. - -The feature under test is ``stream_ckpt_dequant`` on ``MCoreLoadPlanner`` and -``TorchDistLoadShardedStrategy``. When on, the LoadPlanner dequantizes each -quantized destination one at a time inside ``resolve_tensor``/``commit_tensor`` -instead of up-front in ``force_all_tensors_to_non_fp8``. Tests cover: - -- Loaded-content equivalence vs. the legacy upfront path (FP8). -- Delayed-scaling ``amax_history`` is not polluted across a streaming load. -- ``_unwrap_pyt_sharded_tensor`` uses view-based axis stripping (no - dequantize fallback) — exercised implicitly by the MXFP8 save/load test. -- MXFP8 save/load round-trip. -- NVFP4 save/load round-trip (Blackwell+ only, skipped otherwise). -- No-op fall-through for plain (non-quantized) tensors. -""" - -import pytest -import torch - -try: - from transformer_engine.pytorch.float8_tensor import Float8Tensor - from transformer_engine.pytorch.tensor import QuantizedTensor - - HAVE_TE = True -except ImportError: - HAVE_TE = False - Float8Tensor = None # type: ignore - QuantizedTensor = None # type: ignore - -try: - import transformer_engine.pytorch.tensor.mxfp8_tensor # noqa: F401 - - HAVE_MXFP8 = True -except ImportError: - HAVE_MXFP8 = False - -try: - import transformer_engine.pytorch.tensor.nvfp4_tensor # noqa: F401 - - HAVE_NVFP4 = True -except ImportError: - HAVE_NVFP4 = False - -try: - from megatron.training.utils import get_device_arch_version - - _DEVICE_ARCH = get_device_arch_version() -except Exception: - _DEVICE_ARCH = 0 - -# MXFP8 and NVFP4 require Blackwell (arch 10+). -HAVE_MXFP8_HW = HAVE_MXFP8 and _DEVICE_ARCH >= 10 -HAVE_NVFP4_HW = HAVE_NVFP4 and _DEVICE_ARCH >= 10 - -from megatron.core.dist_checkpointing import ShardedTensor, load, save -from megatron.core.dist_checkpointing.strategies.torch import ( - MCoreLoadPlanner, - TorchDistLoadShardedStrategy, - TorchDistSaveShardedStrategy, -) -from tests.unit_tests.dist_checkpointing import TempNamedDir -from tests.unit_tests.test_utilities import Utils - - -def _to_float8(tensor: torch.Tensor): - """Convert a BF16 tensor to delayed-scaling Float8Tensor (TE 2.x API).""" - try: - return Float8Tensor.to_float8(tensor) - except Exception: - import transformer_engine_torch as tex - from transformer_engine.pytorch.tensor.float8_tensor import Float8Quantizer - - quantizer = Float8Quantizer( - scale=torch.full([1], 1.0, dtype=torch.float32, device="cuda"), - amax=torch.empty([1], dtype=torch.float32, device="cuda"), - fp8_dtype=tex.DType.kFloat8E4M3, - ) - return quantizer(tensor.cuda()) - - -def _to_mxfp8(tensor: torch.Tensor): - """Convert a BF16 tensor to MXFP8Tensor.""" - import transformer_engine_torch as tex - from transformer_engine.pytorch.tensor.mxfp8_tensor import MXFP8Quantizer - - quantizer = MXFP8Quantizer(fp8_dtype=tex.DType.kFloat8E4M3) - return quantizer(tensor.cuda().contiguous()) - - -def _to_nvfp4(tensor: torch.Tensor): - """Convert a BF16 tensor to NVFP4Tensor (Blackwell+ only).""" - from transformer_engine.pytorch.tensor.nvfp4_tensor import NVFP4Quantizer - - quantizer = NVFP4Quantizer( - rowwise=True, - columnwise=True, - with_rht=False, - with_post_rht_amax=False, - with_2d_quantization=True, - stochastic_rounding=False, - with_random_sign_mask=False, - ) - return quantizer(tensor.cuda().contiguous()) - - -@pytest.mark.skipif(not HAVE_TE, reason="TransformerEngine not available") -class TestStreamCkptDequant: - """Unit tests for streaming per-tensor dequantize during ckpt load.""" - - def teardown_method(self, method): - Utils.destroy_model_parallel() - - # --------------------------------------------------------------- - # Baseline: FP8 (delayed scaling) save/load equivalence + amax safety - # --------------------------------------------------------------- - - @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) - def test_fp8_save_load_content_equivalence(self, tmp_path_dist_ckpt, stream_ckpt_dequant): - """Loaded FP8 contents must match regardless of which dequantize path is used.""" - Utils.initialize_model_parallel(1, 1) - - fill_val = 0.5 - - def get_fp8_tensor(val): - return _to_float8(torch.full((8,), val, dtype=torch.bfloat16, device='cuda')) - - def get_state_dict(val): - return { - 'w': ShardedTensor.from_rank_offsets( - 'w', get_fp8_tensor(val), replica_id=Utils.rank - ) - } - - with TempNamedDir(tmp_path_dist_ckpt / f'fp8_eq_{stream_ckpt_dequant}') as ckpt_dir: - save(get_state_dict(fill_val), ckpt_dir, TorchDistSaveShardedStrategy()) - - # Fresh state dict with a different fill — the load must overwrite it. - sd_to_load = get_state_dict(99.0) - strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) - loaded = load(sd_to_load, ckpt_dir, strategy) - # Dequantize the loaded tensor (may be Float8 or BF16 depending on path) - loaded_w = loaded['w'] - if isinstance(loaded_w, QuantizedTensor): - loaded_w = loaded_w.dequantize() - # fill_val (0.5) is exactly representable in FP8 E4M3 and the per-tensor - # scale is a power of 2, so the round-trip is numerically lossless modulo - # bf16 rounding. Tight tolerance catches real regressions. - torch.testing.assert_close( - loaded_w, - torch.full((8,), fill_val, dtype=torch.bfloat16, device='cuda'), - rtol=1e-3, - atol=1e-3, - ) - - def test_fp8_amax_history_not_polluted(self, tmp_path_dist_ckpt): - """Delayed-scaling amax must be snapshotted & restored across a streaming load.""" - Utils.initialize_model_parallel(1, 1) - - def get_fp8_tensor(val): - return _to_float8(torch.full((8,), val, dtype=torch.bfloat16, device='cuda')) - - sd_to_save = { - 'w': ShardedTensor.from_rank_offsets('w', get_fp8_tensor(0.25), replica_id=Utils.rank) - } - - with TempNamedDir(tmp_path_dist_ckpt / 'fp8_amax') as ckpt_dir: - save(sd_to_save, ckpt_dir, TorchDistSaveShardedStrategy()) - - # Rebuild destination with a known (distinct) amax value we can check - # survives the streaming load. - dst = ShardedTensor.from_rank_offsets('w', get_fp8_tensor(99.0), replica_id=Utils.rank) - q = getattr(dst.data, "_quantizer", None) - if q is None or not isinstance(getattr(q, "amax", None), torch.Tensor): - pytest.skip("This TE build's Float8Tensor has no quantizer.amax scalar") - sentinel = 42.0 - q.amax.fill_(sentinel) - pre_load_amax = q.amax.detach().clone() - - strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=True) - loaded = load({'w': dst}, ckpt_dir, strategy) - - # The loaded tensor is the same QuantizedTensor object; its quantizer.amax - # must be exactly what we put in before the load. - loaded_q = getattr(loaded['w'], "_quantizer", None) - assert loaded_q is not None - assert torch.equal(loaded_q.amax, pre_load_amax), ( - f"amax was not restored after streaming load; " - f"before={pre_load_amax.item()} after={loaded_q.amax.item()}" - ) - - # --------------------------------------------------------------- - # MXFP8: exercises both the streaming dequant AND the view-based - # _unwrap_pyt_sharded_tensor fix (without it, ten[0] on MXFP8 OOMs). - # --------------------------------------------------------------- - - @pytest.mark.skipif( - not HAVE_MXFP8_HW, - reason="MXFP8 requires TransformerEngine MXFP8Tensor and Blackwell+ (arch 10+)", - ) - @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) - def test_mxfp8_save_load_content_equivalence(self, tmp_path_dist_ckpt, stream_ckpt_dequant): - Utils.initialize_model_parallel(1, 1) - - # MXFP8 requires 2D with last-dim aligned to block size (32). - fill_val = 0.25 - - def get_mxfp8_tensor(val): - return _to_mxfp8(torch.full((64, 128), val, dtype=torch.bfloat16, device='cuda')) - - def get_state_dict(val): - return { - 'w': ShardedTensor.from_rank_offsets( - 'w', get_mxfp8_tensor(val), replica_id=Utils.rank - ) - } - - with TempNamedDir(tmp_path_dist_ckpt / f'mxfp8_eq_{stream_ckpt_dequant}') as ckpt_dir: - save(get_state_dict(fill_val), ckpt_dir, TorchDistSaveShardedStrategy()) - - sd_to_load = get_state_dict(99.0) - strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) - loaded = load(sd_to_load, ckpt_dir, strategy) - loaded_w = loaded['w'] - if isinstance(loaded_w, QuantizedTensor): - loaded_w = loaded_w.dequantize() - # fill_val (0.25) is exactly representable in FP8 E4M3, and MXFP8 stores - # block scales in E8M0 (power-of-2), so the per-block scale is exact and - # every element encodes to the same FP8 code. Round-trip is near-lossless. - torch.testing.assert_close( - loaded_w, - torch.full((64, 128), fill_val, dtype=torch.bfloat16, device='cuda'), - rtol=1e-3, - atol=1e-3, - ) - - # --------------------------------------------------------------- - # NVFP4: round-trip under both paths. Same invariants as MXFP8 but - # with NVFP4Tensor — validates that `is_float8tensor` (which binds to - # QuantizedTensor under TE 2.x) correctly covers the FP4 path, that - # NVFP4Tensor.view works inside _unwrap_pyt_sharded_tensor, and that - # BF16->NVFP4 copy through QuantizedTensor.__torch_dispatch__ -> quantize_ - # produces correct values. Requires Blackwell+ for the FP4 kernels. - # --------------------------------------------------------------- - - @pytest.mark.skipif( - not HAVE_NVFP4_HW, - reason="NVFP4 requires TransformerEngine NVFP4Tensor and Blackwell+ (arch 10+)", - ) - @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) - def test_nvfp4_save_load_content_equivalence(self, tmp_path_dist_ckpt, stream_ckpt_dequant): - Utils.initialize_model_parallel(1, 1) - - # NVFP4BlockScaling uses 16-element blocks along the last dim; use a - # shape that's a multiple of both common block sizes. - fill_val = 0.25 - - def get_nvfp4_tensor(val): - return _to_nvfp4(torch.full((64, 128), val, dtype=torch.bfloat16, device='cuda')) - - def get_state_dict(val): - return { - 'w': ShardedTensor.from_rank_offsets( - 'w', get_nvfp4_tensor(val), replica_id=Utils.rank - ) - } - - with TempNamedDir(tmp_path_dist_ckpt / f'nvfp4_eq_{stream_ckpt_dequant}') as ckpt_dir: - save(get_state_dict(fill_val), ckpt_dir, TorchDistSaveShardedStrategy()) - - sd_to_load = get_state_dict(99.0) - strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) - loaded = load(sd_to_load, ckpt_dir, strategy) - loaded_w = loaded['w'] - if isinstance(loaded_w, QuantizedTensor): - loaded_w = loaded_w.dequantize() - # For a constant block every FP4 code is identical and the dominant error - # source is the per-block scale being stored in FP8 E4M3 (unlike MXFP8's - # power-of-2 E8M0). That rounding is bounded below ~1% relative; 1e-2 is - # tight enough to catch real bugs and loose enough to absorb E4M3 scale - # rounding + bf16 output rounding. - torch.testing.assert_close( - loaded_w, - torch.full((64, 128), fill_val, dtype=torch.bfloat16, device='cuda'), - rtol=1e-2, - atol=1e-2, - ) - - # --------------------------------------------------------------- - # Corner cases - # --------------------------------------------------------------- - - @pytest.mark.parametrize('stream_ckpt_dequant', [False, True]) - def test_plain_tensor_untouched_by_streaming_path( - self, tmp_path_dist_ckpt, stream_ckpt_dequant - ): - """Non-quantized tensors in the state dict must round-trip losslessly under either path.""" - Utils.initialize_model_parallel(1, 1) - - src = torch.arange(64, dtype=torch.bfloat16, device='cuda') - sd_to_save = {'w': ShardedTensor.from_rank_offsets('w', src.clone(), replica_id=Utils.rank)} - - with TempNamedDir(tmp_path_dist_ckpt / f'plain_{stream_ckpt_dequant}') as ckpt_dir: - save(sd_to_save, ckpt_dir, TorchDistSaveShardedStrategy()) - - dst = { - 'w': ShardedTensor.from_rank_offsets( - 'w', torch.zeros_like(src), replica_id=Utils.rank - ) - } - strategy = TorchDistLoadShardedStrategy(stream_ckpt_dequant=stream_ckpt_dequant) - loaded = load(dst, ckpt_dir, strategy) - # Plain BF16 must round-trip exactly. - torch.testing.assert_close(loaded['w'], src) - - def test_default_is_on(self): - """The default for stream_ckpt_dequant must be True (streaming path is now default).""" - strat = TorchDistLoadShardedStrategy() - assert ( - strat.stream_ckpt_dequant is True - ), "Default must be True; users opt out via --no-stream-ckpt-dequant." - planner = MCoreLoadPlanner() - assert planner.stream_ckpt_dequant is True - - def test_planner_state_cleanup_after_load(self, tmp_path_dist_ckpt): - """``_intermediate_read_items`` must be empty after a streaming load completes. - - Lingering entries would indicate a scratch tensor we forgot to drop, defeating - the memory win. - """ - Utils.initialize_model_parallel(1, 1) - - def get_fp8_tensor(val): - return _to_float8(torch.full((32,), val, dtype=torch.bfloat16, device='cuda')) - - sd_to_save = { - f'w{i}': ShardedTensor.from_rank_offsets( - f'w{i}', get_fp8_tensor(0.125), replica_id=Utils.rank - ) - for i in range(4) - } - - with TempNamedDir(tmp_path_dist_ckpt / 'planner_cleanup') as ckpt_dir: - save(sd_to_save, ckpt_dir, TorchDistSaveShardedStrategy()) - - # Instrument: intercept MCoreLoadPlanner to capture the live instance. - captured: list[MCoreLoadPlanner] = [] - original_init = MCoreLoadPlanner.__init__ - - def capturing_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - captured.append(self) - - MCoreLoadPlanner.__init__ = capturing_init # type: ignore[assignment] - try: - dst = { - f'w{i}': ShardedTensor.from_rank_offsets( - f'w{i}', get_fp8_tensor(99.0), replica_id=Utils.rank - ) - for i in range(4) - } - load(dst, ckpt_dir, TorchDistLoadShardedStrategy(stream_ckpt_dequant=True)) - finally: - MCoreLoadPlanner.__init__ = original_init # type: ignore[assignment] - - assert len(captured) == 1 - assert captured[0]._intermediate_read_items == {}, ( - f"Planner left intermediate state after load: " - f"{list(captured[0]._intermediate_read_items.keys())}" - ) - - def test_streaming_flag_forwards_through_fpsl_wrapper(self): - """FullyParallelLoadStrategyWrapper must surface the base strategy's flag.""" - from megatron.core.dist_checkpointing.strategies.fully_parallel import ( - FullyParallelLoadStrategyWrapper, - ) - - base_off = TorchDistLoadShardedStrategy(stream_ckpt_dequant=False) - base_on = TorchDistLoadShardedStrategy(stream_ckpt_dequant=True) - # parallelization_group left default -> GroupMember.WORLD; that's fine since - # we're only reading the forwarded property, not calling load(). - wrapped_off = FullyParallelLoadStrategyWrapper(base_off) - wrapped_on = FullyParallelLoadStrategyWrapper(base_on) - assert wrapped_off.stream_ckpt_dequant is False - assert wrapped_on.stream_ckpt_dequant is True diff --git a/tests/unit_tests/dist_checkpointing/utils.py b/tests/unit_tests/dist_checkpointing/utils.py index 9a8b502eba0..ba774b34fd2 100644 --- a/tests/unit_tests/dist_checkpointing/utils.py +++ b/tests/unit_tests/dist_checkpointing/utils.py @@ -150,7 +150,6 @@ def init_checkpointing_mock_args(args, ckpt_dir, fully_parallel=False): args.no_save_optim = False args.no_save_rng = False args.ckpt_assume_constant_structure = False - args.stream_ckpt_dequant = True args.ckpt_load_validate_sharding_integrity = True args.log_progress = False args.auto_detect_ckpt_format = False diff --git a/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py b/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py index 7ded4abd1a5..1c998181b50 100644 --- a/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py +++ b/tests/unit_tests/pipeline_parallel/test_pipeline_layout.py @@ -140,7 +140,6 @@ def create_args(): args.vocab_file = None args.add_position_embedding = False args.ckpt_assume_constant_structure = False - args.stream_ckpt_dequant = True args.ckpt_load_validate_sharding_integrity = True args.dist_ckpt_strictness = "assume_ok_unexpected" args.fp16 = False diff --git a/tests/unit_tests/test_checkpointing.py b/tests/unit_tests/test_checkpointing.py index cf1b6a76539..2e717e4424f 100644 --- a/tests/unit_tests/test_checkpointing.py +++ b/tests/unit_tests/test_checkpointing.py @@ -214,7 +214,6 @@ def create_ckpt_load_args(create_args): args.tensor_model_parallel_size = 1 args.pipeline_model_parallel_size = 1 args.ckpt_assume_constant_structure = False - args.stream_ckpt_dequant = True args.ckpt_fully_parallel_save = False args.ckpt_fully_parallel_load = False args.ckpt_load_validate_sharding_integrity = True From 7a6f6839ea1028eddf83c43d4bc604a400b0634c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Aug 2026 09:53:52 +0000 Subject: [PATCH 202/290] chore: rotate oncall schedule --- .github/oncall_schedule.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/oncall_schedule.json b/.github/oncall_schedule.json index ba5e92c5980..0fe3ecb86ab 100644 --- a/.github/oncall_schedule.json +++ b/.github/oncall_schedule.json @@ -1,8 +1,4 @@ [ - { - "user": "ilml", - "date": "2026-07-29" - }, { "user": "janEbert", "date": "2026-08-05" @@ -46,5 +42,9 @@ { "user": "ilml", "date": "2026-10-14" + }, + { + "user": "janEbert", + "date": "2026-10-21" } ] From bf71c142b5b563af7348b55a692450869f47d734 Mon Sep 17 00:00:00 2001 From: Dmytro Pykhtar <37850217+dimapihtar@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:36:20 +0300 Subject: [PATCH 203/290] avoid circular dependency usage between mcore and modelopt- #5644 (#6247) Signed-off-by: dimapihtar Signed-off-by: Dmytro Pykhtar --- .../post_training/modelopt/checkpointing.py | 185 ++++++++++++++++++ megatron/post_training/checkpointing.py | 37 +--- megatron/training/checkpointing.py | 29 +-- .../test_modelopt_checkpointing.py | 181 +++++++++++++++++ 4 files changed, 376 insertions(+), 56 deletions(-) create mode 100644 megatron/core/post_training/modelopt/checkpointing.py create mode 100644 tests/unit_tests/post_training/test_modelopt_checkpointing.py diff --git a/megatron/core/post_training/modelopt/checkpointing.py b/megatron/core/post_training/modelopt/checkpointing.py new file mode 100644 index 00000000000..aeb6060cb21 --- /dev/null +++ b/megatron/core/post_training/modelopt/checkpointing.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Dist checkpointing modules needed for ModelOpt.""" + +import copy +import logging +import os +from pathlib import Path +from typing import Any + +import torch + +from megatron.core import mpu +from megatron.core.dist_checkpointing.serialization import load, load_common_state_dict, save +from megatron.core.dist_checkpointing.strategies.torch import TorchDistLoadShardedStrategy +from megatron.core.dist_checkpointing.validation import StrictHandling +from megatron.core.safe_globals import safe_load_from_bytes + +logger = logging.getLogger(__name__) + + +def remove_per_module_state(modelopt_state: dict[str, Any]) -> None: + """Remove metadata from the modelopt_state. + + The metadata of the modelopt_state contains keys which may change with different pipeline + and expert parallelism. As a result, the metadata must be stored as several ShardedObject with + global and local layer offset mapping. + + Args: + modelopt_state: the state_dict that contains all algorithms that have been applied + to the given model. + """ + if "modelopt_state_dict" not in modelopt_state: + return + + for mode, config in modelopt_state["modelopt_state_dict"]: + metadata = config.get("metadata", None) + if metadata is not None: + _ = metadata.pop("quantizer_state", None) + _ = metadata.pop("subnet_config", None) + _ = metadata.pop("real_quantizer_state", None) + _ = metadata.pop("q_tensor_state", None) + else: + config["metadata"] = {} + + +def save_modelopt_state(model: list[torch.nn.Module], state_dict: dict[str, Any]) -> None: + """Save modelopt_state as a part of the per rank state_dict. + + NOTE: Only used for Megatron-LM. + + Args: + model: the modelopt optimized model + state_dict: the current modelopt optimized model state_dict to store + """ + import modelopt.torch.opt as mto + + if not mto.ModeloptStateManager.is_converted(model[0]): + return + if len(model) == 1: + state_dict["modelopt_state"] = mto.modelopt_state(model[0]) + else: + for i in range(len(model)): + mpu.set_virtual_pipeline_model_parallel_rank(i) + state_dict[f"modelopt_state_{i}"] = mto.modelopt_state(model[i]) + + +def save_sharded_modelopt_state( + model: list[torch.nn.Module], + checkpoint_name: str | Path, + sharded_strategy: tuple[str, int] | None = None, + prefix: str = "", +) -> None: + """Save modelopt_state in the sharded state_dict format. + + Args: + model: the model to restore the modelopt optimization + checkpoint_name: the checkpoint folder path + sharded_strategy: configures sharded tensors saving behavior and backend + prefix: the prefix to add to the modelopt_state keys ("model." for NeMo) + """ + import modelopt.torch.opt as mto + import modelopt.torch.utils.distributed as dist + + if not mto.ModeloptStateManager.is_converted(model[0]): + return + if len(model) > 1: + raise ValueError("sharded_modelopt_state does not support virtual pipeline parallel!") + modelopt_checkpoint_name = f"{checkpoint_name}/modelopt_state" + if dist.is_master(): + os.makedirs(modelopt_checkpoint_name, exist_ok=True) + modelopt_state = copy.deepcopy(mto.modelopt_state(model[0])) + remove_per_module_state(modelopt_state) + save(modelopt_state, modelopt_checkpoint_name, sharded_strategy) + + +def _load_extra_state_from_sharded_checkpoint( + model: torch.nn.Module, + checkpoint_name: str | Path, + prefix: str, + metadata: dict[str, Any] | None = None, +) -> None: + """Load extra state from sharded checkpoint. + + Note: since extra_state is a subset of full the sharded_state_dict, we use + strict=StrictHandling.LOG_UNEXPECTED instead of LOG_ALL. + + Args: + model: the model to load extra state into + checkpoint_name: the checkpoint folder path + prefix: the prefix to add to the modelopt_state keys + metadata: the metadata for distributed checkpointing + + Note: + The metadata includes several breaking changes. For example, `singleton_local_shards` + is set to `True` (was not set before) in megatron-core-0.15.0. This flag affects the + sharded state_dict format and must be consistent between saving and loading. + """ + sharded_state_dict = model.sharded_state_dict(prefix=prefix) + extra_sharded_state_dict = {k: v for k, v in sharded_state_dict.items() if "_extra_state" in k} + extra_state_dict = load( + extra_sharded_state_dict, + checkpoint_name, + TorchDistLoadShardedStrategy(), + strict=StrictHandling.LOG_UNEXPECTED, + ) + extra_state_dict_no_prefix = {} + + for k, v in extra_state_dict.items(): + if k.startswith(prefix): + extra_state_dict_no_prefix[k[len(prefix) :]] = v + model.load_state_dict(extra_state_dict_no_prefix, strict=False) + + +def restore_sharded_modelopt_state( + model: list[torch.nn.Module], + checkpoint_name: str | Path, + prefix: str = "", + metadata: dict[str, Any] | None = None, +) -> None: + """Restore modelopt_state from the sharded state_dict format. + + Args: + model: the model to restore the modelopt optimization + checkpoint_name: the checkpoint folder path + prefix: the prefix to add to the modelopt_state keys ("model." for NeMo) + metadata: the metadata for distributed checkpointing + + Note: + The metadata includes several breaking changes. For example, `singleton_local_shards` + is set to `True` (was not set before) in megatron-core-0.15.0. This flag affects the + sharded state_dict format and must be consistent between saving and loading. + """ + import modelopt + import modelopt.torch.opt as mto + + if len(model) > 1: + raise ValueError("sharded_modelopt_state does not support virtual pipeline parallel!") + + modelopt_checkpoint_name = f"{checkpoint_name}/modelopt_state" + + # Early return if the model already has a modelopt_state or the checkpoint does not exist. + if not os.path.exists(modelopt_checkpoint_name) or mto.ModeloptStateManager.is_converted( + model[0] + ): + return + + # Loading the common modelopt_state (replicated on all ranks). + # Detect format: legacy checkpoints store common state in a standalone common.pt file; + # newer sharded checkpoints store it as a ShardedObject inside the torch_dist checkpoint. + legacy_common_path = os.path.join(modelopt_checkpoint_name, "common.py") + if os.path.exists(legacy_common_path): + common_modelopt_state = safe_load_from_bytes(legacy_common_path) + else: + common_modelopt_state = load_common_state_dict(modelopt_checkpoint_name) + + modelopt_load_version = common_modelopt_state["modelopt_version"] + + logger.info( + f"nvidia-modelopt ckpt/inst version: {modelopt_load_version}/{modelopt.__version__}" + ) + + model[0] = mto.restore_from_modelopt_state(model[0], common_modelopt_state) + + _load_extra_state_from_sharded_checkpoint(model[0], checkpoint_name, prefix, metadata=metadata) diff --git a/megatron/post_training/checkpointing.py b/megatron/post_training/checkpointing.py index 1cb730bc450..821130611bf 100644 --- a/megatron/post_training/checkpointing.py +++ b/megatron/post_training/checkpointing.py @@ -8,15 +8,9 @@ import modelopt import modelopt.torch.opt as mto import torch.nn as nn -from modelopt.torch.opt.plugins import ( - restore_sharded_modelopt_state as restore_sharded_modelopt_state_legacy, -) -from modelopt.torch.opt.plugins.mcore_dist_checkpointing import ( - _load_extra_state_from_sharded_checkpoint, -) from megatron.core import dist_checkpointing -from megatron.core.dist_checkpointing.serialization import _legacy_common_state_exists +from megatron.core.post_training.modelopt.checkpointing import restore_sharded_modelopt_state from megatron.core.utils import unwrap_model from megatron.training import get_args from megatron.training.checkpointing import _load_base_checkpoint, load_checkpoint @@ -131,10 +125,7 @@ def load_modelopt_state(model: nn.Module, load_dir: Optional[str] = None) -> Non if sharded_load_dir is None: print_rank_0("No sharded checkpoint found. Skipping loading modelopt_state.") return - if _legacy_common_state_exists(f"{sharded_load_dir}/modelopt_state"): - restore_sharded_modelopt_state_legacy([model], sharded_load_dir) - else: - restore_sharded_modelopt_state([model], sharded_load_dir) + restore_sharded_modelopt_state([model], sharded_load_dir) def load_modelopt_checkpoint( @@ -208,30 +199,6 @@ def _remove_prefix_state_dict_pre_hook( _ = load_checkpoint(model, optimizer, opt_param_scheduler, strict=strict, load_arg=load_arg) -def restore_sharded_modelopt_state(model: list[nn.Module], checkpoint_name: str | Path) -> None: - """Temporary function. Copy of modelopt.torch.opt.plugins.restore_sharded_modelopt_state. - Will be removed once modelopt.torch.opt.plugins.restore_sharded_modelopt_state is up to date. - """ - if len(model) > 1: - raise ValueError("sharded_modelopt_state does not support virtual pipeline parallel!") - - modelopt_checkpoint_name = f"{checkpoint_name}/modelopt_state" - - # Early return if the model already has a modelopt_state or the checkpoint does not exist. - if not os.path.exists(modelopt_checkpoint_name) or mto.ModeloptStateManager.is_converted( - model[0] - ): - return - - common_modelopt_state = dist_checkpointing.load_common_state_dict(modelopt_checkpoint_name) - modelopt_load_version = common_modelopt_state["modelopt_version"] - - print(f"nvidia-modelopt ckpt/inst version: {modelopt_load_version}/{modelopt.__version__}") - - model[0] = mto.restore_from_modelopt_state(model[0], common_modelopt_state) - _load_extra_state_from_sharded_checkpoint(model[0], checkpoint_name, prefix="") - - def load_kd_teacher_checkpoint(model) -> None: """Load the teacher checkpoint for ModelOpt distillation if the model has one.""" args = get_args() diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 8131645f09c..ee7e0077c14 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -39,8 +39,10 @@ from megatron.core.msc_utils import maybe_msc from megatron.core.num_microbatches_calculator import update_num_microbatches from megatron.core.optimizer import DistributedOptimizer +from megatron.core.post_training.modelopt.checkpointing import save_modelopt_state, save_sharded_modelopt_state from megatron.core.rerun_state_machine import get_rerun_state_machine from megatron.core.utils import get_pg_rank, get_pg_size, unwrap_model +from megatron.post_training.utils import print_distributed_quant_summary from ..core.dist_checkpointing.utils import _clean_metadata_for_serialization from . import ft_integration, wandb_utils @@ -64,18 +66,6 @@ except ImportError: HAVE_MEGATRON_FSDP = False - -# [ModelOpt]: Import -try: - from modelopt.torch.opt.plugins import save_modelopt_state, save_sharded_modelopt_state - - from megatron.post_training.utils import print_distributed_quant_summary - - has_nvidia_modelopt = True -except Exception: - has_nvidia_modelopt = False - - _CHECKPOINT_VERSION = None _LOADED_ITERATION = None @@ -858,8 +848,7 @@ def save_checkpoint( verify_integrity=args.verify_integrity, ) # [ModelOpt]: save sharded modelopt_state - if has_nvidia_modelopt: - save_sharded_modelopt_state(model, checkpoint_name, (args.ckpt_format, 1)) + save_sharded_modelopt_state(model, checkpoint_name, (args.ckpt_format, 1)) elif ckpt_type == CheckpointType.GLOBAL and ckpt_format in ['torch_dcp', 'fsdp_dtensor']: if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: # TODO Handle non-empty directories (e.g., after a crash during saving). @@ -913,11 +902,10 @@ def save_checkpoint( ) else: # [ModelOpt]: Inject modelopt_state into state_dict - if has_nvidia_modelopt: - if ckpt_type == CheckpointType.LOCAL: - print_rank_0('WARNING: Local checkpointing does not support nvidia_modelopt.') - else: - save_modelopt_state(model, state_dict) + if ckpt_type == CheckpointType.LOCAL: + print_rank_0('WARNING: Local checkpointing does not support nvidia_modelopt.') + else: + save_modelopt_state(model, state_dict) end_ckpt = time() logger.debug( @@ -2808,8 +2796,7 @@ def load_model_state_dict(module, state_dict, strict: bool): ) log_printed = True - if has_nvidia_modelopt: - print_distributed_quant_summary(model, msg='After loading checkpoint') + print_distributed_quant_summary(model, msg='After loading checkpoint') return iteration, num_floating_point_operations_so_far diff --git a/tests/unit_tests/post_training/test_modelopt_checkpointing.py b/tests/unit_tests/post_training/test_modelopt_checkpointing.py new file mode 100644 index 00000000000..2fe5603f838 --- /dev/null +++ b/tests/unit_tests/post_training/test_modelopt_checkpointing.py @@ -0,0 +1,181 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Unit tests for megatron/core/dist_checkpointing/strategies/modelopt.py. + +Skipped automatically when modelopt is not installed. +""" + +import sys +from unittest import mock + +import pytest +import torch + +pytest.importorskip("modelopt", reason="modelopt is not installed") + +import modelopt.torch.opt as mto +import modelopt.torch.utils.distributed as mdist + +from megatron.core.post_training.modelopt.checkpointing import ( + _load_extra_state_from_sharded_checkpoint, + remove_per_module_state, + restore_sharded_modelopt_state, + save_modelopt_state, + save_sharded_modelopt_state, +) + + +class TestModelOptImports: + def test_modelopt_imports(self): + modelopt_keys = [k for k in sys.modules if k == "modelopt" or k.startswith("modelopt.")] + saved = {k: sys.modules.pop(k) for k in modelopt_keys} + try: + import megatron.core # noqa: F401 + + assert "modelopt" not in sys.modules + finally: + sys.modules.update(saved) + + +class TestRemovePerModuleState: + def test_no_key_is_noop(self): + state = {"other": 1} + remove_per_module_state(state) + assert state == {"other": 1} + + def test_removes_per_module_keys_keeps_others(self): + state = { + "modelopt_state_dict": [ + ( + "mode", + { + "metadata": { + "quantizer_state": "a", + "subnet_config": "b", + "real_quantizer_state": "c", + "q_tensor_state": "d", + "keep": True, + } + }, + ) + ] + } + remove_per_module_state(state) + meta = state["modelopt_state_dict"][0][1]["metadata"] + assert not any( + k in meta + for k in ("quantizer_state", "subnet_config", "real_quantizer_state", "q_tensor_state") + ) + assert meta["keep"] is True + + def test_missing_metadata_filled_with_empty_dict(self): + state = {"modelopt_state_dict": [("mode", {})]} + remove_per_module_state(state) + assert state["modelopt_state_dict"][0][1]["metadata"] == {} + + +class TestSaveModeloptState: + def test_not_converted_is_noop(self): + state_dict = {} + with mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=False): + save_modelopt_state([mock.MagicMock()], state_dict) + assert state_dict == {} + + def test_single_model_saved(self): + fake_state = {"modelopt_state_dict": []} + state_dict = {} + with ( + mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), + mock.patch.object(mto, "modelopt_state", return_value=fake_state), + ): + save_modelopt_state([mock.MagicMock()], state_dict) + assert state_dict["modelopt_state"] is fake_state + + def test_multiple_models_use_indexed_keys(self): + state_dict = {} + with ( + mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), + mock.patch.object(mto, "modelopt_state", side_effect=[{"i": i} for i in range(2)]), + mock.patch("megatron.core.post_training.modelopt.checkpointing.mpu"), + ): + save_modelopt_state([mock.MagicMock(), mock.MagicMock()], state_dict) + assert "modelopt_state_0" in state_dict + assert "modelopt_state_1" in state_dict + + +class TestSaveShardedModeloptState: + def test_multiple_models_raises(self): + with ( + mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), + mock.patch.object(mdist, "is_master", return_value=True), + pytest.raises(ValueError, match="virtual pipeline"), + ): + save_sharded_modelopt_state([mock.MagicMock(), mock.MagicMock()], "/ckpt") + + def test_single_model_calls_save(self, tmp_path): + with ( + mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), + mock.patch.object(mto, "modelopt_state", return_value={"modelopt_state_dict": []}), + mock.patch.object(mdist, "is_master", return_value=True), + mock.patch("megatron.core.post_training.modelopt.checkpointing.save") as mock_save, + ): + save_sharded_modelopt_state([mock.MagicMock()], str(tmp_path)) + mock_save.assert_called_once() + assert mock_save.call_args.args[1] == f"{tmp_path}/modelopt_state" + + +class TestRestoreShardedModeloptState: + def test_multiple_models_raises(self): + with pytest.raises(ValueError, match="virtual pipeline"): + restore_sharded_modelopt_state([mock.MagicMock(), mock.MagicMock()], "/ckpt") + + def test_missing_checkpoint_returns_early(self, tmp_path): + with ( + mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=False), + mock.patch.object(mto, "restore_from_modelopt_state") as mock_restore, + ): + restore_sharded_modelopt_state([mock.MagicMock()], str(tmp_path)) + mock_restore.assert_not_called() + + def test_restores_model(self, tmp_path): + (tmp_path / "modelopt_state").mkdir() + with ( + mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=False), + mock.patch.object( + mto, "restore_from_modelopt_state", side_effect=lambda m, _s: m + ) as mock_restore, + mock.patch( + "megatron.core.post_training.modelopt.checkpointing.load_common_state_dict", + return_value={"modelopt_version": "1.0"}, + ), + mock.patch( + "megatron.core.post_training.modelopt.checkpointing._load_extra_state_from_sharded_checkpoint" + ), + mock.patch("megatron.core.post_training.modelopt.checkpointing.logger", create=True), + ): + restore_sharded_modelopt_state([mock.MagicMock()], str(tmp_path)) + mock_restore.assert_called_once() + + +class TestLoadExtraStateFromShardedCheckpoint: + def test_strips_prefix_and_filters_extra_state(self, tmp_path): + prefix = "model." + model = mock.MagicMock() + model.sharded_state_dict.return_value = { + f"{prefix}layer._extra_state": mock.MagicMock(), + f"{prefix}layer.weight": mock.MagicMock(), + } + loaded = {f"{prefix}layer._extra_state": torch.tensor([1.0])} + + with mock.patch( + "megatron.core.post_training.modelopt.checkpointing.load", return_value=loaded + ) as mock_load: + _load_extra_state_from_sharded_checkpoint(model, str(tmp_path), prefix) + + passed = mock_load.call_args.args[0] + assert f"{prefix}layer._extra_state" in passed + assert f"{prefix}layer.weight" not in passed + + loaded_dict = model.load_state_dict.call_args.args[0] + assert "layer._extra_state" in loaded_dict + assert f"{prefix}layer._extra_state" not in loaded_dict From aa4624e4efebf969a5bb7e881c37dbc6da79318c Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 5 Aug 2026 09:15:32 -0700 Subject: [PATCH 204/290] =?UTF-8?q?Update=20DEEPEP=5FCOMMIT=20in=20Dockerf?= =?UTF-8?q?ile.ci.dev=20to=20commit=20de0dd1=E2=80=A6=20(#6261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ajay Balasa --- docker/Dockerfile.ci.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.ci.dev b/docker/Dockerfile.ci.dev index bd0bd78325b..bd342e613b1 100644 --- a/docker/Dockerfile.ci.dev +++ b/docker/Dockerfile.ci.dev @@ -82,7 +82,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ EOF # Install DeepEP -ARG DEEPEP_COMMIT=17cfb817bccec3a9c247013360cc550c2bac441e +ARG DEEPEP_COMMIT=de0dd1185142c727b9c118aeb4cc4702e34deeba ENV DEEPEP_COMMIT=$DEEPEP_COMMIT ENV HYBRID_EP_MULTINODE=1 ENV RDMA_CORE_HOME=/opt/rdma-core/build From b8ff5654e2fcff8726d892893deadedc4b6eb009 Mon Sep 17 00:00:00 2001 From: kajalj22 Date: Wed, 5 Aug 2026 13:19:48 -0500 Subject: [PATCH 205/290] revert: revert community-bot bump to v1.8.7 (5adbee2) (#6288) Signed-off-by: Kajal Jain --- .github/workflows/community-bot.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/community-bot.yml b/.github/workflows/community-bot.yml index 59623c30157..47a54ec9264 100644 --- a/.github/workflows/community-bot.yml +++ b/.github/workflows/community-bot.yml @@ -21,10 +21,9 @@ on: jobs: community-bot: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@98ea77e930f3e1b0f97a1ce5255e12c407e9d0d4 # v1.8.7 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@f0dadfd1b2d5c3f48a24ded127abd50afbf8ce11 # v0.65.10 with: community_project_id: ${{ vars.COMMUNITY_PROJECT_ID }} - app-id: ${{ vars.BOT_ID }} if: github.repository == 'NVIDIA/Megatron-LM' secrets: - BOT_KEY: ${{ secrets.BOT_KEY }} + GH_TOKEN: ${{ secrets.PAT }} From 9f3fe8c925fb3588356c06300071cd077b90359a Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Thu, 6 Aug 2026 01:25:22 +0800 Subject: [PATCH 206/290] [GTP] Cleanup usage of pg_collection in gtp (#6234) Signed-off-by: Shiqing Fan Signed-off-by: ykarnati Co-authored-by: ykarnati --- .../core/generalized_tensor_parallel.md | 1 + .../core/distributed/finalize_model_grads.py | 33 +- .../core/extensions/transformer_engine.py | 26 +- .../embeddings/language_model_embedding.py | 4 + megatron/core/models/hybrid/hybrid_model.py | 2 + megatron/core/pipeline_parallel/schedules.py | 17 +- megatron/core/process_groups_config.py | 25 ++ megatron/core/ssm/mamba_mixer.py | 2 + .../core/tensor_parallel/inference_layers.py | 10 + megatron/core/tensor_parallel/layers.py | 21 +- megatron/core/tensor_parallel/random.py | 8 +- megatron/core/transformer/attention.py | 2 + megatron/core/transformer/mlp.py | 4 + .../core/transformer/moe/shared_experts.py | 9 +- megatron/training/training.py | 1 - .../test_gtp_custom_pgs.py | 291 ++++++++++++++++++ .../test_gtp_grad_correctness.py | 13 +- .../test_gtp_muon_dcp.py | 2 - 18 files changed, 424 insertions(+), 47 deletions(-) create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_custom_pgs.py diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index 6e03046e01f..364d3fde6c4 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -671,6 +671,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | | `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | | `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. | +| `test_gtp_custom_pgs.py` | `pg_collection` plumbing: a custom `gtp_remat` group (permuted ranks, same size) must give the same fwd/bwd results as the MPU groups — catches modules reading `parallel_state` instead of the collection passed to them. | The fp32-accumulation primitive itself is covered outside this suite, by `tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py`, which does not require GTP_remat. diff --git a/megatron/core/distributed/finalize_model_grads.py b/megatron/core/distributed/finalize_model_grads.py index af81145b6d8..d5be7607714 100644 --- a/megatron/core/distributed/finalize_model_grads.py +++ b/megatron/core/distributed/finalize_model_grads.py @@ -492,7 +492,10 @@ def _allreduce_non_tensor_model_parallel_grads( def _allreduce_replicated_grads_over_gtp_remat_group( - model: List[torch.nn.Module], calculate_per_token_loss: bool = False + model: List[torch.nn.Module], + gtp_remat_group: Optional[torch.distributed.ProcessGroup], + egtp_remat_group: Optional[torch.distributed.ProcessGroup], + calculate_per_token_loss: bool = False, ): """Complete the gtp_remat / egtp_remat axis reduction for replicated parameters. @@ -510,12 +513,6 @@ def _allreduce_replicated_grads_over_gtp_remat_group( No-op when GTP_remat is inactive (group size <= 1). """ - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = pg_collection.gtp_remat - egtp_remat_group = pg_collection.expt_gtp_remat - dense_active = gtp_remat_group is not None and gtp_remat_group.size() > 1 expert_active = egtp_remat_group is not None and egtp_remat_group.size() > 1 if not dense_active and not expert_active: @@ -604,12 +601,29 @@ def finalize_model_grads( # Full DP x CP x gtp_remat group: num_tokens (the per-token-loss divisor below) counts the # gtp_remat peers' distinct tokens. Falls back to replicate dp_cp when gtp is inactive. dp_cp_group = getattr(pg_collection, 'dp_cp_gtp_remat', None) or pg_collection.dp_cp + gtp_remat_group = getattr(pg_collection, 'gtp_remat', None) + egtp_remat_group = getattr(pg_collection, 'expt_gtp_remat', None) else: tp_group = parallel_state.get_tensor_model_parallel_group() pp_group = parallel_state.get_pipeline_model_parallel_group() embd_group = parallel_state.get_embedding_group(check_initialized=False) pos_emb_group = parallel_state.get_position_embedding_group(check_initialized=False) dp_cp_group = parallel_state.get_data_parallel_group(with_context_parallel=True) + gtp_remat_group = parallel_state.get_gtp_weight_remat_group(check_initialized=False) + egtp_remat_group = parallel_state.get_expert_gtp_weight_remat_group(check_initialized=False) + + # A missing group would silently skip the gtp_remat-axis reduction below and train on + # wrong gradients, so fail loudly whenever the config says the axis is active. + for axis, group, axis_size in ( + ('gtp_remat', gtp_remat_group, config.gtp_weight_remat_size), + ('expt_gtp_remat', egtp_remat_group, config.expert_gtp_weight_remat_size), + ): + if axis_size > 1: + found = 'None' if group is None else f'a size-{group.size()} group' + assert group is not None and group.size() == axis_size, ( + f"{axis} is enabled (size={axis_size}) but pg_collection provides {found}. " + f"Pass a pg_collection carrying `{axis}` to finalize_model_grads." + ) # Fence the current stream against all GTP backward grad work before the DP gradient sync. if config.gtp_weight_remat_size > 1 or config.expert_gtp_weight_remat_size > 1: @@ -646,7 +660,10 @@ def finalize_model_grads( ) _allreduce_non_tensor_model_parallel_grads(model, config, tp_group) _allreduce_replicated_grads_over_gtp_remat_group( - model, calculate_per_token_loss=config.calculate_per_token_loss + model, + gtp_remat_group, + egtp_remat_group, + calculate_per_token_loss=config.calculate_per_token_loss, ) if config.timers is not None: config.timers('non-tensor-parallel-grads-all-reduce').stop() diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 1251c85dcee..7f7424e6c02 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -33,7 +33,7 @@ get_tensor_model_parallel_world_size, model_parallel_is_initialized, ) -from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.process_groups_config import ProcessGroupCollection, resolve_gtp_remat_group from megatron.core.quantization.quant_config import QuantizationConfig from megatron.core.quantization.utils import get_quant_config_or_none from megatron.core.tensor_parallel.layers import ( @@ -1362,10 +1362,13 @@ def __init__( tp_group: Optional[torch.distributed.ProcessGroup] = None, stride: int = 1, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): """ Args: name (str | None): module instance name passed top-down from its paranet module + pg_collection (ProcessGroupCollection | None): process groups used by this layer. + Falls back to the MPU global process groups when not given. """ if not HAVE_TE: raise ImportError( @@ -1457,10 +1460,7 @@ def __init__( ), "Must have at least TE version 2.3 or higher to use symmetric memory all reduce" extra_kwargs["symmetric_ar_type"] = self.config.symmetric_ar_type - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat + gtp_remat_group = resolve_gtp_remat_group(pg_collection, is_expert) self.stride = stride self.te_quant_params: Optional[TEQuantizationParams] = None @@ -1621,10 +1621,13 @@ def __init__( tp_group: Optional[torch.distributed.ProcessGroup] = None, stride: int = 1, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): """ Args: name (str | None): module instance name passed top-down from its paranet module + pg_collection (ProcessGroupCollection | None): process groups used by this layer. + Falls back to the MPU global process groups when not given. """ if not HAVE_TE: raise ImportError( @@ -1639,10 +1642,7 @@ def __init__( world_size = get_pg_size(tp_group) rank = get_pg_rank(tp_group) self.stride = stride - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat + gtp_remat_group = resolve_gtp_remat_group(pg_collection, is_expert) super().__init__( input_size=input_size, @@ -1882,10 +1882,13 @@ def __init__( tp_comm_buffer_name: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): """ Args: name (str | None): module instance name passed top-down from its paranet module + pg_collection (ProcessGroupCollection | None): process groups used by this layer. + Falls back to the MPU global process groups when not given. """ if not HAVE_TE: raise ImportError( @@ -1899,10 +1902,7 @@ def __init__( ) tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self._tp_group = tp_group - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = pg_collection.expt_gtp_remat if is_expert else pg_collection.gtp_remat + gtp_remat_group = resolve_gtp_remat_group(pg_collection, is_expert) super().__init__( input_size=input_size, diff --git a/megatron/core/models/common/embeddings/language_model_embedding.py b/megatron/core/models/common/embeddings/language_model_embedding.py index 7e49ec6c02d..d5b6e952b41 100644 --- a/megatron/core/models/common/embeddings/language_model_embedding.py +++ b/megatron/core/models/common/embeddings/language_model_embedding.py @@ -6,6 +6,7 @@ from torch import Tensor from megatron.core import tensor_parallel +from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import get_tensor_model_parallel_group_if_none, nvtx_decorator @@ -24,6 +25,7 @@ class LanguageModelEmbedding(MegatronModule): num_tokentypes (int): Set to 0 without binary head, and 2 with a binary head. Defaults to 0. scatter_to_sequence_parallel (bool): Set to False to disable scatter of embedding across sequence parallel region. Defaults to True. + pg_collection (ProcessGroupCollection, optional): Process groups used by the embedding. """ def __init__( @@ -35,6 +37,7 @@ def __init__( num_tokentypes: int = 0, scatter_to_sequence_parallel: bool = True, tp_group: Optional[torch.distributed.ProcessGroup] = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): super().__init__(config=config) @@ -60,6 +63,7 @@ def __init__( reduce_scatter_embeddings=self.reduce_scatter_embeddings, config=self.config, tp_group=self.tp_group, + pg_collection=pg_collection, ) # Position embedding (serial). diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index f750c77e05b..f0358de57b9 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -239,6 +239,7 @@ def __init__( position_embedding_type=position_embedding_type, scatter_to_sequence_parallel=scatter_embedding_sequence_parallel, tp_group=self.pg_collection.tp, + pg_collection=self.pg_collection, ) # MLA (also used by DeepSeek Sparse Attention) uses its own decoupled RoPE, therefore we do @@ -322,6 +323,7 @@ def __init__( skip_weight_param_allocation=self.pre_process and self.share_embeddings_and_output_weights, tp_group=self.pg_collection.tp, + pg_collection=self.pg_collection, ) if self.pre_process or self.post_process or self.mtp_process: diff --git a/megatron/core/pipeline_parallel/schedules.py b/megatron/core/pipeline_parallel/schedules.py index 29d36ab2c7d..1daeacc9027 100644 --- a/megatron/core/pipeline_parallel/schedules.py +++ b/megatron/core/pipeline_parallel/schedules.py @@ -690,6 +690,15 @@ def _build_default_pg_collection() -> ProcessGroupCollection: pg_collection.dp = parallel_state.get_data_parallel_group( with_context_parallel=False, partial_data_parallel=False ) + # gtp_remat axis: consumers read these with getattr and silently skip the gtp_remat + # reduction when absent, so populate them even when GTP_remat is inactive. + pg_collection.gtp_remat = parallel_state.get_gtp_weight_remat_group(check_initialized=False) + pg_collection.expt_gtp_remat = parallel_state.get_expert_gtp_weight_remat_group( + check_initialized=False + ) + pg_collection.dp_cp_gtp_remat = parallel_state.get_data_parallel_group( + with_context_parallel=True, partial_data_parallel=False + ) return pg_collection @@ -1614,7 +1623,7 @@ def forward_backward_helper_wrapper( recv_next = True if is_pp_last_stage(p2p_communicator.pp_group): recv_next = False - (input_tensor, output_tensor_grad) = ( + input_tensor, output_tensor_grad = ( p2p_communicator.send_forward_backward_recv_forward_backward( output_tensor, input_tensor_grad, @@ -1678,7 +1687,7 @@ def forward_backward_helper_wrapper( if is_pp_last_stage(p2p_communicator.pp_group): recv_next = False - (bwd_recv_buffer[-1], bwd_wait_handles) = ( + bwd_recv_buffer[-1], bwd_wait_handles = ( p2p_communicator.send_backward_recv_backward( input_tensor_grad, recv_next=recv_next, @@ -1831,7 +1840,7 @@ def pp_post_backward(input_tensor_grad, vp_stage=None): backward_k, forward=False ) - (bwd_recv_buffer[backward_k % bwd_recv_buffer_size], bwd_wait_handles) = ( + bwd_recv_buffer[backward_k % bwd_recv_buffer_size], bwd_wait_handles = ( p2p_communicator.send_backward_recv_backward( input_tensor_grad, recv_next=recv_next, @@ -1904,7 +1913,7 @@ def pp_post_backward(input_tensor_grad, vp_stage=None): recv_prev = False # Communicate tensors. - (input_tensor, output_tensor_grad) = ( + input_tensor, output_tensor_grad = ( p2p_communicator.send_forward_backward_recv_forward_backward( output_tensor, input_tensor_grad, diff --git a/megatron/core/process_groups_config.py b/megatron/core/process_groups_config.py index ccb6dce0eb8..970713941ae 100644 --- a/megatron/core/process_groups_config.py +++ b/megatron/core/process_groups_config.py @@ -689,6 +689,31 @@ def setup_process_groups_for_ddp( return result +def resolve_gtp_remat_group( + pg_collection: Optional["ProcessGroupCollection"], is_expert: bool +) -> Optional[torch.distributed.ProcessGroup]: + """Resolve the gtp_remat / expt_gtp_remat group for a weight-owning module. + + Prefers the group carried by ``pg_collection``; falls back to the MPU globals when the + caller passed no collection, or one predating the gtp_remat fields. The fallback keeps + pre-pg_collection callers working — a collection that does carry the field is always + honored, including when it holds a custom (non-MPU) group. + + Args: + pg_collection: Collection supplied by the caller, or None. + is_expert: Select the expert axis (``expt_gtp_remat``) instead of the dense one. + """ + attr = 'expt_gtp_remat' if is_expert else 'gtp_remat' + # `vars()`, not hasattr: __getattr__ makes hasattr always True, so the fallback below + # would be unreachable. + if pg_collection is not None and attr in vars(pg_collection): + return getattr(pg_collection, attr) + mpu_pgs = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['gtp_remat', 'expt_gtp_remat'] + ) + return getattr(mpu_pgs, attr) + + @dataclass class MultiModuleProcessGroupCollection: """Process group collection for multi-module pipelines. diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index f6ae07dd230..73e0561fdbf 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -291,6 +291,7 @@ def __init__( is_expert=False, tp_comm_buffer_name="fc1", tp_group=self.pg_collection.tp, + pg_collection=self.pg_collection, name=(name + f".in_proj") if name is not None else None, ) # in_proj packs [z, x, B, C, dt] into one ColumnParallelLinear. Each @@ -442,6 +443,7 @@ def __init__( is_expert=False, tp_comm_buffer_name="fc2", tp_group=self.pg_collection.tp, + pg_collection=self.pg_collection, name=(name + f".out_proj") if name is not None else None, ) diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 87ba3023d2a..1bf0ea8e74a 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -20,6 +20,7 @@ from megatron.core.inference.quantization.utils import mm_mxfp8 from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.model_parallel_config import ModelParallelConfig +from megatron.core.process_groups_config import ProcessGroupCollection, resolve_gtp_remat_group from megatron.core.tensor_parallel.mappings import ( gather_from_tensor_model_parallel_region, reduce_scatter_to_sequence_parallel_region, @@ -91,6 +92,7 @@ def __init__( symmetric_ar_type: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" super().__init__( @@ -107,6 +109,8 @@ def __init__( symmetric_ar_type=symmetric_ar_type, tp_group=tp_group, name=name, + # TELinear takes the resolved group rather than the collection. + gtp_remat_group=resolve_gtp_remat_group(pg_collection, is_expert), ) def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, None]: @@ -139,6 +143,7 @@ def __init__( tp_comm_buffer_name: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" super().__init__( @@ -155,6 +160,7 @@ def __init__( tp_comm_buffer_name=tp_comm_buffer_name, tp_group=tp_group, name=name, + pg_collection=pg_collection, ) self.tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self.tp_size = dist.get_world_size(self.tp_group) @@ -268,6 +274,7 @@ def __init__( tp_comm_buffer_name: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" super().__init__( @@ -284,6 +291,7 @@ def __init__( tp_comm_buffer_name=tp_comm_buffer_name, tp_group=tp_group, name=name, + pg_collection=pg_collection, ) self.tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self.tp_size = dist.get_world_size(self.tp_group) @@ -366,6 +374,7 @@ def __init__( tp_comm_buffer_name: Optional[str] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): assert HAVE_TE, "--transformer-impl=inference_optimized requires transformer engine" super().__init__( @@ -380,6 +389,7 @@ def __init__( tp_comm_buffer_name=tp_comm_buffer_name, tp_group=tp_group, name=name, + pg_collection=pg_collection, ) self.tp_group = get_tensor_model_parallel_group_if_none(tp_group, is_expert=is_expert) self.tp_size = dist.get_world_size(self.tp_group) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index a42cbd05841..0f248bcf399 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -22,7 +22,7 @@ get_tensor_model_parallel_rank, get_tensor_model_parallel_world_size, ) -from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.process_groups_config import ProcessGroupCollection, resolve_gtp_remat_group from megatron.core.utils import ( divide, get_pg_rank, @@ -251,6 +251,7 @@ def __init__( reduce_scatter_embeddings: bool = False, config: ModelParallelConfig, tp_group: Optional[torch.distributed.ProcessGroup] = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): super(VocabParallelEmbedding, self).__init__() # Keep the input dimensions. @@ -261,7 +262,7 @@ def __init__( self.tp_group = get_tensor_model_parallel_group_if_none(self.tp_group) - (self.vocab_start_index, self.vocab_end_index) = ( + self.vocab_start_index, self.vocab_end_index = ( VocabUtility.vocab_range_from_global_vocab_size( self.num_embeddings, get_pg_rank(self.tp_group), get_pg_size(self.tp_group) ) @@ -314,9 +315,7 @@ def __init__( ) self.gtp_remat_size = 1 - gtp_remat_group = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat"] - ).gtp_remat + gtp_remat_group = resolve_gtp_remat_group(pg_collection, is_expert=False) if gtp_remat_group is not None and gtp_remat_group.size() > 1: from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp @@ -935,6 +934,7 @@ def __init__( disable_grad_reduce: bool = False, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): super(ColumnParallelLinear, self).__init__() @@ -1020,10 +1020,7 @@ def __init__( self.weight = None self.gtp_remat_size = 1 - _pg = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = _pg.expt_gtp_remat if self.is_expert else _pg.gtp_remat + gtp_remat_group = resolve_gtp_remat_group(pg_collection, self.is_expert) if gtp_remat_group is not None and gtp_remat_group.size() > 1: from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp @@ -1299,6 +1296,7 @@ def __init__( tp_comm_buffer_name: str | None = None, # Not used tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): super(RowParallelLinear, self).__init__() @@ -1385,10 +1383,7 @@ def __init__( setattr(self.weight, "allreduce", not use_expert_pgs) self.gtp_remat_size = 1 - _pg = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["gtp_remat", "expt_gtp_remat"] - ) - gtp_remat_group = _pg.expt_gtp_remat if self.is_expert else _pg.gtp_remat + gtp_remat_group = resolve_gtp_remat_group(pg_collection, self.is_expert) if gtp_remat_group is not None and gtp_remat_group.size() > 1: from megatron.core.tensor_parallel.gtp_api import wrap_module_params_gtp diff --git a/megatron/core/tensor_parallel/random.py b/megatron/core/tensor_parallel/random.py index eb726c2eaf4..a9619ea4819 100644 --- a/megatron/core/tensor_parallel/random.py +++ b/megatron/core/tensor_parallel/random.py @@ -451,6 +451,8 @@ def model_parallel_cuda_manual_seed( tp_rank: Optional[int] = None, ep_rank: Optional[int] = None, etp_rank: Optional[int] = None, + gtp_remat_rank: Optional[int] = None, + egtp_remat_rank: Optional[int] = None, force_reset_rng: bool = False, ): """Initialize model parallel cuda seed. @@ -476,6 +478,10 @@ def model_parallel_cuda_manual_seed( ep_rank = get_expert_model_parallel_rank() if etp_rank is None: etp_rank = get_expert_tensor_parallel_rank() + if gtp_remat_rank is None: + gtp_remat_rank = get_gtp_weight_remat_rank() + if egtp_remat_rank is None: + egtp_remat_rank = get_expert_gtp_weight_remat_rank() # 2718 is just for fun and any POSITIVE value will work. offset = seed + 2718 tensor_model_parallel_seed = offset + tp_rank @@ -500,11 +506,9 @@ def model_parallel_cuda_manual_seed( # must draw DIFFERENT values (everything above is identical across peers by design). The 65536 # stride keeps these disjoint from the tp/ep/etp seeds. Added only when the axis is active, so # non-GTP runs keep a byte-identical tracker set (and checkpoint rng payload). - gtp_remat_rank = get_gtp_weight_remat_rank() if get_gtp_weight_remat_world_size() > 1: gtp_remat_seed = tensor_model_parallel_seed + 65536 * (1 + gtp_remat_rank) _CUDA_RNG_STATE_TRACKER.add(_GTP_REMAT_RNG_TRACKER_NAME, gtp_remat_seed) - egtp_remat_rank = get_expert_gtp_weight_remat_rank() if get_expert_gtp_weight_remat_world_size() > 1: egtp_remat_seed = expert_parallel_seed + 32768 + 65536 * (1 + egtp_remat_rank) _CUDA_RNG_STATE_TRACKER.add(_EXPERT_GTP_REMAT_RNG_TRACKER_NAME, egtp_remat_seed) diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py index 4ce5babb9c0..682b75fb701 100644 --- a/megatron/core/transformer/attention.py +++ b/megatron/core/transformer/attention.py @@ -421,6 +421,7 @@ def __init__( is_expert=False, tp_comm_buffer_name='proj', tp_group=self.pg_collection.tp, + pg_collection=self.pg_collection, name=(name + ".linear_proj") if name is not None else None, ) @@ -1689,6 +1690,7 @@ def __init__( is_expert=False, tp_comm_buffer_name='qkv', tp_group=self.pg_collection.tp, + pg_collection=self.pg_collection, name=(name + ".linear_qkv") if name is not None else None, ) diff --git a/megatron/core/transformer/mlp.py b/megatron/core/transformer/mlp.py index ae0f8171fd4..cb3b1b0be82 100644 --- a/megatron/core/transformer/mlp.py +++ b/megatron/core/transformer/mlp.py @@ -178,6 +178,7 @@ def __init__( ffn_hidden_size: Optional[int] = None, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + pg_collection: Optional[ProcessGroupCollection] = None, ): """ Args: @@ -226,6 +227,7 @@ def __init__( is_expert=is_expert, tp_comm_buffer_name="fc1", tp_group=tp_group, + pg_collection=pg_collection, stride=fc1_stride, name=(name + ".linear_fc1") if name is not None else None, ) @@ -248,6 +250,7 @@ def __init__( is_expert=is_expert, tp_comm_buffer_name="fc2", tp_group=tp_group, + pg_collection=pg_collection, name=(name + ".linear_fc2") if name is not None else None, ) @@ -410,6 +413,7 @@ def as_mlp_submodule( config=config, submodules=submodules, tp_group=pg_collection.tp, + pg_collection=pg_collection, is_expert=is_expert, input_size=input_size, ffn_hidden_size=ffn_hidden_size, diff --git a/megatron/core/transformer/moe/shared_experts.py b/megatron/core/transformer/moe/shared_experts.py index 027d0a780ff..038a162f899 100644 --- a/megatron/core/transformer/moe/shared_experts.py +++ b/megatron/core/transformer/moe/shared_experts.py @@ -125,8 +125,13 @@ def __init__( "please set '--disable-bias-linear' instead." config.ffn_hidden_size = config.moe_shared_expert_intermediate_size - # TODO(Hepteract): pass pg_collection to MLP after refactoring MLP - super().__init__(config=config, submodules=submodules, tp_group=pg_collection.tp, name=name) + super().__init__( + config=config, + submodules=submodules, + tp_group=pg_collection.tp, + name=name, + pg_collection=pg_collection, + ) self.use_shared_expert_gate = gate if self.use_shared_expert_gate: diff --git a/megatron/training/training.py b/megatron/training/training.py index a8378a91ced..7d5b954dc61 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -116,7 +116,6 @@ get_rerun_state_machine, ) from megatron.core.resharding.refit import swap_model_weights -from megatron.core.tensor_parallel.gtp_api import HAVE_GTP from megatron.core.transformer.cuda_graphs import TECudaGraphHelper from megatron.core.transformer.experimental_attention_variant.dsa import DSAIndexerLossLoggingHelper from megatron.core.transformer.module import Float16Module diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_custom_pgs.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_custom_pgs.py new file mode 100644 index 00000000000..a649c1ac36d --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_custom_pgs.py @@ -0,0 +1,291 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""GTP_remat must follow the caller's ``pg_collection``, not the MPU globals. + +Two TransformerBlocks, same degrees (TP=1, CP=1, GTP_remat=2 over world=4), same weights, +same input: one built from ``parallel_state`` groups, one from a custom collection whose +``gtp_remat`` group is the PERMUTED pairing ([0,1],[2,3] vs [0,2],[1,3]). The forward +all-gathers every peer's shard, so both must produce identical output and gradients. + +The MPU globals stay initialized as the first topology throughout: a module that reads the +global group instead of the collection it was handed then gathers the wrong peer's shard -- +a valid-but-wrong group, which is the silent failure this test catches. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _requires_multi_gpu, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +WORLD = 4 +GTP_SIZE = 2 +HIDDEN = 256 +NUM_HEADS = 8 +FFN_HIDDEN = 512 +NUM_LAYERS = 2 +SEQ = 16 +BATCH = 1 +dtype = torch.bfloat16 + +# The two ways to split a 4-rank world into gtp_remat pairs. Whichever one the MPU picks, +# the test uses the other. Every rank creates every group in this fixed order so the NCCL +# group tags agree across ranks -- a per-rank "create only the group I belong to" idiom +# assigns mismatched tags and hangs. +_PAIRINGS = {"adjacent": [[0, 1], [2, 3]], "strided": [[0, 2], [1, 3]]} + +# Forward is exact: all-gather is pure data movement, so both blocks feed bit-identical +# operands to identical GEMMs. Gradients additionally carry the attention backward's +# nondeterminism, hence the looser BF16-scale tolerance. +FWD_TOL = dict(atol=1e-5, rtol=1e-5) +GRAD_TOL = dict(atol=2e-2, rtol=2e-2) + + +def _make_config(): + from megatron.core.transformer.transformer_config import TransformerConfig + + return TransformerConfig( + num_attention_heads=NUM_HEADS, + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + ffn_hidden_size=FFN_HIDDEN, + add_bias_linear=False, + params_dtype=dtype, + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + ) + + +def _build_block(pg_collection): + """Build a GTP-sharded TransformerBlock wired to ``pg_collection``.""" + from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec + from megatron.core.transformer.transformer_block import TransformerBlock + + block = TransformerBlock( + _make_config(), get_gpt_layer_with_transformer_engine_spec(), pg_collection=pg_collection + ).cuda() + assert any( + isinstance(p, GTPShardedParam) for p in block.parameters() + ), "GTP is not active: the block has no GTPShardedParam" + return block + + +def _pick_permuted_gtp_group(rank, mpu_ranks): + """Create both candidate pairings on every rank; return this rank's group in the other one. + + Returns the group whose membership differs from ``mpu_ranks``, so any module that reads + the global group instead of the supplied one gathers a different peer's shard. + """ + my_groups = {} # sorted pair -> this rank's group in that pairing + for pairs in _PAIRINGS.values(): + for pair in pairs: + group = dist.new_group(ranks=pair) + if rank in pair: + my_groups[tuple(sorted(pair))] = group + + permuted = [g for pair, g in my_groups.items() if list(pair) != mpu_ranks] + assert len(permuted) == 1, ( + f"rank {rank}: want exactly one pairing differing from the MPU group {mpu_ranks}, " + f"got {list(my_groups)}" + ) + return permuted[0] + + +def _canonical_full_weights(block, gtp_group): + """Gather every parameter to full (unsharded) form, then broadcast rank 0's copy world-wide. + + Returns a name -> tensor dict that is bit-identical on every rank, so both blocks can be + loaded with the same global model no matter how the shards are distributed. + """ + full_weights = {} + for name, param in block.named_parameters(): + if isinstance(param, GTPShardedParam): + shards = [torch.empty_like(param.data) for _ in range(gtp_group.size())] + dist.all_gather(shards, param.data.contiguous(), group=gtp_group) + full = torch.cat(shards, dim=0) + else: + full = param.data.clone() + dist.broadcast(full, src=0) + full_weights[name] = full + return full_weights + + +def _load_full_weights(block, full_weights, gtp_rank): + """Load the canonical weights, slicing GTP params by ``gtp_rank`` and priming main_grad.""" + for name, param in block.named_parameters(): + full = full_weights[name] + if isinstance(param, GTPShardedParam): + shard = param.shape[0] + param.data.copy_(full[gtp_rank * shard : (gtp_rank + 1) * shard]) + # GTP writes the reduce-scattered wgrad here; it must exist before backward. + param.main_grad = torch.zeros(param.shape, dtype=dtype, device='cuda') + else: + param.data.copy_(full) + + +def _full_grads(block, gtp_group): + """Full (unsharded) gradients keyed by parameter name, for cross-topology comparison.""" + grads = {} + for name, param in block.named_parameters(): + if isinstance(param, GTPShardedParam): + shards = [torch.empty_like(param.main_grad) for _ in range(gtp_group.size())] + dist.all_gather(shards, param.main_grad.contiguous(), group=gtp_group) + grads[name] = torch.cat(shards, dim=0).float().cpu() + elif param.grad is not None: + grads[name] = param.grad.detach().float().cpu() + return grads + + +def _fwd_bwd(block, x): + """Run one forward/backward; return (output, input gradient) on cpu in fp32.""" + out = block(hidden_states=x, attention_mask=None) + out.sum().backward() + return out.detach().float().cpu(), x.grad.detach().float().cpu() + + +def _worker_custom_pgs_match_mpu(rank, world_size, port): + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + # ---------------- Topology 1: groups from parallel_state ---------------- + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=GTP_SIZE + ) + model_parallel_cuda_manual_seed(42) + + mpu_pgs = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'cp', 'pp', 'gtp_remat', 'expt_gtp_remat'] + ) + mpu_gtp_group = mpu_pgs.gtp_remat + assert ( + mpu_gtp_group.size() == GTP_SIZE + ), f"GTP_remat inactive: group size {mpu_gtp_group.size()}, want {GTP_SIZE}" + + block_mpu = _build_block(mpu_pgs) + + # One canonical global model, shared by both topologies. + full_weights = _canonical_full_weights(block_mpu, mpu_gtp_group) + _load_full_weights(block_mpu, full_weights, mpu_gtp_group.rank()) + + torch.manual_seed(1234) + x = torch.randn(SEQ, BATCH, HIDDEN, dtype=dtype, device='cuda') + dist.broadcast(x, src=0) # identical input on every rank + + out_mpu, grad_in_mpu = _fwd_bwd(block_mpu, x.clone().requires_grad_(True)) + grads_mpu = _full_grads(block_mpu, mpu_gtp_group) + + del block_mpu + GTPShardedParam._chain_state = {} + + # ---------------- Topology 2: custom collection, permuted gtp ranks ---------------- + mpu_ranks = sorted(dist.get_process_group_ranks(mpu_gtp_group)) + custom_gtp_group = _pick_permuted_gtp_group(rank, mpu_ranks) + + # Only gtp_remat differs; tp/cp/pp are size-1 groups, identical in both topologies. + custom_pgs = ProcessGroupCollection( + tp=mpu_pgs.tp, + cp=mpu_pgs.cp, + pp=mpu_pgs.pp, + gtp_remat=custom_gtp_group, + expt_gtp_remat=mpu_pgs.expt_gtp_remat, + ) + # Seed from the custom topology's gtp rank rather than the global one. The weights are + # overwritten below, so this only has to be self-consistent -- it also exercises the + # explicit-rank arguments of model_parallel_cuda_manual_seed. + model_parallel_cuda_manual_seed( + 42, gtp_remat_rank=custom_gtp_group.rank(), egtp_remat_rank=0, force_reset_rng=True + ) + + block_custom = _build_block(custom_pgs) + _load_full_weights(block_custom, full_weights, custom_gtp_group.rank()) + + out_custom, grad_in_custom = _fwd_bwd(block_custom, x.clone().requires_grad_(True)) + grads_custom = _full_grads(block_custom, custom_gtp_group) + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + + # ---------------- The two topologies must agree ---------------- + torch.testing.assert_close( + out_custom, + out_mpu, + **FWD_TOL, + msg="forward output differs between MPU and custom gtp_remat groups", + ) + torch.testing.assert_close( + grad_in_custom, + grad_in_mpu, + **GRAD_TOL, + msg="input gradient differs between MPU and custom gtp_remat groups", + ) + assert set(grads_custom) == set(grads_mpu), "parameter sets differ between the two blocks" + for name in sorted(grads_mpu): + torch.testing.assert_close( + grads_custom[name], + grads_mpu[name], + **GRAD_TOL, + msg=f"weight gradient for {name} differs between MPU and custom gtp_remat groups", + ) + + +def _worker_partial_pgs_fall_back_to_mpu(rank, world_size, port): + """A collection that omits gtp_remat must fall back to the MPU group, not disable GTP. + + ``__getattr__`` returns None for unset fields, so ``hasattr`` lies: a resolver trusting it + reads None and silently builds an unsharded block. + """ + from megatron.core import parallel_state as ps + from megatron.core.process_groups_config import ProcessGroupCollection + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=GTP_SIZE + ) + model_parallel_cuda_manual_seed(42) + + partial_pgs = ProcessGroupCollection.use_mpu_process_groups(required_pgs=['tp', 'cp', 'pp']) + assert 'gtp_remat' not in vars(partial_pgs), "this collection must omit gtp_remat" + + block = _build_block(partial_pgs) + + gtp_group = ps.get_gtp_weight_remat_group() + sharded = [(n, p) for n, p in block.named_parameters() if isinstance(p, GTPShardedParam)] + assert sharded, "no parameter was sharded: the resolver did not fall back to the MPU group" + for name, param in sharded: + assert param.gtp_remat_size == gtp_group.size(), ( + f"{name} was sharded over a size-{param.gtp_remat_size} axis, " + f"want {gtp_group.size()} (the MPU gtp_remat group)" + ) + + ps.destroy_model_parallel() + ps.initialize_model_parallel() + + +class TestGTPCustomProcessGroups: + def test_custom_gtp_pg_collection_matches_mpu(self): + """A permuted-but-equivalent gtp_remat group must give identical fwd/bwd results.""" + _requires_multi_gpu(WORLD) + _run_distributed(_worker_custom_pgs_match_mpu, WORLD) + + def test_pg_collection_without_gtp_remat_falls_back_to_mpu(self): + """Omitting gtp_remat must fall back to the MPU group, not silently disable sharding.""" + _requires_multi_gpu(WORLD) + _run_distributed(_worker_partial_pgs_fall_back_to_mpu, WORLD) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py index a563bcd3f3d..9186547113e 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_grad_correctness.py @@ -111,12 +111,16 @@ def _run_one_backward(ddp_model, rank, calculate_per_token_loss=False): # overlap_grad_reduce=False. Do NOT also call start_grad_sync() — that double- # reduces, which is idempotent at full-DP size but halves at replicate size. ddp_model.finish_grad_sync() + from megatron.core import parallel_state as ps from megatron.core.distributed.finalize_model_grads import ( _allreduce_replicated_grads_over_gtp_remat_group, ) _allreduce_replicated_grads_over_gtp_remat_group( - [ddp_model], calculate_per_token_loss=calculate_per_token_loss + [ddp_model], + ps.get_gtp_weight_remat_group(check_initialized=False), + ps.get_expert_gtp_weight_remat_group(check_initialized=False), + calculate_per_token_loss=calculate_per_token_loss, ) return float(loss.item()) @@ -307,11 +311,16 @@ def _run_step_distopt(ddp_model, optim, rank): loss.backward() # Production order (finalize_model_grads): reduce across DP first, THEN the gtp_remat finalize. ddp_model.finish_grad_sync() + from megatron.core import parallel_state as ps from megatron.core.distributed.finalize_model_grads import ( _allreduce_replicated_grads_over_gtp_remat_group, ) - _allreduce_replicated_grads_over_gtp_remat_group([ddp_model]) + _allreduce_replicated_grads_over_gtp_remat_group( + [ddp_model], + ps.get_gtp_weight_remat_group(check_initialized=False), + ps.get_expert_gtp_weight_remat_group(check_initialized=False), + ) _, grad_norm, _ = optim.step() return float(grad_norm) diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py index b26d8a974ce..1dff77998c7 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_muon_dcp.py @@ -125,7 +125,6 @@ def test_gtp_muon_moe_save_load(self, tmp_path_dist_ckpt): if int(os.environ.get('WORLD_SIZE', '1')) != 4: pytest.skip("Requires world_size 4 (gtp2 x dp2)") - os.environ['MEGATRON_GTP_FORCE_ENABLE'] = '1' from megatron.core import parallel_state as ps from megatron.core.tensor_parallel import model_parallel_cuda_manual_seed from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( @@ -237,7 +236,6 @@ def test_gtp_muon_moe_native_fp8_save_load(self, tmp_path_dist_ckpt): pytest.skip("Requires world_size 4 (gtp2 x dp2)") _requires_mxfp8() - os.environ['MEGATRON_GTP_FORCE_ENABLE'] = '1' from megatron.core import parallel_state as ps from megatron.core.fp8_utils import is_float8tensor from megatron.core.tensor_parallel import model_parallel_cuda_manual_seed From 3aee84c392e3a265c497991d0d07b27bfffb2490 Mon Sep 17 00:00:00 2001 From: svcnvidia-nemo-ci Date: Wed, 5 Aug 2026 21:18:02 +0200 Subject: [PATCH 207/290] [Copy to main] Update Nemotron 3 Super GB200 release config (#6282) Signed-off-by: svcnvidia-nemo-ci Co-authored-by: Philip Petrakian --- .../model_config.yaml | 44 ++++++++++++------- .../model_config.yaml | 44 ++++++++++++------- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml index c7b9da95622..321f9d2ca9c 100644 --- a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200/model_config.yaml @@ -1,11 +1,18 @@ ENV_VARS: - NVTE_FWD_LAYERNORM_SM_MARGIN: 16 - NVTE_BWD_LAYERNORM_SM_MARGIN: 16 - TORCHINDUCTOR_WORKER_START: fork - QUANTIZATION_TYPE_DEBUG: 1 - # PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True + CUDA_DEVICE_MAX_CONNECTIONS: 32 + NCCL_GRAPH_REGISTER: 0 + NCCL_NVLS_ENABLE: 0 + PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True" + TORCH_NCCL_AVOID_RECORD_STREAMS: 1 + TORCH_NCCL_HIGH_PRIORITY: 1 NUM_OF_HYBRID_EP_RANKS_PER_NVLINK_DOMAIN: 64 + NUM_OF_TOKENS_PER_CHUNK_COMBINE_API: 128 + NVLINK_DOMAIN_SIZE: 72 USE_MNNVL: 1 + NVTE_BWD_LAYERNORM_SM_MARGIN: 20 + NVTE_FWD_LAYERNORM_SM_MARGIN: 20 + TORCHINDUCTOR_WORKER_START: fork + QUANTIZATION_TYPE_DEBUG: 1 NON_DETERMINSTIC_RESULTS: 1 TEST_TYPE: "release" MODEL_ARGS: @@ -30,12 +37,14 @@ MODEL_ARGS: --train-samples: 12207031 --cross-entropy-loss-fusion: true --cross-entropy-fusion-impl: native - --attention-backend: flash - --enable-cuda-graph: true - --cuda-graph-modules: "[mamba attn]" + --attention-backend: fused + --transformer-impl: transformer_engine + --cuda-graph-impl: transformer_engine + --cuda-graph-modules: "[attn mamba moe_router moe_preprocess]" + --cuda-graph-warmup-steps: 3 --te-rng-tracker: true --manual-gc: true - --manual-gc-interval: 10 + --manual-gc-interval: 100 --no-create-attention-mask-in-dataloader: true --num-workers: 1 --exit-interval: 23840 @@ -53,7 +62,7 @@ MODEL_ARGS: --group-query-attention: true --num-query-groups: 2 --kv-channels: 128 - --hybrid-override-pattern: MEMEMEM*EMEMEMEM*EMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEM*EMEMEMEME + --hybrid-layer-pattern: "MEMEMEM*EMEMEMEM*EMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEM*EMEMEMEME/*E/*E" --position-embedding-type: none --normalization: RMSNorm --untie-embeddings-and-output-weights: true @@ -81,6 +90,7 @@ MODEL_ARGS: --moe-router-dtype: fp32 --moe-router-load-balancing-type: seq_aux_loss --moe-aux-loss-coeff: 1e-4 + --moe-router-padding-for-quantization: true --moe-token-dispatcher-type: flex --moe-flex-dispatcher-backend: hybridep --moe-hybridep-num-sms: 32 @@ -91,18 +101,19 @@ MODEL_ARGS: # MTP args --mtp-num-layers: 2 - --mtp-hybrid-override-pattern: \"*E\" --calculate-per-token-loss: true --mtp-loss-scaling-factor: 0.3 # Mixed precision / quantization args --bf16: true - --te-precision-config-file: /mnt/artifacts/model/nemotron3_super_release_gb200/te_quant.cfg + --grad-reduce-in-bf16: true --first-last-layers-bf16: true --num-layers-at-start-in-bf16: 0 --num-layers-at-end-in-bf16: 14 - --fp4-format: e2m1 - --fp4-recipe: nvfp4 + --fp8-format: e4m3 + --fp8-recipe: mxfp8 + --fp8-param-gather: true + --reuse-grad-buf-for-mxfp8-param-ag: true # Regularization args --attention-dropout: 0.0 @@ -128,9 +139,8 @@ MODEL_ARGS: --ckpt-fully-parallel-save: true --ckpt-fully-parallel-load: true --ckpt-assume-constant-structure: true - --async-save: true - --use-persistent-ckpt-worker: true - --save-interval: 100 + --dist-ckpt-strictness: log_all + --save-interval: 200 --save-retain-interval: 2000 # Validation args diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml index 75dbca82700..430de2aafc4 100644 --- a/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_super_release_gb200_sm/model_config.yaml @@ -1,11 +1,18 @@ ENV_VARS: - NVTE_FWD_LAYERNORM_SM_MARGIN: 16 - NVTE_BWD_LAYERNORM_SM_MARGIN: 16 - TORCHINDUCTOR_WORKER_START: fork - QUANTIZATION_TYPE_DEBUG: 1 - # PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True + CUDA_DEVICE_MAX_CONNECTIONS: 32 + NCCL_GRAPH_REGISTER: 0 + NCCL_NVLS_ENABLE: 0 + PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True" + TORCH_NCCL_AVOID_RECORD_STREAMS: 1 + TORCH_NCCL_HIGH_PRIORITY: 1 NUM_OF_HYBRID_EP_RANKS_PER_NVLINK_DOMAIN: 64 + NUM_OF_TOKENS_PER_CHUNK_COMBINE_API: 128 + NVLINK_DOMAIN_SIZE: 72 USE_MNNVL: 1 + NVTE_BWD_LAYERNORM_SM_MARGIN: 20 + NVTE_FWD_LAYERNORM_SM_MARGIN: 20 + TORCHINDUCTOR_WORKER_START: fork + QUANTIZATION_TYPE_DEBUG: 1 NON_DETERMINSTIC_RESULTS: 1 TEST_TYPE: "release" MODEL_ARGS: @@ -30,12 +37,14 @@ MODEL_ARGS: --train-samples: 12207031 --cross-entropy-loss-fusion: true --cross-entropy-fusion-impl: native - --attention-backend: flash - --enable-cuda-graph: true - --cuda-graph-modules: "[mamba attn]" + --attention-backend: fused + --transformer-impl: transformer_engine + --cuda-graph-impl: transformer_engine + --cuda-graph-modules: "[attn mamba moe_router moe_preprocess]" + --cuda-graph-warmup-steps: 3 --te-rng-tracker: true --manual-gc: true - --manual-gc-interval: 10 + --manual-gc-interval: 100 --no-create-attention-mask-in-dataloader: true --num-workers: 1 --exit-interval: 4768 @@ -53,7 +62,7 @@ MODEL_ARGS: --group-query-attention: true --num-query-groups: 2 --kv-channels: 128 - --hybrid-override-pattern: MEMEMEM*EMEMEMEM*EMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEM*EMEMEMEME + --hybrid-layer-pattern: "MEMEMEM*EMEMEMEM*EMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEMEM*EMEMEMEM*EMEMEMEME/*E/*E" --position-embedding-type: none --normalization: RMSNorm --untie-embeddings-and-output-weights: true @@ -81,6 +90,7 @@ MODEL_ARGS: --moe-router-dtype: fp32 --moe-router-load-balancing-type: seq_aux_loss --moe-aux-loss-coeff: 1e-4 + --moe-router-padding-for-quantization: true --moe-token-dispatcher-type: flex --moe-flex-dispatcher-backend: hybridep --moe-hybridep-num-sms: 32 @@ -91,18 +101,19 @@ MODEL_ARGS: # MTP args --mtp-num-layers: 2 - --mtp-hybrid-override-pattern: \"*E\" --calculate-per-token-loss: true --mtp-loss-scaling-factor: 0.3 # Mixed precision / quantization args --bf16: true - --te-precision-config-file: /mnt/artifacts/model/nemotron3_super_release_gb200/te_quant.cfg + --grad-reduce-in-bf16: true --first-last-layers-bf16: true --num-layers-at-start-in-bf16: 0 --num-layers-at-end-in-bf16: 14 - --fp4-format: e2m1 - --fp4-recipe: nvfp4 + --fp8-format: e4m3 + --fp8-recipe: mxfp8 + --fp8-param-gather: true + --reuse-grad-buf-for-mxfp8-param-ag: true # Regularization args --attention-dropout: 0.0 @@ -128,9 +139,8 @@ MODEL_ARGS: --ckpt-fully-parallel-save: true --ckpt-fully-parallel-load: true --ckpt-assume-constant-structure: true - --async-save: true - --use-persistent-ckpt-worker: true - --save-interval: 100 + --dist-ckpt-strictness: log_all + --save-interval: 200 --save-retain-interval: 2000 # Validation args From 8aec64dd064c5ff62f481a7ede5e2a3fbb2da82c Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Wed, 5 Aug 2026 17:57:03 -0400 Subject: [PATCH 208/290] Revert "Add Agent Compose package and runtime interface skeleton" (#6292) Signed-off-by: Philip Petrakian --- experimental/agent_compose/README.md | 141 ++++-------------- .../agent_compose/docs/architecture.md | 77 ---------- experimental/agent_compose/docs/model.md | 44 ------ experimental/agent_compose/docs/runtime.md | 54 ------- .../experimental/agent_compose/__init__.py | 6 - .../agent_compose/model/__init__.py | 6 - .../agent_compose/primitive/__init__.py | 6 - .../agent_compose/runtime/__init__.py | 83 ----------- .../runtime/backends/__init__.py | 110 -------------- .../runtime/contracts/__init__.py | 31 ---- .../agent_compose/runtime/contracts/config.py | 60 -------- .../agent_compose/runtime/contracts/data.py | 103 ------------- .../agent_compose/runtime/contracts/handle.py | 50 ------- .../agent_compose/runtime/contracts/loss.py | 50 ------- experimental/agent_compose/skills/README.md | 29 ---- .../skills/basic/constitution.md | 37 ----- .../agent_compose/skills/basic/lint-skill.md | 42 ------ .../agent_compose/skills/model/compose.md | 35 ----- .../skills/primitive/contract.md | 41 ----- .../agent_compose/skills/runtime/validate.md | 48 ------ .../agent_compose/tests/unit/test_import.py | 56 ------- .../tests/unit/test_layering_contracts.py | 54 ------- .../tests/unit/test_runtime_interface.py | 80 ---------- .../agent_compose/tests/unit/test_skills.py | 38 ----- 24 files changed, 31 insertions(+), 1250 deletions(-) delete mode 100644 experimental/agent_compose/docs/architecture.md delete mode 100644 experimental/agent_compose/docs/model.md delete mode 100644 experimental/agent_compose/docs/runtime.md delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/__init__.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py delete mode 100644 experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py delete mode 100644 experimental/agent_compose/skills/README.md delete mode 100644 experimental/agent_compose/skills/basic/constitution.md delete mode 100644 experimental/agent_compose/skills/basic/lint-skill.md delete mode 100644 experimental/agent_compose/skills/model/compose.md delete mode 100644 experimental/agent_compose/skills/primitive/contract.md delete mode 100644 experimental/agent_compose/skills/runtime/validate.md delete mode 100644 experimental/agent_compose/tests/unit/test_import.py delete mode 100644 experimental/agent_compose/tests/unit/test_layering_contracts.py delete mode 100644 experimental/agent_compose/tests/unit/test_runtime_interface.py delete mode 100644 experimental/agent_compose/tests/unit/test_skills.py diff --git a/experimental/agent_compose/README.md b/experimental/agent_compose/README.md index 0300687e913..ca2c8e43e38 100644 --- a/experimental/agent_compose/README.md +++ b/experimental/agent_compose/README.md @@ -1,124 +1,45 @@ # Agent Compose (experimental) -Agent Compose is an experimental incubation surface for incrementally reviewed -Megatron capabilities. It makes Megatron-LM development agentic-native by -combining Megatron Core primitives with coding agents, rather than maintaining -a fork or introducing a standalone training stack. +Agent Compose is an experimental effort to make Megatron-LM development +agentic-native: composing Megatron Core primitives with coding agents, rather +than introducing a new standalone product or training stack. -`experimental/agent_compose` is the project and review location. The Python -package uses the public namespace `megatron.experimental.agent_compose`; -`experimental.agent_compose` is not an import path. +This directory is a placeholder that establishes the location and naming for +the upstreamed work. Content will land here incrementally as a series of small, +reviewable PRs. -## Main And Dev +## Preview -The complete work-in-progress implementation remains in `experimental/lite` on -the `dev` branch, while `experimental/agent_compose` on `main` is its -independently reviewed upstream incubation surface. Code is promoted from the -development preview one vertically complete and independently validated slice -at a time. +The full work-in-progress implementation lives on the `dev` branch under +`experimental/lite/`: -| Surface | `main` | `dev` | -| --- | --- | --- | -| Project tree | `experimental/agent_compose` | Development preview | -| Role | Reviewed upstream subset | Work-in-progress superset | -| Python namespace | `megatron.experimental.agent_compose` | Preview-local | +- https://github.com/NVIDIA/Megatron-LM/tree/dev/experimental/lite -The upstream package has no runtime dependency on the preview tree. Do not add -both source roots to the same `PYTHONPATH`; select the tree from the branch being -tested. +The preview currently includes: -## Incubation - -Incomplete prototypes remain on `dev`. Changes promoted to `main` must be -functionally complete and validated for their declared scope. - -Successful, reusable capabilities should graduate to their long-term owners: - -- reusable primitives and backend-neutral interfaces to Megatron Core; -- training orchestration to Megatron Bridge or Automodel; -- integration-specific behavior to the owning integration project. - -Agent Compose first switches to the graduated implementation and validates -parity. Duplicate incubating code is removed only after that transition, so the -Agent Compose path remains coherent and runnable. - -## Architecture - -The initial package establishes three layers: - -- `primitive`: replaceable lower-level components built from Megatron Core. -- `model`: model declarations and composition from validated primitives. -- `runtime`: lifecycle and training orchestration through model protocols. - -Dependencies flow from runtime to model to primitive. Model and primitive code -may use an explicitly stable runtime contract, but they must not import runtime -backends. Primitive code must remain model-agnostic. - -```text -experimental/agent_compose/ - README.md - docs/ - architecture.md - model.md - megatron/ - experimental/ - agent_compose/ - primitive/ - model/ - runtime/ - skills/ - basic/ - primitive/ - model/ - runtime/ - tests/ - unit/ -``` - -For local source-tree use: - -```bash -export PYTHONPATH=/path/to/Megatron-LM/experimental/agent_compose:$PYTHONPATH -``` - -The skeleton exposes the initial runtime interface and shared runtime contracts, -but contains no built-in runtime backend, model, or primitive implementation. -Those implementations will be added in separate reviewable PRs. - -```python -from megatron.experimental.agent_compose.runtime import Runtime, RuntimeConfig, create_runtime, register_runtime -``` - -Backends subclass `Runtime` and register a module-level factory before -`create_runtime` is called. The skeleton intentionally registers no built-in -backend. - -## Documentation - -- [Three-layer architecture](docs/architecture.md) -- [Runtime interface](docs/runtime.md) -- [Model layer and protocol](docs/model.md) - -## Skills - -`skills/` contains agent-agnostic operational contracts. The initial skills set -the global constraints and the minimum contract for each architecture layer. -Each primitive PR should add or update the corresponding leaf skill together -with its reference and validation path. +- A lightweight runtime API built from small composable primitives. +- Native model implementations with explicit model/runtime protocols. +- Hugging Face safetensors load/export helpers. +- Validation recipes and benchmark examples against Megatron-Core reference + paths (bitwise loss/grad-norm parity on the distributed-optimizer path). +- Skills playbooks that let coding agents extend models and primitives in a + reviewable way. ## Principles -- **Compose, don't fork.** Reuse Megatron Core wherever appropriate. Document - why a separate implementation is necessary when reuse is not possible. -- **Reviewable by construction.** Keep runtime, model, and primitive contracts - small enough to review independently. -- **Reference before implementation.** Every implementation needs a checkable - Megatron, Hugging Face, Torch, or first-principles reference. -- **Core performance.** Validate accepted code against Megatron Core for both - correctness and speed where applicable. +- **Compose, don't fork.** Primitives reuse and build from existing Megatron + Core modules wherever appropriate. When a primitive cannot reuse an existing + module and needs a separate implementation, the reason is documented in the + docstring, making gaps explicit and providing input for future Megatron Core + improvements. +- **Reviewable by construction.** Runtime, model, and primitive code are split + into small contracts so agents and humans can make targeted changes without + touching unrelated Megatron subsystems. +- **Core performance.** Changes are validated against Megatron-Core reference + paths for both correctness and speed. ## Status -The package and skill boundaries are established here. Implementations will -land incrementally; use the `dev` preview for surfaces not yet present on -`main`. +Upstreaming is being scoped: the current work is being evaluated for splitting +into small PRs, after which a timeline will be shared. Until then, please use +the preview branch above. diff --git a/experimental/agent_compose/docs/architecture.md b/experimental/agent_compose/docs/architecture.md deleted file mode 100644 index fd9685dd2ac..00000000000 --- a/experimental/agent_compose/docs/architecture.md +++ /dev/null @@ -1,77 +0,0 @@ -# Three-Layer Architecture - -Agent Compose provides three reviewable layers under the public -`megatron.experimental.agent_compose` namespace. - -## Layers - -### Primitive - -`megatron.experimental.agent_compose.primitive` owns reusable lower-level -components: parallel operations and state, modules, checkpoint conversion, -optimizer integration, and focused math or kernel shims. A primitive must be -independently selectable and validated. It may build on Megatron Core, but it -must not know model family names or runtime backend implementations. - -### Model - -`megatron.experimental.agent_compose.model` owns model-family configuration and -the protocol that composes validated primitives into model chunks. It also owns -model-specific checkpoint mappings and forward adaptation. It does not own the -training loop or distributed runtime lifecycle. - -### Runtime - -`megatron.experimental.agent_compose.runtime` owns the backend-neutral -lifecycle: model construction, mode changes, forward/backward microbatch -orchestration, checkpoint dispatch, optimizer and scheduler steps, weight -export, and optional device offload. Concrete backends implement the public -`Runtime` interface. - -## Dependency Direction - -```text -runtime orchestration -> model protocol -> primitive -> Megatron Core - | | - +---- runtime contracts <----+ -``` - -`runtime.contracts` is the shared boundary surface, not a runtime backend. -Model and primitive code may import these stable data types. They must not -import `runtime.backends` or backend implementation modules. - -The static layering test enforces import direction: - -- primitive does not import model; -- primitive and model do not import runtime implementation code; -- reviewed code never imports development-preview source at runtime. - -The runtime skill and human review additionally require runtime code to remain -model-family agnostic; that semantic rule cannot be fully expressed as an -import-prefix check before model families exist in this tree. - -## Composition Flow - -1. A runtime resolves a model protocol without importing model-family details - into the runtime layer. -2. The model protocol selects validated primitives and constructs model chunks. -3. The protocol returns backend-consumable model state through shared contracts. -4. The runtime drives training without reaching into model internals. - -## Incubation Lifecycle - -1. Prototype and iterate on `dev`. -2. Promote a vertically complete, referenced, and validated slice into Agent - Compose. -3. Validate correctness, composition, end-to-end behavior, and performance for - the declared scope. -4. Select the long-term owner: Megatron Core for reusable primitives and - backend-neutral interfaces, Megatron Bridge or Automodel for training - orchestration, or the owning project for integration-specific behavior. -5. Switch Agent Compose to consume the graduated implementation and demonstrate - parity. -6. Remove the duplicate incubating implementation only after the Agent Compose - path is complete and runnable with the graduated capability. - -The current skeleton exposes the runtime interface and shared contracts. Model, -primitive, and backend implementations will land in separate PRs. diff --git a/experimental/agent_compose/docs/model.md b/experimental/agent_compose/docs/model.md deleted file mode 100644 index 390f4940916..00000000000 --- a/experimental/agent_compose/docs/model.md +++ /dev/null @@ -1,44 +0,0 @@ -# Model Layer - -Model code lives under `megatron.experimental.agent_compose.model`. The layer -turns a model-family declaration into backend-consumable model state by -composing validated primitives; it is not a second runtime. - -## Responsibilities - -The model layer owns: - -- typed architecture configuration; -- construction of model chunks from primitives; -- model-specific forward input/output adaptation; -- recompute and offload placement choices; -- Hugging Face load and export mappings; -- model-specific optimizer wiring when it cannot be expressed generically. - -It does not own distributed initialization, the training-step lifecycle, -checkpoint scheduling, or runtime backend selection. - -## Protocol Direction - -The development preview currently uses module-level model protocols. A model -protocol provides these required operations: - -```text -ImplConfig -build_model_config(source, **overrides) -build_model(model_cfg, *, impl_cfg) -``` - -Optional operations include Hugging Face load/export helpers and vocabulary -metadata. This document records the intended boundary; no concrete model or -registry is included in the skeleton PR. The first model PR should upstream the -smallest protocol surface justified by its selected primitives rather than -freezing every preview-only hook here. - -## Rules For Adding A Model - -1. Declare required features before choosing primitives. -2. Use only primitives with a checkable reference and validation path. -3. Keep model-family imports out of runtime code. -4. Keep heavyweight optional imports inside protocol operations where possible. -5. Add a composition test and an end-to-end runtime validation before delivery. diff --git a/experimental/agent_compose/docs/runtime.md b/experimental/agent_compose/docs/runtime.md deleted file mode 100644 index b50ac76b8cc..00000000000 --- a/experimental/agent_compose/docs/runtime.md +++ /dev/null @@ -1,54 +0,0 @@ -# Runtime Interface - -The public runtime entrypoint is -`megatron.experimental.agent_compose.runtime`. It defines a backend-neutral -interface so training applications do not depend on a concrete backend. - -## API Tiers - -Every backend implements the pretraining tier: - -- `build_model` -- `save_checkpoint` and `load_checkpoint` -- `train_mode` and `eval_mode` -- `forward_backward` -- `zero_grad` -- `optimizer_step` -- `lr_scheduler_step` - -Implementing `export_weights` adds the `rl_ready` tier. Implementing both -`export_weights` and `to` adds the `rl_best` tier, including model and optimizer -offload between training and rollout phases. `Runtime.tier` reports the highest -implemented tier. - -## Shared Contracts - -The runtime package exposes: - -- `RuntimeConfig`, `ParallelConfig`, and `OptimizerConfig`; -- `Batch`, `PackedBatch`, and legacy `TrainBatch` inputs; -- `ModelOutputs` and `ForwardResult` outputs; -- the opaque `ModelHandle` returned by `build_model`; -- `LossContext` for per-microbatch output and loss policy. - -Imports are lazy where Torch-backed data contracts are involved. Importing -`megatron.experimental.agent_compose.runtime` alone does not load Torch. - -## Backend Registration - -A backend module provides `create(hf_path, backend_cfg)` and registers its -dotted module path: - -```python -from megatron.experimental.agent_compose.runtime import ( - RuntimeConfig, - create_runtime, - register_runtime, -) - -register_runtime("my_backend", "my_package.runtime") -runtime = create_runtime(RuntimeConfig(backend="my_backend")) -``` - -No built-in backend is registered in the skeleton. Each backend will be added -with its own reference, implementation skill, and lifecycle validation. diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/__init__.py deleted file mode 100644 index 9c33c07f264..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Agent Compose components reviewed through Agent Compose.""" - -from __future__ import annotations - -__all__: list[str] = [] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py deleted file mode 100644 index f6c60b7209a..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/model/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Agent Compose model composition layer.""" - -from __future__ import annotations - -__all__: list[str] = [] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py deleted file mode 100644 index 47d000d627d..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/primitive/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Composable Agent Compose primitives.""" - -from __future__ import annotations - -__all__: list[str] = [] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py deleted file mode 100644 index e0246c8c9d6..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/__init__.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Public runtime interface for Agent Compose.""" - -from __future__ import annotations - -import importlib -from typing import TYPE_CHECKING - -from megatron.experimental.agent_compose.runtime.contracts.config import RuntimeConfig - -if TYPE_CHECKING: - from megatron.experimental.agent_compose.runtime.backends import Runtime - from megatron.experimental.agent_compose.runtime.contracts.config import ( - OptimizerConfig, - ParallelConfig, - ) - from megatron.experimental.agent_compose.runtime.contracts.data import ( - Batch, - ForwardResult, - ModelOutputs, - PackedBatch, - TrainBatch, - ) - from megatron.experimental.agent_compose.runtime.contracts.handle import ModelHandle - from megatron.experimental.agent_compose.runtime.contracts.loss import LossContext - -_RUNTIME_REGISTRY: dict[str, str] = {} - - -def register_runtime(name: str, module_path: str) -> None: - """Register a module that provides ``create(hf_path, backend_cfg)``.""" - if not name or not module_path: - raise ValueError("runtime name and module path must be non-empty") - _RUNTIME_REGISTRY[name] = module_path - - -def create_runtime(cfg: RuntimeConfig) -> Runtime: - """Create a registered runtime backend for ``cfg``.""" - if cfg.backend not in _RUNTIME_REGISTRY: - raise ValueError( - f"No runtime backend registered for {cfg.backend!r}. " - f"Available: {sorted(_RUNTIME_REGISTRY)}" - ) - module = importlib.import_module(_RUNTIME_REGISTRY[cfg.backend]) - return module.create(cfg.hf_path, cfg.backend_cfg) - - -def __getattr__(name: str): - lazy = { - "Batch": "megatron.experimental.agent_compose.runtime.contracts.data", - "ForwardResult": "megatron.experimental.agent_compose.runtime.contracts.data", - "LossContext": "megatron.experimental.agent_compose.runtime.contracts.loss", - "ModelHandle": "megatron.experimental.agent_compose.runtime.contracts.handle", - "ModelOutputs": "megatron.experimental.agent_compose.runtime.contracts.data", - "OptimizerConfig": "megatron.experimental.agent_compose.runtime.contracts.config", - "PackedBatch": "megatron.experimental.agent_compose.runtime.contracts.data", - "ParallelConfig": "megatron.experimental.agent_compose.runtime.contracts.config", - "Runtime": "megatron.experimental.agent_compose.runtime.backends", - "TrainBatch": "megatron.experimental.agent_compose.runtime.contracts.data", - } - if name in lazy: - module = importlib.import_module(lazy[name]) - value = getattr(module, name) - globals()[name] = value - return value - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -__all__ = [ - "Batch", - "ForwardResult", - "LossContext", - "ModelHandle", - "ModelOutputs", - "OptimizerConfig", - "PackedBatch", - "ParallelConfig", - "Runtime", - "RuntimeConfig", - "TrainBatch", - "create_runtime", - "register_runtime", -] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py deleted file mode 100644 index abb027ad973..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/backends/__init__.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Runtime interface implemented by Agent Compose backends.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable, Iterator -from typing import TYPE_CHECKING, Any, Literal - -if TYPE_CHECKING: - import torch - - from megatron.experimental.agent_compose.runtime.contracts.data import ForwardResult - from megatron.experimental.agent_compose.runtime.contracts.handle import ModelHandle - - -class Runtime(ABC): - """Backend-neutral lifecycle and training interface. - - The required methods form the pretraining tier. Implementing - :meth:`export_weights` adds the RL-ready tier; implementing both - :meth:`export_weights` and :meth:`to` adds the RL-best tier. - """ - - @abstractmethod - def build_model( - self, hf_path: str | None = None, cfg: Any = None, **kwargs - ) -> ModelHandle: - """Build model state and return an opaque handle.""" - ... - - @abstractmethod - def save_checkpoint(self, handle: ModelHandle, path: str, **kwargs) -> None: ... - - @abstractmethod - def load_checkpoint(self, handle: ModelHandle, path: str, **kwargs) -> int: ... - - @abstractmethod - def train_mode(self, handle: ModelHandle) -> Any: ... - - @abstractmethod - def eval_mode(self, handle: ModelHandle) -> Any: ... - - @abstractmethod - def forward_backward( - self, - handle: ModelHandle, - data: Any, - loss_fn: Callable | None, - *, - num_microbatches: int = 1, - forward_only: bool = False, - router_replay: Any = None, - ) -> ForwardResult: - """Run a logical forward/backward step over one or more microbatches.""" - ... - - @abstractmethod - def zero_grad(self, handle: ModelHandle) -> None: ... - - @abstractmethod - def optimizer_step(self, handle: ModelHandle) -> tuple[bool, float, int | None]: - """Return ``(update_successful, grad_norm, num_zeros_in_grad)``.""" - ... - - @abstractmethod - def lr_scheduler_step(self, handle: ModelHandle) -> float | list[float]: ... - - def is_mp_src_rank_with_outputs(self, handle: ModelHandle) -> bool: - """Return whether this rank owns complete model outputs.""" - return True - - def export_weights( - self, handle: ModelHandle, **kwargs - ) -> Iterator[tuple[str, torch.Tensor]]: - """Iterate over inference-compatible ``(name, tensor)`` pairs.""" - raise NotImplementedError( - f"{type(self).__name__} does not implement export_weights. " - "Implement it to unlock the RL Ready tier." - ) - - def to( - self, - handle: ModelHandle, - device: str, - *, - model: bool = True, - optimizer: bool = True, - grad: bool = True, - ) -> None: - """Move selected model state between devices.""" - raise NotImplementedError( - f"{type(self).__name__} does not implement to(). " - "Implement it to unlock the RL Best tier." - ) - - @property - def tier(self) -> Literal["pretrain", "rl_ready", "rl_best"]: - """Report the highest runtime API tier implemented by this backend.""" - cls = type(self) - has_export = cls.export_weights is not Runtime.export_weights - has_to = cls.to is not Runtime.to - if has_export and has_to: - return "rl_best" - if has_export: - return "rl_ready" - return "pretrain" - - -__all__ = ["Runtime"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py deleted file mode 100644 index 11e6d4d47c5..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Lazy public exports for shared runtime contracts.""" - -from __future__ import annotations - -import importlib - -_EXPORTS = { - "Batch": "megatron.experimental.agent_compose.runtime.contracts.data", - "ForwardResult": "megatron.experimental.agent_compose.runtime.contracts.data", - "LossContext": "megatron.experimental.agent_compose.runtime.contracts.loss", - "ModelHandle": "megatron.experimental.agent_compose.runtime.contracts.handle", - "ModelOutputs": "megatron.experimental.agent_compose.runtime.contracts.data", - "OptimizerConfig": "megatron.experimental.agent_compose.runtime.contracts.config", - "PackedBatch": "megatron.experimental.agent_compose.runtime.contracts.data", - "ParallelConfig": "megatron.experimental.agent_compose.runtime.contracts.config", - "RuntimeConfig": "megatron.experimental.agent_compose.runtime.contracts.config", - "TrainBatch": "megatron.experimental.agent_compose.runtime.contracts.data", -} - - -def __getattr__(name: str): - if name not in _EXPORTS: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - module = importlib.import_module(_EXPORTS[name]) - value = getattr(module, name) - globals()[name] = value - return value - - -__all__ = list(_EXPORTS) diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py deleted file mode 100644 index a379133d99e..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/config.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Backend-neutral runtime configuration contracts.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class ParallelConfig: - """Parallel dimensions shared by runtime backends.""" - - tp: int = 1 - etp: int | None = None - ep: int = 1 - pp: int = 1 - vpp: int = 1 - cp: int = 1 - pp_layout: str | list | None = None - - -@dataclass -class OptimizerConfig: - """Optimizer and learning-rate scheduler settings.""" - - optimizer: str = "adam" - lr: float = 1e-3 - min_lr: float = 0.0 - clip_grad: float = 1.0 - weight_decay: float = 0.01 - lr_warmup_steps_ratio: float = 0.0 - total_training_steps: int = -1 - lr_warmup_steps: int = -1 - lr_warmup_init: float = 0.0 - lr_decay_steps: int | None = None - lr_decay_style: str = "linear" - weight_decay_incr_style: str = "constant" - lr_wsd_decay_style: str = "exponential" - lr_wsd_decay_steps: int | None = None - use_checkpoint_opt_param_scheduler: bool = False - - adam_beta1: float | None = None - adam_beta2: float | None = None - adam_eps: float | None = None - offload_fraction: float | None = None - use_precision_aware_optimizer: bool | None = None - decoupled_weight_decay: bool | None = None - - -@dataclass -class RuntimeConfig: - """Select a runtime backend and provide its configuration.""" - - backend: str = "agent_compose" - hf_path: str = "" - backend_cfg: Any = field(default_factory=dict) - - -__all__ = ["OptimizerConfig", "ParallelConfig", "RuntimeConfig"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py deleted file mode 100644 index 536b02d4615..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/data.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Input and output contracts for ``Runtime.forward_backward``.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import torch - - -class Batch: - """Base contract for a model-agnostic runtime batch.""" - - def __len__(self) -> int: - """Return the number of sequences in the batch.""" - raise NotImplementedError - - def sizes(self) -> torch.Tensor: - """Return per-sequence token counts.""" - raise NotImplementedError - - -@dataclass(slots=True) -class PackedBatch(Batch): - """Variable-length sequences packed without padding.""" - - input_ids: torch.Tensor - labels: torch.Tensor - seq_lens: torch.Tensor - loss_mask: torch.Tensor | None = None - position_ids: torch.Tensor | None = None - routed_experts: torch.Tensor | None = None - extras: dict[str, Any] = field(default_factory=dict) - - def __len__(self) -> int: - return len(self.seq_lens) - - def sizes(self) -> torch.Tensor: - return self.seq_lens - - @property - def cu_seqlens(self) -> torch.Tensor: - """Return cumulative sequence lengths in int32.""" - return torch.cat( - [ - torch.zeros(1, dtype=torch.int32, device=self.seq_lens.device), - self.seq_lens.cumsum(0).to(torch.int32), - ] - ) - - @property - def total_tokens(self) -> int: - return int(self.seq_lens.sum()) - - def make_position_ids(self) -> torch.Tensor: - """Return explicit or generated per-token position IDs.""" - if self.position_ids is not None: - return self.position_ids - return torch.cat( - [ - torch.arange(length, device=self.seq_lens.device) - for length in self.seq_lens.tolist() - ] - ) - - -@dataclass(slots=True) -class TrainBatch: - """Legacy fixed-shape batch contract.""" - - input_ids: torch.Tensor - labels: torch.Tensor - loss_mask: torch.Tensor | None = None - position_ids: torch.Tensor | None = None - routed_experts: torch.Tensor | None = None - cp_size: int | None = None - extras: dict[str, Any] = field(default_factory=dict) - - -@dataclass(slots=True) -class ModelOutputs: - """Model outputs understood by runtime integrations.""" - - loss: torch.Tensor | None = None - vocab_parallel_logits: torch.Tensor | None = None - log_probs: torch.Tensor | None = None - hidden_states: torch.Tensor | None = None - values: torch.Tensor | None = None - mtp_logits: torch.Tensor | None = None - mtp_loss: torch.Tensor | None = None - routed_experts: torch.Tensor | None = None - - -@dataclass(slots=True) -class ForwardResult: - """Result of one logical runtime forward/backward call.""" - - model_output: ModelOutputs = field(default_factory=ModelOutputs) - metrics: dict[str, Any] = field(default_factory=dict) - - -__all__ = ["Batch", "ForwardResult", "ModelOutputs", "PackedBatch", "TrainBatch"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py deleted file mode 100644 index b7f6ab7d4a3..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/handle.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Opaque model handle exchanged through the runtime interface.""" - -from __future__ import annotations - -from typing import Any - - -class ModelHandle: - """Hold backend state while exposing only stable distributed metadata.""" - - def __init__( - self, - *, - model: Any, - optimizer: Any = None, - lr_scheduler: Any = None, - parallel_state: Any = None, - config: Any = None, - _extras: dict[str, Any] | None = None, - ): - self._model = model - self._optimizer = optimizer - self._lr_scheduler = lr_scheduler - self._parallel_state = parallel_state - self._config = config - self._extras = _extras or {} - - @property - def dp_rank(self) -> int: - return getattr(self._parallel_state, "dp_rank", 0) - - @property - def dp_size(self) -> int: - return getattr(self._parallel_state, "dp_size", 1) - - @property - def dp_group(self): - return getattr(self._parallel_state, "dp_group", None) - - @property - def cp_range(self) -> tuple[int, int]: - return self._extras.get("cp_range", (1, 1)) - - @property - def config(self) -> Any: - return self._config - - -__all__ = ["ModelHandle"] diff --git a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py b/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py deleted file mode 100644 index 59d37dc2be8..00000000000 --- a/experimental/agent_compose/megatron/experimental/agent_compose/runtime/contracts/loss.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Per-microbatch loss and output policy.""" - -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import dataclass -from typing import Any - - -@dataclass(frozen=True, slots=True) -class LossContext: - temperature: float = 1.0 - calculate_entropy: bool = False - return_log_probs: bool = True - loss_scale: float = 1.0 - source_batch: Any | None = None - - -_CURRENT_LOSS_CONTEXT: ContextVar[LossContext | None] = ContextVar( - "megatron_agent_compose_loss_context", default=None -) - - -def get_loss_context() -> LossContext | None: - return _CURRENT_LOSS_CONTEXT.get() - - -@contextmanager -def use_loss_context(loss_context: LossContext | None) -> Iterator[None]: - token = _CURRENT_LOSS_CONTEXT.set(loss_context) - try: - yield - finally: - _CURRENT_LOSS_CONTEXT.reset(token) - - -def split_loss_context(item): - if ( - isinstance(item, tuple) - and len(item) == 2 - and (item[1] is None or isinstance(item[1], LossContext)) - ): - return item - return item, None - - -__all__ = ["LossContext", "get_loss_context", "split_loss_context", "use_loss_context"] diff --git a/experimental/agent_compose/skills/README.md b/experimental/agent_compose/skills/README.md deleted file mode 100644 index 80724b6fc80..00000000000 --- a/experimental/agent_compose/skills/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Compose Skills - -This directory defines agent-agnostic operational contracts for upstreaming -capabilities through Agent Compose. Skills describe how to make and validate a -change; they do not replace executable tests. - -## Format - -Each skill is one Markdown file with three parts: - -1. A short human-facing title. -2. A schema between `AGENT_COMPOSE_SKILL_SCHEMA_BEGIN` and - `AGENT_COMPOSE_SKILL_SCHEMA_END`. -3. A finite Python-like pseudocode body with a declared exit. - -Schema names map to paths by replacing underscores with hyphens and dots with -directories. For example, `runtime.validate` maps to -`runtime/validate.md`. - -## Initial Registry - -- `basic.constitution`: global design and validation constraints. -- `basic.lint_skill`: structural validation for skills. -- `primitive.contract`: required contract for a primitive. -- `model.compose`: compose a model only from contracted primitives. -- `runtime.validate`: validate the runtime lifecycle end to end. - -Load this file, then exactly one leaf skill for the current work type and every -skill named by that leaf's `imports`. diff --git a/experimental/agent_compose/skills/basic/constitution.md b/experimental/agent_compose/skills/basic/constitution.md deleted file mode 100644 index 5d57b92fc20..00000000000 --- a/experimental/agent_compose/skills/basic/constitution.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Compose Constitution - -Global constraints for work upstreamed through Agent Compose. - - -```python -schema = Skill( - "basic.constitution", kind="constitution", purpose="set global Agent Compose constraints", - imports=[], calls=[], - inputs=["task", "layer", "reference"], - outputs=["constraints", "validation", "stop"], exits=["done", "blocked", "out_of_scope"], -) -``` - - -```python -def constitution(task, layer, reference): - if layer not in ["primitive", "model", "runtime"]: - return out_of_scope("unknown Agent Compose layer") - if reference is None: - return blocked("no checkable validation reference") - - constraints = [ - occam_razor("choose the smallest correct and reviewable design"), - modularity("keep primitives replaceable unless explicitly fused"), - layering("runtime -> model -> primitive -> Megatron Core"), - isolation("do not import the dev preview at runtime"), - ] - validation = [ - reference_order(["Megatron", "HuggingFace", "Torch", "first principles"]), - require_bitwise_when_possible(reference), - require_layer_test(layer), - require_end_to_end_before_delivery(task), - ] - stop = ["missing reference", "missing validation path", "layer boundary violation"] - return done(constraints=constraints, validation=validation, stop=stop) -``` diff --git a/experimental/agent_compose/skills/basic/lint-skill.md b/experimental/agent_compose/skills/basic/lint-skill.md deleted file mode 100644 index 92a4c2ca903..00000000000 --- a/experimental/agent_compose/skills/basic/lint-skill.md +++ /dev/null @@ -1,42 +0,0 @@ -# Skill Lint - -Validate an Agent Compose skill before review. - - -```python -schema = Skill( - "basic.lint_skill", kind="state_machine", purpose="validate skill structure", - imports=[], calls=[], - inputs=["skill_file", "registry", "budget"], - outputs=["lint", "risks"], exits=["done", "blocked", "out_of_scope"], -) -``` - - -```python -def lint_skill(skill_file, registry, budget): - root = "experimental/agent_compose/skills/" - if not skill_file.path.startswith(root): - return out_of_scope("not an Agent Compose skill") - - schema = extract_schema_block(skill_file) - if schema is None: - return blocked("missing schema markers") - spec = parse_skill_schema(schema) - expected_path = root + module_name_to_path(spec.name) - if skill_file.path != expected_path: - return blocked("schema name does not match file path") - - checks = [ - require_python_like_body(skill_file), - require_top_level_function(skill_file, spec.name.split(".")[-1], spec.inputs), - require_declared_exits(skill_file, spec.exits), - require_bounded_loops(skill_file, max_steps=budget.max_steps), - require_resolved_imports(spec.imports, registry), - require_resolved_calls(spec.calls, registry), - require_body_calls_declared(skill_file.body, spec.calls), - ] - if any(check.fail for check in checks): - return blocked("skill lint failed", lint=checks) - return done(lint=checks, risks=["structural lint does not replace executable tests"]) -``` diff --git a/experimental/agent_compose/skills/model/compose.md b/experimental/agent_compose/skills/model/compose.md deleted file mode 100644 index a0b76ba930e..00000000000 --- a/experimental/agent_compose/skills/model/compose.md +++ /dev/null @@ -1,35 +0,0 @@ -# Model Compose - -Compose a model only from primitives with explicit review contracts. - - -```python -schema = Skill( - "model.compose", kind="state_machine", purpose="compose a model from validated primitives", - imports=["basic.constitution", "primitive.contract"], - calls=["basic.constitution", "primitive.contract"], - inputs=["task", "model_spec", "primitives", "reference", "budget"], - outputs=["model", "evidence", "risks"], exits=["done", "blocked", "out_of_scope"], -) -``` - - -```python -def compose(task, model_spec, primitives, reference, budget): - base = basic.constitution(task, layer="model", reference=reference) - if not base.done: - return blocked("model constitution failed", evidence=base) - - evidence = [] - selected = primitives[:budget.max_primitives] - for candidate in selected: - checked = primitive.contract(task, primitive=candidate, reference=candidate.reference) - evidence.append(checked) - if not checked.done: - return blocked("primitive contract failed before composition", evidence=evidence) - - if not covers_required_features(selected, model_spec.required_features): - return blocked("selected primitives do not cover the model spec", evidence=evidence) - model = compose_layers(model_spec, selected, boundary=["model", "primitive"]) - return done(model=model, evidence=evidence, risks=["composition can hide boundary bugs"]) -``` diff --git a/experimental/agent_compose/skills/primitive/contract.md b/experimental/agent_compose/skills/primitive/contract.md deleted file mode 100644 index bf2ba8e0852..00000000000 --- a/experimental/agent_compose/skills/primitive/contract.md +++ /dev/null @@ -1,41 +0,0 @@ -# Primitive Contract - -Define the minimum review contract for an upstreamed primitive. - - -```python -schema = Skill( - "primitive.contract", kind="constitution", purpose="define primitive review outputs", - imports=["basic.constitution"], calls=["basic.constitution"], - inputs=["task", "primitive", "reference"], - outputs=["principle", "implementation", "usage", "validation", "risks"], - exits=["done", "blocked", "out_of_scope"], -) -``` - - -```python -def contract(task, primitive, reference): - base = basic.constitution(task, layer="primitive", reference=reference) - if not base.done: - return blocked("primitive constitution failed", evidence=base) - - principle = require(["semantics", "invariants", "shape_dtype_rank_rules"]) - implementation = require([ - "owned_modules", "public_api", "state_and_config", "failure_modes", - ]) - usage = require([ - "minimal_example", "selection_rules", "valid_combinations", "unsupported_combinations", - ]) - validation = require([ - "single_gpu_or_single_node_proxy", "reference_comparison", "composition_test", - ]) - risks = ["silent mismatch", "dtype drift", "hidden coupling", "wrong selection rule"] - return done( - principle=principle, - implementation=implementation, - usage=usage, - validation=validation, - risks=risks, - ) -``` diff --git a/experimental/agent_compose/skills/runtime/validate.md b/experimental/agent_compose/skills/runtime/validate.md deleted file mode 100644 index 011284b059a..00000000000 --- a/experimental/agent_compose/skills/runtime/validate.md +++ /dev/null @@ -1,48 +0,0 @@ -# Runtime Validate - -Validate the runtime lifecycle through a composed model. - - -```python -schema = Skill( - "runtime.validate", kind="state_machine", purpose="validate the runtime lifecycle end to end", - imports=["basic.constitution", "model.compose"], - calls=["basic.constitution", "model.compose"], - inputs=["task", "runtime_config", "model_spec", "primitives", "reference", "budget"], - outputs=["runtime", "evidence", "risks"], exits=["done", "blocked", "out_of_scope"], -) -``` - - -```python -def validate(task, runtime_config, model_spec, primitives, reference, budget): - base = basic.constitution(task, layer="runtime", reference=reference) - if not base.done: - return blocked("runtime constitution failed", evidence=base) - if not conforms_to_runtime_interface(runtime_config.backend): - return blocked("backend does not implement the public Runtime interface") - - composed = model.compose( - task, - model_spec=model_spec, - primitives=primitives, - reference=reference, - budget=budget.model, - ) - if not composed.done: - return blocked("model composition failed before runtime validation", evidence=composed) - - run = execute_runtime_steps( - ["init", "build_model", "train_step", "save", "load"], - runtime_config, - composed.model, - max_steps=budget.max_steps, - ) - if run.return_code != 0: - return blocked("runtime lifecycle failed", evidence=[composed, run]) - return done( - runtime=run, - evidence=[composed, run], - risks=["runtime success can hide model-local mismatch"], - ) -``` diff --git a/experimental/agent_compose/tests/unit/test_import.py b/experimental/agent_compose/tests/unit/test_import.py deleted file mode 100644 index 043b25ee9fb..00000000000 --- a/experimental/agent_compose/tests/unit/test_import.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Import checks for the Agent Compose package skeleton.""" - -from __future__ import annotations - -import importlib - - -def test_three_layer_skeleton_imports() -> None: - modules = [ - "megatron.experimental.agent_compose", - "megatron.experimental.agent_compose.primitive", - "megatron.experimental.agent_compose.model", - "megatron.experimental.agent_compose.runtime", - ] - - for module in modules: - imported = importlib.import_module(module) - assert imported.__name__ == module - - -def test_runtime_public_surface_imports() -> None: - from megatron.experimental.agent_compose.runtime import ( - Batch, - ForwardResult, - LossContext, - ModelHandle, - ModelOutputs, - OptimizerConfig, - PackedBatch, - ParallelConfig, - Runtime, - RuntimeConfig, - TrainBatch, - create_runtime, - register_runtime, - ) - - assert all( - value is not None - for value in ( - Batch, - ForwardResult, - LossContext, - ModelHandle, - ModelOutputs, - OptimizerConfig, - PackedBatch, - ParallelConfig, - Runtime, - RuntimeConfig, - TrainBatch, - create_runtime, - register_runtime, - ) - ) diff --git a/experimental/agent_compose/tests/unit/test_layering_contracts.py b/experimental/agent_compose/tests/unit/test_layering_contracts.py deleted file mode 100644 index 7695c599ec4..00000000000 --- a/experimental/agent_compose/tests/unit/test_layering_contracts.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Static import guards for the runtime, model, and primitive layers.""" - -from __future__ import annotations - -import ast -from pathlib import Path - -AGENT_COMPOSE_ROOT = Path(__file__).resolve().parents[2] -PACKAGE_ROOT = AGENT_COMPOSE_ROOT / "megatron" / "experimental" / "agent_compose" -LAYER_ROOTS = { - "primitive": PACKAGE_ROOT / "primitive", - "model": PACKAGE_ROOT / "model", - "runtime": PACKAGE_ROOT / "runtime", -} -DENIED_IMPORTS = { - "primitive": ( - "experimental", - "megatron.experimental.agent_compose.model", - "megatron.experimental.agent_compose.runtime", - ), - "model": ("experimental", "megatron.experimental.agent_compose.runtime"), - "runtime": ("experimental",), -} -SHARED_CONTRACTS = ("megatron.experimental.agent_compose.runtime.contracts",) - - -def _matches(module: str, prefix: str) -> bool: - return module == prefix or module.startswith(prefix + ".") - - -def _imports(path: Path) -> list[tuple[int, str]]: - tree = ast.parse(path.read_text(encoding="utf-8")) - found: list[tuple[int, str]] = [] - for node in ast.walk(tree): - if isinstance(node, ast.Import): - found.extend((node.lineno, alias.name) for alias in node.names) - elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: - found.append((node.lineno, node.module)) - return found - - -def test_layer_import_boundaries() -> None: - violations: list[str] = [] - for layer, root in LAYER_ROOTS.items(): - for path in sorted(root.rglob("*.py")): - for lineno, module in _imports(path): - if any(_matches(module, allowed) for allowed in SHARED_CONTRACTS): - continue - for denied in DENIED_IMPORTS[layer]: - if _matches(module, denied): - rel = path.relative_to(AGENT_COMPOSE_ROOT) - violations.append(f"{rel}:{lineno}: {module} imports {denied}") - assert violations == [] diff --git a/experimental/agent_compose/tests/unit/test_runtime_interface.py b/experimental/agent_compose/tests/unit/test_runtime_interface.py deleted file mode 100644 index ec540e58c00..00000000000 --- a/experimental/agent_compose/tests/unit/test_runtime_interface.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Contract tests for the public runtime interface.""" - -from __future__ import annotations - -import subprocess -import sys -import types - -import pytest - - -def test_runtime_import_stays_lightweight() -> None: - script = ( - "import sys; " - "import megatron.experimental.agent_compose.runtime as runtime; " - "assert runtime.RuntimeConfig().backend == 'agent_compose'; " - "assert 'torch' not in sys.modules" - ) - subprocess.run([sys.executable, "-c", script], check=True) - - -def test_public_runtime_contracts() -> None: - import torch - - from megatron.experimental.agent_compose.runtime import PackedBatch, ParallelConfig - - batch = PackedBatch( - input_ids=torch.tensor([1, 2, 3]), - labels=torch.tensor([2, 3, 4]), - seq_lens=torch.tensor([2, 1]), - ) - - assert ParallelConfig(tp=2).tp == 2 - assert len(batch) == 2 - assert batch.total_tokens == 3 - assert batch.cu_seqlens.tolist() == [0, 2, 3] - assert batch.make_position_ids().tolist() == [0, 1, 0] - - -def test_unregistered_backend_fails_explicitly() -> None: - from megatron.experimental.agent_compose.runtime import ( - RuntimeConfig, - create_runtime, - ) - - with pytest.raises(ValueError, match="No runtime backend registered"): - create_runtime(RuntimeConfig(backend="missing")) - - -def test_runtime_required_method_set() -> None: - from megatron.experimental.agent_compose.runtime import Runtime - - assert Runtime.__abstractmethods__ == { - "build_model", - "eval_mode", - "forward_backward", - "load_checkpoint", - "lr_scheduler_step", - "optimizer_step", - "save_checkpoint", - "train_mode", - "zero_grad", - } - - -def test_registered_runtime_factory(monkeypatch) -> None: - from megatron.experimental.agent_compose.runtime import ( - RuntimeConfig, - create_runtime, - register_runtime, - ) - - module = types.ModuleType("agent_compose_test_runtime") - module.create = lambda hf_path, cfg: (hf_path, cfg) - monkeypatch.setitem(sys.modules, module.__name__, module) - register_runtime("test", module.__name__) - - cfg = RuntimeConfig(backend="test", hf_path="model", backend_cfg={"tp": 2}) - assert create_runtime(cfg) == ("model", {"tp": 2}) diff --git a/experimental/agent_compose/tests/unit/test_skills.py b/experimental/agent_compose/tests/unit/test_skills.py deleted file mode 100644 index 9ca7a8ba636..00000000000 --- a/experimental/agent_compose/tests/unit/test_skills.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Structural checks for the initial Agent Compose skill registry.""" - -from __future__ import annotations - -import re -from pathlib import Path - -AGENT_COMPOSE_ROOT = Path(__file__).resolve().parents[2] -SKILL_ROOT = AGENT_COMPOSE_ROOT / "skills" -EXPECTED = { - "basic.constitution": "basic/constitution.md", - "basic.lint_skill": "basic/lint-skill.md", - "primitive.contract": "primitive/contract.md", - "model.compose": "model/compose.md", - "runtime.validate": "runtime/validate.md", -} -SCHEMA_NAME = re.compile(r'schema\s*=\s*Skill\(\s*"([^"]+)"', re.DOTALL) -LIST_FIELD = re.compile(r"\b(imports|calls)\s*=\s*\[([^]]*)\]", re.DOTALL) -QUOTED = re.compile(r'"([^"]+)"') - - -def test_skill_registry_is_complete_and_resolved() -> None: - for expected_name, relative_path in EXPECTED.items(): - path = SKILL_ROOT / relative_path - text = path.read_text(encoding="utf-8") - assert text.count("AGENT_COMPOSE_SKILL_SCHEMA_BEGIN") == 1 - assert text.count("AGENT_COMPOSE_SKILL_SCHEMA_END") == 1 - match = SCHEMA_NAME.search(text) - assert match is not None - assert match.group(1) == expected_name - assert f"def {expected_name.rsplit('.', 1)[-1]}(" in text - - fields = { - name: QUOTED.findall(values) for name, values in LIST_FIELD.findall(text) - } - for dependency in fields.get("imports", []) + fields.get("calls", []): - assert dependency in EXPECTED From 19c50e864b101abbe7f4e57262ddf5b6daff055d Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 5 Aug 2026 15:24:29 -0700 Subject: [PATCH 209/290] Release MFSDP storage after model deletion (#6230) Signed-off-by: Jingyue Wu --- .../experimental/indexed_order.py | 22 ++++++---- .../src/megatron_fsdp/experimental/module.py | 42 ++++++++++++++----- .../experimental/parameter_group.py | 24 ++++++++--- .../distributed/mfsdp_v2/test_fully_shard.py | 33 +++++++++++++++ 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py index d7c9c63ed0c..50b7fb13991 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/indexed_order.py @@ -16,17 +16,18 @@ from collections.abc import Iterator from typing import Generic, TypeVar +from weakref import WeakKeyDictionary, ref T = TypeVar("T") class IndexedOrder(Generic[T]): - """Insertion order with constant-time successor lookup by item.""" + """Insertion order with weakly held items and successor lookup.""" def __init__(self) -> None: """Create an empty indexed order.""" - self._items: list[T] = [] - self._index_by_item: dict[T, int] = {} + self._items: list[ref[T]] = [] + self._index_by_item: WeakKeyDictionary[T, int] = WeakKeyDictionary() def append(self, item: T) -> None: """Append ``item`` to the order. @@ -40,14 +41,19 @@ def append(self, item: T) -> None: if item in self._index_by_item: raise ValueError("IndexedOrder does not support duplicate items.") self._index_by_item[item] = len(self._items) - self._items.append(item) + self._items.append(ref(item)) def __iter__(self) -> Iterator[T]: - """Iterate over items in order.""" - return iter(self._items) + """Iterate over live items in order.""" + for item_ref in self._items: + item = item_ref() + if item is not None: + yield item def next_item(self, item: T) -> T | None: - """Return the item that follows ``item``, if any.""" + """Return the live item that follows ``item``, if any.""" index = self._index_by_item[item] next_index = index + 1 - return self._items[next_index] if next_index < len(self._items) else None + if next_index >= len(self._items): + return None + return self._items[next_index]() diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 476c1cb6bc7..f26a3a35d94 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -16,6 +16,7 @@ from collections.abc import Callable from typing import Literal, cast +from weakref import ReferenceType, ref import torch from torch import nn @@ -36,7 +37,9 @@ class FsdpContext: # unnecessary because it can be detected when ``model_weight``, after syncing # from ``main_weight``, has placements different from ``Placements.optimizer``. is_last_microbatch: bool - root_module: "FsdpModule" + # A context is owned by its FSDP module tree, so runtime backedges to modules + # must be weak; otherwise deleting the tree requires cyclic GC. + _root_module: ReferenceType["FsdpModule"] # Static orders used to drive all-gather prefetch. We may want to switch to # capturing runtime order if static module order proves too fragile. Each # FsdpModule tracks its own materialized state via ``FsdpModule._unshard_event``. @@ -50,7 +53,7 @@ def __init__(self, device: torch.device, root_module: "FsdpModule") -> None: device: Device on which this context schedules communication. root_module: Outermost module that owns this context. """ - self.root_module = root_module + self._root_module = ref(root_module) self.is_last_microbatch = True self.forward_order = IndexedOrder() self.backward_order = IndexedOrder() @@ -62,6 +65,10 @@ def current_stream(self) -> torch.cuda.Stream: """Current stream on this context's device.""" return torch.cuda.current_stream(self.allgather_stream.device) + def is_module_root(self, module: "FsdpModule") -> bool: + """Return whether ``module`` is this context's root module.""" + return self._root_module() is module + def register_post_backward_final_callback(self) -> None: """Register this root context's final callback for the current backward. @@ -190,16 +197,26 @@ def name(self) -> str: def is_root(self) -> bool: """Return whether this module is the outermost FsdpModule in its context.""" - return self.context.root_module is self + return self.context.is_module_root(self) def _register_hooks(self) -> None: module = cast(nn.Module, self) - module.register_forward_pre_hook(lambda _module, _args: self.pre_forward()) - module.register_forward_hook(lambda _module, _args, _output: self.post_forward()) - module.register_full_backward_pre_hook(lambda _module, _grad_output: self.pre_backward()) + # Use PyTorch's callback module argument instead of capturing self so + # these hooks do not retain a deleted FSDP module. + module.register_forward_pre_hook( + lambda hooked_module, _args: cast(FsdpModule, hooked_module).pre_forward() + ) + module.register_forward_hook( + lambda hooked_module, _args, _output: cast(FsdpModule, hooked_module).post_forward() + ) + module.register_full_backward_pre_hook( + lambda hooked_module, _grad_output: cast(FsdpModule, hooked_module).pre_backward() + ) if self._num_trainable_parameters == 0: module.register_full_backward_hook( - lambda _module, _grad_input, _grad_output: self.post_backward() + lambda hooked_module, _grad_input, _grad_output: cast( + FsdpModule, hooked_module + ).post_backward() ) return @@ -214,10 +231,15 @@ def _register_hooks(self) -> None: fsdp_parameter.unsharded.register_post_accumulate_grad_hook(self._make_grad_hook()) def _make_grad_hook(self) -> Callable[[nn.Parameter], None]: + module_ref = ref(self) + def grad_hook(_parameter: nn.Parameter) -> None: - self._num_ready_grad_parameters += 1 - if self._num_ready_grad_parameters == self._num_trainable_parameters: - self.post_backward() + module = module_ref() + if module is None: + return + module._num_ready_grad_parameters += 1 + if module._num_ready_grad_parameters == module._num_trainable_parameters: + module.post_backward() return grad_hook diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 7e7592eb9f4..cf8a4eaa94a 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -16,6 +16,7 @@ from contextlib import nullcontext from dataclasses import dataclass +from weakref import ReferenceType, ref import torch import torch.distributed as dist @@ -32,7 +33,12 @@ def get_containing_parameter_group(parameter: nn.Parameter) -> "FsdpParameterGroup | None": """Return the FSDP parameter group that owns ``parameter``, if any.""" - return getattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, None) + # This parameter-owned backedge must be weak; otherwise it forms a reference + # cycle with the parameter group and delays releasing its CUDA storage. + parameter_group_ref = getattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, None) + if parameter_group_ref is None: + return None + return parameter_group_ref() @dataclass(frozen=True, eq=False) @@ -49,7 +55,9 @@ class FsdpParameter: class FsdpParameterGroup: """A dtype and requires-grad homogeneous group of FSDP-owned parameters.""" - owning_module: nn.Module + # FsdpModule owns its parameter groups, so this backedge must be weak to avoid + # a reference cycle that delays releasing CUDA storage until cyclic GC. + _owning_module: ReferenceType[nn.Module] fsdp_parameters: tuple[FsdpParameter, ...] mesh: DeviceMesh dtype: torch.dtype @@ -93,7 +101,7 @@ def __init__( # Python dicts preserve insertion order, so parameter_to_fqns and # fsdp_parameters define the same stable DBuffer tensor order. - self.owning_module = owning_module + self._owning_module = ref(owning_module) self.mesh = mesh first_parameter = next(iter(parameter_to_fqns)) self.dtype = first_parameter.dtype @@ -182,14 +190,15 @@ def __init__( else: parameter.data = unsharded_tensor parameter.grad = None - setattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, self) + # Parameter-owned markers must not retain their FSDP module tree. + setattr(parameter, _CONTAINING_PARAMETER_GROUP_ATTR, ref(self)) sharded_parameter = nn.Parameter( self.main_weight.get_dtensor(index), requires_grad=parameter.requires_grad ) if main_grad_dtype: sharded_parameter.grad_dtype = main_grad_dtype - setattr(sharded_parameter, _CONTAINING_PARAMETER_GROUP_ATTR, self) + setattr(sharded_parameter, _CONTAINING_PARAMETER_GROUP_ATTR, ref(self)) fsdp_parameters.append( FsdpParameter(fqns=tuple(fqns), sharded=sharded_parameter, unsharded=parameter) ) @@ -207,8 +216,11 @@ def _symmetric_memory_context(self): return torch.cuda.use_mem_pool(self._symm_mem_pool) def _set_module_parameter(self, fqns: tuple[str, ...], parameter: nn.Parameter) -> None: + owning_module = self._owning_module() + if owning_module is None: + raise RuntimeError("FSDP parameter group outlived its owning module.") for fqn in fqns: - module, parameter_name = _get_parameter_owner(self.owning_module, fqn) + module, parameter_name = _get_parameter_owner(owning_module, fqn) module._parameters[parameter_name] = parameter def _switch_to_sharded_parameters(self) -> None: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index e820abe515d..1a736d1e996 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -73,6 +73,18 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x +class ElementwiseModel(nn.Module): + """Small activation path over a large FSDP-managed weight.""" + + def __init__(self, dim: int) -> None: + super().__init__() + self.weight = nn.Parameter(torch.ones(dim, dim)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Apply the first weight row to an activation tensor.""" + return torch.relu(x + self.weight[0]) + + class TiedLM(nn.Module): """Tiny language model with shared input and output embedding weights.""" @@ -423,6 +435,27 @@ def test_forward_peak_memory_bounds_in_flight_child_all_gathers(distributed_setu ) +def test_deleted_model_releases_fsdp_storage(distributed_setup): + """Deleting an FSDP model should release its persistent storage.""" + world_size = distributed_setup.world_size + device = distributed_setup.device + + mesh = init_device_mesh(device.type, (world_size,)) + # Earlier tests may retain process-global CUDA allocations such as the + # CuBLAS workspace. Capture them before creating this model, so the test + # only detects storage retained by the deleted FSDP model itself. + allocated_before = torch.cuda.memory_allocated(device) + model = ElementwiseModel(dim=8192).to(dtype=torch.bfloat16, device=device) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + x = torch.ones(1, 8192, dtype=torch.bfloat16, device=device) + output = model(x) + del output, x, model + torch.cuda.synchronize(device) + + assert torch.cuda.memory_allocated(device) - allocated_before < 1024**2 + + def test_root_forward_returns_to_resting_memory(distributed_setup): """Root forward should release child all-gather storage before returning.""" rank = distributed_setup.rank From d180b7cca6e71434113915d65035737c55e11964 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Wed, 5 Aug 2026 16:05:46 -0700 Subject: [PATCH 210/290] Document MFSDP optimizer design (#6186) Signed-off-by: Jingyue Wu --- .../src/docs/images/optimizer-data-flow.png | Bin 0 -> 22084 bytes .../distributed/fsdp/src/docs/optimizer.md | 164 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 megatron/core/distributed/fsdp/src/docs/images/optimizer-data-flow.png create mode 100644 megatron/core/distributed/fsdp/src/docs/optimizer.md diff --git a/megatron/core/distributed/fsdp/src/docs/images/optimizer-data-flow.png b/megatron/core/distributed/fsdp/src/docs/images/optimizer-data-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..afe920f51b51299a8b46845746707bfa778c9608 GIT binary patch literal 22084 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 O0000 + +# MFSDP Optimizer + +## Proposal + +- Standalone `megatron_fsdp`: do not depend on any optimizer or optimizer wrapper + in Megatron Core (MCore). +- MFSDP v1 in MCore: continue using `DistributedOptimizer`. +- MFSDP v2 in MCore: prefer a narrow `FullyShardedOptimizer` subclass of + `MixedPrecisionOptimizer` instead of adding MFSDP v2 branches throughout + `DistributedOptimizer`. + +PR [#5865](https://github.com/NVIDIA/Megatron-LM/pull/5865) is the current +reference point for the MFSDP v2 direction in MCore. + +## Background + +![FSDP optimizer data flow](images/optimizer-data-flow.png) + +FSDP needs custom optimizers because: + +- Lower-precision model weights than main weights: after `optimizer.step()`, the + updated main weights must be synchronized back to the lower-precision compute + weights. +- Lower-precision main gradients than main weights: before `optimizer.step()`, + the optimizer must either be precision-aware about the gradient dtype or + explicitly upcast the gradients into the dtype expected by the main weights + and optimizer state. +- Matrix optimizers: optimizer state derived from `main_grad` may need a + different shape (e.g. SOAP) and sharding layout (e.g. SOAP and Muon) than + `main_weight`. That can require redistributing optimizer inputs before + `optimizer.step()` and redistributing updates back into the `main_weight` + layout afterward. +- Performance optimizations such as offloading: when parameters, gradients, or + optimizer state move across devices or memory tiers, the optimizer path needs + explicit coordination beyond a plain `torch.optim.Optimizer.step()` call. + +MFSDP supports two integration modes. Standalone `megatron_fsdp` owns its +FSDP-specific optimizer behavior. Within MCore, MFSDP must integrate with the +existing optimizer, scheduler, checkpoint, and training-loop contracts. Both +modes need to handle the optimizer cases above, but under different dependency +and feature constraints. + +## Alternatives Considered + +### Reuse `DistributedOptimizer` in Standalone `megatron_fsdp` + +`megatron_fsdp` as a standalone package should not depend on MCore optimizer code +just to get FSDP-specific optimizer behavior. Open-source users of standalone +MFSDP [should not need][standalone-dependency] to pull in Megatron-Core's +optimizer stack, DDP assumptions, or training loop contracts. + +For the standalone package, the optimizer should stay local to `megatron_fsdp` +and only solve the FSDP-specific cases that plain `torch.optim` does not handle +out of the box. + +### Reuse `DistributedOptimizer` in MCore for MFSDP + +PR [#5813](https://github.com/NVIDIA/Megatron-LM/pull/5813) is the reference +prototype for this direction. + +`DistributedOptimizer` is an approximately 3k-line MCore class with strong +assumptions about DDP API/classes such as `DistributedDataParallel` and +`ParamAndGradBuffer`. While MFSDP v1 reuses `DistributedOptimizer`, that reuse is +shallow: most of the DDP-specific implementation is bypassed via the +`if use_megatron_fsdp` branches, creating unnecessary mental burden for readers +trying to understand which code paths are enabled for MFSDP and which are not. + +Instead, it makes more sense for MFSDP to depend on the more general and +lightweight `MixedPrecisionOptimizer`. + +## Implementation Details + +There are several ways to wrap an optimizer for standalone MFSDP: + +1. Hooks. This is the direction in PR + [#5411](https://github.com/NVIDIA/Megatron-LM/pull/5411). It is simple and + updates the optimizer object in place, but it is naturally limited to + pre-step and post-step customization points. + +2. Subclassing. Subclassing can override any optimizer method as needed. It can + be applied to one specific optimizer with optimizer-specific logic. It can + also be applied generically to many optimizers, as in the following example. + + ```py + @functools.lru_cache(maxsize=None) + def _make_fsdp_optimizer_cls(base: Type[optim.Optimizer]) -> Type[optim.Optimizer]: + class FsdpOptimizer(base): # type: ignore[valid-type, misc] + def __init__(self, params, module: nn.Module, *args, **kwargs): + super().__init__(params, *args, **kwargs) + self.module = module + + def step(self, closure: Optional[Callable[[], float]] = None): + loss = super().step(closure) + self._post_step() + return loss + + def _post_step(self) -> None: + # Can use self.module, which is passed in through the constructor. + pass + + FsdpOptimizer.__name__ = f"Fsdp{base.__name__}" + FsdpOptimizer.__qualname__ = FsdpOptimizer.__name__ + return FsdpOptimizer + + + def fully_shard_optimizer( + optimizer_class: Type[optim.Optimizer], + model: nn.Module, + *args: Any, + **kwargs: Any, + ) -> optim.Optimizer: + cls = _make_fsdp_optimizer_cls(optimizer_class) + return cls(model.parameters(), model, *args, **kwargs) + ``` + +3. Composition. This uses a dedicated wrapper around one or more underlying + optimizers. `MixedPrecisionOptimizer` in MCore takes this approach. + +The recommendation is: + +- For elementwise optimizers, use hooks. They are simple and match the current + customization needs. +- For matrix optimizers, use composition. For Muon, one important performance + optimization is to overlap optimization of fully owned parameters with + redistribution of partially owned parameters. That scheduling cannot be + expressed cleanly with only pre-step and post-step hooks, but it can be done + by wrapping [`OrthogonalizedOptimizer`][orthogonalized-optimizer]. +- Use subclassing with caution because it increases coupling to inherited + optimizer behavior. See Dave Thomas and Andy Hunt, "Inheritance Tax" from + [The Pragmatic Programmer][inheritance-tax]. + +## Future Direction + +The likely near-term end state is three optimizer implementations: + +- standalone MFSDP optimizer in `megatron_fsdp.experimental` +- MCore `DistributedOptimizer` for DDP and MFSDP v1 +- MCore `FullyShardedOptimizer` for MFSDP v2 + +This looks awkward, but it's still acceptable because they serve three separate +responsibilities. + +When MFSDP v1 is later deprecated, the MFSDP-specific branches in +`DistributedOptimizer` should be removed so it returns to supporting only DDP. + +`DistributedOptimizer` and `FullyShardedOptimizer` could be further unified. +However, that would require a significant refactor to create better code +abstractions that both DDP and FSDP can fit in. + +[inheritance-tax]: + https://media.pragprog.com/titles/tpp20/inheritance-tax.pdf +[orthogonalized-optimizer]: + https://github.com/NVIDIA-NeMo/Emerging-Optimizers/blob/d44aefbc1a2b6da771268e7ba35d2ec7f678e9ad/emerging_optimizers/orthogonalized_optimizers/orthogonalized_optimizer.py#L43 +[standalone-dependency]: + https://docs.google.com/document/d/1FIM7LKD1AvY-DdPjCu1KgvWmyhgOW7iZB5N6FQj6O3E/edit?tab=t.0#heading=h.guildfshf6dn From ef3ce113a2d006ba379b2e744a0bd4d99fe2d8ec Mon Sep 17 00:00:00 2001 From: Yashaswi Karnati <144376261+yashaswikarnati@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:56:21 -0700 Subject: [PATCH 211/290] Handle checkpoint and text-only batches in MIMO encoder prefetch (#6039) Signed-off-by: ykarnati --- examples/mimo/training/encoder_prefetch.py | 35 +++++++++++-------- .../models/mimo/test_mimo_encoder_prefetch.py | 31 ++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/examples/mimo/training/encoder_prefetch.py b/examples/mimo/training/encoder_prefetch.py index c8eb53ddbd1..cba622c7a2a 100644 --- a/examples/mimo/training/encoder_prefetch.py +++ b/examples/mimo/training/encoder_prefetch.py @@ -187,6 +187,10 @@ def __iter__(self): """Return this loader as its own iterator.""" return self + def save_state(self) -> None: + """Keep encoder lookahead derived from the canonical language-loader state.""" + return None + def start(self) -> None: """Initialize CUDA stream state and start the producer thread.""" with self._condition: @@ -274,8 +278,8 @@ def _producer_main(self) -> None: self._condition.notify_all() terminate = source_exhausted or source_error is not None if self._debug: - assert encode_start is not None - _log_producer_debug(batch_id, data_fetch_ms, encode_start, completion_event) + if encode_start is not None: + _log_producer_debug(batch_id, data_fetch_ms, encode_start, completion_event) self._drain_encoder_wait_timings() self._drain_projection_timings() if terminate: @@ -288,20 +292,20 @@ def _enqueue_batch( if not isinstance(batch, dict): raise TypeError("encoder prefetch source must return a batch dictionary") modality_inputs = batch.get("modality_inputs") - if not isinstance(modality_inputs, dict) or self._encoder_name not in modality_inputs: - raise ValueError(f"batch has no inputs for encoder {self._encoder_name!r}") with torch.cuda.device(self._device), torch.cuda.stream(self._stream): # Encoder ranks intentionally retain only fields consumed by their forward step. output_batch = {"input_ids": batch["input_ids"]} - encoder_inputs = move_batch_to_cuda(modality_inputs[self._encoder_name]) - encode_start = torch.cuda.Event(enable_timing=True) if self._debug else None - if encode_start is not None: - encode_start.record(self._stream) - encoded = self._feature_producer(encoder_inputs) - if not isinstance(encoded, torch.Tensor): - raise TypeError("feature_producer must return one combined tensor") - output_batch[PREFETCHED_FEATURES_KEY] = {self._encoder_name: encoded} + encode_start = None + if modality_inputs: + encoder_inputs = move_batch_to_cuda(modality_inputs[self._encoder_name]) + encode_start = torch.cuda.Event(enable_timing=True) if self._debug else None + if encode_start is not None: + encode_start.record(self._stream) + encoded = self._feature_producer(encoder_inputs) + if not isinstance(encoded, torch.Tensor): + raise TypeError("feature_producer must return one combined tensor") + output_batch[PREFETCHED_FEATURES_KEY] = {self._encoder_name: encoded} completion_event = torch.cuda.Event(enable_timing=self._debug) completion_event.record(self._stream) return output_batch, completion_event, encode_start @@ -349,9 +353,12 @@ def __next__(self) -> dict[str, object]: if wait_end_event is not None: wait_end_event.record(current_stream) self._queue_encoder_wait_timing(batch_id, wait_start_event, wait_end_event) - _record_feature_streams(item[PREFETCHED_FEATURES_KEY], current_stream) + features = item.get(PREFETCHED_FEATURES_KEY) + if features is not None: + _record_feature_streams(features, current_stream) + if self._debug: + item[PROJECTION_TIMER_KEY] = _ProjectionTimer(self, batch_id) if self._debug: - item[PROJECTION_TIMER_KEY] = _ProjectionTimer(self, batch_id) _log_consumer_debug( batch_id, ready_at_request, self._depth, completion_event is not None, wait_start ) diff --git a/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py b/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py index a53c682e2fb..cc58e6aa6ec 100644 --- a/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py +++ b/tests/unit_tests/models/mimo/test_mimo_encoder_prefetch.py @@ -159,6 +159,17 @@ def __next__(self): } +def test_prefetch_state_is_not_authoritative(): + loader = EncoderPrefetchLoader( + source=_Source(1), + encoder_name=ENCODER, + feature_producer=lambda _inputs: torch.empty(0), + depth=1, + ) + + assert loader.save_state() is None + + def _wait_until(predicate): deadline = time.monotonic() + 2 while not predicate(): @@ -296,6 +307,26 @@ def record_move(value): assert moved[0] is encoder_inputs +def test_prefetch_passes_through_text_only_batches(fake_cuda): + input_ids = torch.tensor([[1, 2]]) + loader = EncoderPrefetchLoader( + source=[{"input_ids": input_ids}], + encoder_name=ENCODER, + feature_producer=lambda _inputs: pytest.fail("text-only batches must not run the encoder"), + depth=1, + stream=fake_cuda.producer, + debug=True, + ) + loader.start() + _wait_until(lambda: len(loader._ready) == 1) + + batch = next(loader) + loader.close() + + assert set(batch) == {"input_ids"} + assert batch["input_ids"] is input_ids + + def test_debug_logs_prefetch_timing_and_queue_state(fake_cuda, caplog): module_logger_level = encoder_prefetch.logger.level caplog.set_level("INFO", logger=f"{encoder_prefetch.__name__}.debug") From a54bd8cc170073616c714c0a7a00bf1edc375a34 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Wed, 5 Aug 2026 22:00:51 -0400 Subject: [PATCH 212/290] Revert "avoid circular dependency usage between mcore and modelopt- #5644" (#6302) Signed-off-by: Philip Petrakian --- .../post_training/modelopt/checkpointing.py | 185 ------------------ megatron/post_training/checkpointing.py | 37 +++- megatron/training/checkpointing.py | 29 ++- .../test_modelopt_checkpointing.py | 181 ----------------- 4 files changed, 56 insertions(+), 376 deletions(-) delete mode 100644 megatron/core/post_training/modelopt/checkpointing.py delete mode 100644 tests/unit_tests/post_training/test_modelopt_checkpointing.py diff --git a/megatron/core/post_training/modelopt/checkpointing.py b/megatron/core/post_training/modelopt/checkpointing.py deleted file mode 100644 index aeb6060cb21..00000000000 --- a/megatron/core/post_training/modelopt/checkpointing.py +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. - -"""Dist checkpointing modules needed for ModelOpt.""" - -import copy -import logging -import os -from pathlib import Path -from typing import Any - -import torch - -from megatron.core import mpu -from megatron.core.dist_checkpointing.serialization import load, load_common_state_dict, save -from megatron.core.dist_checkpointing.strategies.torch import TorchDistLoadShardedStrategy -from megatron.core.dist_checkpointing.validation import StrictHandling -from megatron.core.safe_globals import safe_load_from_bytes - -logger = logging.getLogger(__name__) - - -def remove_per_module_state(modelopt_state: dict[str, Any]) -> None: - """Remove metadata from the modelopt_state. - - The metadata of the modelopt_state contains keys which may change with different pipeline - and expert parallelism. As a result, the metadata must be stored as several ShardedObject with - global and local layer offset mapping. - - Args: - modelopt_state: the state_dict that contains all algorithms that have been applied - to the given model. - """ - if "modelopt_state_dict" not in modelopt_state: - return - - for mode, config in modelopt_state["modelopt_state_dict"]: - metadata = config.get("metadata", None) - if metadata is not None: - _ = metadata.pop("quantizer_state", None) - _ = metadata.pop("subnet_config", None) - _ = metadata.pop("real_quantizer_state", None) - _ = metadata.pop("q_tensor_state", None) - else: - config["metadata"] = {} - - -def save_modelopt_state(model: list[torch.nn.Module], state_dict: dict[str, Any]) -> None: - """Save modelopt_state as a part of the per rank state_dict. - - NOTE: Only used for Megatron-LM. - - Args: - model: the modelopt optimized model - state_dict: the current modelopt optimized model state_dict to store - """ - import modelopt.torch.opt as mto - - if not mto.ModeloptStateManager.is_converted(model[0]): - return - if len(model) == 1: - state_dict["modelopt_state"] = mto.modelopt_state(model[0]) - else: - for i in range(len(model)): - mpu.set_virtual_pipeline_model_parallel_rank(i) - state_dict[f"modelopt_state_{i}"] = mto.modelopt_state(model[i]) - - -def save_sharded_modelopt_state( - model: list[torch.nn.Module], - checkpoint_name: str | Path, - sharded_strategy: tuple[str, int] | None = None, - prefix: str = "", -) -> None: - """Save modelopt_state in the sharded state_dict format. - - Args: - model: the model to restore the modelopt optimization - checkpoint_name: the checkpoint folder path - sharded_strategy: configures sharded tensors saving behavior and backend - prefix: the prefix to add to the modelopt_state keys ("model." for NeMo) - """ - import modelopt.torch.opt as mto - import modelopt.torch.utils.distributed as dist - - if not mto.ModeloptStateManager.is_converted(model[0]): - return - if len(model) > 1: - raise ValueError("sharded_modelopt_state does not support virtual pipeline parallel!") - modelopt_checkpoint_name = f"{checkpoint_name}/modelopt_state" - if dist.is_master(): - os.makedirs(modelopt_checkpoint_name, exist_ok=True) - modelopt_state = copy.deepcopy(mto.modelopt_state(model[0])) - remove_per_module_state(modelopt_state) - save(modelopt_state, modelopt_checkpoint_name, sharded_strategy) - - -def _load_extra_state_from_sharded_checkpoint( - model: torch.nn.Module, - checkpoint_name: str | Path, - prefix: str, - metadata: dict[str, Any] | None = None, -) -> None: - """Load extra state from sharded checkpoint. - - Note: since extra_state is a subset of full the sharded_state_dict, we use - strict=StrictHandling.LOG_UNEXPECTED instead of LOG_ALL. - - Args: - model: the model to load extra state into - checkpoint_name: the checkpoint folder path - prefix: the prefix to add to the modelopt_state keys - metadata: the metadata for distributed checkpointing - - Note: - The metadata includes several breaking changes. For example, `singleton_local_shards` - is set to `True` (was not set before) in megatron-core-0.15.0. This flag affects the - sharded state_dict format and must be consistent between saving and loading. - """ - sharded_state_dict = model.sharded_state_dict(prefix=prefix) - extra_sharded_state_dict = {k: v for k, v in sharded_state_dict.items() if "_extra_state" in k} - extra_state_dict = load( - extra_sharded_state_dict, - checkpoint_name, - TorchDistLoadShardedStrategy(), - strict=StrictHandling.LOG_UNEXPECTED, - ) - extra_state_dict_no_prefix = {} - - for k, v in extra_state_dict.items(): - if k.startswith(prefix): - extra_state_dict_no_prefix[k[len(prefix) :]] = v - model.load_state_dict(extra_state_dict_no_prefix, strict=False) - - -def restore_sharded_modelopt_state( - model: list[torch.nn.Module], - checkpoint_name: str | Path, - prefix: str = "", - metadata: dict[str, Any] | None = None, -) -> None: - """Restore modelopt_state from the sharded state_dict format. - - Args: - model: the model to restore the modelopt optimization - checkpoint_name: the checkpoint folder path - prefix: the prefix to add to the modelopt_state keys ("model." for NeMo) - metadata: the metadata for distributed checkpointing - - Note: - The metadata includes several breaking changes. For example, `singleton_local_shards` - is set to `True` (was not set before) in megatron-core-0.15.0. This flag affects the - sharded state_dict format and must be consistent between saving and loading. - """ - import modelopt - import modelopt.torch.opt as mto - - if len(model) > 1: - raise ValueError("sharded_modelopt_state does not support virtual pipeline parallel!") - - modelopt_checkpoint_name = f"{checkpoint_name}/modelopt_state" - - # Early return if the model already has a modelopt_state or the checkpoint does not exist. - if not os.path.exists(modelopt_checkpoint_name) or mto.ModeloptStateManager.is_converted( - model[0] - ): - return - - # Loading the common modelopt_state (replicated on all ranks). - # Detect format: legacy checkpoints store common state in a standalone common.pt file; - # newer sharded checkpoints store it as a ShardedObject inside the torch_dist checkpoint. - legacy_common_path = os.path.join(modelopt_checkpoint_name, "common.py") - if os.path.exists(legacy_common_path): - common_modelopt_state = safe_load_from_bytes(legacy_common_path) - else: - common_modelopt_state = load_common_state_dict(modelopt_checkpoint_name) - - modelopt_load_version = common_modelopt_state["modelopt_version"] - - logger.info( - f"nvidia-modelopt ckpt/inst version: {modelopt_load_version}/{modelopt.__version__}" - ) - - model[0] = mto.restore_from_modelopt_state(model[0], common_modelopt_state) - - _load_extra_state_from_sharded_checkpoint(model[0], checkpoint_name, prefix, metadata=metadata) diff --git a/megatron/post_training/checkpointing.py b/megatron/post_training/checkpointing.py index 821130611bf..1cb730bc450 100644 --- a/megatron/post_training/checkpointing.py +++ b/megatron/post_training/checkpointing.py @@ -8,9 +8,15 @@ import modelopt import modelopt.torch.opt as mto import torch.nn as nn +from modelopt.torch.opt.plugins import ( + restore_sharded_modelopt_state as restore_sharded_modelopt_state_legacy, +) +from modelopt.torch.opt.plugins.mcore_dist_checkpointing import ( + _load_extra_state_from_sharded_checkpoint, +) from megatron.core import dist_checkpointing -from megatron.core.post_training.modelopt.checkpointing import restore_sharded_modelopt_state +from megatron.core.dist_checkpointing.serialization import _legacy_common_state_exists from megatron.core.utils import unwrap_model from megatron.training import get_args from megatron.training.checkpointing import _load_base_checkpoint, load_checkpoint @@ -125,7 +131,10 @@ def load_modelopt_state(model: nn.Module, load_dir: Optional[str] = None) -> Non if sharded_load_dir is None: print_rank_0("No sharded checkpoint found. Skipping loading modelopt_state.") return - restore_sharded_modelopt_state([model], sharded_load_dir) + if _legacy_common_state_exists(f"{sharded_load_dir}/modelopt_state"): + restore_sharded_modelopt_state_legacy([model], sharded_load_dir) + else: + restore_sharded_modelopt_state([model], sharded_load_dir) def load_modelopt_checkpoint( @@ -199,6 +208,30 @@ def _remove_prefix_state_dict_pre_hook( _ = load_checkpoint(model, optimizer, opt_param_scheduler, strict=strict, load_arg=load_arg) +def restore_sharded_modelopt_state(model: list[nn.Module], checkpoint_name: str | Path) -> None: + """Temporary function. Copy of modelopt.torch.opt.plugins.restore_sharded_modelopt_state. + Will be removed once modelopt.torch.opt.plugins.restore_sharded_modelopt_state is up to date. + """ + if len(model) > 1: + raise ValueError("sharded_modelopt_state does not support virtual pipeline parallel!") + + modelopt_checkpoint_name = f"{checkpoint_name}/modelopt_state" + + # Early return if the model already has a modelopt_state or the checkpoint does not exist. + if not os.path.exists(modelopt_checkpoint_name) or mto.ModeloptStateManager.is_converted( + model[0] + ): + return + + common_modelopt_state = dist_checkpointing.load_common_state_dict(modelopt_checkpoint_name) + modelopt_load_version = common_modelopt_state["modelopt_version"] + + print(f"nvidia-modelopt ckpt/inst version: {modelopt_load_version}/{modelopt.__version__}") + + model[0] = mto.restore_from_modelopt_state(model[0], common_modelopt_state) + _load_extra_state_from_sharded_checkpoint(model[0], checkpoint_name, prefix="") + + def load_kd_teacher_checkpoint(model) -> None: """Load the teacher checkpoint for ModelOpt distillation if the model has one.""" args = get_args() diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index ee7e0077c14..8131645f09c 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -39,10 +39,8 @@ from megatron.core.msc_utils import maybe_msc from megatron.core.num_microbatches_calculator import update_num_microbatches from megatron.core.optimizer import DistributedOptimizer -from megatron.core.post_training.modelopt.checkpointing import save_modelopt_state, save_sharded_modelopt_state from megatron.core.rerun_state_machine import get_rerun_state_machine from megatron.core.utils import get_pg_rank, get_pg_size, unwrap_model -from megatron.post_training.utils import print_distributed_quant_summary from ..core.dist_checkpointing.utils import _clean_metadata_for_serialization from . import ft_integration, wandb_utils @@ -66,6 +64,18 @@ except ImportError: HAVE_MEGATRON_FSDP = False + +# [ModelOpt]: Import +try: + from modelopt.torch.opt.plugins import save_modelopt_state, save_sharded_modelopt_state + + from megatron.post_training.utils import print_distributed_quant_summary + + has_nvidia_modelopt = True +except Exception: + has_nvidia_modelopt = False + + _CHECKPOINT_VERSION = None _LOADED_ITERATION = None @@ -848,7 +858,8 @@ def save_checkpoint( verify_integrity=args.verify_integrity, ) # [ModelOpt]: save sharded modelopt_state - save_sharded_modelopt_state(model, checkpoint_name, (args.ckpt_format, 1)) + if has_nvidia_modelopt: + save_sharded_modelopt_state(model, checkpoint_name, (args.ckpt_format, 1)) elif ckpt_type == CheckpointType.GLOBAL and ckpt_format in ['torch_dcp', 'fsdp_dtensor']: if not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0: # TODO Handle non-empty directories (e.g., after a crash during saving). @@ -902,10 +913,11 @@ def save_checkpoint( ) else: # [ModelOpt]: Inject modelopt_state into state_dict - if ckpt_type == CheckpointType.LOCAL: - print_rank_0('WARNING: Local checkpointing does not support nvidia_modelopt.') - else: - save_modelopt_state(model, state_dict) + if has_nvidia_modelopt: + if ckpt_type == CheckpointType.LOCAL: + print_rank_0('WARNING: Local checkpointing does not support nvidia_modelopt.') + else: + save_modelopt_state(model, state_dict) end_ckpt = time() logger.debug( @@ -2796,7 +2808,8 @@ def load_model_state_dict(module, state_dict, strict: bool): ) log_printed = True - print_distributed_quant_summary(model, msg='After loading checkpoint') + if has_nvidia_modelopt: + print_distributed_quant_summary(model, msg='After loading checkpoint') return iteration, num_floating_point_operations_so_far diff --git a/tests/unit_tests/post_training/test_modelopt_checkpointing.py b/tests/unit_tests/post_training/test_modelopt_checkpointing.py deleted file mode 100644 index 2fe5603f838..00000000000 --- a/tests/unit_tests/post_training/test_modelopt_checkpointing.py +++ /dev/null @@ -1,181 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. - -"""Unit tests for megatron/core/dist_checkpointing/strategies/modelopt.py. - -Skipped automatically when modelopt is not installed. -""" - -import sys -from unittest import mock - -import pytest -import torch - -pytest.importorskip("modelopt", reason="modelopt is not installed") - -import modelopt.torch.opt as mto -import modelopt.torch.utils.distributed as mdist - -from megatron.core.post_training.modelopt.checkpointing import ( - _load_extra_state_from_sharded_checkpoint, - remove_per_module_state, - restore_sharded_modelopt_state, - save_modelopt_state, - save_sharded_modelopt_state, -) - - -class TestModelOptImports: - def test_modelopt_imports(self): - modelopt_keys = [k for k in sys.modules if k == "modelopt" or k.startswith("modelopt.")] - saved = {k: sys.modules.pop(k) for k in modelopt_keys} - try: - import megatron.core # noqa: F401 - - assert "modelopt" not in sys.modules - finally: - sys.modules.update(saved) - - -class TestRemovePerModuleState: - def test_no_key_is_noop(self): - state = {"other": 1} - remove_per_module_state(state) - assert state == {"other": 1} - - def test_removes_per_module_keys_keeps_others(self): - state = { - "modelopt_state_dict": [ - ( - "mode", - { - "metadata": { - "quantizer_state": "a", - "subnet_config": "b", - "real_quantizer_state": "c", - "q_tensor_state": "d", - "keep": True, - } - }, - ) - ] - } - remove_per_module_state(state) - meta = state["modelopt_state_dict"][0][1]["metadata"] - assert not any( - k in meta - for k in ("quantizer_state", "subnet_config", "real_quantizer_state", "q_tensor_state") - ) - assert meta["keep"] is True - - def test_missing_metadata_filled_with_empty_dict(self): - state = {"modelopt_state_dict": [("mode", {})]} - remove_per_module_state(state) - assert state["modelopt_state_dict"][0][1]["metadata"] == {} - - -class TestSaveModeloptState: - def test_not_converted_is_noop(self): - state_dict = {} - with mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=False): - save_modelopt_state([mock.MagicMock()], state_dict) - assert state_dict == {} - - def test_single_model_saved(self): - fake_state = {"modelopt_state_dict": []} - state_dict = {} - with ( - mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), - mock.patch.object(mto, "modelopt_state", return_value=fake_state), - ): - save_modelopt_state([mock.MagicMock()], state_dict) - assert state_dict["modelopt_state"] is fake_state - - def test_multiple_models_use_indexed_keys(self): - state_dict = {} - with ( - mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), - mock.patch.object(mto, "modelopt_state", side_effect=[{"i": i} for i in range(2)]), - mock.patch("megatron.core.post_training.modelopt.checkpointing.mpu"), - ): - save_modelopt_state([mock.MagicMock(), mock.MagicMock()], state_dict) - assert "modelopt_state_0" in state_dict - assert "modelopt_state_1" in state_dict - - -class TestSaveShardedModeloptState: - def test_multiple_models_raises(self): - with ( - mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), - mock.patch.object(mdist, "is_master", return_value=True), - pytest.raises(ValueError, match="virtual pipeline"), - ): - save_sharded_modelopt_state([mock.MagicMock(), mock.MagicMock()], "/ckpt") - - def test_single_model_calls_save(self, tmp_path): - with ( - mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=True), - mock.patch.object(mto, "modelopt_state", return_value={"modelopt_state_dict": []}), - mock.patch.object(mdist, "is_master", return_value=True), - mock.patch("megatron.core.post_training.modelopt.checkpointing.save") as mock_save, - ): - save_sharded_modelopt_state([mock.MagicMock()], str(tmp_path)) - mock_save.assert_called_once() - assert mock_save.call_args.args[1] == f"{tmp_path}/modelopt_state" - - -class TestRestoreShardedModeloptState: - def test_multiple_models_raises(self): - with pytest.raises(ValueError, match="virtual pipeline"): - restore_sharded_modelopt_state([mock.MagicMock(), mock.MagicMock()], "/ckpt") - - def test_missing_checkpoint_returns_early(self, tmp_path): - with ( - mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=False), - mock.patch.object(mto, "restore_from_modelopt_state") as mock_restore, - ): - restore_sharded_modelopt_state([mock.MagicMock()], str(tmp_path)) - mock_restore.assert_not_called() - - def test_restores_model(self, tmp_path): - (tmp_path / "modelopt_state").mkdir() - with ( - mock.patch.object(mto.ModeloptStateManager, "is_converted", return_value=False), - mock.patch.object( - mto, "restore_from_modelopt_state", side_effect=lambda m, _s: m - ) as mock_restore, - mock.patch( - "megatron.core.post_training.modelopt.checkpointing.load_common_state_dict", - return_value={"modelopt_version": "1.0"}, - ), - mock.patch( - "megatron.core.post_training.modelopt.checkpointing._load_extra_state_from_sharded_checkpoint" - ), - mock.patch("megatron.core.post_training.modelopt.checkpointing.logger", create=True), - ): - restore_sharded_modelopt_state([mock.MagicMock()], str(tmp_path)) - mock_restore.assert_called_once() - - -class TestLoadExtraStateFromShardedCheckpoint: - def test_strips_prefix_and_filters_extra_state(self, tmp_path): - prefix = "model." - model = mock.MagicMock() - model.sharded_state_dict.return_value = { - f"{prefix}layer._extra_state": mock.MagicMock(), - f"{prefix}layer.weight": mock.MagicMock(), - } - loaded = {f"{prefix}layer._extra_state": torch.tensor([1.0])} - - with mock.patch( - "megatron.core.post_training.modelopt.checkpointing.load", return_value=loaded - ) as mock_load: - _load_extra_state_from_sharded_checkpoint(model, str(tmp_path), prefix) - - passed = mock_load.call_args.args[0] - assert f"{prefix}layer._extra_state" in passed - assert f"{prefix}layer.weight" not in passed - - loaded_dict = model.load_state_dict.call_args.args[0] - assert "layer._extra_state" in loaded_dict - assert f"{prefix}layer._extra_state" not in loaded_dict From 3d3323fbdafe697b6d67904a0bf9ad0dbef601ae Mon Sep 17 00:00:00 2001 From: kevjshih Date: Thu, 6 Aug 2026 04:10:18 -0400 Subject: [PATCH 213/290] Fix multimodal MTP loss masking for modality tokens (#6010) Signed-off-by: Kevin Shih --- megatron/core/models/mimo/model/base.py | 3 + .../core/models/multimodal/llava_model.py | 1 + .../unit_tests/models/mimo/test_mimo_model.py | 40 ++++++- tests/unit_tests/models/test_llava_model.py | 105 ++++++++++++++++++ .../test_multi_token_prediction.py | 39 +++++++ 5 files changed, 185 insertions(+), 3 deletions(-) diff --git a/megatron/core/models/mimo/model/base.py b/megatron/core/models/mimo/model/base.py index b226b5c1e4b..d5b70826bb5 100644 --- a/megatron/core/models/mimo/model/base.py +++ b/megatron/core/models/mimo/model/base.py @@ -700,6 +700,7 @@ def _forward_language_module( position_ids=position_ids, decoder_input=combined_embeddings, labels=labels, + loss_mask=loss_mask, attention_mask=attention_mask, packed_seq_params=packed_seq_params, ) @@ -729,6 +730,7 @@ def _forward_language_module( position_ids=position_ids, decoder_input=None, labels=labels, + loss_mask=loss_mask, attention_mask=attention_mask, packed_seq_params=packed_seq_params, ) @@ -850,6 +852,7 @@ def _forward_all_modules( position_ids=position_ids, decoder_input=combined_embeddings, labels=labels, + loss_mask=loss_mask, attention_mask=None, packed_seq_params=packed_seq_params, ) diff --git a/megatron/core/models/multimodal/llava_model.py b/megatron/core/models/multimodal/llava_model.py index a5b5e7cce58..2b73552a297 100644 --- a/megatron/core/models/multimodal/llava_model.py +++ b/megatron/core/models/multimodal/llava_model.py @@ -1270,6 +1270,7 @@ def forward( attention_mask=attention_mask, decoder_input=combined_embeddings, labels=new_labels, + loss_mask=new_loss_mask, inference_context=inference_context, runtime_gather_output=runtime_gather_output, packed_seq_params=packed_seq_params, diff --git a/tests/unit_tests/models/mimo/test_mimo_model.py b/tests/unit_tests/models/mimo/test_mimo_model.py index bdb55443538..8df7449644d 100644 --- a/tests/unit_tests/models/mimo/test_mimo_model.py +++ b/tests/unit_tests/models/mimo/test_mimo_model.py @@ -270,6 +270,7 @@ def test_forward_threads_position_ids_to_language_model(self): mimo_model = self._make_vlm() input_ids = self._make_input_ids() position_ids = self._make_position_ids() + loss_mask = torch.ones(self.batch_size, self.seq_len, device=self.device) captured = {} @@ -277,10 +278,16 @@ def capture_lm_forward(*args, **kwargs): captured['input_ids'] = kwargs.get('input_ids') captured['position_ids'] = kwargs.get('position_ids') captured['decoder_input'] = kwargs.get('decoder_input') + captured['loss_mask'] = kwargs.get('loss_mask') return torch.zeros(self.batch_size, self.seq_len, self.vocab_size, device=self.device) with patch.object(mimo_model.language_model, 'forward', side_effect=capture_lm_forward): - mimo_model(input_ids=input_ids, position_ids=position_ids, modality_inputs=None) + mimo_model( + input_ids=input_ids, + position_ids=position_ids, + loss_mask=loss_mask, + modality_inputs=None, + ) assert ( captured['decoder_input'] is not None @@ -292,6 +299,7 @@ def capture_lm_forward(*args, **kwargs): captured['position_ids'] is not None ), "MimoModel.forward must pass position_ids to the language model (got None)" torch.testing.assert_close(captured['position_ids'], position_ids) + assert captured['loss_mask'] is loss_mask def test_forward_with_image_modality(self): """Test forward pass with text and image input.""" @@ -479,6 +487,7 @@ def test_forward_with_partition_adapter(self): def capture_lm_forward(*args, **kwargs): captured['decoder_input'] = kwargs.get('decoder_input') + captured['loss_mask'] = kwargs.get('loss_mask') return torch.zeros( self.batch_size, sharded_seq_len, self.vocab_size, device=self.device ) @@ -508,6 +517,7 @@ def capture_lm_forward(*args, **kwargs): self.batch_size, self.hidden_size, ) + assert captured['loss_mask'] is sharded_loss_mask # forward() returns the (possibly sharded) loss mask from shard(). assert out_loss_mask is sharded_loss_mask @@ -752,6 +762,8 @@ def test_forward_language_only(self): 0, self.vocab_size, (self.batch_size, self.seq_len), device=self.device ) input_ids[:, 5 : 5 + img_seq_len] = 50257 + loss_mask = torch.ones(self.batch_size, self.seq_len, device=self.device) + loss_mask[input_ids == 50257] = 0 position_ids = ( torch.arange(self.seq_len, device=self.device).unsqueeze(0).expand(self.batch_size, -1) ) @@ -761,9 +773,28 @@ def test_forward_language_only(self): ) model.set_input_tensor({"images": encoder_embeddings}) - outputs, _ = model(input_ids=input_ids, position_ids=position_ids, modality_inputs=None) + captured = {} + + def capture_language_inputs(module, args, kwargs): + captured['loss_mask'] = kwargs.get('loss_mask') + + hook = model.language_model.register_forward_pre_hook( + capture_language_inputs, with_kwargs=True + ) + try: + outputs, out_loss_mask = model( + input_ids=input_ids, + position_ids=position_ids, + loss_mask=loss_mask, + modality_inputs=None, + ) + finally: + hook.remove() + assert isinstance(outputs, torch.Tensor) assert outputs.shape == (self.batch_size, self.seq_len, self.vocab_size) + assert captured['loss_mask'] is loss_mask + assert out_loss_mask is loss_mask def test_forward_language_module_non_first_stage_drops_input_ids(self): """Non-first PP stage in ``_forward_language_module`` must call the LM @@ -782,6 +813,8 @@ def test_forward_language_module_non_first_stage_drops_input_ids(self): hidden_states = torch.randn( self.seq_len, self.batch_size, self.hidden_size, device=self.device ) + loss_mask = torch.ones(self.batch_size, self.seq_len, device=self.device) + loss_mask[:, : self.seq_len // 2] = 0 captured = {} @@ -798,13 +831,14 @@ def capture_lm_forward(*args, **kwargs): input_ids=input_ids, position_ids=position_ids, attention_mask=None, - loss_mask=None, + loss_mask=loss_mask, labels=None, input_tensors={MIMO_LANGUAGE_MODULE_KEY: hidden_states}, ) assert captured['input_ids'] is None assert captured['decoder_input'] is None + assert captured['loss_mask'] is loss_mask torch.testing.assert_close(captured['position_ids'], position_ids) diff --git a/tests/unit_tests/models/test_llava_model.py b/tests/unit_tests/models/test_llava_model.py index ddf9145f444..be62662ea54 100644 --- a/tests/unit_tests/models/test_llava_model.py +++ b/tests/unit_tests/models/test_llava_model.py @@ -410,6 +410,111 @@ def test_forward(self): == torch.Size((max_seq_len, 5, self.language_num_attention_heads, 16)) ) + @pytest.mark.internal + def test_forward_passes_expanded_loss_mask_to_language_model(self): + """The decoder needs the expanded mask to exclude vision positions from MTP loss.""" + self.model.cuda() + + image_token_index = self.model.image_token_index + input_ids = torch.tensor([[10, image_token_index, 11, 12]], device="cuda") + position_ids = torch.arange(input_ids.shape[1], device="cuda").unsqueeze(0) + labels = torch.tensor([[image_token_index, 11, 12, 13]], device="cuda") + loss_mask = torch.ones_like(input_ids, dtype=torch.float) + captured = {} + + def capture_language_inputs(module, args, kwargs): + captured["loss_mask"] = kwargs["loss_mask"] + + hook = self.model.language_model.register_forward_pre_hook( + capture_language_inputs, with_kwargs=True + ) + try: + output, expanded_loss_mask = self.model( + torch.randn((1, 3, 336, 336), device="cuda"), + input_ids, + position_ids, + attention_mask=None, + labels=labels, + loss_mask=loss_mask, + num_image_tiles=torch.ones(1, dtype=torch.int, device="cuda"), + ) + finally: + hook.remove() + + assert torch.equal(captured["loss_mask"], expanded_loss_mask) + assert output.shape == expanded_loss_mask.shape + assert torch.count_nonzero(expanded_loss_mask[0, : self.model.img_seq_len + 1]) == 0 + assert torch.all(expanded_loss_mask[0, self.model.img_seq_len + 1 :] == 1) + + @pytest.mark.internal + def test_forward_masks_image_only_sequence(self): + """An image placeholder expands to visual embeddings with no supervised positions.""" + self.model.cuda() + + image_token_index = self.model.image_token_index + input_ids = torch.tensor([[image_token_index]], device="cuda") + captured = {} + + def capture_language_inputs(module, args, kwargs): + captured["loss_mask"] = kwargs["loss_mask"] + + hook = self.model.language_model.register_forward_pre_hook( + capture_language_inputs, with_kwargs=True + ) + try: + output, expanded_loss_mask = self.model( + torch.randn((1, 3, 336, 336), device="cuda"), + input_ids, + torch.zeros_like(input_ids), + attention_mask=None, + labels=torch.full_like(input_ids, -100), + loss_mask=torch.ones_like(input_ids, dtype=torch.float), + num_image_tiles=torch.ones(1, dtype=torch.int, device="cuda"), + ) + finally: + hook.remove() + + assert torch.equal(captured["loss_mask"], expanded_loss_mask) + assert output.shape == expanded_loss_mask.shape == (1, self.model.img_seq_len) + assert torch.count_nonzero(expanded_loss_mask) == 0 + + @pytest.mark.internal + def test_forward_preserves_all_masked_sample_in_mixed_batch(self): + """A fully masked image sample stays masked beside a sample with valid text.""" + self.model.cuda() + + image_token_index = self.model.image_token_index + input_ids = torch.tensor( + [[image_token_index, 20, 21, 22], [10, image_token_index, 11, 12]], device="cuda" + ) + loss_mask = torch.tensor([[1, 0, 0, 0], [1, 1, 1, 1]], dtype=torch.float, device="cuda") + captured = {} + + def capture_language_inputs(module, args, kwargs): + captured["loss_mask"] = kwargs["loss_mask"] + + hook = self.model.language_model.register_forward_pre_hook( + capture_language_inputs, with_kwargs=True + ) + try: + _, expanded_loss_mask = self.model( + torch.randn((2, 3, 336, 336), device="cuda"), + input_ids, + torch.arange(input_ids.shape[1], device="cuda").expand_as(input_ids), + attention_mask=None, + labels=torch.tensor( + [[20, 21, 22, -100], [image_token_index, 11, 12, 13]], device="cuda" + ), + loss_mask=loss_mask, + num_image_tiles=torch.ones(2, dtype=torch.int, device="cuda"), + ) + finally: + hook.remove() + + assert torch.equal(captured["loss_mask"], expanded_loss_mask) + assert torch.count_nonzero(expanded_loss_mask[0]) == 0 + assert torch.count_nonzero(expanded_loss_mask[1]) == 2 + @pytest.mark.internal def test_forward_fsdp(self): """Test FSDP workaround for text-only data. diff --git a/tests/unit_tests/transformer/test_multi_token_prediction.py b/tests/unit_tests/transformer/test_multi_token_prediction.py index c3c3944e007..1216f845a11 100644 --- a/tests/unit_tests/transformer/test_multi_token_prediction.py +++ b/tests/unit_tests/transformer/test_multi_token_prediction.py @@ -421,6 +421,45 @@ def compute_language_model_loss(labels, logits): else: assert output_weight.grad is not None + def test_process_mtp_loss_all_masked_positions_have_zero_gradient(self): + """An all-zero mask must make MTP a finite no-op, including after mask rolling.""" + config = TransformerConfig( + mtp_num_layers=1, + num_layers=2, + hidden_size=8, + num_attention_heads=2, + use_cpu_initialization=True, + ) + seq_len = 4 + hidden_states = torch.randn( + (1 + config.mtp_num_layers) * seq_len, 1, config.hidden_size, requires_grad=True + ) + output_weight = torch.nn.Parameter(torch.randn(16, config.hidden_size)) + + def output_layer(hidden, weight=None, runtime_gather_output=None): + return torch.matmul(hidden, weight.t()), None + + def compute_language_model_loss(labels, logits): + return logits.square().sum(dim=-1).transpose(0, 1) + + result = process_mtp_loss( + hidden_states=hidden_states, + labels=torch.arange(seq_len).unsqueeze(0), + loss_mask=torch.zeros(1, seq_len), + output_layer=output_layer, + output_weight=output_weight, + runtime_gather_output=None, + is_training=False, + compute_language_model_loss=compute_language_model_loss, + config=config, + ) + result.sum().backward() + + assert torch.isfinite(result).all() + assert torch.count_nonzero(hidden_states.grad[seq_len:]) == 0 + assert output_weight.grad is not None + assert torch.count_nonzero(output_weight.grad) == 0 + class TestMultiTokenPrediction: def setup_method(self, method): From 59b72fa57f2059e858cb4bb5c094e62cc590754f Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 6 Aug 2026 02:08:22 -0700 Subject: [PATCH 214/290] Replace lazy MFSDP context initialization with an explicit scope (#6190) Signed-off-by: Jingyue Wu --- .../distributed/fsdp/mcore_fsdp_adapter.py | 30 +-- .../megatron_fsdp/experimental/__init__.py | 3 +- .../megatron_fsdp/experimental/fully_shard.py | 69 +++++-- .../src/megatron_fsdp/experimental/module.py | 137 ++++++------- .../distributed/mfsdp_v2/test_annotation.py | 26 ++- .../distributed/mfsdp_v2/test_context.py | 125 ++++++++++-- .../distributed/mfsdp_v2/test_cuda_graph.py | 8 +- .../distributed/mfsdp_v2/test_fully_shard.py | 188 +++++++++--------- .../distributed/mfsdp_v2/test_optimizer.py | 31 +-- .../mfsdp_v2/test_symmetric_memory.py | 59 +++--- 10 files changed, 408 insertions(+), 268 deletions(-) diff --git a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py index d50cb220a24..a8393fcf147 100644 --- a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py +++ b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py @@ -57,6 +57,7 @@ Flat, Placements, fully_shard, + fully_shard_context, ) HAVE_MEGATRON_FSDP = True @@ -563,19 +564,22 @@ def __init__( placements = Placements( dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()] ) - for submodule in reversed(list(module.modules())): - if submodule is module: - # The root is always sharded after selected child units so it is not - # wrapped twice when its type also appears in fsdp_unit_modules. - continue - if any(isinstance(submodule, module_type) for module_type in fsdp_unit_modules): - fully_shard( - submodule, - mesh=mesh, - placements=placements, - mixed_precision_policy=self.mp_policy, - ) - fully_shard(module, mesh=mesh, placements=placements, mixed_precision_policy=self.mp_policy) + with fully_shard_context(device=device): + for submodule in reversed(list(module.modules())): + if submodule is module: + # The root is always sharded after selected child units so it is not + # wrapped twice when its type also appears in fsdp_unit_modules. + continue + if any(isinstance(submodule, module_type) for module_type in fsdp_unit_modules): + fully_shard( + submodule, + mesh=mesh, + placements=placements, + mixed_precision_policy=self.mp_policy, + ) + fully_shard( + module, mesh=mesh, placements=placements, mixed_precision_policy=self.mp_policy + ) super().__init__(config=config, module=module) @staticmethod diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py index bae27be831c..7c6dc8ef5ae 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py @@ -15,7 +15,7 @@ """Experimental Megatron-FSDP implementation.""" from .dbuffer import DBuffer -from .fully_shard import fully_shard, microbatch +from .fully_shard import fully_shard, fully_shard_context, microbatch from .optimizer import fully_shard_optimizer from .placement import Flat, Partial, Placement, Placements, Replicate @@ -27,6 +27,7 @@ "Placements", "Replicate", "fully_shard", + "fully_shard_context", "fully_shard_optimizer", "microbatch", ] diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py index 076a5ee227e..135382ab1f1 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py @@ -17,7 +17,9 @@ import dataclasses from collections.abc import Iterator from contextlib import contextmanager +from contextvars import ContextVar +import torch from torch import nn from torch.distributed import DeviceMesh @@ -25,6 +27,38 @@ from .module import FsdpContext, FsdpModule from .placement import MeshAxis, Placements +_FSDP_CONTEXT = ContextVar[FsdpContext | None]("megatron_fsdp_context", default=None) + + +@contextmanager +def fully_shard_context(device: torch.device | None = None) -> Iterator[FsdpContext]: + """Construct FSDP modules that share runtime streams and prefetch orders. + + Independent roots are ordered by their root-level ``fully_shard`` calls. + Construction must finish before any of the registered modules run forward. + + Args: + device: CUDA device on which to create communication streams. Defaults to + the current CUDA device. + """ + if _FSDP_CONTEXT.get() is not None: + raise RuntimeError("fully_shard_context does not support nesting.") + + device = device or torch.device("cuda", torch.cuda.current_device()) + if device.type != "cuda": + raise ValueError(f"fully_shard_context requires a CUDA device, got {device}.") + + context = FsdpContext(device=device) + token = _FSDP_CONTEXT.set(context) + try: + yield context + except Exception: + raise + else: + context.finalize() + finally: + _FSDP_CONTEXT.reset(token) + def fully_shard( module: nn.Module, @@ -49,6 +83,15 @@ def fully_shard( """ if isinstance(module, FsdpModule): raise ValueError("This module is already managed by FSDP.") + context = _FSDP_CONTEXT.get() + if context is None: + raise RuntimeError("fully_shard must run inside fully_shard_context.") + for submodule in module.modules(): + if isinstance(submodule, FsdpModule) and submodule.context is not context: + raise ValueError( + "Cannot fully_shard a module containing an FSDP child from another " + "fully_shard_context." + ) placements = _normalize_placements(mesh, placements) mixed_precision_policy = mixed_precision_policy or MixedPrecisionPolicy() @@ -58,6 +101,7 @@ def fully_shard( assert isinstance(module, FsdpModule) FsdpModule.__init__( module, + context=context, mesh=mesh, placements=placements, mixed_precision_policy=mixed_precision_policy, @@ -90,7 +134,7 @@ def _axis_index(mesh: DeviceMesh, axis: MeshAxis) -> int: @contextmanager -def microbatch(module: nn.Module, is_last: bool) -> Iterator[None]: +def microbatch(context: FsdpContext, is_last: bool) -> Iterator[None]: """Mark an FSDP microbatch as the last accumulation microbatch. At present, this is only needed for HSDP/HFSDP gradient accumulation, so @@ -98,20 +142,17 @@ def microbatch(module: nn.Module, is_last: bool) -> Iterator[None]: parallelism finalizes gradients on every backward and does not need it. Args: - module: Module tree whose FSDP roots should use this microbatch state. + context: FSDP context whose roots should use this microbatch state. is_last: Whether forwards in this scope are for the last microbatch. """ - contexts: list[FsdpContext] = [] - _collect_fsdp_contexts(module, contexts) - previous_states = [(context, context.is_last_microbatch) for context in contexts] - for context in contexts: - context.is_last_microbatch = is_last + context.ensure_finalized() + previous_state = context.is_last_microbatch + context.is_last_microbatch = is_last try: yield finally: - for context, is_last_microbatch in previous_states: - context.is_last_microbatch = is_last_microbatch + context.is_last_microbatch = previous_state def _attach_mixin(module: nn.Module) -> None: @@ -120,13 +161,3 @@ def _attach_mixin(module: nn.Module) -> None: module_cls = module.__class__ fsdp_cls = type(f"ExperimentalFsdp{module_cls.__name__}", (FsdpModule, module_cls), {}) module.__class__ = fsdp_cls - - -def _collect_fsdp_contexts(module: nn.Module, contexts: list[FsdpContext]) -> None: - if isinstance(module, FsdpModule): - module._lazy_init_context() - contexts.append(module.context) - return - - for child in module.children(): - _collect_fsdp_contexts(child, contexts) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index f26a3a35d94..6f0e6c5170f 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -16,7 +16,7 @@ from collections.abc import Callable from typing import Literal, cast -from weakref import ReferenceType, ref +from weakref import ref import torch from torch import nn @@ -29,7 +29,7 @@ class FsdpContext: - """Runtime stream and prefetch state shared by one FSDP subtree.""" + """Runtime stream and prefetch state shared by FSDP roots constructed together.""" allgather_stream: torch.cuda.Stream reduce_scatter_stream: torch.cuda.Stream @@ -37,38 +37,70 @@ class FsdpContext: # unnecessary because it can be detected when ``model_weight``, after syncing # from ``main_weight``, has placements different from ``Placements.optimizer``. is_last_microbatch: bool - # A context is owned by its FSDP module tree, so runtime backedges to modules - # must be weak; otherwise deleting the tree requires cyclic GC. - _root_module: ReferenceType["FsdpModule"] # Static orders used to drive all-gather prefetch. We may want to switch to # capturing runtime order if static module order proves too fragile. Each # FsdpModule tracks its own materialized state via ``FsdpModule._unshard_event``. forward_order: IndexedOrder["FsdpModule"] backward_order: IndexedOrder["FsdpModule"] - def __init__(self, device: torch.device, root_module: "FsdpModule") -> None: - """Create rank-local runtime state for a root FSDP subtree. + def __init__(self, device: torch.device) -> None: + """Create rank-local runtime state for FSDP modules on ``device``. Args: device: Device on which this context schedules communication. - root_module: Outermost module that owns this context. """ - self._root_module = ref(root_module) self.is_last_microbatch = True self.forward_order = IndexedOrder() self.backward_order = IndexedOrder() + # Construction-only; empty after finalization. + self._registered_modules: list[FsdpModule] = [] + self._is_finalized = False with torch.cuda.device(device): self.allgather_stream = torch.cuda.Stream() self.reduce_scatter_stream = torch.cuda.Stream() + def register_module(self, module: "FsdpModule") -> None: + """Register a module constructed in this context.""" + if self._is_finalized: + raise RuntimeError("Cannot register an FSDP module after its context is finalized.") + self._registered_modules.append(module) + + def finalize(self) -> None: + """Finalize roots, names, and cross-root prefetch orders.""" + if self._is_finalized: + raise RuntimeError("FSDP context is already finalized.") + + children: set[FsdpModule] = set() + for module in self._registered_modules: + _collect_fsdp_children(cast(nn.Module, module), children) + # FsdpModules that are not descendants of any other FsdpModule. + roots = [module for module in self._registered_modules if module not in children] + + for root in roots: + root._is_root = True + for name, module in cast(nn.Module, root).named_modules(): + if not isinstance(module, FsdpModule): + continue + module._name = name + self.forward_order.append(module) + + for root in reversed(roots): + _collect_backward_order(cast(nn.Module, root), self.backward_order) + + self._registered_modules.clear() + self._is_finalized = True + + def ensure_finalized(self) -> None: + """Raise if construction has not completed for this context.""" + if not self._is_finalized: + raise RuntimeError( + "FSDP context is not finalized. Exit fully_shard_context before running forward." + ) + def current_stream(self) -> torch.cuda.Stream: """Current stream on this context's device.""" return torch.cuda.current_stream(self.allgather_stream.device) - def is_module_root(self, module: "FsdpModule") -> bool: - """Return whether ``module`` is this context's root module.""" - return self._root_module() is module - def register_post_backward_final_callback(self) -> None: """Register this root context's final callback for the current backward. @@ -91,8 +123,9 @@ class FsdpModule: # Root uses "" and None means uninitialized. _name: str | None _parameter_groups: tuple[FsdpParameterGroup, ...] - _context: FsdpContext | None + _context: FsdpContext _num_ready_grad_parameters: int + _is_root: bool _num_trainable_parameters: int # Event recorded after this FsdpModule's full parameters are materialized. # ``None`` lets pre_forward enqueue an all-gather unless an earlier FsdpModule @@ -101,13 +134,15 @@ class FsdpModule: def __init__( self, + context: FsdpContext, mesh: DeviceMesh, placements: Placements, mixed_precision_policy: MixedPrecisionPolicy, use_symm_mem: bool = False, ) -> None: """Initialize FSDP runtime state on an already-constructed module.""" - self._context = None + self._context = context + self._is_root = False self._name = None self._unshard_event = None owned_parameters = _collect_owned_parameters(self) @@ -131,60 +166,11 @@ def __init__( len(group.fsdp_parameters) for group in self._parameter_groups if group.requires_grad ) self._register_hooks() - - def _lazy_init_context(self) -> None: - """Initialize one shared runtime context for this FSDP root subtree. - - MFSDP v2 requires users to apply ``fully_shard`` bottom-up, so child FSDP - modules are constructed before their eventual root module is constructed. - This method resolves the root lazily on the first forward through the - outermost FSDP module and shares that one context with every FSDP - descendant. - - Alternatives considered: - - Eagerly initialize contexts during ``fully_shard``. When a parent is - sharded, we could create a new root context and reassign it to all - descendant FSDP modules. This creates transient child contexts that are - never used if the parent is later sharded, and each parent shard must - walk its descendants again, making nested sharding quadratic. - - Store an ``is_root`` field on each FSDP module. ``fully_shard`` could - mark newly sharded modules as roots and clear that flag on descendant - FSDP modules when a parent is sharded. This avoids creating unused - contexts but moves root tracking onto every FSDP module, adding - per-module state that must stay consistent with the final sharded - module hierarchy. - """ - if self._context is not None: - return - - root_module = cast(nn.Module, self) - first_parameter = next(root_module.parameters(), None) - if first_parameter is None: - raise RuntimeError("FSDP root module requires at least one parameter in its subtree.") - - context = FsdpContext(device=first_parameter.device, root_module=self) - # named_modules() yields FsdpModules in registration order, which is the static - # forward execution order used to prefetch the next FsdpModule's all-gather. - for submodule_name, submodule in root_module.named_modules(): - if not isinstance(submodule, FsdpModule): - continue - if submodule._context is not None: - raise RuntimeError( - "FSDP context is already initialized for a descendant module. " - "Run forward through the root FSDP module first." - ) - submodule._context = context - submodule._name = submodule_name - context.forward_order.append(submodule) - - # Backward starts from the root pre-backward hook before visiting child - # subtrees in reverse module order. - _collect_backward_order(root_module, context.backward_order) + context.register_module(self) @property def context(self) -> FsdpContext: - """Return the initialized runtime context.""" - assert self._context is not None + """Return the FSDP context.""" return self._context @property @@ -196,8 +182,8 @@ def name(self) -> str: return name def is_root(self) -> bool: - """Return whether this module is the outermost FsdpModule in its context.""" - return self.context.is_module_root(self) + """Return whether this module is an outermost FsdpModule in its context.""" + return self._is_root def _register_hooks(self) -> None: module = cast(nn.Module, self) @@ -249,10 +235,10 @@ def pre_forward(self) -> None: While this FsdpModule computes, we issue the next FsdpModule's all-gather on the comm stream, so ``AG_{i+1}`` is launched before ``F_i`` finishes. """ - self._lazy_init_context() + context = self.context + context.ensure_finalized() torch.cuda.nvtx.range_push(self._nvtx_label("forward")) self._num_ready_grad_parameters = 0 - context = self.context allgather_stream = context.allgather_stream current_stream = context.current_stream() @@ -370,7 +356,7 @@ def _nvtx_label(self, phase: Literal["forward", "backward"]) -> str: def _collect_backward_order(module: nn.Module, order: IndexedOrder["FsdpModule"]) -> None: - """Collect FsdpModules in static backward prefetch order.""" + """Collect one root's static backward prefetch order.""" if isinstance(module, FsdpModule): order.append(module) @@ -378,6 +364,15 @@ def _collect_backward_order(module: nn.Module, order: IndexedOrder["FsdpModule"] _collect_backward_order(child, order) +def _collect_fsdp_children(module: nn.Module, children: set["FsdpModule"]) -> None: + """Collect the nearest FSDP descendants of ``module``.""" + for child in module.children(): + if isinstance(child, FsdpModule): + children.add(child) + else: + _collect_fsdp_children(child, children) + + def _collect_owned_parameters(root_module: nn.Module) -> dict[str, nn.Parameter]: parameters: dict[str, nn.Parameter] = {} diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py b/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py index f65e0b85724..e5624bfb501 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_annotation.py @@ -14,6 +14,7 @@ Flat, Placements, fully_shard, + fully_shard_context, ) _NVTX_LABEL_PATTERN = re.compile(r"MFSDP (.+) (forward|backward)") @@ -95,8 +96,9 @@ def test_fsdp_sibling_roots_emit_root_nvtx_ranges_after_training_step( _setup_nvtx_recording(monkeypatch, events) model = NestedLinearModel(dim=4).to(distributed_setup.device) mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) - fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) - fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=distributed_setup.device): + fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) model(torch.ones(2, 4, device=distributed_setup.device)).sum().backward() @@ -118,9 +120,10 @@ def test_fsdp_training_hooks_emit_stacked_nvtx_ranges(distributed_setup, monkeyp _setup_nvtx_recording(monkeypatch, events) model = NestedLinearModel(dim=4).to(distributed_setup.device) mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) - fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) - fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=distributed_setup.device): + fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) model(torch.ones(2, 4, device=distributed_setup.device)).sum().backward() @@ -148,7 +151,8 @@ def test_fsdp_frozen_parameters_emit_balanced_backward_nvtx_range(distributed_se for parameter in model.parameters(): parameter.requires_grad_(False) mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=distributed_setup.device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) x = torch.ones(2, 4, device=distributed_setup.device, requires_grad=True) model(x).sum().backward() @@ -171,9 +175,10 @@ def test_fsdp_frozen_child_without_grad_inputs_skips_backward_nvtx_range( for parameter in model.layers[0].parameters(): parameter.requires_grad_(False) mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) - fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) - fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=distributed_setup.device): + fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) model(torch.ones(2, 4, device=distributed_setup.device)).sum().backward() @@ -197,7 +202,8 @@ def test_tied_child_parameters_complete_backward_once_per_cycle(distributed_setu _setup_nvtx_recording(monkeypatch, events) model = TiedLM() mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=distributed_setup.device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) token_ids = torch.arange(8, device=distributed_setup.device).reshape(2, 4) for _ in range(2): diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_context.py b/tests/unit_tests/distributed/mfsdp_v2/test_context.py index 102b2fde332..f0bd1ce5fbe 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_context.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_context.py @@ -2,6 +2,7 @@ """Unit tests for experimental Megatron-FSDP runtime contexts.""" +import pytest import torch from torch import nn from torch.distributed.device_mesh import init_device_mesh @@ -10,6 +11,7 @@ Flat, Placements, fully_shard, + fully_shard_context, ) @@ -74,14 +76,17 @@ def _flat_placements() -> Placements: def test_child_then_parent_share_one_context(distributed_setup): - """A parent FsdpModule should lazily create one context for its subtree.""" + """Modules constructed together should eagerly share one context.""" device = distributed_setup.device mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) - model = NestedModel().to(device) + model = NestedModel() - fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device) as context: + fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + assert model.context is context + assert model.inner.context is context with torch.no_grad(): model(torch.ones(2, 4, device=device)) @@ -91,16 +96,17 @@ def test_child_then_parent_share_one_context(distributed_setup): assert not model.inner.is_root() -def test_two_child_subtrees_then_parent_collapse_to_one_context(distributed_setup): - """Sharding a parent should lazily assign one context across child subtrees.""" +def test_two_child_subtrees_then_parent_share_one_context(distributed_setup): + """One construction scope should assign one context across child subtrees.""" device = distributed_setup.device mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) model = MultiChildModel(dim=4, num_children=2).to(device) - fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) - fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) with torch.no_grad(): model(torch.ones(2, 4, device=device)) @@ -109,22 +115,26 @@ def test_two_child_subtrees_then_parent_collapse_to_one_context(distributed_setu assert model.layers[1].context is model.context -def test_sibling_roots_without_parent_keep_separate_contexts(distributed_setup): - """Independent FSDP roots should not share runtime scheduling state.""" +def test_sibling_roots_share_context_and_cross_root_orders(distributed_setup): + """Independent roots should share streams and follow construction order.""" device = distributed_setup.device mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) model = MultiChildModel(dim=4, num_children=2).to(device) - fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) - fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.layers[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model.layers[1], mesh=mesh, placements=_flat_placements()) with torch.no_grad(): model(torch.ones(2, 4, device=device)) - assert model.layers[0].context is not model.layers[1].context + context = model.layers[0].context + assert model.layers[1].context is context assert model.layers[0].is_root() assert model.layers[1].is_root() + assert list(context.forward_order) == [model.layers[0], model.layers[1]] + assert list(context.backward_order) == [model.layers[1], model.layers[0]] def test_nested_prefetch_orders_use_dfs(distributed_setup): @@ -134,10 +144,11 @@ def test_nested_prefetch_orders_use_dfs(distributed_setup): mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) model = NestedSiblingModel(dim=4).to(device) - fully_shard(model.left.inner, mesh=mesh, placements=_flat_placements()) - fully_shard(model.left, mesh=mesh, placements=_flat_placements()) - fully_shard(model.right, mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.left.inner, mesh=mesh, placements=_flat_placements()) + fully_shard(model.left, mesh=mesh, placements=_flat_placements()) + fully_shard(model.right, mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) with torch.no_grad(): model(torch.ones(2, 4, device=device)) @@ -145,3 +156,81 @@ def test_nested_prefetch_orders_use_dfs(distributed_setup): context = model.context assert list(context.forward_order) == [model, model.left, model.left.inner, model.right] assert list(context.backward_order) == [model, model.right, model.left, model.left.inner] + + +def test_nested_and_sibling_roots_use_cross_root_orders(distributed_setup): + """Context orders should concatenate nested roots at construction boundaries.""" + device = distributed_setup.device + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + model = NestedSiblingModel(dim=4).to(device) + + with fully_shard_context(device=device): + fully_shard(model.left.inner, mesh=mesh, placements=_flat_placements()) + fully_shard(model.left, mesh=mesh, placements=_flat_placements()) + fully_shard(model.right, mesh=mesh, placements=_flat_placements()) + + context = model.left.context + assert model.left.is_root() + assert model.right.is_root() + assert not model.left.inner.is_root() + assert list(context.forward_order) == [model.left, model.left.inner, model.right] + assert list(context.backward_order) == [model.right, model.left, model.left.inner] + + +def test_fully_shard_requires_context(distributed_setup): + """fully_shard should reject construction without an active context.""" + device = distributed_setup.device + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + model = nn.Linear(4, 4, bias=False).to(device) + + with pytest.raises(RuntimeError, match="inside fully_shard_context"): + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + +def test_forward_requires_finalized_context(distributed_setup): + """Forward should be unavailable until construction scope exit.""" + device = distributed_setup.device + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + model = nn.Linear(4, 4, bias=False).to(device) + x = torch.ones(2, 4, device=device) + + with fully_shard_context(device=device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) + with pytest.raises(RuntimeError, match="Exit fully_shard_context"): + model(x) + + model(x) + + +def test_fully_shard_context_rejects_nesting(distributed_setup): + """A construction scope should reject an ambiguous nested context.""" + device = distributed_setup.device + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + model = nn.ModuleList([nn.Linear(4, 4, bias=False) for _ in range(2)]).to(device) + + with fully_shard_context(device=device): + fully_shard(model[0], mesh=mesh, placements=_flat_placements()) + outer_context = model[0].context + with pytest.raises(RuntimeError, match="does not support nesting"): + with fully_shard_context(device=device): + pass + fully_shard(model[1], mesh=mesh, placements=_flat_placements()) + + assert model[0].context is outer_context + assert model[1].context is outer_context + + +def test_fully_shard_rejects_child_from_another_context(distributed_setup): + """A parent cannot join a context different from an FSDP child context.""" + device = distributed_setup.device + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + model = NestedModel() + + with fully_shard_context(device=device) as first_context: + fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) + + with fully_shard_context(device=device): + with pytest.raises(ValueError, match="another fully_shard_context"): + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + assert model.inner.context is first_context diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py b/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py index 08920bcea98..86d764f72f7 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_cuda_graph.py @@ -12,6 +12,7 @@ Flat, Placements, fully_shard, + fully_shard_context, ) logger = logging.getLogger(__name__) @@ -51,9 +52,10 @@ def test_captures_full_iteration(distributed_setup): static_target = torch.zeros_like(static_input) placements = _flat_placements() - for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=placements) - fully_shard(model, mesh=mesh, placements=placements) + with fully_shard_context(device=device): + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements) + fully_shard(model, mesh=mesh, placements=placements) optimizer = torch.optim.SGD(model.parameters(), lr=0.25, foreach=False) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 1a736d1e996..357d765f63e 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -18,6 +18,7 @@ Placements, Replicate, fully_shard, + fully_shard_context, fully_shard_optimizer, microbatch, ) @@ -173,8 +174,9 @@ def test_fully_shard_sgd_losses_match_baseline(distributed_setup, num_microbatch model = TinyModel().to(device) model.load_state_dict(baseline.state_dict()) - fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) - fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) baseline_optimizer = torch.optim.SGD(baseline.parameters(), lr=0.05) optimizer = torch.optim.SGD(model.parameters(), lr=0.05) @@ -244,11 +246,10 @@ def test_hsdp_losses_match_baseline(distributed_setup, num_microbatches, set_to_ model = MultiChildModel(dim=dim, num_children=2).to(device) model.load_state_dict(baseline.state_dict()) - # Shard the child layers, then the model, so the children share a root context - # and reduce through the overlap path instead of as independent roots. - for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=_hsdp_placements()) - fully_shard(model, mesh=mesh, placements=_hsdp_placements()) + with fully_shard_context(device=device) as context: + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=_hsdp_placements()) + fully_shard(model, mesh=mesh, placements=_hsdp_placements()) baseline_optimizer = torch.optim.SGD(baseline.parameters(), lr=0.05) optimizer = torch.optim.SGD(model.parameters(), lr=0.05) @@ -264,7 +265,7 @@ def train(model, optimizer, log_prefix) -> list[torch.Tensor]: for microbatch_index, (microbatch_x, microbatch_target) in enumerate(microbatches): is_last = microbatch_index == num_microbatches - 1 - with microbatch(model, is_last=is_last): + with microbatch(context, is_last=is_last): loss = torch.nn.functional.mse_loss(model(microbatch_x), microbatch_target) (loss / num_microbatches).backward() losses.append(loss.detach()) @@ -293,13 +294,10 @@ def train(model, optimizer, log_prefix) -> list[torch.Tensor]: def test_hsdp_defers_dp_outer_allreduce_to_last_microbatch(distributed_setup): """HSDP reduce-scatters DP-inner every microbatch but all-reduces DP-outer once. - ``fully_shard(model)`` makes the child units share a root context so their - reductions run through the overlap path rather than as independent roots. - Counting linked NCCL kernels over a multi-microbatch step, the DP-inner reduce-scatter - fires once per microbatch per group while the DP-outer all-reduce that - finalizes main_grad fires only on the last microbatch, so the reduce-scatter - count is exactly ``num_microbatches`` times the all-reduce count. This asserts - on kernel counts only, not numerics. + Counting linked NCCL kernels over a multi-microbatch step, the DP-inner + reduce-scatter fires once per microbatch per group while the DP-outer + all-reduce that finalizes main_grad fires only on the last microbatch. This + asserts on kernel counts only, not numerics. """ world_size = distributed_setup.world_size device = distributed_setup.device @@ -315,9 +313,10 @@ def test_hsdp_defers_dp_outer_allreduce_to_last_microbatch(distributed_setup): dim = 8 num_children = 2 model = MultiChildModel(dim=dim, num_children=num_children).to(device) - for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=_hsdp_placements()) - fully_shard(model, mesh=mesh, placements=_hsdp_placements()) + with fully_shard_context(device=device) as context: + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=_hsdp_placements()) + fully_shard(model, mesh=mesh, placements=_hsdp_placements()) optimizer = torch.optim.SGD(model.parameters(), lr=0.05) num_microbatches = 3 @@ -330,7 +329,7 @@ def train_one_step() -> None: optimizer.zero_grad(set_to_none=True) for microbatch_index, (microbatch_x, microbatch_target) in enumerate(microbatches): is_last = microbatch_index == num_microbatches - 1 - with microbatch(model, is_last=is_last): + with microbatch(context, is_last=is_last): loss = torch.nn.functional.mse_loss(model(microbatch_x), microbatch_target) (loss / num_microbatches).backward() optimizer.step() @@ -365,8 +364,9 @@ def test_nested_fully_shard_excludes_child_owned_parameters(distributed_setup): mesh = init_device_mesh(device.type, (world_size,)) model = NestedModel().to(device) - fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.inner, mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) (inner_group,) = model.inner.parameter_groups (outer_group,) = model.parameter_groups @@ -379,7 +379,8 @@ def test_tied_child_parameters_allocate_one_physical_weight(distributed_setup): """Tied registrations should allocate one DBuffer entry and optimizer parameter.""" model = TiedLM() mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=distributed_setup.device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) (parameter_group,) = model.parameter_groups (parameter,) = parameter_group.fsdp_parameters @@ -403,9 +404,9 @@ def test_forward_peak_memory_bounds_in_flight_child_all_gathers(distributed_setu model = MultiChildModel(dim=dim, num_children=4).to(dtype=dtype, device=device) placements = _flat_placements() policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) - for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) - fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + with fully_shard_context(device=device): + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) x = torch.randn(2, dim, device=device, dtype=dtype) with torch.no_grad(): @@ -446,7 +447,8 @@ def test_deleted_model_releases_fsdp_storage(distributed_setup): # only detects storage retained by the deleted FSDP model itself. allocated_before = torch.cuda.memory_allocated(device) model = ElementwiseModel(dim=8192).to(dtype=torch.bfloat16, device=device) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) x = torch.ones(1, 8192, dtype=torch.bfloat16, device=device) output = model(x) @@ -470,9 +472,10 @@ def test_root_forward_returns_to_resting_memory(distributed_setup): model = MultiChildModel(dim=dim, num_children=2).to(dtype=dtype, device=device) placements = _flat_placements() policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) - for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) - fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + with fully_shard_context(device=device): + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) x = torch.randn(2, dim, device=device, dtype=dtype) torch.cuda.synchronize(device) @@ -508,9 +511,10 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): model = MultiChildModel(dim=dim, num_children=2).to(dtype=dtype, device=device) placements = _flat_placements() policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) - for layer in model.layers: - fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) - fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) + with fully_shard_context(device=device): + for layer in model.layers: + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) + fully_shard(model, mesh=mesh, placements=placements, mixed_precision_policy=policy) x = torch.randn(2, dim, device=device, dtype=dtype, requires_grad=True) output = model(x) @@ -578,24 +582,18 @@ def test_overlaps_communication_and_compute(distributed_setup, use_symm_mem): dp_group = dist.new_group(backend="nccl") mesh = DeviceMesh.from_group(dp_group, device.type) - model = MultiChildModel(dim=dim, num_children=num_children).to(dtype=dtype) + model = MultiChildModel(dim=dim, num_children=num_children).to(device=device, dtype=dtype) placements = _flat_placements() policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) - for layer in model.layers: - fully_shard( - layer, - mesh=mesh, - placements=placements, - mixed_precision_policy=policy, - use_symm_mem=use_symm_mem, - ) - fully_shard( - model, - mesh=mesh, - placements=placements, - mixed_precision_policy=policy, - use_symm_mem=use_symm_mem, - ) + with fully_shard_context(device=device): + for layer in model.layers: + fully_shard( + layer, + mesh=mesh, + placements=placements, + mixed_precision_policy=policy, + use_symm_mem=use_symm_mem, + ) x = torch.randn(4096, dim, device=device, dtype=dtype, requires_grad=True) @@ -625,17 +623,16 @@ def train_one_iteration() -> None: allgather_kernels = collect_linked_kernels(prof, _ALL_GATHER_OP_NAME_SUBSTRING) reduce_scatter_kernels = collect_linked_kernels(prof, _REDUCE_SCATTER_OP_NAME_SUBSTRING) - # The num_children child layers plus the root are each a sharded module; each does a - # forward and a backward all-gather and one reduce-scatter. Zero-CTA moves the - # all-gather to copy-engine memcpys, so it should not emit all-gather kernels. - num_sharded_modules = num_children + 1 - expected_allgather_kernel_count = 0 if use_symm_mem else 2 * num_sharded_modules + # Each child layer does a forward and a backward all-gather and one + # reduce-scatter. Zero-CTA moves the all-gather to copy-engine memcpys, so it + # should not emit all-gather kernels. + expected_allgather_kernel_count = 0 if use_symm_mem else 2 * num_children assert len(allgather_kernels) == expected_allgather_kernel_count, ( f"Expected {expected_allgather_kernel_count} all-gather kernels, got " f"{len(allgather_kernels)}: {[kernel.name for kernel in allgather_kernels]}" ) - assert len(reduce_scatter_kernels) == num_sharded_modules, ( - f"Expected {num_sharded_modules} reduce-scatter kernels, got " + assert len(reduce_scatter_kernels) == num_children, ( + f"Expected {num_children} reduce-scatter kernels, got " f"{len(reduce_scatter_kernels)}: {[kernel.name for kernel in reduce_scatter_kernels]}" ) @@ -681,9 +678,10 @@ def test_parameterless_parent_with_child_modules_trains(distributed_setup): torch.manual_seed(5678) model = nn.Sequential(nn.Linear(4, 4, bias=False), nn.Linear(4, 2, bias=False)).to(device) - fully_shard(model[0], mesh=mesh, placements=_flat_placements()) - fully_shard(model[1], mesh=mesh, placements=_flat_placements()) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model[0], mesh=mesh, placements=_flat_placements()) + fully_shard(model[1], mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) assert model.parameter_groups == () @@ -707,7 +705,8 @@ def test_frozen_parameter_group_does_not_allocate_main_grad(distributed_setup): model = nn.Linear(4, 4, bias=False).to(device) model.weight.requires_grad_(False) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) (group,) = model.parameter_groups assert not group.requires_grad @@ -727,7 +726,8 @@ def test_backward_averages_across_dp_and_accumulates_across_calls(distributed_se with torch.no_grad(): model.weight.fill_(1.0) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) x = torch.full((1, 1), float(rank + 1), device=device) model(x).sum().backward() @@ -751,12 +751,13 @@ def test_next_forward_uses_optimizer_updated_weights(distributed_setup): with torch.no_grad(): model.weight.fill_(1.0) - fully_shard( - model, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=MixedPrecisionPolicy(main_params_dtype=torch.float32), - ) + with fully_shard_context(device=device): + fully_shard( + model, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=MixedPrecisionPolicy(main_params_dtype=torch.float32), + ) # SGD's foreach/fused CUDA paths require matching parameter and gradient dtypes. # Use the scalar path to exercise FP32 main weights with default BF16 main grads. optimizer = torch.optim.SGD(model.parameters(), lr=0.25, foreach=False) @@ -786,8 +787,9 @@ def test_optimizer_post_step_syncs_once_per_parameter_group(distributed_setup, m mesh = init_device_mesh(device.type, (world_size,)) model = TinyModel().to(device=device, dtype=torch.bfloat16) - fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) - fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) parameter_groups = (*model.fc1.parameter_groups, *model.fc2.parameter_groups) sync_counts = {parameter_group: 0 for parameter_group in parameter_groups} @@ -830,8 +832,9 @@ def test_fully_shard_adam_mixed_precision_losses_match_baseline(distributed_setu baseline = TinyModel().to(device=device, dtype=torch.bfloat16) model = TinyModel().to(device=device, dtype=torch.bfloat16) model.load_state_dict(baseline.state_dict()) - fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) - fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) baseline_optimizer = torch.optim.Adam(baseline.parameters(), lr=0.01) optimizer = torch.optim.Adam(model.parameters(), lr=0.01) @@ -854,22 +857,21 @@ def test_fully_shard_adam_mixed_precision_losses_match_baseline(distributed_setu optimizer.step() -def test_microbatch_scopes_child_contexts(distributed_setup): - """microbatch() should scope FSDP child contexts under an unwrapped parent.""" +def test_microbatch_scopes_context(distributed_setup): + """microbatch() should scope state on the supplied FSDP context.""" world_size = distributed_setup.world_size device = distributed_setup.device mesh = init_device_mesh(device.type, (world_size,)) model = nn.Sequential(nn.Linear(1, 1, bias=False), nn.Linear(1, 1, bias=False)).to(device) - for layer in model: - fully_shard(layer, mesh=mesh, placements=_flat_placements()) - - with microbatch(model, is_last=False): + with fully_shard_context(device=device) as context: for layer in model: - assert not layer.context.is_last_microbatch + fully_shard(layer, mesh=mesh, placements=_flat_placements()) - for layer in model: - assert layer.context.is_last_microbatch + with microbatch(context, is_last=False): + assert not context.is_last_microbatch + + assert context.is_last_microbatch def test_cpu_initialized_parameters_shard_to_mesh_device(distributed_setup): @@ -887,7 +889,8 @@ def test_cpu_initialized_parameters_shard_to_mesh_device(distributed_setup): # Shard the second layer's parameters onto the mesh device; the unwrapped # first layer's parameters remain on CPU until model.to(device) below. - fully_shard(model[1], mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model[1], mesh=mesh, placements=_flat_placements()) assert model[0].weight.device.type == "cpu" assert isinstance(model[1].weight, DTensor) @@ -910,7 +913,8 @@ def test_meta_parameters_shard_to_mesh_device(distributed_setup): nn.Linear(4, 4, bias=False, device="meta", dtype=torch.bfloat16), ) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) with torch.no_grad(): model[0].weight.fill_(2.0) @@ -934,7 +938,8 @@ def test_non_leaf_parameter_view_survives_storage_resize(distributed_setup): mesh = init_device_mesh(device.type, (world_size,)) model = NonLeafViewModel().to(device) - fully_shard(model, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model, mesh=mesh, placements=_flat_placements()) group = model.parameter_groups[0] x = torch.randn(8, device=device, requires_grad=True) @@ -986,15 +991,16 @@ def train_steps(model: nn.Module, optimizer: torch.optim.Optimizer, x: torch.Ten torch.manual_seed(4321) model = nn.Sequential(*[nn.Linear(dim, dim, dtype=dtype) for _ in range(layers)]).to(device) - for layer in model: - fully_shard( - layer, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=MixedPrecisionPolicy( - main_params_dtype=dtype, main_grads_dtype=dtype - ), - ) + with fully_shard_context(device=device): + for layer in model: + fully_shard( + layer, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=MixedPrecisionPolicy( + main_params_dtype=dtype, main_grads_dtype=dtype + ), + ) optimizer = torch.optim.AdamW(model.parameters(), lr=0.01) torch.cuda.empty_cache() diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py b/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py index 4ba4d1be271..c3427073993 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_optimizer.py @@ -12,6 +12,7 @@ Flat, Placements, fully_shard, + fully_shard_context, fully_shard_optimizer, ) from megatron.core.distributed.fsdp.src.megatron_fsdp.mixed_precision import MixedPrecisionPolicy @@ -42,8 +43,9 @@ def test_adam_without_adapter_raises_precision_error(distributed_setup): mesh = init_device_mesh(device.type, (world_size,)) torch.manual_seed(2026) model = TinyModel().to(device=device, dtype=torch.bfloat16) - fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) - fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + with fully_shard_context(device=device): + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) optimizer = torch.optim.Adam(model.parameters(), lr=0.01) x = torch.randn(6, 8, device=device, dtype=torch.bfloat16) @@ -68,18 +70,19 @@ def test_fused_adam_adapter_accepts_mismatched_grads(distributed_setup): mixed_precision_policy = MixedPrecisionPolicy( main_params_dtype=torch.float32, main_grads_dtype=torch.bfloat16 ) - fully_shard( - model.fc1, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=mixed_precision_policy, - ) - fully_shard( - model.fc2, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=mixed_precision_policy, - ) + with fully_shard_context(device=device): + fully_shard( + model.fc1, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + ) + fully_shard( + model.fc2, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + ) optimizer = FusedAdam(model.parameters(), lr=0.01) fully_shard_optimizer(optimizer, precision_aware=True) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py index 87207548140..6ae3f05a95b 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py @@ -14,6 +14,7 @@ Flat, Placements, fully_shard, + fully_shard_context, ) from tests.unit_tests.distributed.mfsdp_v2.profiler_utils import collect_linked_kernels @@ -62,20 +63,21 @@ def train(use_symm_mem: bool) -> list[torch.Tensor]: torch.manual_seed(1234) model = TinyModel().to(device=device, dtype=torch.bfloat16) mixed_precision_policy = MixedPrecisionPolicy(main_params_dtype=torch.float32) - fully_shard( - model.fc1, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=mixed_precision_policy, - use_symm_mem=use_symm_mem, - ) - fully_shard( - model.fc2, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=mixed_precision_policy, - use_symm_mem=use_symm_mem, - ) + with fully_shard_context(device=device): + fully_shard( + model.fc1, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + use_symm_mem=use_symm_mem, + ) + fully_shard( + model.fc2, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + use_symm_mem=use_symm_mem, + ) optimizer = torch.optim.SGD(model.parameters(), lr=0.05, foreach=False) micro_batch_size = 2 @@ -186,20 +188,21 @@ def test_fully_shard_zero_cta_moves_all_gather_to_copy_engine(distributed_setup) num_training_steps = 5 model = TinyModel().to(device=device, dtype=torch.bfloat16) mixed_precision_policy = MixedPrecisionPolicy(main_params_dtype=torch.float32) - fully_shard( - model.fc1, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=mixed_precision_policy, - use_symm_mem=True, - ) - fully_shard( - model.fc2, - mesh=mesh, - placements=_flat_placements(), - mixed_precision_policy=mixed_precision_policy, - use_symm_mem=True, - ) + with fully_shard_context(device=device): + fully_shard( + model.fc1, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + use_symm_mem=True, + ) + fully_shard( + model.fc2, + mesh=mesh, + placements=_flat_placements(), + mixed_precision_policy=mixed_precision_policy, + use_symm_mem=True, + ) optimizer = torch.optim.SGD(model.parameters(), lr=0.05, foreach=False) x = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) target = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) From d62ff04fac09bf1e70bdfcda76931b00c4a63292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Fri, 7 Aug 2026 14:04:01 +0200 Subject: [PATCH 215/290] chore(ci): bump community workflow to v1.8.8 (#6322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/community-bot.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/community-bot.yml b/.github/workflows/community-bot.yml index 47a54ec9264..1c89467cd0f 100644 --- a/.github/workflows/community-bot.yml +++ b/.github/workflows/community-bot.yml @@ -21,9 +21,11 @@ on: jobs: community-bot: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@f0dadfd1b2d5c3f48a24ded127abd50afbf8ce11 # v0.65.10 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@0af357dc15c04c3f28d33478d5055b1ca88a9ea1 # v1.8.8 with: community_project_id: ${{ vars.COMMUNITY_PROJECT_ID }} + app-id: ${{ vars.BOT_ID }} if: github.repository == 'NVIDIA/Megatron-LM' secrets: + BOT_KEY: ${{ secrets.BOT_KEY }} GH_TOKEN: ${{ secrets.PAT }} From 07a0de65ad95fb4fb999809f260d8b3868e60a97 Mon Sep 17 00:00:00 2001 From: liuyun7345 <51505092+liuyun7345@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:19:55 +0800 Subject: [PATCH 216/290] fix(moe): free activations immediately in full recompute mode (#5197) Signed-off-by: liuyun7345 Co-authored-by: Cursor Co-authored-by: Guihong Li Co-authored-by: Xin Yao --- megatron/core/transformer/moe/moe_utils.py | 9 +- .../moe/test_moe_recompute_memory.py | 164 ++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/transformer/moe/test_moe_recompute_memory.py diff --git a/megatron/core/transformer/moe/moe_utils.py b/megatron/core/transformer/moe/moe_utils.py index dfdb9a14460..1d0768c4a97 100644 --- a/megatron/core/transformer/moe/moe_utils.py +++ b/megatron/core/transformer/moe/moe_utils.py @@ -615,7 +615,14 @@ def unpermute( output_tokens.scatter_add_( 0, sorted_indices.unsqueeze(1).expand(-1, hidden), permuted_tokens ) - return output_tokens.to(dtype=input_dtype) + out = output_tokens.to(dtype=input_dtype) + # Explicitly release intermediate tensor references to enable CUDA + # caching allocator to reclaim memory immediately during full + # recomputation. Without this, scatter_add_/index_add_ autograd + # references prevent GC until the next training iteration. + # See: https://github.com/NVIDIA/Megatron-LM/issues/3221 + del output_tokens, permuted_tokens, sorted_indices + return out def sort_chunks_by_idxs( diff --git a/tests/unit_tests/transformer/moe/test_moe_recompute_memory.py b/tests/unit_tests/transformer/moe/test_moe_recompute_memory.py new file mode 100644 index 00000000000..ffcb952014d --- /dev/null +++ b/tests/unit_tests/transformer/moe/test_moe_recompute_memory.py @@ -0,0 +1,164 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +"""CUDA memory regression test for MoE full recompute activation cleanup. + +Verifies that explicit deletion of intermediate tensors in unpermute() +reduces peak CUDA memory during full recomputation. + +Issue: https://github.com/NVIDIA/Megatron-LM/issues/3221 +PR: https://github.com/NVIDIA/Megatron-LM/pull/5197 +""" + +import gc + +import pytest +import torch + + +def unpermute_without_del( + permuted_tokens: torch.Tensor, sorted_indices: torch.Tensor, restore_shape: torch.Size +) -> torch.Tensor: + """Original unpermute WITHOUT the explicit del statements (baseline).""" + _, hidden = restore_shape + input_dtype = permuted_tokens.dtype + output_tokens = torch.zeros( + restore_shape, dtype=permuted_tokens.dtype, device=permuted_tokens.device + ) + output_tokens.scatter_add_(0, sorted_indices.unsqueeze(1).expand(-1, hidden), permuted_tokens) + out = output_tokens.to(dtype=input_dtype) + return out + + +def unpermute_with_del( + permuted_tokens: torch.Tensor, sorted_indices: torch.Tensor, restore_shape: torch.Size +) -> torch.Tensor: + """Fixed unpermute WITH explicit del (the PR's approach).""" + _, hidden = restore_shape + input_dtype = permuted_tokens.dtype + output_tokens = torch.zeros( + restore_shape, dtype=permuted_tokens.dtype, device=permuted_tokens.device + ) + output_tokens.scatter_add_(0, sorted_indices.unsqueeze(1).expand(-1, hidden), permuted_tokens) + out = output_tokens.to(dtype=input_dtype) + # Explicitly release intermediate tensor references so CUDA allocator + # can reclaim memory immediately during full recomputation. + del output_tokens, permuted_tokens, sorted_indices + return out + + +class MoELayer(torch.nn.Module): + """Minimal MoE layer wrapper for recompute memory testing.""" + + def __init__(self, hidden_size: int, ffn_hidden: int, unpermute_fn): + super().__init__() + self.ffn = torch.nn.Sequential( + torch.nn.Linear(hidden_size, ffn_hidden), + torch.nn.GELU(), + torch.nn.Linear(ffn_hidden, hidden_size), + ) + self.unpermute_fn = unpermute_fn + + def forward( + self, permuted_tokens: torch.Tensor, sorted_indices: torch.Tensor, restore_shape: torch.Size + ) -> torch.Tensor: + expert_out = self.ffn(permuted_tokens) + return self.unpermute_fn(expert_out, sorted_indices, restore_shape) + + +def measure_peak_memory(fn, *args, **kwargs): + """Run fn with CUDA memory tracking and return (result, peak_allocated, retained_after).""" + torch.cuda.empty_cache() + gc.collect() + torch.cuda.reset_peak_memory_stats() + torch.cuda.synchronize() + + result = fn(*args, **kwargs) + torch.cuda.synchronize() + + peak_mb = torch.cuda.max_memory_allocated() / 1024**2 + retained_mb = torch.cuda.memory_allocated() / 1024**2 + return result, peak_mb, retained_mb + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") +@pytest.mark.parametrize( + "num_permuted_tokens, num_total_tokens, hidden_size, ffn_hidden", + [ + (65536, 131072, 4096, 14336), # DeepSeek-V3 scale + (32768, 65536, 2048, 7168), # Medium scale + (16384, 32768, 1024, 4096), # Small scale + ], +) +def test_moe_recompute_memory_reduction( + num_permuted_tokens, num_total_tokens, hidden_size, ffn_hidden +): + """Verify del statements reduce peak CUDA memory in full recompute mode. + + The test simulates MoE's token permutation pattern: tokens are first + permuted (grouped by expert), fed through FFN, then unpermuted back + to original token order via scatter_add_. In full recompute mode, + the forward pass runs again during backward, and intermediate tensors + from scatter_add_ can accumulate if not explicitly freed. + """ + + device = torch.device("cuda") + dtype = torch.bfloat16 + restore_shape = torch.Size((num_total_tokens, hidden_size)) + + def build_inputs(): + # Fresh tensors per measurement so grads from the other path do not leak. + permuted_tokens = torch.randn( + num_permuted_tokens, hidden_size, device=device, dtype=dtype, requires_grad=True + ) + sorted_indices = torch.randint(0, num_total_tokens, (num_permuted_tokens,), device=device) + target = torch.randn(num_total_tokens, hidden_size, device=device, dtype=dtype) + return permuted_tokens, sorted_indices, target + + def build_layer(unpermute_fn): + # Must match activation dtype: Linear defaults to float32 weights. + # CI failure was: mat1 BFloat16 vs mat2 Float. + return MoELayer(hidden_size, ffn_hidden, unpermute_fn).to(device=device, dtype=dtype) + + def compute_loss_and_backward(layer, permuted_tokens, sorted_indices, target): + """Simulate one MoE step with full recompute.""" + output = torch.utils.checkpoint.checkpoint( + layer, permuted_tokens, sorted_indices, restore_shape, use_reentrant=False + ) + loss = torch.nn.functional.mse_loss(output, target) + loss.backward() + return loss.item() + + # Measure with del (fix) + permuted_tokens, sorted_indices, target = build_inputs() + layer_with_del = build_layer(unpermute_with_del) + _, peak_with_del, retained_with_del = measure_peak_memory( + compute_loss_and_backward, layer_with_del, permuted_tokens, sorted_indices, target + ) + + del layer_with_del, permuted_tokens, sorted_indices, target + torch.cuda.empty_cache() + gc.collect() + + # Measure without del (baseline / original behavior) + permuted_tokens, sorted_indices, target = build_inputs() + layer_no_del = build_layer(unpermute_without_del) + _, peak_no_del, retained_no_del = measure_peak_memory( + compute_loss_and_backward, layer_no_del, permuted_tokens, sorted_indices, target + ) + + print( + f"\n[scale={num_permuted_tokens}x{hidden_size}] " + f"Peak: {peak_no_del:.1f} MB (no del) vs {peak_with_del:.1f} MB (with del) " + f"| Retained after backward: {retained_no_del:.1f} MB vs {retained_with_del:.1f} MB" + ) + + # The del version should use less or equal peak memory + # In practice, the difference is ~the size of intermediate activations + # (output_tokens + permuted_tokens + sorted_indices indices) + assert peak_with_del <= peak_no_del, ( + f"Peak memory with del ({peak_with_del:.1f} MB) should NOT exceed " + f"without del ({peak_no_del:.1f} MB) at scale {num_permuted_tokens}x{hidden_size}" + ) + assert retained_with_del <= retained_no_del, ( + f"Retained memory after backward with del ({retained_with_del:.1f} MB) " + f"should NOT exceed without del ({retained_no_del:.1f} MB)" + ) From fa0758c3b6777e7e33a71d01e44ccdd8f9be7e4b Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 7 Aug 2026 07:30:20 -0700 Subject: [PATCH 217/290] Allocate main_grad on the reduce-scatter stream (#6187) Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 1 + .../experimental/parameter_group.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 6f0e6c5170f..ee4a68dfb2e 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -156,6 +156,7 @@ def __init__( mesh=mesh, placements=placements, mixed_precision_policy=mixed_precision_policy, + reduce_scatter_stream=context.reduce_scatter_stream, use_symm_mem=use_symm_mem, ) for group_parameters in _group_parameters(owned_parameters) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index cf8a4eaa94a..1a432ed2944 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -75,6 +75,7 @@ def __init__( mesh: DeviceMesh, placements: Placements, mixed_precision_policy: MixedPrecisionPolicy, + reduce_scatter_stream: torch.cuda.Stream, use_symm_mem: bool = False, ) -> None: """Create persistent sharded buffers for a group of parameters. @@ -85,6 +86,7 @@ def __init__( mesh: Device mesh used for all DBuffer storage in this version. placements: Parameter, gradient, and optimizer placements. mixed_precision_policy: Precision policy for main weights and gradients. + reduce_scatter_stream: Stream on which to allocate the main-gradient buffer. use_symm_mem: Allocate communication staging buffers from PyTorch's NCCL symmetric-memory pool. """ @@ -160,13 +162,14 @@ def __init__( # eagerly deallocated right after optimizer.step(), avoiding main_grad # storage during forward. That requires a separate lifetime contract with # the optimizer, so this version keeps the simpler persistent buffer. - self.main_grad = DBuffer( - mesh=self.mesh, - placements=main_grad_placements, - tensor_shapes=self.main_weight.layout.tensor_shapes, - dtype=grad_dtype, - device=self.main_weight.device, - ) + with torch.cuda.stream(reduce_scatter_stream): + self.main_grad = DBuffer( + mesh=self.mesh, + placements=main_grad_placements, + tensor_shapes=self.main_weight.layout.tensor_shapes, + dtype=grad_dtype, + device=self.main_weight.device, + ) assert self.main_grad.layout == self.main_weight.layout, ( "main_grad is built from main_weight tensor shapes on the same mesh, " "and DBuffer layouts are deterministic from those shapes and mesh size." From cde3b5c25a649b4d73233a2abb3b09a5d9f75078 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 7 Aug 2026 09:55:03 -0700 Subject: [PATCH 218/290] Encapsulate symmetric-memory AVG in DBuffer (#6169) Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/dbuffer.py | 17 +++- .../experimental/parameter_group.py | 15 +--- .../distributed/mfsdp_v2/conftest.py | 4 + .../distributed/mfsdp_v2/test_dbuffer.py | 77 +++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py index c6344955822..1761232a848 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/dbuffer.py @@ -396,12 +396,27 @@ def reduce_scatter( placements[axis] = new_placement _validate_placements(placements) out = self._create_or_validate_out(out, placements=placements) + reduce_op = partial_placement.reduce_op + # Symmetric-memory MFSDP requires this detector, but ordinary DBuffer + # reductions remain supported on older PyTorch versions that lack it. + is_symm_mem = hasattr(symm_mem, "is_symm_mem_tensor") and symm_mem.is_symm_mem_tensor( + self.local_buffer + ) + if is_symm_mem: + self.rendezvous(axis) + # NCCL symmetric-memory reduce-scatter selects its symmetric kernel + # for SUM. Preserve the placement's AVG semantics by scaling the + # SUM result after the collective. + if reduce_op == dist.ReduceOp.AVG: + reduce_op = dist.ReduceOp.SUM dist.reduce_scatter_tensor( output=out.local_buffer, input=self.local_buffer, - op=partial_placement.reduce_op, + op=reduce_op, group=self.mesh.get_group(axis), ) + if is_symm_mem and partial_placement.reduce_op == dist.ReduceOp.AVG: + out.local_buffer.div_(self.mesh.size(axis)) return out def scatter( diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 1a432ed2944..aa35a6e8233 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -92,6 +92,8 @@ def __init__( """ if not parameters: raise ValueError("FsdpParameterGroup requires at least one parameter.") + if use_symm_mem and not hasattr(symm_mem, "is_symm_mem_tensor"): + raise RuntimeError("Symmetric-memory MFSDP requires PyTorch 2.12 or later.") parameter_to_fqns: dict[nn.Parameter, list[str]] = {} for fqn, parameter in parameters.items(): @@ -294,9 +296,6 @@ def allocate_partial_grad_buffer(self) -> DBuffer: """Allocate the unreduced reduce-scatter input buffer.""" assert self.main_grad is not None - # NCCL symmetric-memory reduce-scatter only selects the symmetric kernel for SUM today. - # Preserve AVG semantics by reducing SUM and scaling the output below. - partial_op = dist.ReduceOp.AVG if self._symm_mem_pool is None else dist.ReduceOp.SUM grads: list[torch.Tensor] = [] for fsdp_parameter in self.fsdp_parameters: if fsdp_parameter.unsharded.grad is None: @@ -305,7 +304,7 @@ def allocate_partial_grad_buffer(self) -> DBuffer: with self._symmetric_memory_context(): return DBuffer( mesh=self.mesh, - placements=[Partial(partial_op)] * self.mesh.ndim, + placements=[Partial(dist.ReduceOp.AVG)] * self.mesh.ndim, tensor_shapes=tuple(grad.shape for grad in grads), dtype=grads[0].dtype, device=grads[0].device, @@ -362,18 +361,10 @@ def reduce_partial_gradients( reduce_axis = changed_mesh_axis(partial_grad.placements, self.main_grad.placements) if reduce_axis is None: raise RuntimeError("FSDP gradient reduction requires a changed placement axis.") - partial_reduce_op = partial_grad.placements[reduce_axis].reduce_op - grad_divisor = self.mesh.size(reduce_axis) if partial_reduce_op == dist.ReduceOp.SUM else 1 - if self._symm_mem_pool is not None: - partial_grad.rendezvous(reduce_axis) if can_reduce_into_main_grad: partial_grad.redistribute(self.main_grad.placements, out=self.main_grad) - if grad_divisor != 1: - self.main_grad.local_buffer.div_(grad_divisor) else: reduced_grad = partial_grad.redistribute(self.main_grad.placements) - if grad_divisor != 1: - reduced_grad.local_buffer.div_(grad_divisor) if has_sharded_grads: self.main_grad.local_buffer.add_(reduced_grad.local_buffer) else: diff --git a/tests/unit_tests/distributed/mfsdp_v2/conftest.py b/tests/unit_tests/distributed/mfsdp_v2/conftest.py index 741c0f57e82..819a8d3ae99 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/conftest.py +++ b/tests/unit_tests/distributed/mfsdp_v2/conftest.py @@ -7,6 +7,7 @@ import pytest import torch import torch.distributed as dist +import torch.distributed._symmetric_memory as symm_mem @dataclasses.dataclass(frozen=True) @@ -38,6 +39,9 @@ def distributed_setup() -> Iterator[DistributedSetup]: if torch.cuda.is_available(): torch.cuda.set_device(local_rank) device = torch.device(f"cuda:{local_rank}") + # is_symm_mem_tensor() marks the current symmetric-memory backend as in use, + # even for ordinary tensors, so select NCCL before any DBuffer test calls it. + symm_mem.set_backend("NCCL") else: device = torch.device("cpu") diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py b/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py index 1180cd55c80..febb95d770d 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_dbuffer.py @@ -7,6 +7,7 @@ import pytest import torch import torch.distributed as dist +import torch.distributed._symmetric_memory as symm_mem from torch.distributed.device_mesh import init_device_mesh from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental.dbuffer import ( @@ -473,6 +474,82 @@ def test_partial_reduce_scatter_to_flat_average(distributed_setup): _assert_dbuffer_local_tensors_close(replicated_buffer, expected_tensors) +def test_partial_reduce_scatter_to_flat_average_without_symm_mem_detector( + distributed_setup, monkeypatch +): + """Ordinary AVG remains available when PyTorch lacks the symmetric-memory detector.""" + device, world_size = distributed_setup.device, distributed_setup.world_size + mesh = init_device_mesh(device.type, (world_size,)) + monkeypatch.delattr(symm_mem, "is_symm_mem_tensor") + rank_scale = float(distributed_setup.rank + 1) + partial_buffer = DBuffer.distribute_tensors( + [torch.full((5, 3), rank_scale, dtype=torch.float32, device=device)], + mesh, + [Partial(reduce_op=dist.ReduceOp.AVG)], + ) + + replicated_buffer = partial_buffer.reduce_scatter(0, Flat()).allgather(0) + + expected = torch.full((5, 3), (world_size + 1) / 2.0, dtype=torch.float32, device=device) + _assert_dbuffer_local_tensors_close(replicated_buffer, [expected]) + + +def test_symmetric_memory_partial_reduce_scatter_to_flat_average(distributed_setup): + """Symmetric-memory reduce-scatter preserves AVG semantics.""" + device, world_size = distributed_setup.device, distributed_setup.world_size + mesh = init_device_mesh(device.type, (world_size,)) + dist.barrier(device_ids=[device.index]) + rank_scale = float(distributed_setup.rank + 1) + tensors = [ + torch.full((5, 3), rank_scale, dtype=torch.float32, device=device), + torch.full((4,), rank_scale * 10, dtype=torch.float32, device=device), + ] + pool = symm_mem.get_mem_pool(device) + with torch.cuda.use_mem_pool(pool): + partial_buffer = DBuffer.distribute_tensors( + tensors, mesh, [Partial(reduce_op=dist.ReduceOp.AVG)] + ) + assert symm_mem.is_symm_mem_tensor(partial_buffer.local_buffer) + + sharded_buffer = partial_buffer.reduce_scatter(0, Flat()) + replicated_buffer = sharded_buffer.allgather(0) + + scale_average = (world_size + 1) / 2.0 + expected_tensors = [ + torch.full((5, 3), scale_average, dtype=torch.float32, device=device), + torch.full((4,), scale_average * 10, dtype=torch.float32, device=device), + ] + _assert_dbuffer_local_tensors_close(replicated_buffer, expected_tensors) + + +def test_symmetric_memory_partial_reduce_scatter_to_flat_sum(distributed_setup): + """Symmetric-memory reduce-scatter preserves explicit SUM semantics.""" + device, world_size = distributed_setup.device, distributed_setup.world_size + mesh = init_device_mesh(device.type, (world_size,)) + dist.barrier(device_ids=[device.index]) + rank_scale = float(distributed_setup.rank + 1) + tensors = [ + torch.full((5, 3), rank_scale, dtype=torch.float32, device=device), + torch.full((4,), rank_scale * 10, dtype=torch.float32, device=device), + ] + pool = symm_mem.get_mem_pool(device) + with torch.cuda.use_mem_pool(pool): + partial_buffer = DBuffer.distribute_tensors( + tensors, mesh, [Partial(reduce_op=dist.ReduceOp.SUM)] + ) + assert symm_mem.is_symm_mem_tensor(partial_buffer.local_buffer) + + sharded_buffer = partial_buffer.reduce_scatter(0, Flat()) + replicated_buffer = sharded_buffer.allgather(0) + + scale_sum = float(world_size * (world_size + 1) // 2) + expected_tensors = [ + torch.full((5, 3), scale_sum, dtype=torch.float32, device=device), + torch.full((4,), scale_sum * 10, dtype=torch.float32, device=device), + ] + _assert_dbuffer_local_tensors_close(replicated_buffer, expected_tensors) + + def test_get_dtensor_from_sharded_buffer(distributed_setup): """Sharded DBuffer exposes per-tensor local shards as DTensors.""" mesh = init_device_mesh(distributed_setup.device.type, (distributed_setup.world_size,)) From c4968aad9745ab0dd0f9c3dea391111cfced687a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Fri, 7 Aug 2026 21:28:29 +0200 Subject: [PATCH 219/290] fix(ci): remove community workflow PAT (#6350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/community-bot.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/community-bot.yml b/.github/workflows/community-bot.yml index 1c89467cd0f..dbabff36126 100644 --- a/.github/workflows/community-bot.yml +++ b/.github/workflows/community-bot.yml @@ -21,11 +21,10 @@ on: jobs: community-bot: - uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@0af357dc15c04c3f28d33478d5055b1ca88a9ea1 # v1.8.8 + uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_community_bot.yml@affd689912d7975a1aa29ea60c1b983f12dfb7e7 # v1.8.9 with: community_project_id: ${{ vars.COMMUNITY_PROJECT_ID }} - app-id: ${{ vars.BOT_ID }} + app-id: ${{ vars.COMMUNITY_BOT_ID }} if: github.repository == 'NVIDIA/Megatron-LM' secrets: - BOT_KEY: ${{ secrets.BOT_KEY }} - GH_TOKEN: ${{ secrets.PAT }} + BOT_KEY: ${{ secrets.COMMUNITY_BOT_KEY }} From 628c2a5296e4273412880638f0967a36ac043181 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 7 Aug 2026 13:02:17 -0700 Subject: [PATCH 220/290] Add multimodal argument and config plumbing (#6332) Signed-off-by: Matthieu Co-authored-by: Tuomas Rintamaki --- examples/multimodal/config.py | 195 +++++++++++------ examples/multimodal/multimodal_args.py | 283 ++++++++++++++++++++++++- 2 files changed, 411 insertions(+), 67 deletions(-) diff --git a/examples/multimodal/config.py b/examples/multimodal/config.py index bbca0fcb47a..956f31818f3 100644 --- a/examples/multimodal/config.py +++ b/examples/multimodal/config.py @@ -6,162 +6,152 @@ from megatron.core.activations import fast_gelu, quick_gelu, squared_relu -def get_language_model_config(config): +def get_language_model_config(config, enable_fusions=False, apply_rope_fusion=None): + config.bias_activation_fusion = enable_fusions + config.bias_dropout_fusion = enable_fusions + config.apply_rope_fusion = enable_fusions + if apply_rope_fusion is not None: + config.apply_rope_fusion = apply_rope_fusion + if config.language_model_type == "llama3_8b": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 14336 elif config.language_model_type == "llama3.1_8b": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 14336 elif config.language_model_type == "llama3.1_70B": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 28672 elif config.language_model_type == "mistral_7b": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 14336 elif config.language_model_type == "nemotron5-8b": config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = False - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.activation_func = squared_relu + config.bias_activation_fusion = False config.ffn_hidden_size = 21504 config.masked_softmax_fusion = True config.attention_softmax_in_fp32 = True elif config.language_model_type == "yi-34b": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 20480 elif config.language_model_type == "qwen2.0_72B": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False config.add_qkv_bias = True - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 29568 elif config.language_model_type == "qwen2.5_7B": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False config.add_qkv_bias = True - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 18944 elif config.language_model_type == "qwen2.5_72B": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False config.add_qkv_bias = True - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 29568 elif config.language_model_type == "nemotron5-hybrid-8b": config.activation_func = squared_relu + config.bias_activation_fusion = False config.squared_relu = True config.add_bias_linear = False - config.bias_activation_fusion = False config.apply_query_key_layer_scaling = False config.gated_linear_unit = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 21504 - elif config.language_model_type == "nemotron5-hybrid-56b": + elif config.language_model_type == "nemotron5-hybrid-12b": config.activation_func = squared_relu + config.bias_activation_fusion = False config.squared_relu = True config.add_bias_linear = False + config.apply_query_key_layer_scaling = False + config.gated_linear_unit = False + config.layernorm_zero_centered_gamma = ( + False # Zero centered gamma not supported for RMSNorm + ) + config.attention_softmax_in_fp32 = True + config.ffn_hidden_size = 20480 + config.mamba_state_dim = 128 + config.mamba_num_heads = 128 + config.mamba_head_dim = 80 + elif config.language_model_type == "nemotron5-hybrid-56b": + config.activation_func = squared_relu config.bias_activation_fusion = False + config.squared_relu = True + config.add_bias_linear = False config.apply_query_key_layer_scaling = False config.gated_linear_unit = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 32768 config.mamba_state_dim = 256 elif config.language_model_type == "llama3.2_1b": config.activation_func = torch.nn.functional.silu config.add_bias_linear = False - config.bias_activation_fusion = False config.gated_linear_unit = True config.apply_query_key_layer_scaling = False config.layernorm_zero_centered_gamma = ( False # Zero centered gamma not supported for RMSNorm ) - config.bias_dropout_fusion = False - config.apply_rope_fusion = False config.attention_softmax_in_fp32 = True config.ffn_hidden_size = 8192 elif config.language_model_type.startswith("hf://"): @@ -170,13 +160,36 @@ def get_language_model_config(config): hf_config = transformers.AutoConfig.from_pretrained(config.language_model_type.split("hf://")[1]) config.hf_config = hf_config config.hidden_size = hf_config.hidden_size + elif config.language_model_type == "llama_nemotron_8b": + config.activation_func = torch.nn.functional.silu + config.add_bias_linear = False + config.bias_activation_fusion = False + config.gated_linear_unit = True + config.apply_query_key_layer_scaling = False + config.layernorm_zero_centered_gamma = ( + False # Zero centered gamma not supported for RMSNorm + ) + config.bias_dropout_fusion = False + config.apply_rope_fusion = False + config.attention_softmax_in_fp32 = True + config.ffn_hidden_size = 14336 + elif config.language_model_type == "nemotron6-moe": + config.bias_activation_fusion = False + config.bias_dropout_fusion = False else: raise ValueError(f"unknown language model type {config.language_model_type}") return config -def get_vision_model_config(config, apply_query_key_layer_scaling): +def get_vision_model_config(config, enable_fusions=False): + config.bias_activation_fusion = False # Radio uses an incompatible activation func. + config.bias_dropout_fusion = enable_fusions + config.apply_rope_fusion = enable_fusions + + if config.language_model_type == "nemotron6-moe": + config.bias_dropout_fusion = False + if config.vision_model_type == "clip": config.num_layers = 24 config.num_attention_heads = 16 @@ -191,12 +204,10 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.kv_channels = 64 config.num_query_groups = 16 config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'LayerNorm' - config.apply_rope_fusion = False + config.class_token_len = 1 elif config.vision_model_type == "siglip": config.num_layers = 27 config.num_attention_heads = 16 @@ -211,14 +222,12 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.kv_channels = 72 config.num_query_groups = 16 config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'LayerNorm' - config.apply_rope_fusion = False config.qk_layernorm = False config.layernorm_epsilon = 1e-6 + config.class_token_len = 0 elif config.vision_model_type == "internvit": config.num_layers = 45 config.num_attention_heads = ((24 // config.tensor_model_parallel_size) + 1) * config.tensor_model_parallel_size @@ -232,13 +241,11 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.gated_linear_unit = False config.activation_func = torch.nn.functional.gelu config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'RMSNorm' config.layernorm_epsilon = 1e-6 - config.apply_rope_fusion = False + config.class_token_len = 1 elif config.vision_model_type == "internvit300M": config.num_layers = 24 config.num_attention_heads = 16 @@ -252,14 +259,12 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.gated_linear_unit = False config.activation_func = torch.nn.functional.gelu config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'LayerNorm' config.layernorm_epsilon = 1e-6 - config.apply_rope_fusion = False config.qk_layernorm = False + config.class_token_len = 1 elif config.vision_model_type == "radio": config.num_layers = 32 config.num_attention_heads = 16 @@ -272,14 +277,12 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.kv_channels = 80 config.num_query_groups = 16 config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'LayerNorm' - config.apply_rope_fusion = False config.qk_layernorm = False config.layernorm_epsilon = 1e-6 + config.class_token_len = 8 elif config.vision_model_type == "radio-g": config.num_layers = 40 config.num_attention_heads = 24 @@ -292,14 +295,12 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.kv_channels = 64 config.num_query_groups = 24 config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'LayerNorm' - config.apply_rope_fusion = False config.qk_layernorm = False config.layernorm_epsilon = 1e-6 + config.class_token_len = 5 elif config.vision_model_type == "cradio-g": config.num_layers = 40 config.num_attention_heads = 24 @@ -312,14 +313,12 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): config.kv_channels = 64 config.num_query_groups = 24 config.layernorm_zero_centered_gamma = False - config.apply_query_key_layer_scaling = apply_query_key_layer_scaling - config.bias_activation_fusion = False - config.bias_dropout_fusion = False + config.apply_query_key_layer_scaling = False config.attention_softmax_in_fp32 = True config.normalization = 'LayerNorm' - config.apply_rope_fusion = False config.qk_layernorm = False config.layernorm_epsilon = 1e-6 + config.class_token_len = 8 elif config.vision_model_type.startswith("hf://"): import transformers hf_config = transformers.AutoConfig.from_pretrained(config.vision_model_type.split("hf://")[1]) @@ -331,16 +330,20 @@ def get_vision_model_config(config, apply_query_key_layer_scaling): return config -def get_vision_projection_config(config, hidden_size): +def get_vision_projection_config(config, hidden_size, enable_fusions=False): # If using FP8, then keep the whole vision projection in FP8. config.first_last_layers_bf16 = False config.num_layers_at_start_in_bf16 = 0 config.num_layers_at_end_in_bf16 = 0 config.gated_linear_unit = False - config.bias_activation_fusion = False config.add_bias_linear = False config.hidden_size = hidden_size # Used as the vision projection output size, i.e., the input to the language model. + + config.bias_activation_fusion = enable_fusions + config.bias_dropout_fusion = enable_fusions + config.apply_rope_fusion = enable_fusions + if config.language_model_type == "llama3_8b": config.ffn_hidden_size = 14336 config.activation_func = torch.nn.functional.gelu @@ -372,9 +375,19 @@ def get_vision_projection_config(config, hidden_size): elif config.language_model_type == "nemotron5-hybrid-56b": config.ffn_hidden_size = 32768 config.activation_func = squared_relu + config.bias_activation_fusion = False elif config.language_model_type in ("nemotron5-8b", "nemotron5-hybrid-8b"): config.ffn_hidden_size = 21504 config.activation_func = squared_relu + config.bias_activation_fusion = False + elif config.language_model_type == "nemotron5-hybrid-12b": + config.ffn_hidden_size = 20480 + config.activation_func = squared_relu + config.bias_activation_fusion = False + elif config.language_model_type == "nemotron6-moe": + config.ffn_hidden_size = 20480 + config.bias_activation_fusion = False + config.bias_dropout_fusion = False elif config.language_model_type == "llama3.2_1b": config.ffn_hidden_size = 2048 config.activation_func = torch.nn.functional.gelu @@ -383,6 +396,53 @@ def get_vision_projection_config(config, hidden_size): config.activation_func = torch.nn.functional.gelu config.ffn_hidden_size = 4096 config.normalization = "LayerNorm" + elif config.language_model_type == "llama_nemotron_8b": + config.ffn_hidden_size = 14336 + config.activation_func = torch.nn.functional.gelu + config.layernorm_epsilon = 1e-5 + config.add_bias_linear = True + config.normalization = "LayerNorm" + else: + raise ValueError(f"unknown language model type {config.language_model_type}") + + return config + + +def get_sound_model_config(config): + if config.sound_model_type.startswith("nemo://"): + from megatron.core.models.huggingface.fastconformer_model import get_nemo_sound_model + + _, encoder = get_nemo_sound_model(config.sound_model_type) + config.hidden_size = encoder.d_model + else: + raise ValueError(f"unknown sound model type {config.sound_model_type}") + + return config + +def get_sound_projection_config(config, hidden_size, enable_fusions=False): + config.gated_linear_unit = False + config.add_bias_linear = False + config.hidden_size = hidden_size # Used as the vision projection output size, i.e., the input to the language model. + + config.bias_activation_fusion = enable_fusions + config.bias_dropout_fusion = enable_fusions + config.apply_rope_fusion = enable_fusions + + if config.language_model_type == "llama3.1_8b": + config.ffn_hidden_size = 4096 + config.activation_func = torch.nn.functional.gelu + config.layernorm_epsilon = 1e-5 + config.add_bias_linear = True + config.normalization = "LayerNorm" + elif config.language_model_type == "nemotron6-moe": + config.ffn_hidden_size = 4096 + config.bias_activation_fusion = False + elif config.language_model_type == "llama_nemotron_8b": + config.ffn_hidden_size = 14336 + config.activation_func = torch.nn.functional.gelu + config.layernorm_epsilon = 1e-5 + config.add_bias_linear = True + config.normalization = "LayerNorm" else: raise ValueError(f"unknown language model type {config.language_model_type}") @@ -410,3 +470,6 @@ class EvaluationConfig: num_partitions: int = 0 partition_id: int = 0 num_samples_per_partition: int = 0 + + # Tokenizer kwargs + enable_thinking: bool = False diff --git a/examples/multimodal/multimodal_args.py b/examples/multimodal/multimodal_args.py index 885d39d545f..5ccce48393e 100644 --- a/examples/multimodal/multimodal_args.py +++ b/examples/multimodal/multimodal_args.py @@ -9,12 +9,20 @@ def add_multimodal_extra_args(parser): group.add_argument("--prompt-path", type=str, default=None) group.add_argument('--freeze-LM', action='store_true', default=False) group.add_argument('--freeze-ViT', action='store_true', default=False) + group.add_argument('--freeze-sound-model', action='store_true', default=False) group.add_argument('--language-model-type', type=str, required=True) group.add_argument('--vision-model-type', type=str, default="clip") + group.add_argument('--sound-model-type', type=str, default=None) group.add_argument("--disable-vision-class-token", action="store_true", default=False) group.add_argument( "--allow-missing-vision-projection-checkpoint", action="store_true", default=False ) + group.add_argument( + "--allow-missing-sound-projection-checkpoint", action="store_true", default=False + ) + group.add_argument( + "--allow-missing-sound-model-checkpoint", action="store_true", default=False + ) group.add_argument("--use-te", action="store_true", default=False) group.add_argument( "--dataloader-save", type=str, default=None, help="Energon dataloader state save path" @@ -26,11 +34,33 @@ def add_multimodal_extra_args(parser): group.add_argument( "--use-thumbnail", action="store_true", default=False, help="Add image thumbnail as a tile" ) + group.add_argument( + "--thumbnail-area-threshold", type=float, default=0.8, + help="Maximum area percentage (0.0-1.0) of resized image relative to thumbnail area for which to add thumbnail. Default 0.8 (80%)" + ) group.add_argument( "--dataloader-seq-length", type=int, help="Make dataloader to produce sequences of specific length.", ) + group.add_argument( + "--dataloader-seed", + type=int, + default=0, + help="The seed for the dataloader to use for training.", + ) + group.add_argument( + "--lr-data-range-start", + type=float, + default=0, + help="Start of the learning rate range as percentage (0-100) of the full training schedule. 0% means start from the beginning of the training schedule. E.g. setting to 10, means start at 10% of the training schedule (the dataloader still starts from the beginning of the dataset, but assume that corresponds to 10% of the training schedule)." + ) + group.add_argument( + "--lr-data-range-end", + type=float, + default=100, + help="End of the learning rate range as percentage (0-100) of the full training schedule. 100% means the end of the training schedule. E.g. setting to 90, means end at 90% of the training schedule (the dataloader still ends at the end of the dataset, but assume that corresponds to 90% of the training schedule)." + ) group.add_argument( "--num-frames", type=int, @@ -50,7 +80,8 @@ def add_multimodal_extra_args(parser): "--tokenizer-prompt-format", type=str, choices=["mistral", "llama3", "chatml", "nvlm-yi-34b", "qwen2p0", "qwen2p5", "llama3p1", "nemotron5", - "nemotron5-aligned"], + "nemotron5-aligned", "llama_nemotron_8b", "nemotron-h-5p5-reasoning", + "nemotron-h-5p5-reasoning-inference", "nemotron6-moe"], required=True, help="Prompt format to use with the tokenizer.", ) @@ -63,6 +94,7 @@ def add_multimodal_extra_args(parser): help="Surround image tokens with tags.", ) group.add_argument("--use-tile-tags", action="store_true", default=False, help="Use tile tags") + group.add_argument("--class-token-len", type=int, default=None, help="Length of class token. If not set, uses model-specific defaults (radio: 8, radio-g: 5, cradio-g: 8). FP8 overrides to 16.") group.add_argument( "--packing-buffer-size", type=int, @@ -72,9 +104,15 @@ def add_multimodal_extra_args(parser): group.add_argument( "--packing-seq-length", type=int, default=0, help="Packing sequence length. Must be > 0 if using packing." ) + group.add_argument( + "--packing-knapsack-algorithm", type=str, default="greedy_knapsack", help="Knapsack algorithm to use for packing." + ) group.add_argument( "--recompute-vision", action="store_true", default=False, help="Enable activation checkpointing in the vision model" ) + group.add_argument( + "--recompute-sound", action="store_true", default=False, help="Enable activation checkpointing in the sound model" + ) group.add_argument( "--use-loss-scaling", action="store_true", default=False, help="Scale loss based on conversation turn length (in tokens)." ) @@ -89,5 +127,248 @@ def add_multimodal_extra_args(parser): "image aspect ratio and the area covered by the tiles.") ) group.add_argument("--use-mcore-inference", action="store_true", default=False, help="Use the MCore inference API") + group.add_argument("--use-vision-backbone-fp8-arch", action="store_true", default=False, help="Use the FP8 arch in the vision backbone. This is used to load the FP8 checkpoint when running inference.") + group.add_argument( + "--dynamic-resolution", action="store_true", default=False, help="Use input image dynamic resolution" + ) + group.add_argument( + "--dynamic-resolution-min-patches", type=int, default=0, help="Minimum number of patches per image for dynamic resolution" + ) + group.add_argument( + "--dynamic-resolution-max-patches", type=int, default=0, help="Maximum number of patches per image for dynamic resolution" + ) + group.add_argument( + "--dynamic-resolution-min-side", type=int, default=None, help="Minimum side length for dynamic resolution" + ) + group.add_argument( + "--match-tiling-dynamic-resolution", action="store_true", default=False, + help="Use match-tiling dynamic resolution strategy that combines tiling logic with dynamic resolution processing" + ) + group.add_argument( + "--masked-tiling-dynamic-resolution", action="store_true", default=False, + help="Use masked-tiling dynamic resolution strategy that isolates tiles as separate packed samples" + ) + group.add_argument( + "--image-break-token", type=str, default=None, help="Token to use for image break tokens, must be added to --special-tokens as well" + ) + group.add_argument("--conv-merging", action="store_true", default=False, help="Use convolution merging which uses a convolution to merge tokens after the vision encoder") + group.add_argument( + "--allow-missing-conv-merge-checkpoint", action="store_true", default=False + ) + group.add_argument( + "--video-min-num-frames", type=int, default=8, help="Minimum number of frames to sample from the video as input to the model.", + ) + group.add_argument( + "--video-max-num-frames", type=int, default=32, help="Maximum number of frames to sample from the video as input to the model.", + ) + group.add_argument( + "--video-default-fps", type=int, default=2, help="Default frames per second to sample from the video as input to the model.", + ) + group.add_argument( + "--video-frame-temporal-jitter", action="store_true", default=False, help="Enable temporal jittering of the frames to sample from the video as input to the model.", + ) + group.add_argument( + "--video-target-img-size", type=int, default=None, + help="Target image size (pixels) for video frames with dynamic resolution. " + "Default None, must specify this or video_target_num_patches." + ) + group.add_argument( + "--video-target-num-patches", type=int, default=None, + help=( + "Target number of patches for video frames. Default None, must specify this or video_target_img_size." + ) + ) + group.add_argument( + "--video-maintain-aspect-ratio", action="store_true", default=False, + help="Match video native aspect ratio while respecting target patch budget." + ) + # Temporal compression arguments + group.add_argument( + "--video-temporal-patch-size", type=int, default=1, + help="Temporal patch size for video frames. Default 1 (no temporal compression). " + "Set to 2 to group pairs of frames into 3D tubelets for temporal compression." + ) + group.add_argument( + "--allow-checkpoint-without-temporal-compression", action="store_true", default=False, + help="Allow loading a checkpoint without temporal compression into a model with temporal compression. " + "When set, the embedder weights will be duplicated along the temporal dimension if needed." + ) + group.add_argument( + "--separate-video-embedder", action="store_true", default=False, + help="Use separate embedders for images and videos. When set, the image embedder (self.embedder) " + "expects C*P*P input, and a separate video embedder (self.video_embedder) expects C*T*P*P input. " + "This avoids duplicating image patches along the temporal dimension. " + "Only relevant when --video-temporal-patch-size > 1." + ) + group.add_argument( + "--video-prompt-version", type=int, default=2, + help="Video prompt format version." + "1 = each frame on its own line, at tubelet boundaries. " + "2 = group T frames with 'and', one per group (generalization of 1 to support temporal compression)" + ) + group.add_argument( + "--enable-fusions", action="store_true", default=True, help="Enable fusions in the model." + ) + group.add_argument( + "--optimize-broadcast", action="store_true", default=True, help="Optimize the broadcast of data.", + ) + group.add_argument( + "--recompute-vision-num-layers", type=int, default=0, help="Number of layers to recompute in the vision model." + ) + group.add_argument( + "--recompute-granularity-vision", type=str, default=None, help="Granularity to recompute in the vision model.", + choices=["full", "selective"], + ) + group.add_argument( + "--recompute-method-vision", type=str, default=None, + choices=['uniform', 'block'], help="Method to recompute in the vision model.", + ) + group.add_argument( + "--recompute-vision-projection", action="store_true", default=False, help="Enable activation checkpointing in the vision projection layer." + ) + group.add_argument( + "--recompute-sound-projection", action="store_true", default=False, help="Enable activation checkpointing in the sound projection layer." + ) + group.add_argument( + "--allow-large-videos", action="store_true", default=False, help="Allow large videos to be loaded into the model." + ) + group.add_argument( + "--efficient-video-sampling-variant", type=str, default=None, help="The EVS variant. Read docstring on EVSHelper" + ) + group.add_argument( + "--sound-target-rate", + type=int, + default=16000, + help="Target rate of sound clips to regularly sample from the audio as input to the model.", + ) + group.add_argument( + "--sound-embedding-size", + type=int, + default=750, + help="Size of the sound embedding.", + ) + group.add_argument( + "--sound-clip-duration", + type=int, + default=30, + help="Sound model clip duration in seconds." + ) + group.add_argument( + "--sound-min-duration", + type=float, + default=0.1, + help="We will pad the audio clip to at least this duration (in seconds), even when sound-pad-to-clip-duration is False." + ) + group.add_argument( + "--sound-pad-to-clip-duration", + action="store_true", + default=False, + help="Pad every audio clip to the clip duration (introduces potentially many padding tokens in the LLM input)." + ) + group.add_argument( + "--sound-batch-split", + type=int, + default=1, + help="Splits the sound batch into this many chunks to avoid OOMs. Not necessary when using bucketing; use this only when --sound-pad-to-clip-duration is not specified and bucketing is not enabled." + ) + group.add_argument( + "--use-new-dataloader-path", action="store_true", default=False, help="Use the new dataloader path." + ) + group.add_argument( + "--decoder-tp-comm-overlap", action="store_true", default=False, help="Enable tensor parallel communication overlap in the decoder." + ) + group.add_argument( + "--freeze-vision-projection", action="store_true", default=False, help="Freeze the vision projection module." + ) + group.add_argument( + "--freeze-sound-projection", action="store_true", default=False, help="Freeze the sound projection module." + ) + group.add_argument( + "--relax-sender-check", action="store_true", default=False, help="Relax the sender check in the dataloader to allow other role than user and assistant." + ) + group.add_argument( + "--relax-thinking-trace-check", action="store_true", default=False, help="Relax the checks in the dataloader which ensure the thinking trace is well formatted." + ) + group.add_argument( + "--allow-cross-sample-attention", action="store_true", default=False, help="Allow cross sample attention when using sample packing." + ) + group.add_argument( + "--only-keep-samples-with-img", action="store_true", default=False, help="Discard samples that do not have an image." + ) + group.add_argument( + "--unfreeze-router", action="store_true", default=False, help="Unfreeze MoE router weights." + ) + group.add_argument( + "--apply-data-augment", action="store_true", default=False, + help="Apply data augmentation to the image. DEPRECATED, will throw NotImplementedError if set to True." + ) + group.add_argument( + "--radio-force-eval-mode", + action="store_true", + default=False, + help="Force RADIO to stay in eval mode (eval-mode CPE, no dropout). Recommended for pre-training." + ) + group.add_argument( + "--radio-force-cpe-eval-mode", + action="store_true", + default=False, + help="Force RADIO to use CPE (cropped position embeddings) in eval mode. Recommended for SFT." + ) + group.add_argument( + "--radio-interpolate-only-cpe", + action="store_true", + default=False, + help="Interpolate the position embeddings to input size, without any cropping." + ) + group.add_argument( + "--radio-cpe-aspect-ratio-select", + action="store_true", + default=False, + help="Select position embeddings based on aspect ratio so long edge always mapped to 1." + ) + group.add_argument( + "--radio-disable-cpe", + action="store_true", + default=False, + help="Disable cropped position embeddings in the radio model." + ) + group.add_argument( + "--no-calculate-per-token-loss", action="store_true", default=False, + help="Disable calculating per-token loss." + ) + group.add_argument( + "--tokenizer-keep-history-thinking", action="store_true", default=False, + help="Keep the history thinking in the tokenizer." + ) + group.add_argument( + "--log-model-grad-norms", action="store_true", default=False, help="Log the gradient norms of the model components." + ) + group.add_argument( + "--log-model-act-norms", action="store_true", default=False, help="Log the activation norms of the model components." + ) + group.add_argument( + "--dynamic-resolution-no-truncate", action="store_true", default=False, + help="Disable trunctation during dynamic resolution, and instead throw away the entire sample" + ) + group.add_argument( + "--video-aug-scale-frames-up", type=int, default=None, + help="Video data augmentation (scale UP): randomly sample s from 1..N and scale both " + "FPS and max_frames by s (iso-token mode), while dividing video_target_num_patches " + "by s. E.g. --video-aug-scale-frames-up 8 samples uniformly from {1,2,...,8}." + ) + group.add_argument( + "--video-aug-scale-resolution-only", action="store_true", default=False, + help="When used with --video-aug-scale-frames-up or --video-aug-scale-resolution-up, " + "only change the image patch count without changing the frame count. " + "With --video-aug-scale-frames-up this reduces spatial resolution; " + "with --video-aug-scale-frames-down this increases spatial resolution." + ) + group.add_argument( + "--video-aug-scale-resolution-up", type=int, default=None, + help="Video data augmentation (resolution UP): randomly sample s from 1..N and divide " + "both FPS and max_frames by s, while multiplying video_target_num_patches by s " + "(higher resolution per frame, fewer frames). " + "E.g. --video-aug-scale-resolution-up 4 samples uniformly from {1,2,3,4}." + ) return parser From 07a23196d44932e0b1257a9900bfffcc6e31734c Mon Sep 17 00:00:00 2001 From: Jianbin Chang Date: Sat, 8 Aug 2026 04:09:00 +0800 Subject: [PATCH 221/290] Fix FSDP activation recompute prefetch (#6153) Signed-off-by: Jianbin Chang Signed-off-by: jianbinc Signed-off-by: user.email Co-authored-by: Jingyue Wu Co-authored-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 47 +++++++++++++-- .../distributed/mfsdp_v2/test_fully_shard.py | 57 +++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index ee4a68dfb2e..1e1a229a73a 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -14,6 +14,7 @@ """Module mixin for the minimal Megatron-FSDP path.""" +import enum from collections.abc import Callable from typing import Literal, cast from weakref import ref @@ -28,6 +29,11 @@ from .placement import Placements +def _is_in_backward() -> bool: + """Return whether the current thread is executing an autograd GraphTask.""" + return torch._C._current_graph_task_id() != -1 + + class FsdpContext: """Runtime stream and prefetch state shared by FSDP roots constructed together.""" @@ -119,6 +125,13 @@ def post_backward_final_callback() -> None: class FsdpModule: """Mixin attached to modules managed by the minimal FSDP path.""" + class Phase(enum.Enum): + """Lifecycle phase of this FsdpModule.""" + + RESTING = enum.auto() + FORWARD = enum.auto() + BACKWARD = enum.auto() + # Name relative to the root FSDP module from named_modules(). # Root uses "" and None means uninitialized. _name: str | None @@ -131,6 +144,9 @@ class FsdpModule: # ``None`` lets pre_forward enqueue an all-gather unless an earlier FsdpModule # already prefetched this module. _unshard_event: torch.cuda.Event | None + # Backward-pre hook sets this to BACKWARD before activation recomputation + # can run. Forward and backward hooks own all other transitions. + _phase: Phase def __init__( self, @@ -145,6 +161,7 @@ def __init__( self._is_root = False self._name = None self._unshard_event = None + self._phase = FsdpModule.Phase.RESTING owned_parameters = _collect_owned_parameters(self) assert tuple(placements.dp_axes) == tuple( range(mesh.ndim) @@ -238,6 +255,16 @@ def pre_forward(self) -> None: """ context = self.context context.ensure_finalized() + # post_forward() resets the phase after a non-recomputed forward, so a + # FORWARD phase here means this forward-pre hook ran while the previous + # forward was still in progress. + assert self._phase is not FsdpModule.Phase.FORWARD + # A reentrant checkpoint recomputes before the child module's backward-pre + # hook can set its phase. Its forward still runs inside the active autograd + # GraphTask, which is the signal PyTorch FSDP2 uses as well. + is_recomputing = self._phase is FsdpModule.Phase.BACKWARD or _is_in_backward() + if not is_recomputing: + self._phase = FsdpModule.Phase.FORWARD torch.cuda.nvtx.range_push(self._nvtx_label("forward")) self._num_ready_grad_parameters = 0 allgather_stream = context.allgather_stream @@ -252,9 +279,13 @@ def pre_forward(self) -> None: # issued afterwards, so it is free to run concurrently with this FsdpModule). current_stream.wait_event(self._unshard_event) - next_module = context.forward_order.next_item(self) - if next_module is not None: - next_module._unshard_parameter_groups() + # Activation recomputation runs forward hooks inside backward. Do not + # prefetch the next module in forward order: its backward may already + # be complete, so no later backward hook would reshard it. + if not is_recomputing: + next_module = context.forward_order.next_item(self) + if next_module is not None: + next_module._unshard_parameter_groups() def _unshard_parameter_groups(self) -> None: """Unshard this FsdpModule's parameter groups on the all-gather stream. @@ -275,7 +306,13 @@ def _unshard_parameter_groups(self) -> None: def post_forward(self) -> None: """Return parameters to their sharded resting state after forward compute.""" - self._reshard_parameter_groups() + # Recomputed parameters are consumed immediately by this module's + # backward. Keep them materialized to avoid an unnecessary all-gather; + # post_backward() will reshard them after gradient reduction. + is_recomputing = self._phase is FsdpModule.Phase.BACKWARD or _is_in_backward() + if not is_recomputing: + self._reshard_parameter_groups() + self._phase = FsdpModule.Phase.RESTING torch.cuda.nvtx.range_pop() def _reshard_parameter_groups(self) -> None: @@ -298,6 +335,7 @@ def _reshard_parameter_groups(self) -> None: def pre_backward(self) -> None: """Prepare full parameters and prefetch the next FsdpModule in backward order.""" + self._phase = FsdpModule.Phase.BACKWARD torch.cuda.nvtx.range_push(self._nvtx_label("backward")) context = self.context current_stream = context.current_stream() @@ -324,6 +362,7 @@ def post_backward(self) -> None: """Reduce gradients and return parameters to their sharded resting state.""" self._reduce_gradient_groups() self._reshard_parameter_groups() + self._phase = FsdpModule.Phase.RESTING torch.cuda.nvtx.range_pop() def _reduce_gradient_groups(self) -> None: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 357d765f63e..863becf8b77 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -11,6 +11,7 @@ from torch.distributed.device_mesh import DeviceMesh, init_device_mesh from torch.distributed.tensor import DTensor from torch.profiler import ProfilerActivity, profile +from torch.utils.checkpoint import checkpoint from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( Flat, @@ -22,6 +23,7 @@ fully_shard_optimizer, microbatch, ) +from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental.module import FsdpModule from megatron.core.distributed.fsdp.src.megatron_fsdp.mixed_precision import MixedPrecisionPolicy from tests.unit_tests.distributed.mfsdp_v2.profiler_utils import ( collect_linked_kernels, @@ -45,6 +47,19 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return self.fc2(self.relu(self.fc1(x))) +class CheckpointedTinyModel(TinyModel): + """Tiny model that activation-checkpoints each shardable module.""" + + def __init__(self, use_reentrant: bool) -> None: + super().__init__() + self.use_reentrant = use_reentrant + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Run each linear layer through activation checkpointing.""" + x = checkpoint(self.fc1, x, use_reentrant=self.use_reentrant) + return checkpoint(self.fc2, self.relu(x), use_reentrant=self.use_reentrant) + + class NestedModel(nn.Module): """Model with direct and child-owned parameters.""" @@ -217,6 +232,48 @@ def train(model, optimizer, log_prefix) -> list[torch.Tensor]: ) +@pytest.mark.parametrize("use_reentrant", [False, True], ids=["non_reentrant", "reentrant"]) +def test_fully_shard_activation_recompute_reshards_parameters(distributed_setup, use_reentrant): + """Activation recomputation should leave every FSDP module resharded. + + Backward completes ``fc2`` before recomputing ``fc1``. Without suppressing + forward prefetch during recomputation, ``fc1`` unshards ``fc2`` again after + its backward hook has run, leaving ``fc2.weight`` as an unsharded Parameter + instead of a sharded DTensor at the end of backward. + """ + world_size = distributed_setup.world_size + device = distributed_setup.device + + mesh = init_device_mesh(device.type, (world_size,)) + model = CheckpointedTinyModel(use_reentrant=use_reentrant).to(device) + with fully_shard_context(device=device): + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + fully_shard(model, mesh=mesh, placements=_flat_placements()) + + x = torch.randn(2, 8, device=device, requires_grad=True) + model(x).sum().backward() + + # Without the forward-prefetch suppression, ``fc1``'s recomputed forward + # would unshard ``fc2`` after ``fc2``'s backward already resharded it, + # leaving an unsharded Parameter here. + assert isinstance(model.fc1.weight, DTensor) + assert isinstance(model.fc2.weight, DTensor) + + # Backward completes each module before recomputing the previous one, so + # every module-local phase must be cleared after its matching backward. + assert model._phase is FsdpModule.Phase.RESTING + assert model.fc1._phase is FsdpModule.Phase.RESTING + assert model.fc2._phase is FsdpModule.Phase.RESTING + + # A second forward after backward runs in the forward phase again, so + # forward-order prefetch resumes and the module phases return to resting. + model(x).sum().backward() + assert model._phase is FsdpModule.Phase.RESTING + assert model.fc1._phase is FsdpModule.Phase.RESTING + assert model.fc2._phase is FsdpModule.Phase.RESTING + + @pytest.mark.parametrize("set_to_none", [True, False]) @pytest.mark.parametrize("num_microbatches", [1, 3]) def test_hsdp_losses_match_baseline(distributed_setup, num_microbatches, set_to_none): From d2715d78a8b69261dc43cd0e9f7a2072788c32b3 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Fri, 7 Aug 2026 18:37:15 -0400 Subject: [PATCH 222/290] Add Nemotron 3.5 Lightning GB200 nightly test (#6312) Signed-off-by: Philip Petrakian --- .../get_test_results_from_tensorboard_logs.py | 1 + .../test_pretraining_regular_pipeline.py | 1 + .../golden_values_dev_dgx_gb200.json | 190 ++++++++++++++++++ .../model_config.yaml | 163 +++++++++++++++ tests/test_utils/recipes/gb200/nemotron.yaml | 112 +++++++++++ 5 files changed, 467 insertions(+) create mode 100644 tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/golden_values_dev_dgx_gb200.json create mode 100644 tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/model_config.yaml create mode 100644 tests/test_utils/recipes/gb200/nemotron.yaml diff --git a/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py b/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py index fcee30e61e6..e47961a31a1 100644 --- a/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py +++ b/tests/functional_tests/python_test_utils/get_test_results_from_tensorboard_logs.py @@ -50,6 +50,7 @@ def collect_train_test_metrics( "lm loss", "num-zeros", "mtp_1 loss", + "mtp_2 loss", "total loss", ] } diff --git a/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py b/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py index a5ad326f49d..832558adaad 100644 --- a/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py +++ b/tests/functional_tests/python_test_utils/test_pretraining_regular_pipeline.py @@ -17,6 +17,7 @@ "mem-max-allocated-bytes": [common.ApproximateTest(atol=0, rtol=0.05)], "lm loss": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], "mtp_1 loss": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], + "mtp_2 loss": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], "num-zeros": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.20)], "generated_tokens": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], "logprobs": [common.DeterministicTest(), common.ApproximateTest(atol=0, rtol=0.05)], diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/golden_values_dev_dgx_gb200.json new file mode 100644 index 00000000000..557529b414b --- /dev/null +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/golden_values_dev_dgx_gb200.json @@ -0,0 +1,190 @@ +{ + "lm loss": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 10.77293, + "2": 10.7731, + "3": 9.68815, + "4": 11.06906, + "5": 9.77569, + "6": 9.47808, + "7": 9.06072, + "8": 8.8026, + "9": 8.76839, + "10": 8.59208, + "11": 8.38807, + "12": 8.07211, + "13": 8.00935, + "14": 7.92114, + "15": 7.75658, + "16": 7.632, + "17": 7.52375, + "18": 7.44319, + "19": 7.32021, + "20": 7.29368 + } + }, + "num-zeros": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 8283006.0, + "2": 8215530.0, + "3": 8558295.0, + "4": 1945319168.0, + "5": 10112309248.0, + "6": 15437038592.0, + "7": 19477004288.0, + "8": 21703987200.0, + "9": 23075272704.0, + "10": 23760054272.0, + "11": 23957757952.0, + "12": 24046585856.0, + "13": 23972829184.0, + "14": 23722366976.0, + "15": 23549724672.0, + "16": 23252019200.0, + "17": 22770812928.0, + "18": 22064881664.0, + "19": 21154738176.0, + "20": 20261984256.0 + } + }, + "mem-allocated-bytes": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 88137433088.0, + "2": 88137441280.0, + "3": 88137605120.0, + "4": 88138850304.0, + "5": 88139759616.0, + "6": 88139350016.0, + "7": 88139849728.0, + "8": 88139620352.0, + "9": 88140791808.0, + "10": 88141807616.0, + "11": 88141611008.0, + "12": 88142356480.0, + "13": 88142135296.0, + "14": 88142127104.0, + "15": 88142004224.0, + "16": 88140652544.0, + "17": 88138448896.0, + "18": 88138235904.0, + "19": 88137572352.0, + "20": 88138219520.0 + } + }, + "mem-max-allocated-bytes": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 119058399232.0, + "2": 151382818816.0, + "3": 151812358144.0, + "4": 155852455936.0, + "5": 158491279360.0, + "6": 158491279360.0, + "7": 159239569408.0, + "8": 159239569408.0, + "9": 162155266048.0, + "10": 165063098368.0, + "11": 165063098368.0, + "12": 166633291776.0, + "13": 166633291776.0, + "14": 166633291776.0, + "15": 166633291776.0, + "16": 166633291776.0, + "17": 166633291776.0, + "18": 166633291776.0, + "19": 166633291776.0, + "20": 166633291776.0 + } + }, + "mtp_1 loss": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 10.78576, + "2": 10.78552, + "3": 10.66957, + "4": 10.42839, + "5": 10.18673, + "6": 9.92491, + "7": 9.54198, + "8": 9.20471, + "9": 8.98346, + "10": 8.73057, + "11": 8.52848, + "12": 8.34207, + "13": 8.14723, + "14": 8.02206, + "15": 7.87516, + "16": 7.7349, + "17": 7.55731, + "18": 7.46251, + "19": 7.3314, + "20": 7.23513 + } + }, + "mtp_2 loss": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 10.81667, + "2": 10.81777, + "3": 10.69916, + "4": 10.46612, + "5": 10.22667, + "6": 9.93677, + "7": 9.51459, + "8": 9.17991, + "9": 9.01314, + "10": 8.68501, + "11": 8.53294, + "12": 8.34325, + "13": 8.12935, + "14": 7.99982, + "15": 7.87422, + "16": 7.73445, + "17": 7.54824, + "18": 7.45019, + "19": 7.3192, + "20": 7.21849 + } + }, + "iteration-time": { + "start_step": 2, + "end_step": 20, + "step_interval": 1, + "values": { + "2": 103.07695, + "3": 26.75034, + "4": 24.96478, + "5": 23.51423, + "6": 24.71646, + "7": 25.16894, + "8": 24.63826, + "9": 23.91178, + "10": 23.75633, + "11": 24.20485, + "12": 23.30083, + "13": 22.73128, + "14": 22.04589, + "15": 22.34995, + "16": 22.52285, + "17": 23.71327, + "18": 22.43595, + "19": 22.85244, + "20": 21.68526 + } + } +} diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/model_config.yaml new file mode 100644 index 00000000000..28b0943f3d6 --- /dev/null +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G/model_config.yaml @@ -0,0 +1,163 @@ +ENV_VARS: + NCCL_GRAPH_REGISTER: 0 + NCCL_NVLS_ENABLE: 0 + PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True" + TORCH_NCCL_AVOID_RECORD_STREAMS: 1 + TORCH_NCCL_HIGH_PRIORITY: 1 + CUDA_DEVICE_MAX_CONNECTIONS: 32 + NUM_OF_HYBRID_EP_RANKS_PER_NVLINK_DOMAIN: 8 + NUM_OF_TOKENS_PER_CHUNK_COMBINE_API: 128 + NVLINK_DOMAIN_SIZE: 72 + USE_MNNVL: 1 + NVTE_BWD_LAYERNORM_SM_MARGIN: 20 + NVTE_FWD_LAYERNORM_SM_MARGIN: 20 + NVTE_NORM_BWD_USE_CUDNN: 1 + NVTE_NORM_FWD_USE_CUDNN: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 1 + NON_DETERMINSTIC_RESULTS: 1 + +TEST_TYPE: regular + +MODEL_ARGS: + # Distributed topology: 2 nodes x 4 GB200 GPUs. + --tensor-model-parallel-size: 1 + --pipeline-model-parallel-size: 1 + --context-parallel-size: 1 + --expert-model-parallel-size: 8 + --expert-tensor-parallel-size: 1 + --data-parallel-sharding-strategy: optim_grads_params + --use-distributed-optimizer: true + --overlap-grad-reduce: true + --overlap-param-gather: true + --check-for-large-grads: true + + # Functional-test training budget. Keep the full Bridge LR schedule. + --micro-batch-size: 2 + --global-batch-size: 512 + --train-iters: 20 + --manual-gc: true + --manual-gc-interval: 100 + --cross-entropy-loss-fusion: true + # Bridge uses TE here, but current MCore rejects TE cross-entropy fusion. + --cross-entropy-fusion-impl: native + --attention-backend: fused + --te-rng-tracker: true + + # Nemotron 3 Nano / Nemotron 3.5 Lightning architecture. + --spec: "[megatron.core.models.hybrid.hybrid_layer_specs hybrid_stack_spec]" + --hybrid-layer-pattern: MEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEMEM*EMEMEMEME/*E/*E + --num-layers: 52 + --hidden-size: 2688 + --ffn-hidden-size: 1856 + --moe-ffn-hidden-size: 1856 + --num-attention-heads: 32 + --group-query-attention: true + --num-query-groups: 2 + --kv-channels: 128 + --mamba-num-heads: 64 + --mamba-head-dim: 64 + --mamba-state-dim: 128 + --mamba-num-groups: 8 + --position-embedding-type: none + --normalization: RMSNorm + --norm-epsilon: 1.0e-5 + --untie-embeddings-and-output-weights: true + --disable-bias-linear: true + --squared-relu: true + --use-fused-weighted-squared-relu: true + --init-method-std: 0.0173 + --make-vocab-size-divisible-by: 128 + --first-last-layers-bf16: true + --transformer-impl: transformer_engine + + # Mixture of experts and HybridEP. + --num-experts: 128 + --moe-router-topk: 6 + --moe-router-topk-scaling-factor: 2.5 + --moe-router-num-groups: 1 + --moe-router-group-topk: 1 + --moe-router-score-function: sigmoid + --moe-router-enable-expert-bias: true + --moe-router-dtype: fp32 + --moe-router-load-balancing-type: seq_aux_loss + --moe-aux-loss-coeff: 1.0e-4 + --moe-shared-expert-intermediate-size: 3712 + --moe-token-dispatcher-type: flex + --moe-flex-dispatcher-backend: hybridep + --moe-flex-dispatcher-num-sms: 16 + --moe-grouped-gemm: true + --moe-permute-fusion: true + + # Lightning multi-token prediction. The unified pattern above carries both depths. + --mtp-num-layers: 2 + --mtp-use-repeated-layer: true + --mtp-loss-scaling-factor: 0.3 + + # Data and tokenizer. Square brackets make the functional launcher expand the blend. + --seq-length: 8192 + --max-position-embeddings: 8192 + --data-path: "[${DATA_BLEND}]" + --data-cache-path: ${DATA_CACHE_PATH} + --split: "9999,8,2" + --dataloader-type: single + --num-workers: 8 + --no-mmap-bin-files: true + --no-create-attention-mask-in-dataloader: true + --tokenizer-type: ${TOKENIZER_TYPE} + --tokenizer-model: ${TOKENIZER_MODEL_PATH} + + # BF16 compute and BF16 gradient reduction with FP32 optimizer state. + --bf16: true + --grad-reduce-in-bf16: true + --main-grads-dtype: fp32 + --main-params-dtype: fp32 + --exp-avg-dtype: fp32 + --exp-avg-sq-dtype: fp32 + + # Adam and cosine schedule inherited from Bridge. + --optimizer: adam + --lr: 1.6e-3 + --min-lr: 1.6e-5 + --lr-decay-style: cosine + --lr-decay-iters: 39735 + --lr-warmup-iters: 333 + --lr-warmup-init: 0.0 + --lr-wsd-decay-style: minus_sqrt + --adam-beta1: 0.9 + --adam-beta2: 0.95 + --adam-eps: 1.0e-8 + --weight-decay: 0.1 + --clip-grad: 1.0 + --override-opt-param-scheduler: true + --attention-dropout: 0.0 + --hidden-dropout: 0.0 + + # Synchronous distributed checkpointing; 20 test steps do not reach the save interval. + --save: ${CHECKPOINT_SAVE_PATH} + --load: ${CHECKPOINT_LOAD_PATH} + --save-interval: 50 + --ckpt-format: torch_dist + --ckpt-assume-constant-structure: true + --dist-ckpt-strictness: log_all + + # Validation and functional metrics. + --eval-interval: 500 + --eval-iters: 32 + --log-interval: 1 + --log-num-zeros-in-grad: true + --log-timers-to-tensorboard: true + --log-memory-to-tensorboard: true + --tensorboard-dir: ${TENSORBOARD_PATH} + --timing-log-level: 0 + --seed: 1234 + +METRICS: + - iteration-time + - lm loss + - mtp_1 loss + - mtp_2 loss + - num-zeros + - mem-allocated-bytes + - mem-max-allocated-bytes + +LAUNCHER: ft_launcher diff --git a/tests/test_utils/recipes/gb200/nemotron.yaml b/tests/test_utils/recipes/gb200/nemotron.yaml new file mode 100644 index 00000000000..e5024ceba24 --- /dev/null +++ b/tests/test_utils/recipes/gb200/nemotron.yaml @@ -0,0 +1,112 @@ +type: basic +format_version: 1 +maintainers: [mcore] +loggers: [stdout] +spec: + name: "{test_case}_{environment}_{platforms}" + model: nemotron + build: mcore-pyt-{environment} + nodes: 2 + gpus: 4 + segment: 2 + n_repeat: 5 + platforms: dgx_gb200 + script_setup: | + set -euo pipefail + unset https_proxy + echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + + # Checkout latest + cd /opt + rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm + git init + git remote add origin $MCORE_REPO + git fetch origin '+refs/merge-requests/*:refs/remotes/merge-requests/*' + git fetch origin $MCORE_MR_COMMIT + git checkout $MCORE_MR_COMMIT + git rev-parse HEAD + + # Checkout backwards-ref + cd /opt + rm -rf /opt/megatron-lm-legacy; mkdir megatron-lm-legacy; cd megatron-lm-legacy + git init + git remote add origin $MCORE_REPO + git fetch origin $MCORE_BACKWARDS_COMMIT + git checkout $MCORE_BACKWARDS_COMMIT + git rev-parse HEAD + rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + script: |- + set -euo pipefail + : "${{RUN_ID:=pr-$$}}" + cd /opt/megatron-lm + + export GPUS_PER_NODE={gpus} + DATA_PATH=/mnt/rp2 + DATA_BLEND=( + 0.03846154 "$DATA_PATH/perp_head/head_01_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_02_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_03_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_04_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_05_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_06_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_07_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_08_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_09_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_10_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_11_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_12_text_document" + 0.03846154 "$DATA_PATH/perp_head/head_13_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_01_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_02_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_03_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_04_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_05_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_06_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_07_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_08_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_09_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_10_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_11_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_12_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_13_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_14_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_15_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_16_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_17_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_18_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_19_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_20_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_21_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_22_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_23_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_24_text_document" + 0.02 "$DATA_PATH/perp_middle/middle_25_text_document" + ) + + ARGUMENTS=( + "DATA_PATH=$DATA_PATH" + "DATA_BLEND=${{DATA_BLEND[*]}}" + "DATA_CACHE_PATH=/lustre/fsw/coreai_dlalgo_mcore/mcore_ci/data/$RUN_ID/cache/" + "TOKENIZER_TYPE=SentencePieceTokenizer" + "TOKENIZER_MODEL_PATH=$DATA_PATH/tokenizer/tokenizer.model" + "OUTPUT_PATH={assets_dir}" + "TENSORBOARD_PATH={assets_dir}/tensorboard" + "CHECKPOINT_SAVE_PATH={artifacts_dir}/checkpoints" + "CHECKPOINT_LOAD_PATH=$DATA_PATH" + "TRAINING_SCRIPT_PATH=pretrain_hybrid.py" + "TRAINING_PARAMS_PATH=./tests/functional_tests/test_cases/{model}/{test_case}/model_config.yaml" + "GOLDEN_VALUES_PATH=./tests/functional_tests/test_cases/{model}/{test_case}/golden_values_{environment}_{platforms}.json" + "N_REPEAT={n_repeat}" + "ENABLE_LIGHTWEIGHT_MODE=${{ENABLE_LIGHTWEIGHT_MODE:-}}" + "RECORD_CHECKPOINTS=${{RECORD_CHECKPOINTS:-}}" + ) + + bash ./tests/functional_tests/shell_test_utils/run_ci_test.sh "${{ARGUMENTS[@]}}" + +products: + - test_case: [nemotron3_5_lightning_nightly_tp1_pp1_cp1_ep8_dgx_gb200_2N8G] + products: + - environment: [dev] + scope: [L2] + cadence: [nightly] + platforms: [dgx_gb200] From a6fb1621ff1ec2c47614634d29546919c798f668 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 8 Aug 2026 00:11:50 +0000 Subject: [PATCH 223/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index 666df55c8af..eface2e458a 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "DanialTaheri", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From e04ed198bebc4c76cab4d23c03f0094327e57224 Mon Sep 17 00:00:00 2001 From: Shanmugam Ramasamy Date: Fri, 7 Aug 2026 18:03:22 -0700 Subject: [PATCH 224/290] Exit non-zero when --exit-on-missing-checkpoint triggers (#6321) Signed-off-by: shanmugamr1992 Co-authored-by: Claude Opus 4.7 --- megatron/training/checkpointing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 8131645f09c..84b687b25df 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -1740,10 +1740,16 @@ def _load_base_checkpoint( print_rank_0(' will not load any checkpoints and will start from random') # Conditionally exit if checkpoint not found. if args.exit_on_missing_checkpoint: - print_rank_0(">> '--exit-on-missing-checkpoint' set ... exiting. <<") + print_rank_0( + ">> '--exit-on-missing-checkpoint' is set but no checkpoint was found under " + f"load directory '{load_dir}' (missing metadata/tracker file " + f"'{tracker_filename}'). Exiting with a non-zero status. <<" + ) if torch.distributed.is_initialized(): torch.distributed.barrier() - sys.exit() + # Exit non-zero so that callers (e.g. CI harnesses) detect the missing + # checkpoint as a failure instead of silently treating exit code 0 as success. + sys.exit(1) return None, '', False, None From f8ec15e4a8bcd84103ac7d14d1c301c0d85dcb7a Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Fri, 7 Aug 2026 19:33:20 -0700 Subject: [PATCH 225/290] Divide expert gradients by the expert-parallel size (#5956) Signed-off-by: Jingyue Wu Co-authored-by: Claude Opus 5 (1M context) --- .../megatron_fsdp/experimental/fully_shard.py | 13 + .../src/megatron_fsdp/experimental/module.py | 4 + .../experimental/parameter_group.py | 16 ++ .../mfsdp_v2/test_expert_parallel.py | 235 ++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 tests/unit_tests/distributed/mfsdp_v2/test_expert_parallel.py diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py index 135382ab1f1..02a242feec3 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py @@ -66,6 +66,7 @@ def fully_shard( placements: Placements, mixed_precision_policy: MixedPrecisionPolicy | None = None, use_symm_mem: bool = False, + grad_divisor: int = 1, ) -> None: """Apply FSDP to a module in place. @@ -80,6 +81,17 @@ def fully_shard( and parameter-dtype main gradients. use_symm_mem: Allocate all-gather and reduce-scatter staging buffers from PyTorch's NCCL symmetric-memory pool. + grad_divisor: Additional divisor applied to the reduced gradient, on top of the + averaging the mesh already performs. Defaults to 1, which is correct whenever + each mesh rank contributes exactly one term to the gradient. + + Expert parallelism is the motivating case. A rank's experts process tokens + routed to them from every rank in the expert-parallel group, and the backward + pass routes those tokens' gradients back, so a rank's expert gradient already + sums over ``ep_size`` ranks' data before any reduction happens. Averaging over + the expert-data-parallel mesh alone therefore divides by too little, and + ``grad_divisor=ep_size`` makes up the difference. Dense parameters see only + their own rank's tokens and need no divisor. """ if isinstance(module, FsdpModule): raise ValueError("This module is already managed by FSDP.") @@ -106,6 +118,7 @@ def fully_shard( placements=placements, mixed_precision_policy=mixed_precision_policy, use_symm_mem=use_symm_mem, + grad_divisor=grad_divisor, ) except Exception: module.__class__ = original_cls diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 1e1a229a73a..895c9963e26 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -155,6 +155,7 @@ def __init__( placements: Placements, mixed_precision_policy: MixedPrecisionPolicy, use_symm_mem: bool = False, + grad_divisor: int = 1, ) -> None: """Initialize FSDP runtime state on an already-constructed module.""" self._context = context @@ -166,6 +167,8 @@ def __init__( assert tuple(placements.dp_axes) == tuple( range(mesh.ndim) ), "FSDP requires dp_axes to match every mesh axis in mesh order for now." + if grad_divisor <= 0: + raise ValueError(f"grad_divisor must be positive, got {grad_divisor}.") parameter_groups = [ FsdpParameterGroup( owning_module=self, @@ -175,6 +178,7 @@ def __init__( mixed_precision_policy=mixed_precision_policy, reduce_scatter_stream=context.reduce_scatter_stream, use_symm_mem=use_symm_mem, + grad_divisor=grad_divisor, ) for group_parameters in _group_parameters(owned_parameters) ] diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index aa35a6e8233..6728af5691e 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -67,6 +67,7 @@ class FsdpParameterGroup: main_grad: DBuffer | None _unsharded_model_weight: DBuffer _symm_mem_pool: torch.cuda.MemPool | None + grad_divisor: int def __init__( self, @@ -77,6 +78,7 @@ def __init__( mixed_precision_policy: MixedPrecisionPolicy, reduce_scatter_stream: torch.cuda.Stream, use_symm_mem: bool = False, + grad_divisor: int = 1, ) -> None: """Create persistent sharded buffers for a group of parameters. @@ -89,6 +91,8 @@ def __init__( reduce_scatter_stream: Stream on which to allocate the main-gradient buffer. use_symm_mem: Allocate communication staging buffers from PyTorch's NCCL symmetric-memory pool. + grad_divisor: Additional divisor applied on top of the mesh-size + averaging. See ``fully_shard``. """ if not parameters: raise ValueError("FsdpParameterGroup requires at least one parameter.") @@ -107,6 +111,7 @@ def __init__( # fsdp_parameters define the same stable DBuffer tensor order. self._owning_module = ref(owning_module) self.mesh = mesh + self.grad_divisor = grad_divisor first_parameter = next(iter(parameter_to_fqns)) self.dtype = first_parameter.dtype self.requires_grad = first_parameter.requires_grad @@ -361,10 +366,21 @@ def reduce_partial_gradients( reduce_axis = changed_mesh_axis(partial_grad.placements, self.main_grad.placements) if reduce_axis is None: raise RuntimeError("FSDP gradient reduction requires a changed placement axis.") + if self._symm_mem_pool is not None: + partial_grad.rendezvous(reduce_axis) + # Divide this backward's contribution, not the accumulated total: with plain + # all-Flat DP every backward is a last microbatch, so main_grad accumulates + # across microbatches below and a scale applied to the running sum would + # compound. Dividing before the deferred DP-outer reduction is equivalent because + # both that reduction and this scale are linear. if can_reduce_into_main_grad: partial_grad.redistribute(self.main_grad.placements, out=self.main_grad) + if self.grad_divisor != 1: + self.main_grad.local_buffer.div_(self.grad_divisor) else: reduced_grad = partial_grad.redistribute(self.main_grad.placements) + if self.grad_divisor != 1: + reduced_grad.local_buffer.div_(self.grad_divisor) if has_sharded_grads: self.main_grad.local_buffer.add_(reduced_grad.local_buffer) else: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_expert_parallel.py b/tests/unit_tests/distributed/mfsdp_v2/test_expert_parallel.py new file mode 100644 index 00000000000..deffbe99667 --- /dev/null +++ b/tests/unit_tests/distributed/mfsdp_v2/test_expert_parallel.py @@ -0,0 +1,235 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Megatron-FSDP v2 composed with expert parallelism through a real MCore HybridModel. + +Checks that an ``EP=4`` transformer-MoE ``HybridModel`` (an attention layer + a MoE layer) +sharded with mFSDP v2 (experts over the expert-DP sub-mesh, dense params over the full DP +mesh), consuming its ``1/dp`` shard of a global batch, reproduces a single **full-batch +``EP=1`` reference**. + +The reference processes the whole global batch on every rank, so its gradients are +identical across ranks and need no reduction -- it has no distributed logic. The model's +gradients are reduced only by mFSDP. So this independently validates EP all-to-all +dispatch, FSDP sharding, and the gradient reduction/scaling: a broken reduction (or a +missing expert-grad scaling factor) would diverge from full-batch training. + +Both models are built from explicit ``ProcessGroupCollection``s (no global +``parallel_state`` / ``initialize_model_parallel``): the reference with a size-1 ``ep`` +group (all experts local), the model with the 2-way ``ep`` group. Model shapes and the +``(ep, dp)`` split are test-local so different tests can vary them. +""" + +import pytest +import torch +import torch.distributed as dist +from torch.distributed.device_mesh import init_device_mesh + +from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( + Flat, + Placements, + fully_shard, + fully_shard_context, +) +from megatron.core.models.hybrid.hybrid_layer_specs import hybrid_stack_spec +from megatron.core.models.hybrid.hybrid_model import HybridModel +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.transformer.enums import AttnBackend +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.transformer.transformer_layer import MoETransformerLayer + +_FLAT_SHARD = Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) + + +def _transformer_config( + num_layers: int, num_experts: int, ep_size: int, hidden: int, ffn_hidden: int +) -> TransformerConfig: + return TransformerConfig( + num_layers=num_layers, + hidden_size=hidden, + num_attention_heads=4, + num_moe_experts=num_experts, + expert_model_parallel_size=ep_size, + moe_token_dispatcher_type="alltoall", + moe_router_topk=2, + moe_aux_loss_coeff=0.0, + moe_grouped_gemm=True, + moe_ffn_hidden_size=ffn_hidden, + add_bias_linear=False, + gradient_accumulation_fusion=False, + use_cpu_initialization=True, + params_dtype=torch.float32, + hidden_dropout=0.0, + attention_dropout=0.0, + # unfused (native-PyTorch) attention: flash/fused don't support the fp32 params we use. + attention_backend=AttnBackend.unfused, + ) + + +def _build_process_group_collection( + one: dist.ProcessGroup, + dp: dist.ProcessGroup, + ep: dist.ProcessGroup, + expert_dp: dist.ProcessGroup, +) -> ProcessGroupCollection: + """A ProcessGroupCollection for a TP=PP=CP=1 MoE model. `one` is a size-1 group for the + trivial TP/PP/CP axes; dp, ep, expert_dp are the data-, expert-, and expert-data-parallel + groups. + """ + return ProcessGroupCollection( + tp=one, + expt_tp=one, + cp=one, + pp=one, + tp_cp=one, + tp_dp_cp=dp, + ep=ep, + tp_ep=ep, + expt_dp=expert_dp, + dp=dp, + dp_cp=dp, + embd=None, + pos_embd=None, + ) + + +def _build_hybrid_model( + config: TransformerConfig, + pg_collection: ProcessGroupCollection, + vocab: int, + seq: int, + pattern: str, +) -> HybridModel: + return HybridModel( + config=config, + hybrid_stack_spec=hybrid_stack_spec, + vocab_size=vocab, + max_sequence_length=seq, + hybrid_layer_pattern=pattern, + pg_collection=pg_collection, + ).cuda() + + +def _train( + model: torch.nn.Module, + ids: torch.Tensor, + pos: torch.Tensor, + mask: torch.Tensor | None, + target: torch.Tensor, + loss_reduce_group: dist.ProcessGroup | None = None, +) -> list[torch.Tensor]: + """Run 5 SGD steps; return the per-step losses (globally averaged if loss_reduce_group given).""" + optimizer = torch.optim.SGD(model.parameters(), lr=0.02, foreach=False) + losses = [] + for _ in range(5): + optimizer.zero_grad() + loss = torch.nn.functional.mse_loss( + model(input_ids=ids, position_ids=pos, attention_mask=mask), target + ) + loss.backward() + optimizer.step() + # The model sees a shard, so average the loss across ranks for the global loss. + loss = loss.detach() + if loss_reduce_group is not None: + dist.all_reduce(loss, op=dist.ReduceOp.AVG, group=loss_reduce_group) + losses.append(loss) + return losses + + +def test_ep_fsdp_matches_fullbatch_reference(distributed_setup): + """EP=4 + mFSDP on 1/dp-sharded data reproduces single full-batch EP=1 training.""" + device = distributed_setup.device + world_size, rank = distributed_setup.world_size, distributed_setup.rank + + num_experts, ep_size = 8, 4 + # hidden=64 with 4 heads -> head_dim=16, large enough for the attention backend. + hidden, ffn_hidden, vocab, seq, b_local = 64, 128, 128, 8, 2 + # HybridModel builds one layer per pattern symbol, so "*E" is a two-layer stack: "*" a + # self-attention-only layer, "E" a MoE layer -- i.e. a real transformer-MoE block + # (attention then MoE, two residuals; equivalent to a Mixtral-style layer). This exercises + # mFSDP sharding the dense attention params over full DP alongside EP-sharded experts. + layer_pattern = "*E" + num_layers = len(layer_pattern) + if world_size % ep_size != 0 or num_experts % ep_size != 0: + pytest.skip(f"world_size {world_size} is incompatible with EP={ep_size}.") + edp_size = world_size // ep_size + global_batch = world_size * b_local # one shard per rank + + # Process groups (no global parallel_state). world_mesh: the full DP group; moe_mesh: the + # ep (ep_size-way) and expert-DP (edp_size-way) groups for the EP=4 model. Meshes also + # initialize the default process group, so build them before the size-1 group below. + world_mesh = init_device_mesh(device.type, (world_size,)) + world = world_mesh.get_group() + moe_mesh = init_device_mesh(device.type, (edp_size, ep_size), mesh_dim_names=("edp", "ep")) + ep_group, expert_dp_group = moe_mesh.get_group("ep"), moe_mesh.get_group("edp") + # This rank's size-1 group: the trivial TP=PP=CP axes and the EP=1 reference's ep group. + one = dist.new_group([rank], use_local_synchronization=True) + + # Reference EP=1 (all experts local); model EP=4. Seed once so the reference is + # deterministic and identical across ranks (CPU init); the model's own init is irrelevant + # since its weights are copied from the reference below. + torch.manual_seed(123) + reference = _build_hybrid_model( + _transformer_config(num_layers, num_experts, 1, hidden, ffn_hidden), + _build_process_group_collection(one, dp=one, ep=one, expert_dp=one), + vocab, + seq, + layer_pattern, + ) + model = _build_hybrid_model( + _transformer_config(num_layers, num_experts, ep_size, hidden, ffn_hidden), + _build_process_group_collection(one, dp=world, ep=ep_group, expert_dp=expert_dp_group), + vocab, + seq, + layer_pattern, + ) + + # Dense params line up by name (load_state_dict); the experts do not -- EP=1 stores all + # experts as weight0.., EP=4 stores num_experts/EP as weight0.. per rank -- so patch them + # by global index (model local weight i == reference global weight local_expert_indices[i]). + model.load_state_dict(reference.state_dict(), strict=False) + for model_layer, reference_layer in zip(model.decoder.layers, reference.decoder.layers): + if not isinstance(model_layer, MoETransformerLayer): + continue # only MoE layers have experts to remap; the attention layer has none + for fc in ("linear_fc1", "linear_fc2"): + model_fc = getattr(model_layer.mlp.experts, fc) + reference_fc = getattr(reference_layer.mlp.experts, fc) + for local, global_ in enumerate(model_layer.mlp.local_expert_indices): + getattr(model_fc, f"weight{local}").data.copy_( + getattr(reference_fc, f"weight{global_}").data + ) + + # Shard the model: experts over the expert-DP sub-mesh, dense params over the full DP mesh. + # Experts additionally need grad_divisor=ep_size; see fully_shard. + with fully_shard_context(device=device): + for decoder_layer in model.decoder.layers: + if isinstance(decoder_layer, MoETransformerLayer): + fully_shard( + decoder_layer.mlp.experts, + mesh=moe_mesh["edp"], + placements=_FLAT_SHARD, + grad_divisor=ep_size, + ) + fully_shard(model, mesh=world_mesh, placements=_FLAT_SHARD) + + # One global batch, identical on every rank; the reference sees all of it, the model its shard. + torch.manual_seed(4321) + ids = torch.randint(0, vocab, (global_batch, seq), dtype=torch.int64, device=device) + pos = torch.arange(seq, dtype=torch.int64, device=device).repeat(global_batch, 1) + mask = None # attention layer is attn_mask_type=causal, so TE builds the causal mask itself + target = torch.randn(global_batch, seq, vocab, device=device) + shard = slice(rank * b_local, (rank + 1) * b_local) + + reference_losses = _train(reference, ids, pos, mask, target) + model_losses = _train( + model, ids[shard], pos[shard], mask, target[shard], loss_reduce_group=world + ) + + torch.testing.assert_close( + torch.stack(model_losses), + torch.stack(reference_losses), + msg="EP=4 mFSDP model did not reproduce full-batch EP=1 training.", + ) + + # Destroy the groups this test created; leave the default (world) group for later tests. + for group in (one, ep_group, expert_dp_group): + dist.destroy_process_group(group) From 79e6b6a7f525a9fb42a8adcb347534b4fc791b0a Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Sat, 8 Aug 2026 11:13:42 +0800 Subject: [PATCH 226/290] [feat] GTP+MTP (#6242) Signed-off-by: Shiqing Fan --- .../core/generalized_tensor_parallel.md | 53 +- .../generalized_tensor_parallelism.py | 90 ++- .../test_gtp_mtp.py | 564 ++++++++++++++++++ 3 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_mtp.py diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index 364d3fde6c4..288d421253f 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -46,6 +46,7 @@ Both `GTP_remat` collectives are prefetched one step ahead, so they overlap the - [2.2 High-priority streams (Blackwell and later)](#22-high-priority-streams-blackwell-and-later) - [2.3 Minimal end-to-end example](#23-minimal-end-to-end-example) - [2.4 Tuning knobs](#24-tuning-knobs) + - [2.5 FP32-accumulation wgrad reduce-scatter (optional)](#25-fp32-accumulation-wgrad-reduce-scatter-optional) - [3. Implementation details](#3-implementation-details) - [3.1 GTP\_remat architecture (Mcore ↔ TE integration)](#31-gtp_remat-architecture-mcore--te-integration) - [What the flags do under the hood](#what-the-flags-do-under-the-hood) @@ -58,7 +59,11 @@ Both `GTP_remat` collectives are prefetched one step ahead, so they overlap the - [3.3 Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) - [3.4 Prefetch-chain construction and its design assumptions](#34-prefetch-chain-construction-and-its-design-assumptions) - [Grouped-expert chains (one-block-ahead)](#grouped-expert-chains-one-block-ahead) - - [3.5 CUDA graph integration](#35-cuda-graph-integration) + - [3.5 GTP\_remat + Multi-Token Prediction (MTP)](#35-gtp_remat--multi-token-prediction-mtp) + - [What MTP does to the chain](#what-mtp-does-to-the-chain) + - [How the chain supports it](#how-the-chain-supports-it) + - [Configuration traps](#configuration-traps) + - [3.6 CUDA graph integration](#36-cuda-graph-integration) - [Cross-graph backward reduce-scatter overlap](#cross-graph-backward-reduce-scatter-overlap) - [4. Testing](#4-testing) @@ -529,7 +534,7 @@ The prefetch chains (§3.1) are **not configured — they are observed at runtim 2. **Linking (lazily, on the first forward).** The doubly-linked list (`prev_w` / `next_w`) is built the **first time each weight is materialized** inside `all_gather_and_prefetch`: a class-level per-chain cursor (`GTPShardedParam._chain_state[chain_id]["last_weight"]`) records the previously-seen weight, and the current weight links itself after it. The chain therefore **encodes the forward execution order of the first step** and replays it every step after to predict the next weight to prefetch. The recompute chain (`_recompute_next`) self-populates the same way, from the weights re-gathered while `in_fp8_activation_recompute_phase()` is true. -Weights that must **not** join a chain (embedding, output_layer — they all-gather synchronously and run outside the CUDA-graph boundary) are excluded by setting `weight.prefetch_initialized = True` (and `_need_weight_prefetch = False`) at construction, which skips registration entirely. +A weight can be kept **out** of a chain by setting `weight.prefetch_initialized = True` (and `_need_weight_prefetch = False`) before its first materialization, which skips registration entirely. Nothing does this today: `embedding` and `output_layer` are ordinary `UNGRAPHED` chain members (they are the head and the tail — see the link table GTP logs on the first backward), and only run outside the CUDA-graph boundary. The hook remains available as the fallback for any weight that cannot satisfy the assumptions below. **Why this needs careful consideration.** Because `_chain_state` is a *class attribute* and `prev_w`/`next_w` are strong references between `GTPShardedParam` instances, the chain **holds the weights alive for the life of the process** and **assumes the first step's behavior is representative of every step**. Neither is free: @@ -539,8 +544,9 @@ Weights that must **not** join a chain (embedding, output_layer — they all-gat | **Deterministic, fixed forward order** — the observed order is replayed every step | Data-dependent control flow: conditional layers, early exit, MoE routing that skips experts, reordered visitation | Predicted `next_w` is wrong → stale-buffer read or missed prefetch | | **Single, non-reentrant pass** — one global `last_weight` cursor + per-weight in-flight handles | Two models in one process, an extra autograd graph, unexpected microbatch interleaving | Corrupted cursor / async handles | | **Fixed, single membership** — `chain_id` and graphed-vs-eager decided once | A weight whose CG scope or dense/expert context changes between steps | Unrepresentable in one linear slot | -| **No parameter sharing/tying** — a linear list gives each weight one slot | A tied/shared param used in two positions (e.g. tied I/O embeddings) | One identity cannot occupy two chain positions; must be excluded | +| **One consume per weight per step** — a linear list gives each weight one slot, so one pass of the chain issues one all-gather and expects one backward per weight | A weight used at two points in one forward (MTP's shared embedding / output_layer and its replayed layer, tied I/O embeddings) | Forward: the extra consumes get no all-gather of their own. Backward: the weight is reached out of chain order and its reduce-scatters overlap. Both supported since §3.5 — anything else in this shape must be checked against it | | **Build-once, run-forever lifetime** — strong refs never released | Building/tearing down GTP models in-process (successive UTs, model re-init, multi-model drivers) | Leaks all GTP params/buffers; a new model's chain can cross-link onto a previous model's stale params | +| **The prefetched weight is already updated** — the chain gathers a weight before the module that owns it runs | DDP's `overlap_param_gather`: `_make_forward_pre_hook` waits `finish_param_sync` only for the module about to execute, and GTP never calls it, so a prefetch reaching into a bucket whose all-gather has not landed is unordered against it | Gathers the pre-update weight. Widens with prefetch depth — one-block-ahead grouped chains reach furthest. `overlap_param_gather=False` removes it, at the cost of that overlap | **Mitigations.** @@ -580,7 +586,45 @@ Three consequences: - why it must: `cuda_graphs.py` drains with `wait_async_comms(GTPChain.GRAPHED.value)`, matching the id **literally**, so a weight in `GTP_remat_grouped_fc1_ungraphed` would never be joined at the graph boundary — a **correctness** hazard, not just a lost overlap; - lifting it would mean draining by chain-id *prefix* (`_chain_is_grouped`) or registering the grouped streams before capture — neither is done today. -### 3.5 CUDA graph integration +### 3.5 GTP_remat + Multi-Token Prediction (MTP) + +**The one thing to know:** MTP consumes `embedding` and `output_layer` **`1 + mtp_num_layers` times per forward**, not once. Everything below follows from that. + +#### What MTP does to the chain + +Two independent violations of the *one consume per weight per step* assumption in §3.4: + +- **Shared weights.** Each MTP layer re-embeds its shifted input with the main `embedding`, and every prediction head (main + one per depth) runs the main `output_layer`. +- **A replayed layer.** With `--mtp-use-repeated-layer` a *single* MTP layer object is built and applied `mtp_num_layers` times (`MultiTokenPredictionBlock.forward` indexes `self.layers[0]` every iteration), so its weights — grouped experts included — are consumed once per depth. + +**The chain has one node per weight, but the model has several consumes.** Linking happens on a weight's *first* materialization, so a re-consumed weight is skipped rather than relinked. An L6 + 2-depth chain reads `embedding → decoder.0..5 → mtp.0.eh_proj → mtp.0 attn/shared-experts → mtp.1 … → output_layer`, with MTP's routed experts on the grouped `fc1`/`fc2` chains — 19 nodes, but 36 consumption events. + +**Both directions follow consumption events, not chain nodes.** `embedding` and `output_layer` each contribute `mtp_num_layers` extra events; under `--mtp-use-repeated-layer` every weight of the replayed layer does too. A weight is therefore reached far from its chain position, and anything that assumed "one visit per weight, in chain order" fails. + +#### How the chain supports it + +The chain stays a plain linear list — one slot per weight, no branching. MTP is absorbed by three rules: + +- **Every consume needs its own all-gather.** A weight is gathered by its chain *neighbour* — predecessor in forward, successor in backward — so one pass of the chain issues exactly one gather per node. Consumes past the first have none of their own, and the prefetched path would hand the GEMM whatever the shared buffer last held. They fall back to an on-demand gather instead: correct, at the cost of that consume's comm/compute overlap. + +- **Per-consume gradients accumulate.** Every consume produces its own wgrad and its own reduce-scatter, and the weight's `main_grad` ends up holding their sum — which is its true gradient. A weight keeps only one reduce-scatter in flight at a time, so an outstanding one is completed and accumulated before the next begins. + +- **The deferred finalize is conditional.** Normally a weight finalizes its chain *successor's* reduce-scatter, hiding that latency behind the next backward. Once backward stops following chain order, the successor may not have started one yet, so the finalize runs only when something is actually in flight. + +The first rule always applies. The other two apply only under `async_reduction`; with it off, every wgrad reduce-scatters and accumulates inline. + +> Both hazards are **silent**. A stale gather keeps the loss finite and merely wrong, and a dropped reduce-scatter trains on an incomplete gradient — neither raises. The state guard that would catch the first (`check_param_states`) is off outside debug builds. + +Link tables are logged from the first backward all-gather — the earliest point at which every chain is complete, and one that is still reached if backward later fails. + +#### Configuration traps + +- **One `/` segment per depth, all identical.** `MEM*EM/*E/*E` = 6-layer decoder + 2 MTP depths. `MEM*EM/*E*E` = *one* depth whose MTP layer is 4 layers deep — a different model. +- **The pattern silently overrides `--mtp-num-layers`** to the number of `/`-separated segments (`arguments.py`, warning `"conflicts with MTP depth count"`). If a run appears to execute fewer MTP layers than requested, this is almost always why — **trust the arg dump, not the flag**. +- `--mtp-use-repeated-layer` is generated from the `TransformerConfig` dataclass, so it never appears as a literal string in `arguments.py`. At `mtp_num_layers=1` it is a no-op: the loop runs once either way and the parameter set is identical. + + +### 3.6 CUDA graph integration GTP supports both **full-iteration CUDA graphs** and **local/partial CUDA graphs**. The common integration keeps graph and eager chains separate, builds lazy prefetch links during warmup, materializes side streams before capture, and preserves stable addresses for captured communication buffers. Full-iteration capture has no boundary between individual layer graphs. Local capture divides the model into independently replayed graph runners, so communication at a runner boundary requires an explicit completion protocol. The features below describe CUDA-graph-specific GTP optimizations and the ownership rules required to make them safe. @@ -670,6 +714,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_gtp_partial_cg.py` | Four-layer partial-CG loss and eager-vs-replay grad-norm parity with two-slot ring reuse across independently replayed graphs (§3.5). | | `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | | `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | +| `test_gtp_mtp.py` | GTP_remat + MTP shared weights (§3.5), 14 cases over `mtp_use_repeated_layer` × dense/MoE. Both MTP hazards are silent, so each needs its own guard: the async reduce-scatter path is compared numerically against the sync path on an identical model/sharding/batch, and all-gathers issued are tallied against consumes to catch a consume reading a buffer nothing gathered into. | | `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. | | `test_gtp_custom_pgs.py` | `pg_collection` plumbing: a custom `gtp_remat` group (permuted ranks, same size) must give the same fwd/bwd results as the MPU groups — catches modules reading `parallel_state` instead of the collection passed to them. | diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index c9c890c4bd2..5eac911d1b3 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -925,6 +925,8 @@ class GTPShardedParam(torch.nn.Parameter): # Recompute-forward prefetch cursor, keyed by chain_id; also cleared by reset_gtp_state(). _recompute_chain_state: Dict[str, dict] = {} + _link_tables_flushed: bool = False + @classmethod def _get_chain_state(cls, chain_id: str) -> dict: if chain_id not in cls._chain_state: @@ -932,7 +934,6 @@ def _get_chain_state(cls, chain_id: str) -> dict: "last_weight": None, "link_node_count": 0, "link_table_buffer": [], - "link_table_flushed": False, } return cls._chain_state[chain_id] @@ -942,6 +943,18 @@ def _get_recompute_chain_state(cls, chain_id: str) -> dict: cls._recompute_chain_state[chain_id] = {"last_weight": None} return cls._recompute_chain_state[chain_id] + @classmethod + def flush_link_tables(cls) -> None: + """Log every chain's buffered prefetch-link table once, atomically. + + Call only where the chains are complete -- NOT on "this weight is already linked", which + MTP hits mid-forward while later links are still being created. + """ + for chain in cls._chain_state.values(): + if chain["link_table_buffer"]: + log_single_rank(logger, logging.INFO, "\n".join(chain["link_table_buffer"]) + "\n") + cls._link_tables_flushed = True + @classmethod def _buffer_link_table_row( cls, prev: "GTPShardedParam", curr: "GTPShardedParam", chain: dict @@ -1342,6 +1355,20 @@ def _all_gather_weight_on_demand(self, fwd): result = [r.detach().requires_grad_(w.requires_grad) for r, w in zip(result, self._weights)] return result if self.is_routed_expert else result[0] + def _prefetch_available(self) -> bool: + """True when an all-gather was actually issued for THIS consume (either direction). + + A weight is prefetched by its chain neighbour -- predecessor in forward, successor in + backward -- so one pass of the chain issues one AG per weight. MTP with + --mtp-use-repeated-layer replays the MTP block once per depth while those neighbours run + only once, so the second consume has no AG of its own. Taking the prefetched path there + would cache.get() the previous AG's buffer: stale weights, silently, since the state + guard is compiled out unless check_param_states is on. + + Falling back to an on-demand AG costs the overlap for that consume but keeps it correct. + """ + return self._prefetch_handle is not None or getattr(self, "_already_ag_drained", False) + def _get_prefetched_weight(self, fwd): # Stale-read guard: state must reflect an AG issued for this cycle; # otherwise cache.get() would return the prior iter's AG buffer. @@ -1428,8 +1455,13 @@ def all_gather_and_prefetch_bwd(self, nvtx_label=None): Returns: weight_total """ + # Links are only created during forward, so every chain is complete by the first + # backward all-gather. Logging here rather than at the end of the iteration means the + # tables are still emitted if backward then fails, which is when they are most useful. + if not type(self)._link_tables_flushed: + type(self).flush_link_tables() - if GTP_CONFIG.weight_prefetch and self.next_w is not None: + if GTP_CONFIG.weight_prefetch and self.next_w is not None and self._prefetch_available(): result = self._get_prefetched_weight(False) else: result = self._all_gather_weight_on_demand(False) @@ -1479,7 +1511,12 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): # Consume current weight. if use_recompute_chain and self._recompute_prev is not None: result = self._get_recompute_prefetched_weight() - elif not in_recompute and GTP_CONFIG.weight_prefetch and self.prev_w is not None: + elif ( + not in_recompute + and GTP_CONFIG.weight_prefetch + and self.prev_w is not None + and self._prefetch_available() + ): result = self._get_prefetched_weight(True) else: # On-demand: chain head (fwd or recompute global-first) or first-iter build. @@ -1553,10 +1590,6 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): self.prefetch_initialized = True chain["last_weight"] = self - elif not chain["link_table_flushed"] and chain["link_table_buffer"]: - # Second forward pass: flush the complete table atomically to avoid interleaving - chain["link_table_flushed"] = True - log_single_rank(logger, logging.INFO, "\n".join(chain["link_table_buffer"]) + "\n") return result @@ -1612,17 +1645,27 @@ def _handle_megatron_grad_accum(param): param._set_rs_state(GTPWeightState.NONE) return dummy_grad - def _wait_reduce_scatter(self, finalize_grad=False): - # Enter rs_stream context so handle.wait() + rs_event.record() land on rs_stream - # (mirrors _wait_param_gather). With finalize_grad=True, main_grad.add_ also runs on - # rs_stream right after the NCCL RS — starts during AG drain, not after, avoiding - # SM-saturation that blocks cross-graph overlap. + def _wait_reduce_scatter(self, finalize_grad=False) -> bool: + """Wait on this weight's in-flight wgrad reduce-scatter, optionally accumulating it. + + Enters the rs_stream context so handle.wait() + rs_event.record() land on rs_stream + (mirrors _wait_param_gather). With finalize_grad=True, main_grad.add_ also runs on + rs_stream right after the NCCL RS — starts during AG drain, not after, avoiding + SM-saturation that blocks cross-graph overlap. + + Returns: + True if this weight had a reduce-scatter in flight and it was waited on, False if + there was nothing to wait for. wgrad_reduce_scatter needs to tell those apart + before it reads the result buffer. + """ + waited = False rs_stream = self._cached_rs_stream if rs_stream is None: rs_stream = get_rs_stream(self.chain_id, self.group) self._cached_rs_stream = rs_stream with torch.cuda.stream(rs_stream): if self._wgrad_rs_handle is not None: + waited = True self._wgrad_rs_handle.wait() self._record_graph_wgrad_ring_slots_ready() self._wgrad_rs_handle = None @@ -1641,6 +1684,7 @@ def _wait_reduce_scatter(self, finalize_grad=False): self._handle_megatron_grad_accum(w) self._already_finalized = True self._release_comm_scratch() + return waited def _release_comm_scratch(self, attrs=("_wgrad_input_bufs", "_rs_a2a_bufs")): """Release the buffers a finished RS was reading. @@ -1857,6 +1901,15 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None): wgrads = list(wgrad) if batched else [wgrad] weights = self._weights + # MTP feeds embedding and output_layer into more than one GEMM per forward, so they get + # more than one backward. The previous reduce-scatter may still be running: starting + # another would reuse this weight's ticket, i.e. the same output buffer, and overwrite + # the one handle we track -- discarding that gradient with no error. Finish it first. + if GTP_CONFIG.async_reduction and self._wgrad_rs_handle is not None: + self._wait_reduce_scatter(finalize_grad=True) + # Accounted for here, so the cascade below must not skip the next one. + self._already_finalized = False + # UNGRAPHED wgrads recycle via the standalone pool (_wgrad_pool_put); GRAPHED wgrads # cannot, since CUDA graphs require stable buffer addresses across replay. poolable = not _chain_is_graphed(self.chain_id) @@ -1889,9 +1942,15 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None): # Wait for last reduce scatter if it was async # Currently only support reduce scattering in reverse order if GTP_CONFIG.async_reduction and self.next_w is not None: - self.next_w._wait_reduce_scatter() - - if getattr(self.next_w, "_already_finalized", False): + # Backward normally walks the chain in reverse, so next_w has already started its + # reduce-scatter by now. That only holds while each weight is used once per forward. + # MTP's second embedding lookup sits late in the forward, so the embedding's backward + # runs before decoder layer 0 has reduce-scattered anything -- check first. + waited = self.next_w._wait_reduce_scatter() + + if not waited: + pass # next_w has not reduce-scattered yet, or something already finalized it + elif getattr(self.next_w, "_already_finalized", False): self.next_w._already_finalized = False else: self.next_w.rs_event.wait() @@ -2313,6 +2372,7 @@ def reset_gtp_state(): """ GTPShardedParam._chain_state.clear() GTPShardedParam._recompute_chain_state.clear() + GTPShardedParam._link_tables_flushed = False _GTP_GROUPED_BUF_PARITY_COUNTER.clear() diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_mtp.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_mtp.py new file mode 100644 index 00000000000..fae23a29c66 --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_mtp.py @@ -0,0 +1,564 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""GTP weight-remat correctness with Multi-Token Prediction (MTP). + +GTP's chain assumes one consume per weight per pass: one all-gather from its neighbour, one +backward in reverse chain order. MTP consumes the shared embedding/output_layer once per +prediction head, and under ``mtp_use_repeated_layer`` replays its own layer once per depth. + +Three ways that breaks: + + * Forward -- consumes past the first get no all-gather of their own, so the GEMM reads + whatever the shared buffer last held. + * Backward -- a weight's reduce-scatters overlap. They share one ticket and one tracked + handle, so a later one overwrites an earlier result and drops a head's gradient. + * Backward -- a shared weight is reached far from its chain position, so the deferred + finalize cannot assume its successor has a reduce-scatter pending (``KeyError: None``). + +Only the third ever raised. The other two just train on wrong numbers, hence the numeric and +accounting guards below rather than smoke tests. +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( # noqa: F401 + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +HIDDEN = 128 +NUM_HEADS = 8 +FFN_HIDDEN = 256 +NUM_LAYERS = 2 +MTP_NUM_LAYERS = 2 +VOCAB = 128 +SEQ = 16 +BATCH = 2 +NUM_EXPERTS = 4 +dtype = torch.bfloat16 + + +def _expert_parallel_kwargs(moe, world_size): + """MoE runs EP x EGTP over the world (mirrors the a55b shape: EP2 x EGTP2 on 4 ranks).""" + if not moe: + return {} + return dict(expert_model_parallel_size=2, expert_gtp_remat_size=world_size // 2) + + +def _build_mtp_gpt_model(repeated_layer=False, moe=False): + """Full GPTModel (embedding + decoder + output_layer) with an MTP block attached. + + ``moe=True`` puts grouped experts in both the decoder and the MTP layer, which is what puts + weights on the ``GTP_remat_grouped_fc1/fc2`` chains -- a separate code path from the dense + chain, with its own one-block-ahead prefetch and double buffering. + """ + from megatron.core.models.gpt import GPTModel + from megatron.core.models.gpt.gpt_layer_specs import ( + get_gpt_layer_with_transformer_engine_spec, + get_gpt_mtp_block_spec, + ) + from megatron.core.transformer.enums import AttnBackend + from megatron.core.transformer.transformer_config import TransformerConfig + + moe_kwargs = ( + dict( + num_moe_experts=NUM_EXPERTS, + moe_router_topk=2, + moe_ffn_hidden_size=FFN_HIDDEN, + moe_grouped_gemm=True, + moe_token_dispatcher_type="alltoall", + moe_aux_loss_coeff=0.0, + ) + if moe + else {} + ) + config = TransformerConfig( + num_layers=NUM_LAYERS, + hidden_size=HIDDEN, + num_attention_heads=NUM_HEADS, + kv_channels=HIDDEN // NUM_HEADS, + ffn_hidden_size=FFN_HIDDEN, + use_cpu_initialization=False, + params_dtype=dtype, + bf16=True, + add_bias_linear=False, + hidden_dropout=0.0, + attention_dropout=0.0, + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + # The unit-test conftest pins NVTE_FLASH_ATTN=0 / NVTE_FUSED_ATTN=0; AttnBackend.auto + # asserts they are unset or 1, so select the backend that matches that env. + attention_backend=AttnBackend.unfused, + mtp_num_layers=MTP_NUM_LAYERS, + mtp_use_repeated_layer=repeated_layer, + mtp_loss_scaling_factor=0.1, + **moe_kwargs, + ) + spec = get_gpt_layer_with_transformer_engine_spec( + num_experts=NUM_EXPERTS if moe else None, moe_grouped_gemm=moe + ) + return GPTModel( + config=config, + transformer_layer_spec=spec, + vocab_size=VOCAB, + max_sequence_length=SEQ, + pre_process=True, + post_process=True, + mtp_block_spec=get_gpt_mtp_block_spec(config, spec, use_transformer_engine=True), + ).cuda() + + +def _forward_backward(model): + """One fwd+bwd on a fixed batch, then drain in-flight GTP comms the way production does.""" + from megatron.core.tensor_parallel.generalized_tensor_parallelism import wait_async_comms + + for p in model.parameters(): + p.main_grad = torch.zeros(p.shape, dtype=torch.float32, device='cuda') + + gen = torch.Generator(device='cuda').manual_seed(7) + input_ids = torch.randint(0, VOCAB, (BATCH, SEQ), device='cuda', generator=gen) + position_ids = torch.arange(SEQ, device='cuda').unsqueeze(0).expand(BATCH, SEQ) + labels = torch.randint(0, VOCAB, (BATCH, SEQ), device='cuda', generator=gen) + + # TE rejects fp32 activations against bf16 params outside an autocast region. + with torch.autocast(device_type='cuda', dtype=torch.bfloat16): + loss = model(input_ids, position_ids, attention_mask=None, labels=labels).mean() + loss.backward() + # Match the eager production path: finalize_model_grads reaches + # wait_for_gtp_grad_reduction_on_current_stream, which calls wait_async_comms() WITHOUT + # finalize_after_drain. So a reduce-scatter still pending here is waited on but never + # accumulated, and its gradient is lost. Draining with finalize_after_drain=True would + # rescue exactly that case and hide it from the comparison below. + wait_async_comms() + torch.cuda.synchronize() + return float(loss.item()) + + +def _gathered_main_grads(model): + """Full (unsharded) main_grad per param name; GTP shards all-gathered over their own axis. + + Expert weights shard over expert_gtp_remat, dense weights over gtp_remat. + """ + from megatron.core import parallel_state as ps + + out = {} + for name, p in model.named_parameters(): + mg = p.main_grad + if isinstance(p, GTPShardedParam): + group = ( + ps.get_expert_gtp_weight_remat_group() + if ('experts' in name or not getattr(p, 'allreduce', True)) + else ps.get_gtp_weight_remat_group() + ) + shards = [torch.empty_like(mg) for _ in range(group.size())] + dist.all_gather(shards, mg.contiguous(), group=group) + out[name] = torch.cat(shards, dim=0).float().cpu() + else: + out[name] = mg.detach().float().cpu() + return out + + +def _assert_grouped_chains_used(model, moe): + """With MoE the MTP layer's experts must land on the grouped fc1/fc2 chains. + + Those chains use one-block-ahead prefetch with a shape-keyed double buffer, which is a + different hazard from the dense chain -- so a MoE-flavoured run that quietly produced no + grouped-chain params would be testing nothing new. + """ + from megatron.core.tensor_parallel.generalized_tensor_parallelism import _chain_is_grouped + + grouped_mtp = [ + n + for n, p in model.named_parameters() + if isinstance(p, GTPShardedParam) + and 'mtp.' in n + and _chain_is_grouped(getattr(p, 'chain_id', '')) + ] + if moe: + assert grouped_mtp, "MoE variant produced no MTP params on a grouped fc1/fc2 chain" + else: + assert not grouped_mtp, f"dense variant unexpectedly used grouped chains: {grouped_mtp}" + + +def _worker_shared_weight_grads(rank, world_size, port, repeated_layer=False, moe=False): + """Async reduce-scatter path must produce the same gradients as the sync path, with MTP. + + Both phases run the SAME model, sharding (gtp_remat=world), init weights and batch -- only + ``GTP_CONFIG.async_reduction`` differs. The sync path reduce-scatters and accumulates inline + on every wgrad, so it has no deferred cascade and no repeated-backward hazard; it is a + trusted reference for exactly the code the async path adds. Holding the sharding identical + on both sides also keeps every DP/GTP grad-scaling subtlety out of the comparison. + """ + from megatron.core import parallel_state as ps + from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG + from megatron.core.tensor_parallel.gtp_api import classify_gtp_remat_chains + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + saved_async = GTP_CONFIG.async_reduction + saved_pad = GTP_CONFIG.pad_for_alignment + grads, losses = {}, {} + try: + # pad_for_alignment=0 keeps each shard exactly 1/gtp of the full weight, so the + # all-gather in _gathered_main_grads reconstructs the unsharded gradient directly. + GTP_CONFIG.pad_for_alignment = 0 + for phase, use_async in (("sync_ref", False), ("async", True)): + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + gtp_remat_size=world_size, + **_expert_parallel_kwargs(moe, world_size), + ) + model_parallel_cuda_manual_seed(42) + torch.manual_seed(42) + GTP_CONFIG.async_reduction = use_async + + model = _build_mtp_gpt_model(repeated_layer, moe) + classify_gtp_remat_chains([model]) + + shared = [ + n + for n, p in model.named_parameters() + if isinstance(p, GTPShardedParam) + and ("output_layer" in n or "word_embeddings" in n) + ] + assert len(shared) == 2, ( + f"expected embedding + output_layer to be GTP-sharded (the MTP-shared weights), " + f"got {shared} -- test would not exercise the shared-weight path" + ) + _assert_grouped_chains_used(model, moe) + + losses[phase] = _forward_backward(model) + grads[phase] = _gathered_main_grads(model) + + del model + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + GTPShardedParam._recompute_chain_state = {} + GTPShardedParam._link_tables_flushed = False + finally: + GTP_CONFIG.async_reduction = saved_async + GTP_CONFIG.pad_for_alignment = saved_pad + + if rank != 0: + return + + ref, test = grads["sync_ref"], grads["async"] + assert set(ref) == set(test), "param sets differ between phases" + torch.testing.assert_close( + torch.tensor(losses["async"]), torch.tensor(losses["sync_ref"]), atol=1e-2, rtol=1e-2 + ) + + max_err, worst = 0.0, None + for name in ref: + rg, tg = ref[name], test[name] + assert rg.shape == tg.shape, f"{name}: {rg.shape} vs {tg.shape}" + rel = (rg - tg).abs().max().item() / (rg.abs().max().item() + 1e-8) + if "output_layer" in name or "word_embeddings" in name: + ratio = (tg.norm() / (rg.norm() + 1e-12)).item() + print( + f"[mtp-shared] {name:48s} rel_max_err={rel:.3e} " + f"norm_ratio(async/sync)={ratio:.4f}", + flush=True, + ) + if rel > max_err: + max_err, worst = rel, name + print(f"[mtp-shared] max relative grad error async-vs-sync = {max_err:.3e} (worst: {worst})") + + assert max_err < 2e-2, ( + f"MTP shared-weight gradient mismatch between the async and sync GTP reduce-scatter " + f"paths (max rel err {max_err:.3e} on {worst}). A weight consumed more than once per " + f"forward had one of its wgrad reduce-scatters dropped or overwritten." + ) + + +def _worker_runs_end_to_end(rank, world_size, port, repeated_layer=False, moe=False): + """GTP + MTP completes a fwd+bwd at all (regression guard for the KeyError: None crash).""" + from megatron.core import parallel_state as ps + from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG + from megatron.core.tensor_parallel.gtp_api import classify_gtp_remat_chains + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + saved_pad = GTP_CONFIG.pad_for_alignment + try: + GTP_CONFIG.pad_for_alignment = 0 + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + gtp_remat_size=world_size, + **_expert_parallel_kwargs(moe, world_size), + ) + model_parallel_cuda_manual_seed(42) + torch.manual_seed(42) + + model = _build_mtp_gpt_model(repeated_layer, moe) + classify_gtp_remat_chains([model]) + mtp_gtp = [ + n + for n, p in model.named_parameters() + if isinstance(p, GTPShardedParam) and ".mtp." in f".{n}" + ] + assert mtp_gtp, "no MTP parameter was GTP-sharded; test would be vacuous" + _assert_grouped_chains_used(model, moe) + + loss = _forward_backward(model) + assert torch.isfinite(torch.tensor(loss)), f"non-finite loss {loss}" + for name, p in model.named_parameters(): + assert torch.isfinite(p.main_grad).all(), f"non-finite grad in {name}" + + del model + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + GTPShardedParam._recompute_chain_state = {} + GTPShardedParam._link_tables_flushed = False + finally: + GTP_CONFIG.pad_for_alignment = saved_pad + + +def _worker_repeated_consume_all_gathers(rank, world_size, port, repeated_layer=False, moe=False): + """N consumes of a weight need N all-gathers, not the one its chain neighbour issues. + + The neighbour runs once per pass; MTP consumes shared weights once per prediction head and + replays the MTP block once per depth. Extra consumes would ``cache.get()`` a stale buffer -- + silently, since that keeps the loss finite and merely wrong. + + So: tally all-gathers issued against consumes, and fail if a consume outruns its issues. + Behavioural, not structural, so it survives a redesign of how the prefetch is armed. + + Does not cover the recompute chain (separate ``_recompute_*`` slots). + """ + from collections import defaultdict + + from megatron.core import parallel_state as ps + from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG + from megatron.core.tensor_parallel.gtp_api import classify_gtp_remat_chains + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + P = GTPShardedParam + o_ag = P._all_gather_weight + o_get = P._get_prefetched_weight + o_ondemand = P._all_gather_weight_on_demand + + # Keyed by (name, direction) rather than id(), which can be recycled onto another object. + issued, consumed, violations = defaultdict(int), defaultdict(int), [] + + # Signatures spelled out, not *args: a signature change should fail loudly, not mis-key. + def ag(self, async_op, fwd, nvtx_label=None): + issued[(self._debug_name, bool(fwd))] += 1 + return o_ag(self, async_op, fwd, nvtx_label=nvtx_label) + + def get_prefetched(self, fwd): + key = (self._debug_name, bool(fwd)) + if consumed[key] >= issued[key]: + violations.append( + f"{self._debug_name} ({'fwd' if fwd else 'bwd'}): consume " + f"#{consumed[key] + 1} but only {issued[key]} all-gather(s) issued" + ) + consumed[key] += 1 + return o_get(self, fwd) + + def on_demand(self, fwd): + # Issues its own AG then consumes it, so both tallies stay balanced on this path. + out = o_ondemand(self, fwd) + consumed[(self._debug_name, bool(fwd))] += 1 + return out + + saved_pad = GTP_CONFIG.pad_for_alignment + try: + GTP_CONFIG.pad_for_alignment = 0 + P._all_gather_weight, P._get_prefetched_weight = ag, get_prefetched + P._all_gather_weight_on_demand = on_demand + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, + pipeline_model_parallel_size=1, + gtp_remat_size=world_size, + **_expert_parallel_kwargs(moe, world_size), + ) + model_parallel_cuda_manual_seed(42) + torch.manual_seed(42) + + model = _build_mtp_gpt_model(repeated_layer, moe) + classify_gtp_remat_chains([model]) + _forward_backward(model) + del model + finally: + # _run_distributed shares ONE process across tests: undo everything even on failure. + P._all_gather_weight = o_ag + P._get_prefetched_weight = o_get + P._all_gather_weight_on_demand = o_ondemand + GTP_CONFIG.pad_for_alignment = saved_pad + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + GTPShardedParam._recompute_chain_state = {} + GTPShardedParam._link_tables_flushed = False + + # Every rank asserts: the tallies are rank-local, so rank 0 alone could miss a violation. + repeats = {name: n for (name, _), n in consumed.items() if n > 1} + assert repeats, ( + "no GTP weight was consumed more than once per pass, so this test would pass even with " + "the prefetch bug present -- check that MTP is still attached and GTP-sharded" + ) + assert not violations, ( + "a GTP weight was consumed without an all-gather issued for that consume, so the GEMM " + "read a stale shared buffer (silently wrong weights):\n " + "\n ".join(violations) + ) + + +def _worker_ddp_grad_ready_counts(rank, world_size, port, repeated_layer=False): + """A weight consumed N times per forward fires DDP grad-ready N times, not once. + + Each consume finalizes the previous reduce-scatter, and every finalize calls the param's + grad-ready hook. DDP absorbs that only because bucket completion compares the per-param + count against a golden snapshot taken at the end of the first batch -- so the count has to + be identical on every later iteration. + + Two requirements keep this separate from the other cases in this file: register_grad_ready + asserts overlap_grad_reduce, and the golden gate is first evaluated on batch 2, hence three + iterations. A desynchronized count makes finish_grad_sync raise, so completing the loop is + itself an assertion. + """ + import collections + + from megatron.core import parallel_state as ps + from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig + from megatron.core.distributed import param_and_grad_buffer as pgb + from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG + from megatron.core.tensor_parallel.gtp_api import classify_gtp_remat_chains + from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed + + saved_pad = GTP_CONFIG.pad_for_alignment + orig_register = pgb._ParamAndGradBucketGroup.register_grad_ready + try: + GTP_CONFIG.pad_for_alignment = 0 + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=world_size + ) + model_parallel_cuda_manual_seed(42) + torch.manual_seed(42) + + model = _build_mtp_gpt_model(repeated_layer=repeated_layer, moe=False) + classify_gtp_remat_chains([model]) + name_of = {p: n for n, p in model.named_parameters()} + + counts = collections.Counter() + + def counting_register(self, param, *a, **k): + counts[name_of.get(param, "?")] += 1 + return orig_register(self, param, *a, **k) + + pgb._ParamAndGradBucketGroup.register_grad_ready = counting_register + + ddp = DistributedDataParallel( + model.config, + DistributedDataParallelConfig( + use_distributed_optimizer=False, overlap_grad_reduce=True + ), + model, + ) + + gen = torch.Generator(device='cuda').manual_seed(7) + input_ids = torch.randint(0, VOCAB, (BATCH, SEQ), device='cuda', generator=gen) + position_ids = torch.arange(SEQ, device='cuda').unsqueeze(0).expand(BATCH, SEQ) + labels = torch.randint(0, VOCAB, (BATCH, SEQ), device='cuda', generator=gen) + + # 3 iterations: batch 1 records golden, batches 2 and 3 are compared against it. + per_iter = [] + for _ in range(3): + counts.clear() + ddp.zero_grad_buffer() + with torch.autocast(device_type='cuda', dtype=torch.bfloat16): + loss = ddp(input_ids, position_ids, attention_mask=None, labels=labels).mean() + loss.backward() + ddp.finish_grad_sync() # raises if a bucket never reached its golden count + torch.cuda.synchronize() + per_iter.append(dict(counts)) + + del model, ddp + ps.destroy_model_parallel() + GTPShardedParam._chain_state = {} + GTPShardedParam._recompute_chain_state = {} + GTPShardedParam._link_tables_flushed = False + finally: + pgb._ParamAndGradBucketGroup.register_grad_ready = orig_register + GTP_CONFIG.pad_for_alignment = saved_pad + + if rank != 0: + return + + expected = 1 + MTP_NUM_LAYERS # main head + one per MTP depth + for name in ("embedding.word_embeddings.weight", "output_layer.weight"): + got = [it.get(name, 0) for it in per_iter] + print(f"[ddp-grad-ready] {name:38s} fires per iteration={got}", flush=True) + assert got[0] == expected, f"{name}: {got[0]} grad-ready fires, expected {expected}" + + assert per_iter[1] == per_iter[0] and per_iter[2] == per_iter[0], ( + f"grad-ready counts vary across iterations {per_iter}; DDP's golden gate would never " + f"match and the bucket would go unreduced" + ) + + +class TestGTPMTP: + @pytest.mark.parametrize("moe", [False, True], ids=["dense", "moe"]) + @pytest.mark.parametrize("repeated_layer", [False, True]) + def test_gtp_mtp_runs_end_to_end(self, repeated_layer, moe): + """GTP + MTP fwd+bwd must complete with finite loss and gradients. + + Before the shared-weight fix this raised ``KeyError: None`` from the deferred + reduce-scatter finalize, because the embedding's backward runs out of chain order. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_runs_end_to_end, 4, repeated_layer, moe) + + @pytest.mark.parametrize("moe", [False, True], ids=["dense", "moe"]) + @pytest.mark.parametrize("repeated_layer", [False, True]) + def test_mtp_shared_weight_grads_match_sync_reduce_scatter(self, repeated_layer, moe): + """MTP re-uses embedding/output_layer, so those weights get several backward passes. + + Guards a SILENT failure: a dropped or overwritten wgrad reduce-scatter trains on wrong + gradients without raising, so only a numeric comparison catches it. The ``moe`` variant + additionally puts the MTP layer's experts on the grouped fc1/fc2 chains, which prefetch + one block ahead into a shape-keyed double buffer. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_shared_weight_grads, 4, repeated_layer, moe) + + @pytest.mark.parametrize("moe", [False, True], ids=["dense", "moe"]) + @pytest.mark.parametrize("repeated_layer", [False, True]) + def test_repeated_consume_gets_its_own_all_gather(self, repeated_layer, moe): + """A weight consumed N times per pass needs N all-gathers, not one. + + The chain prefetches from a weight's neighbour, which runs once, so every consume past + the first used to read whatever the shared buffer last held. Guards a SILENT failure: + the stale buffer keeps the loss finite and merely wrong, and the in-tree state guard + that would catch it is disabled outside debug builds. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_repeated_consume_all_gathers, 4, repeated_layer, moe) + + @pytest.mark.parametrize("repeated_layer", [False, True]) + def test_mtp_shared_weight_ddp_grad_ready_counts(self, repeated_layer): + """A re-used weight fires DDP grad-ready once per consume, not once per iteration. + + Guards the DDP side of the repeated-backward finalize: bucket completion is gated on a + golden per-param count, so that count must be stable across iterations. + """ + if torch.cuda.device_count() < 4: + pytest.skip("Requires 4 CUDA devices") + _run_distributed(_worker_ddp_grad_ready_counts, 4, repeated_layer) From 67700f79fbc70453917fabc19dbbf824f704be13 Mon Sep 17 00:00:00 2001 From: Danial Mohseni Taheri <49656670+DanialTaheri@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:23:03 -0700 Subject: [PATCH 227/290] fix(mtp): scatter the MTP embedding when the model's embedding does not (#6256) Signed-off-by: DanialTaheri Co-authored-by: Claude Fable 5 --- .../transformer/multi_token_prediction.py | 15 ++++- .../test_multi_token_prediction.py | 58 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index b20514ce6a4..b37c4c9d0f4 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -1103,6 +1103,19 @@ def _get_embeddings( # embedding decoder_input = embedding(input_ids=input_ids, position_ids=position_ids) + # Mirror the scatter in the model's own forward (see hybrid_model.py: + # "the embedding skips SP scatter for models whose outer wrapper + # scatters instead"). Multimodal LMs build LanguageModelEmbedding with + # scatter_to_sequence_parallel=False so they can insert media into a + # full-length embedding before scattering. The MTP block calls the + # embedding directly and so must apply the same scatter, otherwise + # decoder_input stays full-length while the backbone hidden_states + # arrive sequence-parallel sharded and _concat_embeddings fails. + if self.config.sequence_parallel and not getattr( + embedding, "scatter_to_sequence_parallel", True + ): + decoder_input = scatter_to_sequence_parallel_region(decoder_input, group=self.tp_group) + if self.config.mtp_detach_heads: decoder_input = decoder_input.detach() @@ -1816,7 +1829,7 @@ def forward( for iteration in range(self.config.mtp_num_layers): layer_idx = 0 if self.mtp_use_repeated_layer else iteration - (hidden_states, input_ids, position_ids, padding_mask) = self.layers[layer_idx]( + hidden_states, input_ids, position_ids, padding_mask = self.layers[layer_idx]( input_ids=input_ids, position_ids=position_ids, hidden_states=hidden_states, diff --git a/tests/unit_tests/transformer/test_multi_token_prediction.py b/tests/unit_tests/transformer/test_multi_token_prediction.py index 1216f845a11..ee1eb02267f 100644 --- a/tests/unit_tests/transformer/test_multi_token_prediction.py +++ b/tests/unit_tests/transformer/test_multi_token_prediction.py @@ -298,6 +298,64 @@ def fake_embedding(input_ids, position_ids): # layer parameters keep a differentiable path. assert returned_hidden_states.requires_grad is True + @pytest.mark.parametrize( + ("embedding_scatters", "expect_scatter"), [(False, True), (True, False), (None, False)] + ) + def test_get_embeddings_scatters_when_embedding_does_not( + self, monkeypatch, embedding_scatters, expect_scatter + ): + """Multimodal language models build LanguageModelEmbedding with + scatter_to_sequence_parallel=False so media features can be inserted into a + full-length embedding, and their outer forward performs the sequence-parallel + scatter afterwards. _get_embeddings calls the embedding directly, so it has to + apply the same scatter; otherwise decoder_input stays full length while the + backbone hidden_states arrive sequence-parallel sharded and _concat_embeddings + fails to concatenate them. + + embedding_scatters=None covers a plain callable with no such attribute, which + must keep the previous behaviour of not scattering. + """ + torch.manual_seed(_SEED) + config, mtp_block_spec = self._create_config_and_mtp_block_spec(tp=1, cp=1) + config.sequence_parallel = True + mtp = MultiTokenPredictionBlock(config=config, spec=mtp_block_spec) + mtp_layer = mtp.layers[0] + + seq_len = 4 + batch_size = 2 + input_ids = torch.tensor([[1, 2, 3, 0], [4, 5, 0, 0]], dtype=torch.int64) + position_ids = torch.arange(seq_len, dtype=torch.int64).repeat(batch_size, 1) + hidden_states = torch.randn(seq_len, batch_size, config.hidden_size) + embeddings = torch.randn(seq_len, batch_size, config.hidden_size) + + def embedding(input_ids, position_ids): + return embeddings.clone() + + if embedding_scatters is not None: + embedding.scatter_to_sequence_parallel = embedding_scatters + + scattered = [] + + def fake_scatter(tensor, group=None): + scattered.append(tensor) + return tensor + + monkeypatch.setattr( + "megatron.core.transformer.multi_token_prediction." + "scatter_to_sequence_parallel_region", + fake_scatter, + ) + + mtp_layer._get_embeddings( + input_ids=input_ids, + position_ids=position_ids, + embedding=embedding, + hidden_states=hidden_states, + packed_seq_params=None, + ) + + assert (len(scattered) == 1) is expect_scatter + @pytest.mark.parametrize("detach_heads", [False, True]) def test_forward_detach_heads_gradient_flow(self, monkeypatch, detach_heads): """Block-level check of mtp_detach_heads: with the flag on, MTP gradients must From 0ced2060146be52ab9d90980e4773daccf3f56ca Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Fri, 7 Aug 2026 22:56:45 -0700 Subject: [PATCH 228/290] Apply the layerwise param layout on the ModelBuilder DDP path (#6286) Signed-off-by: Deepak Narayanan Co-authored-by: Claude Opus 5 (1M context) --- examples/mimo/training/builder.py | 9 ++++ megatron/training/models/base.py | 5 ++ megatron/training/models/dist_utils.py | 73 ++++++++++++++++++++------ megatron/training/models/gpt.py | 7 +++ megatron/training/models/hybrid.py | 7 +++ megatron/training/training.py | 2 + 6 files changed, 87 insertions(+), 16 deletions(-) diff --git a/examples/mimo/training/builder.py b/examples/mimo/training/builder.py index 06507c8f478..cfa0c1ef1ba 100644 --- a/examples/mimo/training/builder.py +++ b/examples/mimo/training/builder.py @@ -124,10 +124,19 @@ def build_distributed_models( Callable[[Any, MegatronModule], MegatronModule] | None ) = Float16Module, model_type: ModelType = ModelType.encoder_or_decoder, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[MimoModel]: """Seed, build, prepare, and configure the active rank-local MIMO model.""" if wrap_with_ddp and ddp_config is None: raise ValueError("ddp_config is required when wrap_with_ddp is True") + # MIMO wraps its submodules via wrap_active_modules_with_ddp() rather than the + # shared dist_utils path, which is where the layerwise param layout is applied. + if use_layer_wise_distributed_optimizer: + raise NotImplementedError( + "MIMO does not support the layerwise distributed optimizer " + "(--optimizer muon and friends)." + ) topology = self._topology args = get_args() diff --git a/megatron/training/models/base.py b/megatron/training/models/base.py index a8889aa1417..747c6442c38 100644 --- a/megatron/training/models/base.py +++ b/megatron/training/models/base.py @@ -222,6 +222,8 @@ def build_distributed_models( data_parallel_random_init: bool = False, mixed_precision_wrapper: Callable[[Any, MegatronModule], MegatronModule] | None = Float16Module, model_type: ModelType = ModelType.encoder_or_decoder, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[ModelT]: """Build model stages and wrap for distributed training. @@ -235,6 +237,9 @@ def build_distributed_models( data_parallel_random_init: Whether to use data parallel random initialization mixed_precision_wrapper: Mixed precision wrapper, e.g. ``Float16Module`` model_type: Deprecated flag, only used for backwards compatibility. + use_layer_wise_distributed_optimizer: Whether the layerwise wiring runs. + use_layer_wise_param_layout: When ``use_layer_wise_distributed_optimizer=True``, + controls whether to compute and supply a shard-aligned param layout to DDP. Returns: List of model stages. If the model does not support virtual pipeline parallelism, diff --git a/megatron/training/models/dist_utils.py b/megatron/training/models/dist_utils.py index 46e562edc18..30dd52f8adb 100644 --- a/megatron/training/models/dist_utils.py +++ b/megatron/training/models/dist_utils.py @@ -13,6 +13,10 @@ ) from megatron.core.full_cuda_graph import get_shared_capture_stream from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer +from megatron.core.optimizer.layer_wise_optimizer import ( + LayerWiseDistributedOptimizer, + tag_params_for_buffer_routing, +) try: from megatron.core.distributed import TorchFullyShardedDataParallel @@ -50,6 +54,8 @@ def unimodal_build_distributed_models( mixed_precision_wrapper: Callable[[Any, MegatronModule], MegatronModule] | None = Float16Module, pre_wrap_hook: Callable[[list[MegatronModule]], list[MegatronModule]] | None = None, model_type: ModelType = ModelType.encoder_or_decoder, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[MegatronModule]: """Build model stages and wrap for distributed training. @@ -78,6 +84,9 @@ def unimodal_build_distributed_models( Pass ``None`` to skip. pre_wrap_hook: Hook applied to the model stage list before any wrapping. model_type: Deprecated flag, only used for backwards compatibility. + use_layer_wise_distributed_optimizer: Whether the layerwise wiring runs. + use_layer_wise_param_layout: When ``use_layer_wise_distributed_optimizer=True``, + controls whether to compute and supply a shard-aligned param layout to DDP. Returns: List of model stages, wrapped and ready for distributed training. @@ -114,6 +123,8 @@ def unimodal_build_distributed_models( wrap_with_ddp=wrap_with_ddp, data_parallel_random_init=data_parallel_random_init, mixed_precision_wrapper=mixed_precision_wrapper, + use_layer_wise_distributed_optimizer=use_layer_wise_distributed_optimizer, + use_layer_wise_param_layout=use_layer_wise_param_layout, ) @@ -128,6 +139,8 @@ def prepare_existing_model_chunks_for_distributed_training( wrap_with_ddp: bool = True, data_parallel_random_init: bool = False, mixed_precision_wrapper: Callable[[Any, MegatronModule], MegatronModule] | None = Float16Module, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[MegatronModule]: """Apply the shared post-build distributed lifecycle to already-built model chunks. @@ -146,6 +159,9 @@ def prepare_existing_model_chunks_for_distributed_training( data_parallel_random_init: Whether to broadcast parameters from data-parallel rank 0. mixed_precision_wrapper: Mixed precision wrapper applied per model stage, e.g. ``Float16Module``. Pass ``None`` to skip. + use_layer_wise_distributed_optimizer: Whether the layerwise wiring runs. + use_layer_wise_param_layout: When ``use_layer_wise_distributed_optimizer=True``, + controls whether to compute and supply a shard-aligned param layout to DDP. Returns: List of model chunks, wrapped and ready for distributed training. @@ -194,6 +210,8 @@ def prepare_existing_model_chunks_for_distributed_training( use_megatron_fsdp=use_megatron_fsdp, use_torch_fsdp2=use_torch_fsdp2, pg_collection=pg_collection, + use_layer_wise_distributed_optimizer=use_layer_wise_distributed_optimizer, + use_layer_wise_param_layout=use_layer_wise_param_layout, ) return model_list @@ -249,6 +267,8 @@ def _ddp_wrap( use_torch_fsdp2: bool = False, *, pg_collection: ProcessGroupCollection, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[MegatronModule]: """Wrap model with Distributed Data Parallel (DDP) or Fully Sharded Data Parallel (FSDP). @@ -261,6 +281,10 @@ def _ddp_wrap( use_megatron_fsdp: Whether to use Megatron FSDP. use_torch_fsdp2: Whether to use PyTorch FSDP v2 instead of DDP pg_collection: Model communication process groups. + use_layer_wise_distributed_optimizer: Whether the layerwise wiring runs. + use_layer_wise_param_layout: When ``use_layer_wise_distributed_optimizer=True``, + controls whether to compute and supply a shard-aligned param layout to DDP. + ``False`` keeps LayerWise on its legacy ``allgather_params`` sync path. Returns: list[MegatronModule]: List of DDP/FSDP wrapped model modules @@ -294,6 +318,25 @@ def _ddp_wrap( if not ddp_config.overlap_grad_reduce: ddp_config.bucket_size = None + # Argument validation converts --use-distributed-optimizer into + # use_layer_wise_distributed_optimizer and clears the original, so re-enable it here: + # the layerwise optimizer needs the reduce-scatter and the shard-aligned param layout + # that the distributed-optimizer path provides. Mirrors wrap_model_chunks_with_ddp() in + # megatron/training/training.py, which handles the non-ModelBuilder path. + compute_full_param_layout = DistributedOptimizer.compute_full_param_layout + if ( + DP is DistributedDataParallel + and use_layer_wise_distributed_optimizer + and use_layer_wise_param_layout + ): + ddp_config.use_distributed_optimizer = True + compute_full_param_layout = LayerWiseDistributedOptimizer.compute_full_param_layout + # Tag params so DDP buffer grouping routes LayerWise-managed matrices + # (Muon's Newton-Schulz domain) to a shard-aligned buffer and routes + # everything else (embeddings, biases, layernorm) to a separate + # DistOpt-style buffer. + tag_params_for_buffer_routing(model) + if get_model_config(model[0]).cuda_graph_impl == "full_iteration": # DDP initialization must use the full-iteration capture stream so its retained # AccumulateGrad nodes do not reference a different, non-capturing stream. @@ -334,22 +377,20 @@ def _ddp_wrap( # leave the trailing shard of every bucket owned by no rank. intra_dp_cp_group = getattr(pg_collection, "intra_dp_cp", None) intra_expt_dp_group = getattr(pg_collection, "intra_expt_dp", None) - chunk_kwargs["full_param_layout"] = ( - DistributedOptimizer.compute_full_param_layout( - all_params, - effective_bucket_size, - ( - intra_dp_cp_group - if intra_dp_cp_group is not None - else pg_collection.dp_cp - ).size(), - ddp_config, - expert_data_parallel_world_size=( - intra_expt_dp_group - if intra_expt_dp_group is not None - else pg_collection.expt_dp - ).size(), - ) + chunk_kwargs["full_param_layout"] = compute_full_param_layout( + all_params, + effective_bucket_size, + ( + intra_dp_cp_group + if intra_dp_cp_group is not None + else pg_collection.dp_cp + ).size(), + ddp_config, + expert_data_parallel_world_size=( + intra_expt_dp_group + if intra_expt_dp_group is not None + else pg_collection.expt_dp + ).size(), ) wrapped_chunk = DP( diff --git a/megatron/training/models/gpt.py b/megatron/training/models/gpt.py index 9dd5dc61a80..63448b196c0 100644 --- a/megatron/training/models/gpt.py +++ b/megatron/training/models/gpt.py @@ -344,6 +344,8 @@ def build_distributed_models( data_parallel_random_init: bool = True, mixed_precision_wrapper: Callable[[Any, MegatronModule], MegatronModule] | None = Float16Module, model_type: ModelType = ModelType.encoder_or_decoder, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[GPTModel]: """Build model stages and wrap for distributed training. @@ -358,6 +360,9 @@ def build_distributed_models( data_parallel_random_init: Whether to use data parallel random initialization mixed_precision_wrapper: Mixed precision wrapper, e.g. ``Float16Module`` model_type: Deprecated flag, only used for backwards compatibility. + use_layer_wise_distributed_optimizer: Whether the layerwise wiring runs. + use_layer_wise_param_layout: When ``use_layer_wise_distributed_optimizer=True``, + controls whether to compute and supply a shard-aligned param layout to DDP. Returns: List of model stages. @@ -377,6 +382,8 @@ def build_distributed_models( mixed_precision_wrapper, composed_pre_wrap_hook, model_type, + use_layer_wise_distributed_optimizer=use_layer_wise_distributed_optimizer, + use_layer_wise_param_layout=use_layer_wise_param_layout, ) composed_post_wrap_hook = compose_hooks(self._model_config.post_wrap_hooks) diff --git a/megatron/training/models/hybrid.py b/megatron/training/models/hybrid.py index 287ca8ec2a3..99f98920eff 100644 --- a/megatron/training/models/hybrid.py +++ b/megatron/training/models/hybrid.py @@ -202,6 +202,8 @@ def build_distributed_models( data_parallel_random_init: bool = False, mixed_precision_wrapper: Callable[[Any, MegatronModule], MegatronModule] | None = Float16Module, model_type: ModelType = ModelType.encoder_or_decoder, + use_layer_wise_distributed_optimizer: bool = False, + use_layer_wise_param_layout: bool = True, ) -> list[HybridModel]: """Build model stages and wrap for distributed training. @@ -216,6 +218,9 @@ def build_distributed_models( data_parallel_random_init: Whether to use data parallel random initialization mixed_precision_wrapper: Mixed precision wrapper, e.g. ``Float16Module`` model_type: Deprecated flag, only used for backwards compatibility. + use_layer_wise_distributed_optimizer: Whether the layerwise wiring runs. + use_layer_wise_param_layout: When ``use_layer_wise_distributed_optimizer=True``, + controls whether to compute and supply a shard-aligned param layout to DDP. Returns: List of model stages. @@ -235,6 +240,8 @@ def build_distributed_models( mixed_precision_wrapper, composed_pre_wrap_hook, model_type, + use_layer_wise_distributed_optimizer=use_layer_wise_distributed_optimizer, + use_layer_wise_param_layout=use_layer_wise_param_layout, ) composed_post_wrap_hook = compose_hooks(self._model_config.post_wrap_hooks) diff --git a/megatron/training/training.py b/megatron/training/training.py index 7d5b954dc61..acdd727b82d 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -2086,6 +2086,8 @@ def _build_model_wrapper(wrap_with_ddp: bool): use_torch_fsdp2=cfg.dist.use_torch_fsdp2, wrap_with_ddp=wrap_with_ddp, data_parallel_random_init=cfg.rng.data_parallel_random_init, + use_layer_wise_distributed_optimizer=cfg.optimizer.use_layer_wise_distributed_optimizer, + use_layer_wise_param_layout=getattr(args, 'use_layer_wise_param_layout', True), ) else: assert model_provider_func is not None, "Must provide a model config via config_container or a model_provider_func." From 14499ef070880fa442991bb1e3825e9865bd6f74 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sat, 8 Aug 2026 10:16:30 +0200 Subject: [PATCH 229/290] test: AUT-1363 prevent recipe fetch-token exposure (#6364) Signed-off-by: svcnemo-autobot --- tests/test_utils/recipes/gb200/gpt-1node.yaml | 8 +++++++- tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml | 8 +++++++- tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml | 8 +++++++- tests/test_utils/recipes/gb200/gpt-perf.yaml | 8 +++++++- tests/test_utils/recipes/gb200/gpt.yaml | 8 +++++++- tests/test_utils/recipes/gb200/hybrid-perf-ep4.yaml | 8 +++++++- tests/test_utils/recipes/gb200/hybrid-perf.yaml | 8 +++++++- tests/test_utils/recipes/gb200/moe-1node.yaml | 8 +++++++- tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml | 8 +++++++- tests/test_utils/recipes/gb200/moe.yaml | 8 +++++++- tests/test_utils/recipes/gb200/nemotron.yaml | 8 +++++++- tests/test_utils/recipes/gb200/unit-tests.yaml | 8 +++++++- tests/test_utils/recipes/h100/bert.yaml | 8 +++++++- tests/test_utils/recipes/h100/ckpt_converter.yaml | 8 +++++++- tests/test_utils/recipes/h100/determinism-perf.yaml | 8 +++++++- tests/test_utils/recipes/h100/flextron.yaml | 8 +++++++- .../recipes/h100/gpt-dynamic-inference-cuda-graphs.yaml | 8 +++++++- .../h100/gpt-dynamic-inference-with-coordinator.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-grads.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-grpo.yaml | 8 +++++++- .../recipes/h100/gpt-inference-server-smoke.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-nemo.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-offline-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-perf-dp8.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-perf.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt-static-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/gpt.yaml | 8 +++++++- tests/test_utils/recipes/h100/hybrid-perf-ep8.yaml | 8 +++++++- tests/test_utils/recipes/h100/hybrid-perf.yaml | 8 +++++++- .../h100/mamba-dynamic-inference-with-coordinator.yaml | 8 +++++++- .../test_utils/recipes/h100/mamba-dynamic-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/mamba-static-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/mamba.yaml | 8 +++++++- tests/test_utils/recipes/h100/mimo.yaml | 8 +++++++- tests/test_utils/recipes/h100/module_performance.yaml | 8 +++++++- .../h100/moe-dynamic-inference-with-coordinator.yaml | 8 +++++++- tests/test_utils/recipes/h100/moe-dynamic-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/moe-grpo.yaml | 8 +++++++- tests/test_utils/recipes/h100/moe-static-inference.yaml | 8 +++++++- tests/test_utils/recipes/h100/moe.yaml | 8 +++++++- tests/test_utils/recipes/h100/multimodal-llava.yaml | 8 +++++++- tests/test_utils/recipes/h100/t5.yaml | 8 +++++++- tests/test_utils/recipes/h100/unit-tests.yaml | 8 +++++++- 44 files changed, 308 insertions(+), 44 deletions(-) diff --git a/tests/test_utils/recipes/gb200/gpt-1node.yaml b/tests/test_utils/recipes/gb200/gpt-1node.yaml index 9b009735963..3485dc41d25 100644 --- a/tests/test_utils/recipes/gb200/gpt-1node.yaml +++ b/tests/test_utils/recipes/gb200/gpt-1node.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail : "${{RUN_ID:=pr-$$}}" ls diff --git a/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml b/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml index 6a81fab3b9a..4287eea3bae 100644 --- a/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml +++ b/tests/test_utils/recipes/gb200/gpt-dynamic-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml b/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml index 71a9dccf1b2..fa0539c77d9 100644 --- a/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml +++ b/tests/test_utils/recipes/gb200/gpt-perf-dp4.yaml @@ -17,7 +17,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -27,7 +29,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/gpt-perf.yaml b/tests/test_utils/recipes/gb200/gpt-perf.yaml index 9bbd6cf33b7..14717ed1654 100644 --- a/tests/test_utils/recipes/gb200/gpt-perf.yaml +++ b/tests/test_utils/recipes/gb200/gpt-perf.yaml @@ -19,7 +19,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -29,7 +31,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/gpt.yaml b/tests/test_utils/recipes/gb200/gpt.yaml index a7ea92e8b99..1142861ea20 100644 --- a/tests/test_utils/recipes/gb200/gpt.yaml +++ b/tests/test_utils/recipes/gb200/gpt.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail : "${{RUN_ID:=pr-$$}}" ls diff --git a/tests/test_utils/recipes/gb200/hybrid-perf-ep4.yaml b/tests/test_utils/recipes/gb200/hybrid-perf-ep4.yaml index f34b66dae5c..99f9ed67af6 100644 --- a/tests/test_utils/recipes/gb200/hybrid-perf-ep4.yaml +++ b/tests/test_utils/recipes/gb200/hybrid-perf-ep4.yaml @@ -17,7 +17,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -27,7 +29,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/hybrid-perf.yaml b/tests/test_utils/recipes/gb200/hybrid-perf.yaml index be09301b004..1f17e5d1590 100644 --- a/tests/test_utils/recipes/gb200/hybrid-perf.yaml +++ b/tests/test_utils/recipes/gb200/hybrid-perf.yaml @@ -18,7 +18,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -28,7 +30,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/moe-1node.yaml b/tests/test_utils/recipes/gb200/moe-1node.yaml index 54a674b9a6f..330e911c6c5 100644 --- a/tests/test_utils/recipes/gb200/moe-1node.yaml +++ b/tests/test_utils/recipes/gb200/moe-1node.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail : "${{RUN_ID:=pr-$$}}" ls diff --git a/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml b/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml index 436a86b017f..49d5f23c92c 100644 --- a/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml +++ b/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/moe.yaml b/tests/test_utils/recipes/gb200/moe.yaml index 8f934ae13ab..79799201386 100644 --- a/tests/test_utils/recipes/gb200/moe.yaml +++ b/tests/test_utils/recipes/gb200/moe.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail : "${{RUN_ID:=pr-$$}}" ls diff --git a/tests/test_utils/recipes/gb200/nemotron.yaml b/tests/test_utils/recipes/gb200/nemotron.yaml index e5024ceba24..f88e3c4aefa 100644 --- a/tests/test_utils/recipes/gb200/nemotron.yaml +++ b/tests/test_utils/recipes/gb200/nemotron.yaml @@ -14,7 +14,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -35,7 +37,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail : "${{RUN_ID:=pr-$$}}" cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/gb200/unit-tests.yaml b/tests/test_utils/recipes/gb200/unit-tests.yaml index bfed8fc4e44..0b462f95cf5 100644 --- a/tests/test_utils/recipes/gb200/unit-tests.yaml +++ b/tests/test_utils/recipes/gb200/unit-tests.yaml @@ -12,7 +12,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -30,7 +32,11 @@ spec: --backwards-commit $MCORE_BACKWARDS_COMMIT \ --repo $MCORE_REPO + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls diff --git a/tests/test_utils/recipes/h100/bert.yaml b/tests/test_utils/recipes/h100/bert.yaml index 0f638a83151..b0e35676169 100644 --- a/tests/test_utils/recipes/h100/bert.yaml +++ b/tests/test_utils/recipes/h100/bert.yaml @@ -14,7 +14,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -35,7 +37,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/ckpt_converter.yaml b/tests/test_utils/recipes/h100/ckpt_converter.yaml index e1d0f87873c..03719103548 100644 --- a/tests/test_utils/recipes/h100/ckpt_converter.yaml +++ b/tests/test_utils/recipes/h100/ckpt_converter.yaml @@ -12,7 +12,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls diff --git a/tests/test_utils/recipes/h100/determinism-perf.yaml b/tests/test_utils/recipes/h100/determinism-perf.yaml index 1359673eb84..d1d14bf0749 100644 --- a/tests/test_utils/recipes/h100/determinism-perf.yaml +++ b/tests/test_utils/recipes/h100/determinism-perf.yaml @@ -11,7 +11,9 @@ spec: platforms: dgx_h100 script_setup: | unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -21,7 +23,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm bash tests/performance_tests/shell_test_utils/determinism/perf_breakdown.sh \ diff --git a/tests/test_utils/recipes/h100/flextron.yaml b/tests/test_utils/recipes/h100/flextron.yaml index 4b89f78037e..783d6b1476b 100644 --- a/tests/test_utils/recipes/h100/flextron.yaml +++ b/tests/test_utils/recipes/h100/flextron.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-dynamic-inference-cuda-graphs.yaml b/tests/test_utils/recipes/h100/gpt-dynamic-inference-cuda-graphs.yaml index 3c39f880123..23a67467176 100644 --- a/tests/test_utils/recipes/h100/gpt-dynamic-inference-cuda-graphs.yaml +++ b/tests/test_utils/recipes/h100/gpt-dynamic-inference-cuda-graphs.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml b/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml index 1cc8c1a47ae..55a07a36090 100644 --- a/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml +++ b/tests/test_utils/recipes/h100/gpt-dynamic-inference-with-coordinator.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml b/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml index 79e97beb5f0..f41fc3cdca4 100644 --- a/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml +++ b/tests/test_utils/recipes/h100/gpt-dynamic-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-grads.yaml b/tests/test_utils/recipes/h100/gpt-grads.yaml index c8ff49c47f6..39c92c20c9a 100644 --- a/tests/test_utils/recipes/h100/gpt-grads.yaml +++ b/tests/test_utils/recipes/h100/gpt-grads.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-grpo.yaml b/tests/test_utils/recipes/h100/gpt-grpo.yaml index b2c2ce5bb99..82d9d44b9a9 100644 --- a/tests/test_utils/recipes/h100/gpt-grpo.yaml +++ b/tests/test_utils/recipes/h100/gpt-grpo.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-inference-server-smoke.yaml b/tests/test_utils/recipes/h100/gpt-inference-server-smoke.yaml index 7002f28d8c6..9998e9a4b74 100644 --- a/tests/test_utils/recipes/h100/gpt-inference-server-smoke.yaml +++ b/tests/test_utils/recipes/h100/gpt-inference-server-smoke.yaml @@ -12,7 +12,9 @@ spec: platforms: dgx_a100 script_setup: | unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -32,7 +34,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN cd /opt/megatron-lm python \ diff --git a/tests/test_utils/recipes/h100/gpt-nemo.yaml b/tests/test_utils/recipes/h100/gpt-nemo.yaml index f11317c61c4..75fbbfa5c58 100644 --- a/tests/test_utils/recipes/h100/gpt-nemo.yaml +++ b/tests/test_utils/recipes/h100/gpt-nemo.yaml @@ -14,7 +14,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -35,7 +37,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/NeMo diff --git a/tests/test_utils/recipes/h100/gpt-offline-inference.yaml b/tests/test_utils/recipes/h100/gpt-offline-inference.yaml index 8451993b19f..472245ed476 100644 --- a/tests/test_utils/recipes/h100/gpt-offline-inference.yaml +++ b/tests/test_utils/recipes/h100/gpt-offline-inference.yaml @@ -12,7 +12,9 @@ spec: platforms: dgx_a100 script_setup: | unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -32,7 +34,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml b/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml index 7484989358f..834920b2995 100644 --- a/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml +++ b/tests/test_utils/recipes/h100/gpt-perf-dp8.yaml @@ -16,7 +16,9 @@ spec: time_limit: 3600 script_setup: | unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -26,7 +28,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN cd /opt/megatron-lm ARGUMENTS=( diff --git a/tests/test_utils/recipes/h100/gpt-perf.yaml b/tests/test_utils/recipes/h100/gpt-perf.yaml index 88817a9b2df..2b0f922b9c8 100644 --- a/tests/test_utils/recipes/h100/gpt-perf.yaml +++ b/tests/test_utils/recipes/h100/gpt-perf.yaml @@ -15,7 +15,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -25,7 +27,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt-static-inference.yaml b/tests/test_utils/recipes/h100/gpt-static-inference.yaml index c904a533bfa..8331f048721 100644 --- a/tests/test_utils/recipes/h100/gpt-static-inference.yaml +++ b/tests/test_utils/recipes/h100/gpt-static-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/gpt.yaml b/tests/test_utils/recipes/h100/gpt.yaml index 39bdbde5819..10f2bebf722 100644 --- a/tests/test_utils/recipes/h100/gpt.yaml +++ b/tests/test_utils/recipes/h100/gpt.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/hybrid-perf-ep8.yaml b/tests/test_utils/recipes/h100/hybrid-perf-ep8.yaml index 5c81e98b49d..a4e501912f1 100644 --- a/tests/test_utils/recipes/h100/hybrid-perf-ep8.yaml +++ b/tests/test_utils/recipes/h100/hybrid-perf-ep8.yaml @@ -15,7 +15,9 @@ spec: time_limit: 3600 script_setup: | unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -25,7 +27,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN cd /opt/megatron-lm ARGUMENTS=( diff --git a/tests/test_utils/recipes/h100/hybrid-perf.yaml b/tests/test_utils/recipes/h100/hybrid-perf.yaml index 11835061ec2..9efbb27b740 100644 --- a/tests/test_utils/recipes/h100/hybrid-perf.yaml +++ b/tests/test_utils/recipes/h100/hybrid-perf.yaml @@ -14,7 +14,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT cd /opt rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm @@ -24,7 +26,11 @@ spec: git fetch origin $MCORE_MR_COMMIT git checkout $MCORE_MR_COMMIT git rev-parse HEAD + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/mamba-dynamic-inference-with-coordinator.yaml b/tests/test_utils/recipes/h100/mamba-dynamic-inference-with-coordinator.yaml index 52673ae5a5c..e58d70fe70c 100644 --- a/tests/test_utils/recipes/h100/mamba-dynamic-inference-with-coordinator.yaml +++ b/tests/test_utils/recipes/h100/mamba-dynamic-inference-with-coordinator.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/mamba-dynamic-inference.yaml b/tests/test_utils/recipes/h100/mamba-dynamic-inference.yaml index 56f56c17a82..bf3c6e26d67 100644 --- a/tests/test_utils/recipes/h100/mamba-dynamic-inference.yaml +++ b/tests/test_utils/recipes/h100/mamba-dynamic-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/mamba-static-inference.yaml b/tests/test_utils/recipes/h100/mamba-static-inference.yaml index bafabf084fc..8450dba539f 100644 --- a/tests/test_utils/recipes/h100/mamba-static-inference.yaml +++ b/tests/test_utils/recipes/h100/mamba-static-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/mamba.yaml b/tests/test_utils/recipes/h100/mamba.yaml index 77668e5d6ca..bea183b36c2 100644 --- a/tests/test_utils/recipes/h100/mamba.yaml +++ b/tests/test_utils/recipes/h100/mamba.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/mimo.yaml b/tests/test_utils/recipes/h100/mimo.yaml index be10af08b66..26f68251d55 100644 --- a/tests/test_utils/recipes/h100/mimo.yaml +++ b/tests/test_utils/recipes/h100/mimo.yaml @@ -19,7 +19,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -40,7 +42,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/module_performance.yaml b/tests/test_utils/recipes/h100/module_performance.yaml index 96e109498da..63530d825e6 100644 --- a/tests/test_utils/recipes/h100/module_performance.yaml +++ b/tests/test_utils/recipes/h100/module_performance.yaml @@ -12,7 +12,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls diff --git a/tests/test_utils/recipes/h100/moe-dynamic-inference-with-coordinator.yaml b/tests/test_utils/recipes/h100/moe-dynamic-inference-with-coordinator.yaml index 1ecc7247c72..035927b2e17 100644 --- a/tests/test_utils/recipes/h100/moe-dynamic-inference-with-coordinator.yaml +++ b/tests/test_utils/recipes/h100/moe-dynamic-inference-with-coordinator.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml b/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml index 8df28139ae3..219eca8a2c3 100644 --- a/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml +++ b/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/moe-grpo.yaml b/tests/test_utils/recipes/h100/moe-grpo.yaml index fcad7111f05..a15bb590fe2 100644 --- a/tests/test_utils/recipes/h100/moe-grpo.yaml +++ b/tests/test_utils/recipes/h100/moe-grpo.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/moe-static-inference.yaml b/tests/test_utils/recipes/h100/moe-static-inference.yaml index e82b452b2b9..96578b3d1c2 100644 --- a/tests/test_utils/recipes/h100/moe-static-inference.yaml +++ b/tests/test_utils/recipes/h100/moe-static-inference.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/moe.yaml b/tests/test_utils/recipes/h100/moe.yaml index 69b02ce639f..4cbcd298b23 100644 --- a/tests/test_utils/recipes/h100/moe.yaml +++ b/tests/test_utils/recipes/h100/moe.yaml @@ -13,7 +13,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -34,7 +36,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/multimodal-llava.yaml b/tests/test_utils/recipes/h100/multimodal-llava.yaml index 0c6ff97a002..90b23f438c2 100644 --- a/tests/test_utils/recipes/h100/multimodal-llava.yaml +++ b/tests/test_utils/recipes/h100/multimodal-llava.yaml @@ -16,7 +16,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -37,7 +39,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/t5.yaml b/tests/test_utils/recipes/h100/t5.yaml index 039a7beafb6..648afe9b34f 100644 --- a/tests/test_utils/recipes/h100/t5.yaml +++ b/tests/test_utils/recipes/h100/t5.yaml @@ -12,7 +12,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -33,7 +35,11 @@ spec: git checkout $MCORE_BACKWARDS_COMMIT git rev-parse HEAD rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls cd /opt/megatron-lm diff --git a/tests/test_utils/recipes/h100/unit-tests.yaml b/tests/test_utils/recipes/h100/unit-tests.yaml index 054b4a33f3c..e3c8e3bea85 100644 --- a/tests/test_utils/recipes/h100/unit-tests.yaml +++ b/tests/test_utils/recipes/h100/unit-tests.yaml @@ -12,7 +12,9 @@ spec: script_setup: | set -euo pipefail unset https_proxy - echo "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" | tee -a /root/.netrc + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT # Checkout latest cd /opt @@ -30,7 +32,11 @@ spec: --backwards-commit $MCORE_BACKWARDS_COMMIT \ --repo $MCORE_REPO + rm -f /root/.netrc + unset RO_API_TOKEN script: |- + rm -f /root/.netrc + unset RO_API_TOKEN set -euo pipefail ls From ccf686edd41c79ff876239d0d31f1ad24fc81116 Mon Sep 17 00:00:00 2001 From: svcnemo-autobot Date: Sat, 8 Aug 2026 13:39:17 +0200 Subject: [PATCH 230/290] fix(ci): AUT-1396 use one global external queue worker (#6366) Signed-off-by: svcnemo-autobot --- .github/scripts/test_approve_test_queue.sh | 22 +++++++++++++++++++ .github/workflows/cicd-approve-test-queue.yml | 20 +++++++++++++---- .github/workflows/cicd-main.yml | 3 +++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100755 .github/scripts/test_approve_test_queue.sh diff --git a/.github/scripts/test_approve_test_queue.sh b/.github/scripts/test_approve_test_queue.sh new file mode 100755 index 00000000000..9e091978651 --- /dev/null +++ b/.github/scripts/test_approve_test_queue.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly WORKFLOW=${WORKFLOW:-.github/workflows/cicd-approve-test-queue.yml} +readonly EXTERNAL_QUEUE=' - branch: all' + +if [[ $(/usr/bin/grep -c -F "$EXTERNAL_QUEUE" "$WORKFLOW") -ne 1 ]]; then + echo "Approve Test Queue must define exactly one global external worker" >&2 + exit 1 +fi + +if /usr/bin/grep -q -F 'contributor_type: [internal, external]' "$WORKFLOW"; then + echo "Approve Test Queue must not race multiple workers against the global external queue" >&2 + exit 1 +fi + +if [[ $(/usr/bin/grep -c -F 'if CONTRIBUTOR_TYPE == "external":' "$WORKFLOW") -ne 3 ]]; then + echo "Approve Test Queue must filter queued, running, and waiting external runs globally" >&2 + exit 1 +fi + +echo "Approve Test Queue uses one global external worker" diff --git a/.github/workflows/cicd-approve-test-queue.yml b/.github/workflows/cicd-approve-test-queue.yml index 120b40f4fbb..b4b52aab20d 100644 --- a/.github/workflows/cicd-approve-test-queue.yml +++ b/.github/workflows/cicd-approve-test-queue.yml @@ -26,8 +26,16 @@ jobs: if: github.repository == 'NVIDIA/Megatron-LM' strategy: matrix: - branch: [main, dev, others] - contributor_type: [internal, external] + include: + - branch: main + contributor_type: internal + - branch: dev + contributor_type: internal + - branch: others + contributor_type: internal + # External contributors share one global queue across all target branches. + - branch: all + contributor_type: external steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -231,8 +239,12 @@ jobs: exit(1) pending_workflows = waiting_resp.get("workflow_runs", []) print("Pending workflows:", len(pending_workflows)) - pending_workflows = [run for run in pending_workflows - if run["name"] == "CICD Megatron-LM" and matches_queue(run, "${{ matrix.branch }}", CONTRIBUTOR_TYPE)] + if CONTRIBUTOR_TYPE == "external": + pending_workflows = [run for run in pending_workflows + if run["name"] == "CICD Megatron-LM" and matches_contributor(run, CONTRIBUTOR_TYPE)] + else: + pending_workflows = [run for run in pending_workflows + if run["name"] == "CICD Megatron-LM" and matches_queue(run, "${{ matrix.branch }}", CONTRIBUTOR_TYPE)] # Sort deployments by creation date (oldest first) print("Sorting workflows...") diff --git a/.github/workflows/cicd-main.yml b/.github/workflows/cicd-main.yml index 20750eb55f5..9312f3d4eb5 100644 --- a/.github/workflows/cicd-main.yml +++ b/.github/workflows/cicd-main.yml @@ -357,6 +357,9 @@ jobs: - name: Test CI cache keys run: .github/scripts/test_cache_keys.sh + - name: Test queue approval topology + run: .github/scripts/test_approve_test_queue.sh + - name: Get PR info id: get-pr-info if: startsWith(github.ref, 'refs/heads/pull-request/') && github.event_name == 'push' From d12f6c8c9aff51e166d872fd70151687a8e3f375 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Sat, 8 Aug 2026 12:54:34 -0700 Subject: [PATCH 231/290] Add generic interface for SSM inference (#5382) Signed-off-by: Keshav Santhanam Co-authored-by: Shanmugam Ramasamy --- .../attention_context/mamba_metadata.py | 4 +- megatron/core/ssm/mamba_mixer.py | 794 ++++++++---------- megatron/core/ssm/ssm_inference.py | 207 +++++ tests/unit_tests/ssm/ops/test_ssm_kernel.py | 39 +- 4 files changed, 576 insertions(+), 468 deletions(-) create mode 100644 megatron/core/ssm/ssm_inference.py diff --git a/megatron/core/inference/contexts/attention_context/mamba_metadata.py b/megatron/core/inference/contexts/attention_context/mamba_metadata.py index 045ede4b502..da48faced90 100644 --- a/megatron/core/inference/contexts/attention_context/mamba_metadata.py +++ b/megatron/core/inference/contexts/attention_context/mamba_metadata.py @@ -481,7 +481,7 @@ def _update_intermediate_metadata( # - abs_positions=d_conv: conv gather reads tokens [0..d_conv-1]. # These are within bounds only when the prefill has at least # d_conv tokens; shorter sequences (e.g. small CUDA-graph warmup - # buckets) would overrun the token axis, so _ssm_prefill clamps + # buckets) would overrun the token axis, so ssm_prefill clamps # the gather positions into range. The gathered state is unused. if real_count < max_count: self._intermediate_chunk_indices_buffer[real_count:max_count].fill_(0) @@ -506,7 +506,7 @@ def _update_intermediate_metadata( else: # No extraction: fill with safe defaults for CUDA graph warmup # (same rationale as padding comment above; abs_positions=d_conv may - # exceed a sub-d_conv warmup sequence, so _ssm_prefill clamps the + # exceed a sub-d_conv warmup sequence, so ssm_prefill clamps the # gather positions into range and the gathered state is unused) self._intermediate_chunk_indices_buffer[:max_count] = 0 self._intermediate_abs_positions_buffer[:max_count] = self.d_conv diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index 73e0561fdbf..e374592a125 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -9,7 +9,7 @@ import logging import math from dataclasses import dataclass, replace -from typing import List, Optional, Tuple, Union +from typing import Optional, Tuple, Union import torch import torch.nn as nn @@ -18,9 +18,7 @@ from megatron.core import parallel_state from megatron.core.inference.contexts import BaseInferenceContext, DynamicInferenceContext from megatron.core.inference.contexts.attention_context.triton.tensor_ops import ( - tensor_get_slice_after, tensor_masked_update, - tensor_merge, ) from megatron.core.inference.utils import InferenceMode from megatron.core.packed_seq_params import PackedSeqParams @@ -32,6 +30,7 @@ scatter_intermediate_ssm, ) from megatron.core.ssm.ops.mamba_ssm import selective_state_update +from megatron.core.ssm.ssm_inference import SSMDynamicInferenceMixin from megatron.core.ssm.utils import _split_tensor_factory from megatron.core.tensor_parallel import get_cuda_rng_tracker from megatron.core.tensor_parallel.gtp_api import HAVE_GTP @@ -47,7 +46,6 @@ deprecate_inference_params, is_causal_conv1d_min_version, is_mamba_min_version, - is_using_quantization_scales, log_single_rank, make_tp_sharded_tensor_for_checkpoint, ) @@ -141,7 +139,7 @@ class MambaMixerSubmodules: out_proj: Union[ModuleSpec, type] = None -class MambaMixer(MegatronModule): +class MambaMixer(SSMDynamicInferenceMixin, MegatronModule): """ Args: config: The config of the model. @@ -490,7 +488,7 @@ def forward( if in_inference_mode and inference_context is not None: if inference_context.is_dynamic_batching(): - return self._dynamic_inference(hidden_states, inference_context) + return self.ssm_dynamic_inference(hidden_states, inference_context) else: assert inference_context.is_static_batching() assert not self.config.batch_invariant_mode, ( @@ -501,7 +499,7 @@ def forward( conv_state, ssm_state = self._get_states_from_cache(inference_context, batch) if inference_context.seqlen_offset > 0: # The states are updated inplace - out, out_bias = self._decode(hidden_states, conv_state, ssm_state) + out, out_bias = self._static_decode(hidden_states, conv_state, ssm_state) return out, out_bias zxBCdt, _ = self.in_proj(hidden_states) @@ -514,7 +512,7 @@ def forward( "Training with packed sequences is not supported " "in the non-memory-efficient code path." ) - y = self._ssm_prefill(zxBCdt, conv_state=conv_state, ssm_state=ssm_state) + y = self._static_prefill(zxBCdt, conv_state=conv_state, ssm_state=ssm_state) else: assert ssm_state is None y = self._ssm_training(zxBCdt, packed_seq_params) @@ -523,215 +521,155 @@ def forward( return out, out_bias - def _dynamic_inference(self, hidden_states: torch.Tensor, context: DynamicInferenceContext): - """ - Executes dynamic inference by separating decode and prefill requests and - running them independently. - """ - sequence_packing_available, reason_for_no_sequence_packing = ( - _check_mamba_sequence_packing_support(for_inference_not_training=True) - ) - assert sequence_packing_available, reason_for_no_sequence_packing - - # Grab standard states - conv_state, ssm_state = context.mamba_states_cache(self.layer_number - self.pp_layer_offset) - - # Fetch intermediate states for speculative decoding - # (just buffers, existing data is overwritten) - int_conv_state = None - int_ssm_state = None - if context.num_speculative_tokens > 0: - int_conv_state, int_ssm_state = context.mamba_states_cache( - self.layer_number - self.pp_layer_offset, intermediate=True - ) - - padded_dims = context.padded_batch_dimensions - token_count = padded_dims.token_count - decode_req_count = padded_dims.decode_req_count - prefill_req_count = padded_dims.prefill_req_count + # ================================================================== + # Static / eager inference + # + # These methods implement legacy static-batching inference (and the + # non-memory-efficient training prefill fallback). They are deliberately + # kept separate from the dynamic inference hooks (`ssm_decode` / + # `ssm_prefill`) so that static-batching bookkeeping does not pollute the + # dynamic inference interface defined by `SSMDynamicInferenceMixin`. + # ================================================================== + def _static_decode( + self, hidden_states, conv_state, ssm_state + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Single-token static-batching decode step (updates state in place).""" + # assert self.ngroups_local_tp == 1, "Only support ngroups=1 for inference for now" + assert hidden_states.shape[0] == 1, "Only support decoding with 1 token at a time for now" - # Input projection + # (1, b, d_model) -> (1, b, proj_dim) zxBCdt, _ = self.in_proj(hidden_states) - y_decode = None - y_prefill = None - - # Decode - if decode_req_count > 0: - # For mixed batch, the decode tokens are at the start of zxBCdt - seq_len = 1 + context.num_speculative_tokens - decode_token_count = decode_req_count * seq_len - - zxBCdt_decode = zxBCdt[:decode_token_count] if prefill_req_count > 0 else zxBCdt - - # Reshape from [N*S, 1, d] to [N, S, d] for the 3D Triton kernels - zxBCdt_decode = zxBCdt_decode.squeeze(1).view(decode_req_count, seq_len, -1) - - y_decode = self._ssm_decode( - zxBCdt_decode, - conv_state, - ssm_state, - batch_indices=context.mamba_metadata.batch_indices_decode, - intermediate_conv_state=int_conv_state, - intermediate_ssm_state=int_ssm_state, - ) - - # Flatten back to [N*S, 1, d] to match merge logic - y_decode = y_decode.view(decode_token_count, 1, -1) - - # Prefill - if prefill_req_count > 0: - if decode_req_count > 0: - # If mixed, slice the prefill portion out of zxBCdt - zxBCdt_prefill = torch.empty_like(zxBCdt) - tensor_get_slice_after( - zxBCdt, - zxBCdt_prefill, - context.mamba_metadata.device_decode_prefill, - check_bounds=False, - ) - else: - zxBCdt_prefill = zxBCdt + assert self.cp.cp_size == 1, "Context parallel not supported for Mamba inference decode" - mamba_layer_idx = context.layer_map[self.layer_number - self.pp_layer_offset - 1] - y_prefill = self._dynamic_inference_prefill( - zxBCdt_prefill, context, conv_state, ssm_state, mamba_layer_idx=mamba_layer_idx - ) - - # Merge decode and prefill results if necessary - if y_decode is not None and y_prefill is not None: - y = torch.empty( - [token_count, 1, y_prefill.shape[-1]], - dtype=y_prefill.dtype, - device=y_prefill.device, - ) - tensor_merge( - y_decode, y_prefill, context.mamba_metadata.device_decode_prefill, output_tensor=y - ) - elif y_decode is not None: - y = y_decode - elif y_prefill is not None: - y = y_prefill - else: - raise RuntimeError("Dynamic inference called with 0 decode and 0 prefill requests") + # Static batching has no slot remapping, so batch_indices is None. + y = self.ssm_decode(zxBCdt, conv_state=conv_state, ssm_state=ssm_state, batch_indices=None) - # Clear the outputs for padding tokens when using quantization scales - # to avoid corrupting amax calculations - if is_using_quantization_scales(self.config): - y[context.padding_slice] = 0.0 - - # Output projection + # y has shape (1, b, d_inner), which is what out_proj expects out, out_bias = self.out_proj(y) return out, out_bias - def _dynamic_inference_prefill( + def _static_prefill( self, zxBCdt: torch.Tensor, - context: DynamicInferenceContext, - conv_state: torch.Tensor, - ssm_state: torch.Tensor, - mamba_layer_idx: Optional[int] = None, + conv_state: Optional[torch.Tensor], + ssm_state: Optional[torch.Tensor], ) -> torch.Tensor: - """Helper to run dynamic inference prefill. - - All prefill requests (including chunked prefill) are processed together - through the unified varlen path. Uses precomputed metadata from - MambaMetadata.update() to avoid .item() calls and data-dependent - control flow, enabling CUDA graph compatibility. - - When padded_prefill_count > 0 but real_prefill_count == 0 (e.g. a - decode-only rank in expert parallelism that must match a mixed CUDA - graph), this function still executes the full kernel path. - The metadata reflects zero-length sequences (cu_seqlens all equal, - batch_indices all -1) so kernels produce a zero output tensor of the - correct padded shape, which is required by the merge logic in - _dynamic_inference. - - Intermediate state extraction (for Mamba prefix caching) is performed - inside _ssm_prefill via pre-allocated output buffers, making it fully - CUDA graph compatible. """ - metadata = context.mamba_metadata + Performs single-sequence SSM prefill for static-batching inference and the + non-memory-efficient (`use_mem_eff_path=False`) training fallback. - # Use precomputed metadata (no .item() calls, no stripping). - cu_seqlens = metadata.cu_seqlens - batch_indices = metadata.batch_indices_prefill - real_token_count = metadata.real_prefill_token_count - seq_idx = metadata.seq_idx + `conv_state` / `ssm_state` are `None` for the training fallback and + non-`None` for static-batching inference (updated in place). - # Pass full padded tensor — SSM kernel uses cu_chunk_seqlens for - # boundaries and never accesses tokens beyond the last boundary. - # Output y is initialized to zeros in _ssm_prefill so padding - # positions remain zero (safe for RMSNorm and downstream ops). + Args: + zxBCdt: The input tensor of shape (l, b, d), a concatenation of + z, x, B, C, and dt projections. + conv_state: The convolution state tensor, or `None` for training. + ssm_state: The selective scan state tensor, or `None` for training. - # Prepare intermediate extraction buffers (always passed, CUDA graph compat) - slot_allocator = context.mamba_slot_allocator - intermediate_chunk_indices = metadata.intermediate_chunk_indices - intermediate_abs_positions = metadata.intermediate_abs_positions - intermediate_real_count = metadata.intermediate_real_count - intermediate_ssm_out = None - intermediate_conv_out = None - if slot_allocator is not None and mamba_layer_idx is not None: - intermediate_ssm_out = slot_allocator.intermediate_ssm_out[mamba_layer_idx] - intermediate_conv_out = slot_allocator.intermediate_conv_out[mamba_layer_idx] + Returns: + Output tensor of shape (l, b, d). + """ + # transpose: l b pd --> b l pd + zxBCdt = rearrange(zxBCdt, "l b d -> b l d").contiguous() + + # (nheads_local_tpcp) + A = -torch.exp(self.cp.get_A_log().float()) - y_prefill = self._ssm_prefill( + z, xBC, dt = torch.split( zxBCdt, - conv_state=conv_state, - ssm_state=ssm_state, - seq_idx=seq_idx, - cu_seqlens=cu_seqlens, - batch_indices=batch_indices, - intermediate_chunk_indices=intermediate_chunk_indices, - intermediate_abs_positions=intermediate_abs_positions, - intermediate_real_count=intermediate_real_count, - intermediate_ssm_out=intermediate_ssm_out, - intermediate_conv_out=intermediate_conv_out, - cu_chunk_seqlens=metadata.cu_chunk_seqlens, - last_chunk_indices=metadata.last_chunk_indices, - seq_idx_for_varlen=metadata.seq_idx_for_varlen, - cu_seqlens_list=metadata.cu_seqlens_list, - real_token_count=real_token_count, - conv_seq_idx=metadata.conv_seq_idx, - conv_seq_start=metadata.conv_seq_start, + [ + self.cp.d_inner_local_tpcp, + self.cp.d_inner_local_tpcp + 2 * self.cp.ngroups_local_tpcp * self.d_state, + self.cp.nheads_local_tpcp, + ], + dim=-1, ) - return y_prefill - - def _decode( - self, hidden_states, conv_state, ssm_state, batch_indices: Optional[torch.Tensor] = None - ) -> Tuple[torch.Tensor, torch.Tensor]: - """Performs inference step for decoding.""" - # assert self.ngroups_local_tp == 1, "Only support ngroups=1 for inference for now" - is_dynamic_batching = batch_indices is not None + # Compute short convolution (single-sequence / non-varlen). + xBC = rearrange(xBC, "b l d -> b d l").contiguous() + if conv_state is not None: + # If we just take x[:, :, -self.d_conv :], it will error if seqlen < self.d_conv + # Instead F.pad will pad with zeros if seqlen < self.d_conv, and truncate otherwise. + conv_state.copy_(F.pad(xBC, (self.d_conv - xBC.shape[-1], 0))) # Update state (B D W) - if not is_dynamic_batching: - assert ( - hidden_states.shape[0] == 1 - ), "Only support decoding with 1 token at a time for now" + seqlen = xBC.size(2) + if causal_conv1d_fn is None: + xBC = self.act(self.cp.conv1d(xBC)[..., :seqlen]) + else: + assert self.activation in ["silu", "swish"] + xBC = causal_conv1d_fn( + x=xBC, + weight=rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w"), + bias=self.cp.get_conv1d_bias(), + activation=self.activation, + ) + xBC = rearrange(xBC, "b d l -> b l d").contiguous() - # (1, b, d_model) -> (1, b, proj_dim) - zxBCdt, _ = self.in_proj(hidden_states) + x, B, C = torch.split( + xBC, + [ + self.cp.d_inner_local_tpcp, + self.cp.ngroups_local_tpcp * self.d_state, + self.cp.ngroups_local_tpcp * self.d_state, + ], + dim=-1, + ) - # Make batch size leading dimension since that is 1 - if is_dynamic_batching: - zxBCdt = zxBCdt.transpose(0, 1) + # TODO Vijay: fuse most of the transposes with the GEMMS + x = rearrange(x, "b l (h p) -> b l h p", p=self.headdim).contiguous() + dt = dt.contiguous() + B = rearrange(B, "b l (g n) -> b l g n", n=self.d_state).contiguous() + C = rearrange(C, "b l (g n) -> b l g n", n=self.d_state).contiguous() + z = rearrange(z, "b l (h p) -> b l h p", p=self.headdim).contiguous() - assert self.cp.cp_size == 1, "Context parallel not supported for Mamba inferenece decode" + # If `rmsnorm == False`, then the norm inside `mamba_chunk_scan_combined` will be used. + # In this case, if `cp_size > 1` then that norm could be performed on less heads than if + # `cp_size == 1` (groups of heads can be sharded across CP ranks), which would be + # mathematically incorrect, and potentially arithmetically unstable. + assert ( + self.cp.cp_size == 1 or self.rmsnorm + ), "Context parallel not supported for use_mem_eff_path==False and rmsnorm==False" - y = self._ssm_decode( - zxBCdt, conv_state=conv_state, ssm_state=ssm_state, batch_indices=batch_indices + initial_ssm_state = None + state_dtype_kwarg = ( + {"state_dtype": self.mamba_training_ssm_states_dtype} if MAMBA_HAS_STATE_DTYPE else {} + ) + y = mamba_chunk_scan_combined( + x, + dt, + A, + B, + C, + self.chunk_size, + D=( + rearrange(self.cp.get_D().float(), "(h p) -> h p", p=self.headdim) + if self.D_has_hdim + else self.cp.get_D() + ), + z=z if not self.rmsnorm else None, + dt_bias=self.cp.get_dt_bias().float(), + dt_softplus=True, + return_final_states=ssm_state is not None, + initial_states=initial_ssm_state, + **state_dtype_kwarg, ) - # Restore sequence length as first dimension - if is_dynamic_batching: - y = y.transpose(0, 1) + if ssm_state is not None: + y, last_state = y + ssm_state.copy_(last_state) - # y has shape (1, b, d_inner), which is what out_proj expects - out, out_bias = self.out_proj(y) + y = rearrange(y, "b l h p -> l b (h p)").contiguous() + y = self.cp.post_conv_ssm(y) - return out, out_bias + if self.rmsnorm: + z = rearrange(z, "b l h p -> l b (h p)").contiguous() + z = self.cp.post_conv_ssm(z) + y = self.norm(y, z) + + return y def _ssm_training( self, zxBCdt: torch.Tensor, packed_seq_params: Optional[PackedSeqParams] = None @@ -789,63 +727,70 @@ def _ssm_training( return y - def _ssm_prefill( + def ssm_prefill( self, zxBCdt: torch.Tensor, - conv_state: Optional[torch.Tensor], - ssm_state: Optional[torch.Tensor], - seq_idx: Optional[torch.Tensor] = None, - cu_seqlens: Optional[torch.Tensor] = None, - batch_indices: Optional[torch.Tensor] = None, - intermediate_chunk_indices: Optional[torch.Tensor] = None, - intermediate_abs_positions: Optional[torch.Tensor] = None, - intermediate_real_count: Optional[torch.Tensor] = None, - intermediate_ssm_out: Optional[torch.Tensor] = None, - intermediate_conv_out: Optional[torch.Tensor] = None, - cu_chunk_seqlens: Optional[torch.Tensor] = None, - last_chunk_indices: Optional[torch.Tensor] = None, - seq_idx_for_varlen: Optional[torch.Tensor] = None, - cu_seqlens_list: Optional[List[int]] = None, - real_token_count: Optional[int] = None, - conv_seq_idx: Optional[torch.Tensor] = None, - conv_seq_start: Optional[torch.Tensor] = None, + conv_state: torch.Tensor, + ssm_state: torch.Tensor, + context: DynamicInferenceContext, ) -> torch.Tensor: """ - Performs SSM computation for inference prefill step. + Performs the variable-length SSM prefill for all dynamic-batching prefill requests. + + All varlen metadata (cu_seqlens, seq_idx, batch_indices, chunk boundaries, + intermediate extraction buffers, etc.) is read directly from + `context.mamba_metadata` / `context.mamba_slot_allocator` -- there is no + intermediate layer that unpacks the metadata into a long argument list. All + prefill requests (including chunked prefill) are processed together through a + single varlen kernel call; the precomputed metadata avoids `.item()` calls + and data-dependent control flow, enabling CUDA graph compatibility. + Intermediate state extraction (for Mamba prefix caching) is performed via + pre-allocated output buffers, also CUDA graph compatible. + + When padded_prefill_count > 0 but real_prefill_count == 0 (e.g. a decode-only + rank in expert parallelism that must match a mixed CUDA graph), the full kernel + path still runs; the metadata reflects zero-length sequences (cu_seqlens all + equal, batch_indices all -1) so the kernels produce a correctly-shaped zero + output tensor, which is required by the merge logic in `ssm_dynamic_inference`. Args: zxBCdt: The input tensor of shape (l, b, d), which is a concatenation of z, x, B, C, and dt projections. conv_state: The convolution state tensor for inference. ssm_state: The selective scan state tensor for inference. - seq_idx: A map from token index to request index for variable-length sequences. - cu_seqlens: Cumulative sequence lengths for variable-length sequences. - batch_indices: A map from batch id to position in the Mamba state tensors for - dynamic inference. - intermediate_chunk_indices: Pre-allocated tensor of chunk indices for - intermediate state extraction (fixed size, padded with 0). - intermediate_abs_positions: Pre-allocated tensor of absolute token - positions for conv state extraction (fixed size, padded with d_conv). - intermediate_real_count: int32[1] GPU tensor holding the number of - meaningful entries in the intermediate buffers this step. Read - inside the Triton scatter kernels so padded slots cost nothing. - intermediate_ssm_out: Output buffer for extracted SSM states - [max_intermediate_count, *ssm_shape]. - intermediate_conv_out: Output buffer for extracted conv states - [max_intermediate_count, *conv_shape]. - cu_chunk_seqlens: Precomputed chunk boundaries from MambaMetadata. - last_chunk_indices: Precomputed last chunk index per sequence. - seq_idx_for_varlen: Precomputed request ID per chunk. - cu_seqlens_list: Python list of cumulative sequence lengths (avoids .item()). - real_token_count: Number of real (non-padding) tokens. - conv_seq_idx: Precomputed per-token request ID for Triton conv1d. - conv_seq_start: Precomputed per-token request start for Triton conv1d. + context: The dynamic inference context supplying all varlen metadata. Returns: - Output tensor of shape (l, b, d). Intermediate states (if any) are - written directly to intermediate_ssm_out and intermediate_conv_out. + Output tensor of shape (l, b, d). Intermediate states (if any) are written + directly into the slot-allocator buffers held by `context`. """ - is_dynamic_batching = seq_idx is not None + assert ( + self.cp.cp_size == 1 + ), "Context parallel is not supported for MambaMixer dynamic inference prefill" + + metadata = context.mamba_metadata + slot_allocator = context.mamba_slot_allocator + + seq_idx = metadata.seq_idx + cu_seqlens = metadata.cu_seqlens + batch_indices = metadata.batch_indices_prefill + intermediate_chunk_indices = metadata.intermediate_chunk_indices + intermediate_abs_positions = metadata.intermediate_abs_positions + intermediate_real_count = metadata.intermediate_real_count + cu_chunk_seqlens = metadata.cu_chunk_seqlens + last_chunk_indices = metadata.last_chunk_indices + seq_idx_for_varlen = metadata.seq_idx_for_varlen + conv_seq_idx = metadata.conv_seq_idx + conv_seq_start = metadata.conv_seq_start + + # Wire the per-layer intermediate extraction buffers (prefix caching) when a + # slot allocator is present; otherwise extraction is disabled below. + intermediate_ssm_out = None + intermediate_conv_out = None + if slot_allocator is not None: + mamba_layer_idx = context.layer_map[self.layer_number - self.pp_layer_offset - 1] + intermediate_ssm_out = slot_allocator.intermediate_ssm_out[mamba_layer_idx] + intermediate_conv_out = slot_allocator.intermediate_conv_out[mamba_layer_idx] # transpose: l b pd --> b l pd zxBCdt = rearrange(zxBCdt, "l b d -> b l d").contiguous() @@ -863,74 +808,47 @@ def _ssm_prefill( dim=-1, ) - # Compute short convolution - xBC_pre_conv = None - if conv_state is not None and is_dynamic_batching: - assert batch_indices is not None - - # Extract initial conv states BEFORE saving new ones. - # causal_conv1d_varlen_states computes the final conv state from the - # input sequence and tensor_masked_update writes it into the conv_state - # buffer. If we read initial_conv_states after this write, restored - # requests see their own newly-computed states instead of the cached - # initial states from a previous request, corrupting the conv output. - initial_conv_states = conv_state[batch_indices, :, 1:] - - # Save final conv states from the input sequence - conv_varlen_states = causal_conv1d_varlen_states( - xBC.squeeze(0), cu_seqlens, state_len=conv_state.shape[-1] - ) - tensor_masked_update(conv_state, batch_indices, conv_varlen_states) + # Compute short convolution (unified varlen path over all prefill requests). + assert batch_indices is not None - # Conv state dtype might differ from params dtype, so cast xBC and weight / bias - # tensors to the conv state dtype for causal_conv1d_varlen_fn and then cast xBC - # back to the original dtype - xBC_dtype = xBC.dtype - conv_state_dtype = conv_state.dtype + # Extract initial conv states BEFORE saving new ones. + # causal_conv1d_varlen_states computes the final conv state from the + # input sequence and tensor_masked_update writes it into the conv_state + # buffer. If we read initial_conv_states after this write, restored + # requests see their own newly-computed states instead of the cached + # initial states from a previous request, corrupting the conv output. + initial_conv_states = conv_state[batch_indices, :, 1:] - xBC = xBC.to(conv_state_dtype) - conv_weight = rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w").to( - conv_state_dtype - ) - conv_bias = self.cp.get_conv1d_bias().to(conv_state_dtype) + # Save final conv states from the input sequence + conv_varlen_states = causal_conv1d_varlen_states( + xBC.squeeze(0), cu_seqlens, state_len=conv_state.shape[-1] + ) + tensor_masked_update(conv_state, batch_indices, conv_varlen_states) - xBC_pre_conv = xBC if intermediate_conv_out is not None else None - from megatron.core.ssm.ops.causal_conv1d_varlen import causal_conv1d_varlen_fn + # Conv state dtype might differ from params dtype, so cast xBC and weight / bias + # tensors to the conv state dtype for causal_conv1d_varlen_fn and then cast xBC + # back to the original dtype + xBC_dtype = xBC.dtype + conv_state_dtype = conv_state.dtype - xBC_out = causal_conv1d_varlen_fn( - x=xBC.squeeze(0).contiguous(), - weight=conv_weight, - bias=conv_bias, - cu_seqlens=cu_seqlens, - initial_states=initial_conv_states, - activation=self.activation, - precomputed_seq_idx=conv_seq_idx, - precomputed_seq_start=conv_seq_start, - ) - xBC = xBC_out.to(xBC_dtype).unsqueeze(0) - else: - # Non-dynamic-batching path (static batching / training fallback) - xBC = rearrange(xBC, "b l d -> b d l").contiguous() - if conv_state is not None: - # If we just take x[:, :, -self.d_conv :], it will error if seqlen < self.d_conv - # Instead F.pad will pad with zeros if seqlen < self.d_conv, and truncate otherwise. - conv_state.copy_( - F.pad(xBC, (self.d_conv - xBC.shape[-1], 0)) - ) # Update state (B D W) - - seqlen = xBC.size(2) - if causal_conv1d_fn is None: - xBC = self.act(self.cp.conv1d(xBC)[..., :seqlen]) - else: - assert self.activation in ["silu", "swish"] - xBC = causal_conv1d_fn( - x=xBC, - weight=rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w"), - bias=self.cp.get_conv1d_bias(), - activation=self.activation, - seq_idx=seq_idx, - ) - xBC = rearrange(xBC, "b d l -> b l d").contiguous() + xBC = xBC.to(conv_state_dtype) + conv_weight = rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w").to(conv_state_dtype) + conv_bias = self.cp.get_conv1d_bias().to(conv_state_dtype) + + xBC_pre_conv = xBC if intermediate_conv_out is not None else None + from megatron.core.ssm.ops.causal_conv1d_varlen import causal_conv1d_varlen_fn + + xBC_out = causal_conv1d_varlen_fn( + x=xBC.squeeze(0).contiguous(), + weight=conv_weight, + bias=conv_bias, + cu_seqlens=cu_seqlens, + initial_states=initial_conv_states, + activation=self.activation, + precomputed_seq_idx=conv_seq_idx, + precomputed_seq_start=conv_seq_start, + ) + xBC = xBC_out.to(xBC_dtype).unsqueeze(0) x, B, C = torch.split( xBC, @@ -957,173 +875,141 @@ def _ssm_prefill( self.cp.cp_size == 1 or self.rmsnorm ), "Context parallel not supported for use_mem_eff_path==False and rmsnorm==False" - if is_dynamic_batching: - # Unified varlen SSM path: all prefill requests through single kernel call - initial_ssm_state = ssm_state[batch_indices] - - x = x.squeeze(0) - dt = dt.squeeze(0) - A = A.squeeze(0) - B = B.squeeze(0) - C = C.squeeze(0) - z = z.squeeze(0) - # Initialize with zeros so padding positions (beyond cu_chunk_seqlens - # boundaries) remain zero, which is safe for RMSNorm and downstream ops. - y = torch.zeros_like(x) - - if cu_chunk_seqlens is not None: - # Use precomputed chunk metadata (CUDA graph compatible, no .item()) - pass - else: - # Fallback: build chunk metadata from cu_seqlens (non-precomputed) - chunk_boundaries = [0] - last_chunk_indices_list = [] - num_seqs = cu_seqlens.numel() - 1 - for i in range(num_seqs): - start = cu_seqlens[i].item() - end = cu_seqlens[i + 1].item() - pos = start + self.chunk_size - while pos < end: - chunk_boundaries.append(pos) - pos += self.chunk_size - chunk_boundaries.append(end) - last_chunk_indices_list.append(len(chunk_boundaries) - 2) - - cu_chunk_seqlens = cu_seqlens.new_tensor(chunk_boundaries) - last_chunk_indices = cu_seqlens.new_tensor(last_chunk_indices_list) - - seq_idx_for_varlen = None - if seq_idx is not None: - chunk_starts = cu_chunk_seqlens[:-1] - seq_idx_for_varlen = seq_idx[0, chunk_starts].contiguous() - - # Batch-invariant decode replays the partial prefill tail, so keep - # the cached SSM state at the last complete chunk boundary. - if self.config.batch_invariant_mode: - prefill_lens = (cu_seqlens[1:] - cu_seqlens[:-1]).to(torch.long) - tail_lens = prefill_lens % self.chunk_size - has_boundary = prefill_lens >= self.chunk_size - # A partial tail uses the preceding full chunk's state. - boundary_chunk_indices = ( - last_chunk_indices.to(torch.long) - (tail_lens > 0).to(torch.long) - ).clamp(min=0) - - # Extraction is enabled when the slot allocator wired buffers in via - # the caller. When enabled, the chunk scan returns its raw states so - # our Triton kernels do a fused gather+conditional-scatter directly, - # skipping the dense intermediate tensor and the padded-slot writes. - extract_intermediates = ( - not self.config.batch_invariant_mode - and intermediate_chunk_indices is not None - and intermediate_ssm_out is not None - ) - ssm_varlen_result = mamba_chunk_scan_combined_varlen( - x=x, - dt=dt, - A=A, - B=B, - C=C, - chunk_size=self.chunk_size, - cu_chunk_seqlens=cu_chunk_seqlens, - last_chunk_indices=last_chunk_indices, - seq_idx=seq_idx_for_varlen, - out=y, - D=( - rearrange(self.cp.get_D().float(), "(h p) -> h p", p=self.headdim) - if self.D_has_hdim - else self.cp.get_D() - ), - z=z if (self.config.batch_invariant_mode or not self.rmsnorm) else None, - dt_bias=self.cp.get_dt_bias().float(), - initial_states=initial_ssm_state, - return_raw_states=self.config.batch_invariant_mode or extract_intermediates, - dt_softplus=True, - dt_limit=(0.0, float("inf")), - state_dtype=ssm_state.dtype, - ) - - if self.config.batch_invariant_mode or extract_intermediates: - ssm_varlen_states, raw_ssm_states = ssm_varlen_result - else: - ssm_varlen_states = ssm_varlen_result - raw_ssm_states = None + # Unified varlen SSM path: all prefill requests through single kernel call + initial_ssm_state = ssm_state[batch_indices] + + x = x.squeeze(0) + dt = dt.squeeze(0) + A = A.squeeze(0) + B = B.squeeze(0) + C = C.squeeze(0) + z = z.squeeze(0) + # Initialize with zeros so padding positions (beyond cu_chunk_seqlens + # boundaries) remain zero, which is safe for RMSNorm and downstream ops. + y = torch.zeros_like(x) + + if cu_chunk_seqlens is not None: + # Use precomputed chunk metadata (CUDA graph compatible, no .item()) + pass + else: + # Fallback: build chunk metadata from cu_seqlens (non-precomputed) + chunk_boundaries = [0] + last_chunk_indices_list = [] + num_seqs = cu_seqlens.numel() - 1 + for i in range(num_seqs): + start = cu_seqlens[i].item() + end = cu_seqlens[i + 1].item() + pos = start + self.chunk_size + while pos < end: + chunk_boundaries.append(pos) + pos += self.chunk_size + chunk_boundaries.append(end) + last_chunk_indices_list.append(len(chunk_boundaries) - 2) + + cu_chunk_seqlens = cu_seqlens.new_tensor(chunk_boundaries) + last_chunk_indices = cu_seqlens.new_tensor(last_chunk_indices_list) + + seq_idx_for_varlen = None + if seq_idx is not None: + chunk_starts = cu_chunk_seqlens[:-1] + seq_idx_for_varlen = seq_idx[0, chunk_starts].contiguous() + + # Batch-invariant decode replays the partial prefill tail, so keep + # the cached SSM state at the last complete chunk boundary. + if self.config.batch_invariant_mode: + prefill_lens = (cu_seqlens[1:] - cu_seqlens[:-1]).to(torch.long) + tail_lens = prefill_lens % self.chunk_size + has_boundary = prefill_lens >= self.chunk_size + # A partial tail uses the preceding full chunk's state. + boundary_chunk_indices = ( + last_chunk_indices.to(torch.long) - (tail_lens > 0).to(torch.long) + ).clamp(min=0) + + # Extraction is enabled when the slot allocator wired buffers in via + # the caller. When enabled, the chunk scan returns its raw states so + # our Triton kernels do a fused gather+conditional-scatter directly, + # skipping the dense intermediate tensor and the padded-slot writes. + extract_intermediates = ( + not self.config.batch_invariant_mode + and intermediate_chunk_indices is not None + and intermediate_ssm_out is not None + ) + ssm_varlen_result = mamba_chunk_scan_combined_varlen( + x=x, + dt=dt, + A=A, + B=B, + C=C, + chunk_size=self.chunk_size, + cu_chunk_seqlens=cu_chunk_seqlens, + last_chunk_indices=last_chunk_indices, + seq_idx=seq_idx_for_varlen, + out=y, + D=( + rearrange(self.cp.get_D().float(), "(h p) -> h p", p=self.headdim) + if self.D_has_hdim + else self.cp.get_D() + ), + z=z if (self.config.batch_invariant_mode or not self.rmsnorm) else None, + dt_bias=self.cp.get_dt_bias().float(), + initial_states=initial_ssm_state, + return_raw_states=self.config.batch_invariant_mode or extract_intermediates, + dt_softplus=True, + dt_limit=(0.0, float("inf")), + state_dtype=ssm_state.dtype, + ) - y = y.unsqueeze(0) - z = z.unsqueeze(0) + if self.config.batch_invariant_mode or extract_intermediates: + ssm_varlen_states, raw_ssm_states = ssm_varlen_result + else: + ssm_varlen_states = ssm_varlen_result + raw_ssm_states = None - if self.config.batch_invariant_mode: - boundary_mask = has_boundary.view(-1, 1, 1, 1) - cache_states = torch.where( - boundary_mask, raw_ssm_states[boundary_chunk_indices], initial_ssm_state - ) - else: - cache_states = ssm_varlen_states - - tensor_masked_update(ssm_state, batch_indices, cache_states) - if self.config.batch_invariant_mode: - self._get_batch_invariant_decoder().seed( - x, - z.squeeze(0), - dt, - B, - C, - cu_seqlens, - batch_indices, - max_requests=ssm_state.shape[0], - ) + y = y.unsqueeze(0) + z = z.unsqueeze(0) - if extract_intermediates: - # Fused gather+conditional-scatter for SSM: read row - # raw_ssm_states[chunk_indices[i]] into intermediate_ssm_out[i], - # only for i < real_count. - scatter_intermediate_ssm( - raw_ssm_states, - intermediate_chunk_indices, - intermediate_real_count, - intermediate_ssm_out, - ) - # Same pattern for conv: gather a length-d_conv window ending at - # abs_positions[i] (clamped into the valid token range) from - # xBC_pre_conv and scatter (transposed) into intermediate_conv_out[i], - # only for i < real_count. - scatter_intermediate_conv( - xBC_pre_conv, - intermediate_abs_positions, - intermediate_real_count, - intermediate_conv_out, - d_conv=intermediate_conv_out.shape[-1], - ) - else: - # Non-dynamic-batching path (static batching) - initial_ssm_state = None - state_dtype_kwarg = ( - {"state_dtype": self.mamba_training_ssm_states_dtype} - if MAMBA_HAS_STATE_DTYPE - else {} + if self.config.batch_invariant_mode: + boundary_mask = has_boundary.view(-1, 1, 1, 1) + cache_states = torch.where( + boundary_mask, raw_ssm_states[boundary_chunk_indices], initial_ssm_state ) - y = mamba_chunk_scan_combined( + else: + cache_states = ssm_varlen_states + + tensor_masked_update(ssm_state, batch_indices, cache_states) + if self.config.batch_invariant_mode: + self._get_batch_invariant_decoder().seed( x, + z.squeeze(0), dt, - A, B, C, - self.chunk_size, - D=( - rearrange(self.cp.get_D().float(), "(h p) -> h p", p=self.headdim) - if self.D_has_hdim - else self.cp.get_D() - ), - z=z if not self.rmsnorm else None, - dt_bias=self.cp.get_dt_bias().float(), - dt_softplus=True, - return_final_states=ssm_state is not None, - initial_states=initial_ssm_state, - **state_dtype_kwarg, + cu_seqlens, + batch_indices, + max_requests=ssm_state.shape[0], ) - if ssm_state is not None: - y, last_state = y - ssm_state.copy_(last_state) + if extract_intermediates: + # Fused gather+conditional-scatter for SSM: read row + # raw_ssm_states[chunk_indices[i]] into intermediate_ssm_out[i], + # only for i < real_count. + scatter_intermediate_ssm( + raw_ssm_states, + intermediate_chunk_indices, + intermediate_real_count, + intermediate_ssm_out, + ) + # Same pattern for conv: gather a length-d_conv window ending at + # abs_positions[i] (clamped into the valid token range) from + # xBC_pre_conv and scatter (transposed) into intermediate_conv_out[i], + # only for i < real_count. + scatter_intermediate_conv( + xBC_pre_conv, + intermediate_abs_positions, + intermediate_real_count, + intermediate_conv_out, + d_conv=intermediate_conv_out.shape[-1], + ) y = rearrange(y, "b l h p -> l b (h p)").contiguous() y = self.cp.post_conv_ssm(y) @@ -1167,7 +1053,7 @@ def train(self, mode: bool = True): self._A_neg_exp_cache_stale = True return super().train(mode) - def _ssm_decode( + def ssm_decode( self, zxBCdt: torch.Tensor, conv_state: torch.Tensor, diff --git a/megatron/core/ssm/ssm_inference.py b/megatron/core/ssm/ssm_inference.py new file mode 100644 index 00000000000..a850b51ebfa --- /dev/null +++ b/megatron/core/ssm/ssm_inference.py @@ -0,0 +1,207 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Shared dynamic-batching inference scaffolding for linear-attention mixers. + +A growing family of mixers in Megatron behave like "linear attention" / SSM +recurrences for inference purposes: they carry a small per-request recurrent +state (a short-convolution state plus a matrix-valued SSM state) instead of a +growing KV cache. Mamba was the first; Gated Delta Net / Gated Delta Product +(GDP) and friends are the same shape of computation with different kernels. + +All of these variants share an *identical* request-level control flow for the +dynamic inference engine: + + 1. Fetch this layer's (conv_state, ssm_state) slabs from the context. + 2. Project the packed input (`in_proj`). + 3. Split the packed batch into a decode partition (1 token per request, + placed first) and a prefill partition (variable length, placed after). + The kernels cannot mix the two, so they run independently. + 4. Run the decode and prefill kernels on their respective partitions. + 5. Merge the two partitions back into packed token order. + 6. Apply the output projection (`out_proj`). + +Only the kernels in step 4 differ between variants. This mixin owns the shared +control flow (steps 1-3, 5, 6 and the orchestration) and delegates the +variant-specific work to two hooks, `ssm_decode` and `ssm_prefill`. New +linear-attention variants should subclass this mixin and implement those two +hooks rather than re-deriving the decode/prefill bookkeeping. + +Both hooks are given the `DynamicInferenceContext` directly and read whatever +per-step metadata they need from `context.mamba_metadata` / +`context.mamba_slot_allocator` themselves; there is deliberately no +intermediate "unpack the metadata into a long argument list" layer. + +Speculative decoding is supported by the shared orchestration: the decode path +reshapes tokens into `[batch, seq_len, d]`, fetches intermediate state buffers +from the context, and passes them to `ssm_decode`. Variants that do not yet +support speculative decoding should assert `seq_len == 1` inside their +`ssm_decode` implementation. + +Chunked prefill and prefix caching are handled entirely inside `ssm_prefill` +via `context.mamba_metadata` and `context.mamba_slot_allocator`; the mixin +orchestration is unaware of them. + +Note: static-batching ("legacy") inference is intentionally *not* part of this +interface. Concrete mixers keep any static/eager inference path separate so +it does not pollute the dynamic decode/prefill hooks defined here. +""" + +from __future__ import annotations + +from typing import Tuple + +import torch + +from megatron.core.inference.contexts import DynamicInferenceContext +from megatron.core.inference.contexts.attention_context.triton.tensor_ops import ( + tensor_get_slice_after, + tensor_merge, +) +from megatron.core.utils import is_using_quantization_scales + + +class SSMDynamicInferenceMixin: + """Mixin providing the shared decode/prefill orchestration for the dynamic + inference engine. Concrete mixers implement the two `ssm_*` hooks below.""" + + # ------------------------------------------------------------------ + # Hooks implemented by concrete mixers. + # ------------------------------------------------------------------ + def ssm_decode( + self, + zxBCdt: torch.Tensor, + conv_state: torch.Tensor, + ssm_state: torch.Tensor, + batch_indices: torch.Tensor, + intermediate_conv_state: torch.Tensor = None, + intermediate_ssm_state: torch.Tensor = None, + ) -> torch.Tensor: + """Run the single-token-per-request decode kernels. + + Args: + zxBCdt: `[decode_req_count, seq_len, proj_dim]` projected decode tokens, + where `seq_len = 1 + num_speculative_tokens`. + conv_state: `[num_slots, conv_channels, d_conv]` conv state cache. + ssm_state: `[num_slots, *ssm_shape]` SSM state cache. + batch_indices: `[decode_req_count]` slot index per decode request + (`-1` marks padding slots). + intermediate_conv_state: Optional buffer for storing conv states at + intermediate sequence steps (speculative decoding). + intermediate_ssm_state: Optional buffer for storing SSM states at + intermediate sequence steps (speculative decoding). + + Returns `[decode_req_count, seq_len, d_inner]`; updates state in place. + Variants that do not yet support speculative decoding should assert + `seq_len == 1` inside their implementation. + """ + raise NotImplementedError + + def ssm_prefill( + self, + zxBCdt: torch.Tensor, + conv_state: torch.Tensor, + ssm_state: torch.Tensor, + context: DynamicInferenceContext, + ) -> torch.Tensor: + """Run the variable-length prefill kernels for all prefill requests. + + The implementation reads its varlen metadata (`cu_seqlens`, + `batch_indices_prefill`, `seq_idx`, chunk boundaries, intermediate + extraction buffers, etc.) directly from `context.mamba_metadata` and + `context.mamba_slot_allocator` and processes every prefill request in + one varlen call, writing the resulting final states back into the caches. + + Returns `[prefill_token_count, 1, d_inner]`; updates state in place. + """ + raise NotImplementedError + + # ------------------------------------------------------------------ + # Shared orchestration. + # ------------------------------------------------------------------ + def ssm_dynamic_inference( + self, hidden_states: torch.Tensor, context: DynamicInferenceContext + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Execute one dynamic inference step for a linear-attention mixer. + + Separates decode and prefill requests, runs them through the + variant-specific kernels independently, and merges the results back + into packed token order. + """ + # Grab standard states. + conv_state, ssm_state = context.mamba_states_cache(self.layer_number - self.pp_layer_offset) + + # Fetch intermediate state buffers for speculative decoding. + # These are pre-allocated output buffers; existing data is overwritten. + int_conv_state = None + int_ssm_state = None + if context.num_speculative_tokens > 0: + int_conv_state, int_ssm_state = context.mamba_states_cache( + self.layer_number - self.pp_layer_offset, intermediate=True + ) + + padded_dims = context.padded_batch_dimensions + token_count = padded_dims.token_count + decode_req_count = padded_dims.decode_req_count + prefill_req_count = padded_dims.prefill_req_count + + # Input projection over the full packed batch. + zxBCdt, _ = self.in_proj(hidden_states) + + y_decode = None + y_prefill = None + + # --- Decode partition (placed first in the packed batch) --------- + if decode_req_count > 0: + seq_len = 1 + context.num_speculative_tokens + decode_token_count = decode_req_count * seq_len + zxBCdt_decode = zxBCdt[:decode_token_count] if prefill_req_count > 0 else zxBCdt + # Reshape from [N*S, 1, d] to [N, S, d] for the decode kernels. + zxBCdt_decode = zxBCdt_decode.squeeze(1).view(decode_req_count, seq_len, -1) + y_decode = self.ssm_decode( + zxBCdt_decode, + conv_state, + ssm_state, + batch_indices=context.mamba_metadata.batch_indices_decode, + intermediate_conv_state=int_conv_state, + intermediate_ssm_state=int_ssm_state, + ) + # Flatten back to [N*S, 1, d] to match the merge logic. + y_decode = y_decode.view(decode_token_count, 1, -1) + + # --- Prefill partition ------------------------------------------- + if prefill_req_count > 0: + if decode_req_count > 0: + # Mixed batch: gather the prefill tokens out of the packed tensor. + zxBCdt_prefill = torch.empty_like(zxBCdt) + tensor_get_slice_after( + zxBCdt, + zxBCdt_prefill, + context.mamba_metadata.device_decode_prefill, + check_bounds=False, + ) + else: + zxBCdt_prefill = zxBCdt + y_prefill = self.ssm_prefill(zxBCdt_prefill, conv_state, ssm_state, context) + + # --- Merge back into packed token order -------------------------- + if y_decode is not None and y_prefill is not None: + y = torch.empty( + [token_count, 1, y_prefill.shape[-1]], + dtype=y_prefill.dtype, + device=y_prefill.device, + ) + tensor_merge( + y_decode, y_prefill, context.mamba_metadata.device_decode_prefill, output_tensor=y + ) + elif y_decode is not None: + y = y_decode + elif y_prefill is not None: + y = y_prefill + else: + raise RuntimeError("Dynamic inference called with 0 decode and 0 prefill requests") + + # Zero padding positions to avoid corrupting quantization amax calculations. + if is_using_quantization_scales(self.config): + y[context.padding_slice] = 0.0 + + return self.out_proj(y) diff --git a/tests/unit_tests/ssm/ops/test_ssm_kernel.py b/tests/unit_tests/ssm/ops/test_ssm_kernel.py index 62c25f43f0f..5067b06028a 100644 --- a/tests/unit_tests/ssm/ops/test_ssm_kernel.py +++ b/tests/unit_tests/ssm/ops/test_ssm_kernel.py @@ -119,17 +119,18 @@ def setUp(self): self.mixer.D = nn.Parameter(torch.ones(self.nheads, device=self.device)) # Bind methods - self.mixer._ssm_prefill = MambaMixer._ssm_prefill.__get__(self.mixer, MambaMixer) - self.mixer._ssm_decode = MambaMixer._ssm_decode.__get__(self.mixer, MambaMixer) + self.mixer.ssm_prefill = MambaMixer.ssm_prefill.__get__(self.mixer, MambaMixer) + self.mixer.ssm_decode = MambaMixer.ssm_decode.__get__(self.mixer, MambaMixer) def test_ssm_prefill_padding_isolation(self): """ Tests that ssm_prefill only updates states for the real request and that padding request states remain untouched. - _ssm_prefill expects inputs pre-stripped to real tokens only - (stripping is done by _dynamic_inference_prefill). This test - passes only the real tokens and verifies that only the active + ssm_prefill reads all varlen metadata from the DynamicInferenceContext + and expects `zxBCdt` pre-stripped to real tokens only (stripping is + done upstream). This test passes only the real tokens, wires the + metadata through a mock context, and verifies that only the active request's state is modified. """ num_requests = 48 @@ -153,15 +154,29 @@ def test_ssm_prefill_padding_isolation(self): num_requests, self.nheads, self.headdim, self.d_state, device=self.device ) - # Run - self.mixer.norm = MagicMock(side_effect=lambda x, z: x * z) - output = self.mixer._ssm_prefill( - zxBCdt=zxBCdt, - conv_state=conv_state, - ssm_state=ssm_state, + # Mock the dynamic inference context. Leaving the chunk metadata (and + # extraction buffers) unset exercises the non-precomputed fallback path, + # which rebuilds chunk boundaries from cu_seqlens; no slot allocator means + # intermediate-state extraction (prefix caching) is disabled. + mamba_metadata = SimpleNamespace( seq_idx=seq_idx, cu_seqlens=cu_seqlens, - batch_indices=batch_indices, + batch_indices_prefill=batch_indices, + intermediate_chunk_indices=None, + intermediate_abs_positions=None, + intermediate_real_count=None, + cu_chunk_seqlens=None, + last_chunk_indices=None, + seq_idx_for_varlen=None, + conv_seq_idx=None, + conv_seq_start=None, + ) + context = SimpleNamespace(mamba_metadata=mamba_metadata, mamba_slot_allocator=None) + + # Run + self.mixer.norm = MagicMock(side_effect=lambda x, z: x * z) + output = self.mixer.ssm_prefill( + zxBCdt=zxBCdt, conv_state=conv_state, ssm_state=ssm_state, context=context ) # Output should have real_seq_len tokens From 6518b75ecb93ad27f8c3e4d8512860faae7e7bb2 Mon Sep 17 00:00:00 2001 From: Ahmad Kiswani Date: Sun, 9 Aug 2026 23:41:59 +0300 Subject: [PATCH 232/290] Add PyTorch DCP checkpoint save/load for MFSDP v2 (#6024) Signed-off-by: Ahmad Kiswani Signed-off-by: Jingyue Wu Co-authored-by: Jingyue Wu --- .../megatron_fsdp/experimental/__init__.py | 3 + .../megatron_fsdp/experimental/checkpoint.py | 142 ++++++++++++ .../distributed/mfsdp_v2/test_checkpoint.py | 202 ++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py create mode 100644 tests/unit_tests/distributed/mfsdp_v2/test_checkpoint.py diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py index 7c6dc8ef5ae..2a7601224f0 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/__init__.py @@ -14,6 +14,7 @@ """Experimental Megatron-FSDP implementation.""" +from .checkpoint import load_checkpoint, save_checkpoint from .dbuffer import DBuffer from .fully_shard import fully_shard, fully_shard_context, microbatch from .optimizer import fully_shard_optimizer @@ -29,5 +30,7 @@ "fully_shard", "fully_shard_context", "fully_shard_optimizer", + "load_checkpoint", "microbatch", + "save_checkpoint", ] diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py new file mode 100644 index 00000000000..c9dc44b04db --- /dev/null +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026, 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. + +"""PyTorch Distributed Checkpoint (DCP) save/load for the experimental Megatron-FSDP path. + +After :func:`fully_shard`, a module's parameters rest as ``DTensor`` views over the optimizer +(``main_weight``) buffers, and the optimizer's ``exp_avg``/``exp_avg_sq`` states are ``DTensor`` s +on the same device mesh. The standard DCP state-dict helpers +(:func:`torch.distributed.checkpoint.state_dict.get_model_state_dict` / +:func:`~torch.distributed.checkpoint.state_dict.get_optimizer_state_dict`) expose those as FQN-keyed +DTensors and initialize the (empty) optimizer state on load, so we do not reimplement that here. + +The one Megatron-FSDP-specific step is :func:`preprocess_state_dict_for_uneven_dtensor`. A +``FsdpParameterGroup`` packs several parameters into one flat buffer with least-common-multiple row +padding, so a parameter's per-rank shard does not tile like torch's canonical ``Shard(0)`` (a rank +may own several rows of one parameter and none of the next). The helper attaches each DTensor's true +per-shard chunk offsets so DCP writes and reshards it correctly; without it the default planner +assumes canonical ``Shard(0)`` offsets and silently corrupts the checkpoint. +""" + +import os + +import torch +import torch.distributed.checkpoint as dcp +from torch.distributed.checkpoint.state_dict import ( + get_model_state_dict, + get_optimizer_state_dict, + set_model_state_dict, + set_optimizer_state_dict, +) + +from ..uneven_dtensor import preprocess_state_dict_for_uneven_dtensor +from .module import FsdpModule + +__all__ = ["save_checkpoint", "load_checkpoint"] + + +def _sync_model_weight_from_main_weight(model: torch.nn.Module) -> None: + """Refresh every FSDP group's compute weights from its (loaded) main weights. + + A load writes into the ``main_weight``-backed sharded DTensors. When mixed precision keeps a + separate lower-precision compute buffer, that buffer is stale until the next forward pre-hook + would resync it; doing it here makes the post-load state deterministic. It is a no-op when the + compute buffer aliases the main buffer. + + Args: + model: Root module (or any module tree) containing ``FsdpModule`` instances. + """ + for module in model.modules(): + if isinstance(module, FsdpModule): + for parameter_group in module.parameter_groups: + parameter_group.sync_model_weight_from_main_weight() + + +def _init_optimizer_state(optimizer: torch.optim.Optimizer) -> None: + """Allocate optimizer state so a DCP load has DTensors to fill. + + :func:`get_optimizer_state_dict` initializes empty optimizer state via torch's + ``_init_optim_state``, but that assigns a parameter-dtype gradient. A Megatron-FSDP sharded + parameter advertises the FSDP gradient dtype through ``grad_dtype``, which differs from the + (main-weight) parameter dtype under mixed precision, and rejects a mismatched gradient. So + initialize the state here with a ``grad_dtype``-matched zero gradient; the subsequent load + overwrites it. This is a no-op once the state exists (for example after a training step). + + TODO: this function becomes unnecessary once torch's ``_init_optim_state`` honors a parameter's + ``grad_dtype`` when it allocates the placeholder gradient (``torch.zeros_like(param)`` in + ``torch/distributed/checkpoint/state_dict.py``); an upstream issue is being filed. + """ + if optimizer.state: + return + for group in optimizer.param_groups: + for param in group["params"]: + if param.grad is None: + grad_dtype = getattr(param, "grad_dtype", None) or param.dtype + param.grad = torch.zeros_like(param, dtype=grad_dtype) + optimizer.step() + optimizer.zero_grad() + + +def save_checkpoint( + model: torch.nn.Module, optimizer: torch.optim.Optimizer, checkpoint_dir: str | os.PathLike +) -> None: + """Save a ``fully_shard``-wrapped model and its optimizer as a DCP checkpoint. + + Args: + model: A module tree that has been sharded with :func:`fully_shard`. + optimizer: Optimizer stepping the sharded parameters. + checkpoint_dir: Destination directory for the DCP checkpoint. + """ + model_state_dict = get_model_state_dict(model) + optimizer_state_dict = get_optimizer_state_dict(model, optimizer) + preprocess_state_dict_for_uneven_dtensor(model_state_dict) + preprocess_state_dict_for_uneven_dtensor(optimizer_state_dict) + dcp.save( + {"model": model_state_dict, "optimizer": optimizer_state_dict}, checkpoint_id=checkpoint_dir + ) + + +def load_checkpoint( + model: torch.nn.Module, + optimizer: torch.optim.Optimizer, + checkpoint_dir: str | os.PathLike, + *, + sync_model_weights: bool = True, +) -> None: + """Load a DCP checkpoint into a ``fully_shard``-wrapped model and its optimizer. + + The model and optimizer must already be sharded with the same layout used at save time (the same + module structure and mesh); DCP reshards the on-disk data to this rank's shards. + :func:`~torch.distributed.checkpoint.state_dict.get_optimizer_state_dict` initializes the + (empty) optimizer state so DCP has DTensors to load into in place, and the ``set_*`` helpers + reinstall the loaded state. + + Args: + model: A module tree sharded with :func:`fully_shard`, whose weights receive the load. + optimizer: Optimizer whose state receives the load. + checkpoint_dir: Source directory of the DCP checkpoint. + sync_model_weights: Refresh compute weights from the loaded main weights afterwards. + """ + _init_optimizer_state(optimizer) + model_state_dict = get_model_state_dict(model) + optimizer_state_dict = get_optimizer_state_dict(model, optimizer) + preprocess_state_dict_for_uneven_dtensor(model_state_dict) + preprocess_state_dict_for_uneven_dtensor(optimizer_state_dict) + dcp.load( + {"model": model_state_dict, "optimizer": optimizer_state_dict}, checkpoint_id=checkpoint_dir + ) + set_model_state_dict(model, model_state_dict) + set_optimizer_state_dict(model, optimizer, optimizer_state_dict) + if sync_model_weights: + _sync_model_weight_from_main_weight(model) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_checkpoint.py b/tests/unit_tests/distributed/mfsdp_v2/test_checkpoint.py new file mode 100644 index 00000000000..50c96f0ec73 --- /dev/null +++ b/tests/unit_tests/distributed/mfsdp_v2/test_checkpoint.py @@ -0,0 +1,202 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""DCP save/load roundtrip tests for the experimental Megatron-FSDP path.""" + +from pathlib import Path + +import pytest +import torch +from torch import nn +from torch.distributed.checkpoint import FileSystemReader +from torch.distributed.device_mesh import DeviceMesh, init_device_mesh +from torch.distributed.tensor import DTensor + +from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental import ( + Flat, + Placements, + fully_shard, + fully_shard_context, + fully_shard_optimizer, + load_checkpoint, + save_checkpoint, +) +from tests.unit_tests.dist_checkpointing import TempNamedDir + + +class _TinyModel(nn.Module): + """Two shardable Linear modules; each group packs a weight and a bias unevenly.""" + + def __init__(self) -> None: + super().__init__() + self.fc1 = nn.Linear(8, 16) + self.fc2 = nn.Linear(16, 4) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.fc2(torch.relu(self.fc1(x))) + + +def _flat_placements() -> Placements: + return Placements(dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()]) + + +def _build_sharded( + mesh: DeviceMesh, device: torch.device, *, param_dtype: torch.dtype, zero_init: bool +) -> tuple[nn.Module, torch.optim.Optimizer]: + model = _TinyModel().to(device=device, dtype=param_dtype) + if zero_init: + # Zero the destination weights so they are obviously different from the saved (trained) + # source; a correct load must overwrite them. + for parameter in model.parameters(): + nn.init.zeros_(parameter) + with fully_shard_context(device=device): + fully_shard(model.fc1, mesh=mesh, placements=_flat_placements()) + fully_shard(model.fc2, mesh=mesh, placements=_flat_placements()) + optimizer = torch.optim.Adam(model.parameters(), lr=0.02) + # main_weight is fp32 by default, so a bf16 model feeds the fp32 optimizer bf16 grads; the + # adapter casts them around each step. + fully_shard_optimizer(optimizer) + return model, optimizer + + +def _train_one_step( + model: nn.Module, + optimizer: torch.optim.Optimizer, + device: torch.device, + *, + param_dtype: torch.dtype, +) -> None: + x = torch.randn(4, 8, device=device, dtype=param_dtype) + target = torch.randn(4, 4, device=device, dtype=param_dtype) + optimizer.zero_grad() + ((model(x) - target) ** 2).mean().backward() + optimizer.step() + + +def _assert_tensors_identical(expected: torch.Tensor, actual: torch.Tensor, what: str) -> None: + """Assert two tensors are bit-identical, checking DTensor global metadata when applicable. + + A checkpoint roundtrip must reproduce the values exactly, so tolerances are zero. For DTensors + it must also reproduce the *global* view: an entry whose global shape or placement changed would + be silently wrong even if this rank's local shard happens to match. + """ + assert type(expected) is type(actual), f"{what}: {type(expected)} became {type(actual)}" + if isinstance(expected, DTensor): + assert ( + expected.shape == actual.shape + ), f"{what}: global shape {expected.shape} != {actual.shape}" + assert expected.placements == actual.placements, f"{what}: placements changed" + assert expected.device_mesh == actual.device_mesh, f"{what}: device mesh changed" + expected, actual = expected.to_local(), actual.to_local() + torch.testing.assert_close(actual, expected, rtol=0, atol=0, msg=f"{what}: value mismatch") + + +def _snapshot_state( + model: nn.Module, optimizer: torch.optim.Optimizer +) -> tuple[dict[str, torch.Tensor], dict[int, dict]]: + """Clone the model weights and optimizer state, keyed as their state dicts are. + + DTensor entries are cloned as DTensors so the comparison can check the global shape and + placements, not just this rank's local shard. + """ + model_snapshot = {key: value.clone() for key, value in model.state_dict().items()} + optimizer_snapshot: dict[int, dict] = {} + for index, state in optimizer.state_dict()["state"].items(): + optimizer_snapshot[index] = { + key: (value.clone() if torch.is_tensor(value) else value) + for key, value in state.items() + } + return model_snapshot, optimizer_snapshot + + +def _assert_model_matches_snapshot( + model: nn.Module, model_snapshot: dict[str, torch.Tensor] +) -> bool: + """Assert the model's weights equal the snapshot. + + Returns: + bool: whether this rank held at least one non-empty local shard. The caller all-gathers + this flag across ranks and asserts that some rank made a real (non-empty) comparison, so + the test cannot pass vacuously when a rank happens to own only empty shards. + """ + current = model.state_dict() + assert model_snapshot.keys() == current.keys() + # Tracks whether this rank owned any real (non-empty) shard data; returned for the caller's + # cross-rank "at least one rank compared something" check. + local_nonempty = False + for key, expected in model_snapshot.items(): + assert isinstance(current[key], DTensor), f"{key} should rest as a DTensor" + _assert_tensors_identical(expected, current[key], f"model[{key}]") + local_nonempty = local_nonempty or expected.to_local().numel() > 0 + return local_nonempty + + +def _assert_optimizer_matches_snapshot( + optimizer: torch.optim.Optimizer, optimizer_snapshot: dict[int, dict] +) -> None: + """Assert the optimizer's state equals the snapshot.""" + current = optimizer.state_dict()["state"] + assert optimizer_snapshot.keys() == current.keys() + for index, expected_state in optimizer_snapshot.items(): + for key, expected in expected_state.items(): + actual = current[index][key] + if torch.is_tensor(expected): + _assert_tensors_identical(expected, actual, f"optim[{index}][{key}]") + else: + assert expected == actual, f"optim[{index}][{key}] scalar mismatch" + + +def _assert_checkpoint_records_global_shapes(checkpoint_dir: Path, model: nn.Module) -> None: + """Assert the saved checkpoint describes every parameter by its full global shape. + + A checkpoint of a sharded model must describe the assembled tensor, not this rank's fragment, + so a reader that reshards differently sees the right geometry. + + Only the global sizes are checked. The per-chunk offsets deliberately are not: dropping the + uneven-DTensor metadata does not make them look wrong -- DCP then records exactly the canonical + even-``Shard(0)`` chunks (two 8-row chunks for a 16-row parameter that the ranks really split + 9/7), which tile the tensor perfectly while the bytes underneath belong to different rows. That + self-consistency is why the corruption is silent, and why the value comparison after the load is + what actually guards it. + """ + metadata = FileSystemReader(checkpoint_dir).read_metadata() + for key, value in model.state_dict().items(): + entry = metadata.state_dict_metadata[f"model.{key}"] + assert tuple(entry.size) == tuple(value.shape), f"model.{key}: saved {entry.size}" + + +@pytest.mark.parametrize("param_dtype", [torch.float32, torch.bfloat16], ids=["fp32", "bf16"]) +def test_checkpoint_roundtrip_flat_dp( + distributed_setup, tmp_path_dist_ckpt: Path, param_dtype: torch.dtype +) -> None: + """Saving then loading a flat-DP sharded model+optimizer restores state bit-exactly. + + The fc1 group packs ``weight (16, 8)`` and ``bias (16,)`` into one flat buffer, so with >=2 + ranks the per-rank shards do not tile like canonical ``Shard(0)`` (e.g. one rank owns no bias + rows), which is what exercises the uneven-DTensor metadata path in :func:`save_checkpoint`. On + a single rank the sharding degenerates to one full shard and this is a plain roundtrip sanity + check. With a bf16 model the optimizer's ``main_weight`` stays fp32, covering the + mixed-precision master-weight path. + """ + device = distributed_setup.device + mesh = init_device_mesh(device.type, (distributed_setup.world_size,)) + + # Source: train one step so weights and optimizer state are non-trivial, then save. + model, optimizer = _build_sharded(mesh, device, param_dtype=param_dtype, zero_init=False) + _train_one_step(model, optimizer, device, param_dtype=param_dtype) + model_snapshot, optimizer_snapshot = _snapshot_state(model, optimizer) + + with TempNamedDir(tmp_path_dist_ckpt / f"ckpt_{param_dtype}", sync=True) as checkpoint_dir: + save_checkpoint(model, optimizer, checkpoint_dir) + _assert_checkpoint_records_global_shapes(checkpoint_dir, model) + + # Destination: zero-initialized, so a correct load is non-trivial. + model, optimizer = _build_sharded(mesh, device, param_dtype=param_dtype, zero_init=True) + load_checkpoint(model, optimizer, checkpoint_dir) + + local_nonempty = _assert_model_matches_snapshot(model, model_snapshot) + _assert_optimizer_matches_snapshot(optimizer, optimizer_snapshot) + + # At least one rank must have held non-empty local shards for the check to be meaningful. + nonempty_flags = [None] * distributed_setup.world_size + torch.distributed.all_gather_object(nonempty_flags, local_nonempty) + assert any(nonempty_flags), "All ranks had empty local shards." From 3656752dec6193f0630dcee0c7581eba1c4eff5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 14:21:49 +0000 Subject: [PATCH 233/290] =?UTF-8?q?chore(beep=20boop=20=F0=9F=A4=96):=20Bu?= =?UTF-8?q?mp=20=20(main)=20(2026-08-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uv.lock | 1643 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 844 insertions(+), 799 deletions(-) diff --git a/uv.lock b/uv.lock index 2853330f83f..9630ea2b0b2 100644 --- a/uv.lock +++ b/uv.lock @@ -76,7 +76,7 @@ wheels = [ [[package]] name = "aiobotocore" -version = "3.8.0" +version = "3.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -87,9 +87,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/a7/bc31b7046c610471f0630819ca5d2a57ac4efa8d47135cb53e43f2785390/aiobotocore-3.8.0.tar.gz", hash = "sha256:80a1eb64ea915f3af3c1518669975bae74a17b2f37c14eb0fa2f83b915974670", size = 131368, upload-time = "2026-07-17T03:10:30.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/c0/18abcb7e4e504a68714c280853fd180afe376a4a55e5511fb04ba76702e4/aiobotocore-3.9.0.tar.gz", hash = "sha256:5d344e97c518b010bea167c7f7ba4f9e785f9d2b8ac7af4fd00846c62f2c0a10", size = 514972, upload-time = "2026-08-01T11:54:07.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl", hash = "sha256:8bc605132cadfe844a3f334635a0a64fa5e360a4a206e915d99d53db5b6deeba", size = 91169, upload-time = "2026-07-17T03:10:28.771Z" }, + { url = "https://files.pythonhosted.org/packages/30/c5/6290519dec32f3cdf6827e3bbcbf7a9f4fb29a55a9204199901fed69957b/aiobotocore-3.9.0-py3-none-any.whl", hash = "sha256:7354659eac9ba6034675b3ea178330b7de97c45989d6fda1bf01d3da167b6135", size = 100764, upload-time = "2026-08-01T11:54:06.128Z" }, ] [[package]] @@ -112,7 +112,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -124,90 +124,90 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -264,20 +264,20 @@ wheels = [ [[package]] name = "annotated-doc" -version = "0.0.4" +version = "0.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -498,16 +498,16 @@ wheels = [ [[package]] name = "botocore" -version = "1.43.46" +version = "1.43.56" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117, upload-time = "2026-07-24T19:31:38.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773, upload-time = "2026-07-24T19:31:34.758Z" }, ] [[package]] @@ -521,11 +521,11 @@ wheels = [ [[package]] name = "bracex" -version = "3.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/f5/4473ad9b48cd0420a2d762a3750fa0e078e23e060b1af72662e5987e5530/bracex-3.0.tar.gz", hash = "sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111", size = 43162, upload-time = "2026-06-30T00:43:35.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/2e/68781b78e764e5ccc4af1e3d27e060069c73af90234853fa80000e7ee79d/bracex-3.0-py3-none-any.whl", hash = "sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5", size = 11738, upload-time = "2026-06-30T00:43:34.196Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, ] [[package]] @@ -550,96 +550,96 @@ sdist = { url = "https://files.pythonhosted.org/packages/63/5c/2403b8410122d1594 [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cffi" -version = "2.1.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] @@ -756,121 +756,151 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -920,10 +950,10 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.5.6" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/53/8fc9b0cdc5b7f62746e6a01b85b6461e5ae27f871010a5fcf8fa6950766d/cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0", size = 52972, upload-time = "2026-06-30T00:58:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl", hash = "sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51", size = 54591, upload-time = "2026-07-21T15:03:56.224Z" }, ] [[package]] @@ -963,37 +993,37 @@ wheels = [ [[package]] name = "cython" -version = "3.2.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6b/80101e02ebacaf9232ecf32bf6a788d36b27d820ee02434746252569ef98/cython-3.2.8.tar.gz", hash = "sha256:f4f23a56b25221a06f91817fe8f3114ab8b48a4fac73187dbb64bc2c4a87961f", size = 3290300, upload-time = "2026-06-30T07:41:57.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c4/47e0bcfc15b36b1c5cbde5235c60bf88df552ab216ddb836d7f816386ae6/cython-3.2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2547a31fbd3b1610a8859a16edee2a141f7781691cb98a2c6fd54870c5f7541", size = 2995546, upload-time = "2026-06-30T07:42:23.618Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f4/bc5830abeb57a7c7498cd9a0f2df953fd9fc7f33e3f5352c9824802b83bb/cython-3.2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab1fe11ebd61e497a848622cdd157a4324ac06e7935b1219715408844e15dd13", size = 3179546, upload-time = "2026-06-30T07:42:25.566Z" }, - { url = "https://files.pythonhosted.org/packages/89/38/a70e879ea52debac11d2810e066a5a2cb16e71229edae303f024506bc142/cython-3.2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bce1f079734753649f8a3d3a95832297207feb120d0ef4fd5db4c813cc6c04", size = 3351714, upload-time = "2026-06-30T07:42:27.513Z" }, - { url = "https://files.pythonhosted.org/packages/45/f1/f071c5e7050a7924ffad9822558c74d489afe4764f7486cb68555a509219/cython-3.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:8297efe129e6421c34ddbeb09ed5627ef6c0fc4868bf7f9bdf6c147f595ccaed", size = 2774196, upload-time = "2026-06-30T07:42:29.47Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/9487462239ccd47feb70fc023b2f416cee6cf30fe79d15f6a1eda59ea107/cython-3.2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d78205f525287de94effa2f2fab6b9edafdab44ef8c58ecf52fcb1013225d68", size = 2984097, upload-time = "2026-06-30T07:42:31.335Z" }, - { url = "https://files.pythonhosted.org/packages/68/68/4305333cd2a27fcf4769c79941772f067007671e90caeb5e7af33db45387/cython-3.2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:474d9c5378032c0237b70c7af4d2122eb00719fec2cab296ddc9cf2907d55d58", size = 3181866, upload-time = "2026-06-30T07:42:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/ff/3f/ce32b14ee64c5f5923fcd207a82b86c2531fae1dc3a964a32cfd7bc72ecf/cython-3.2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2221be06b6aa92486d7c3644c18cb3643b3e794600f0d6c8555a523e959a069", size = 3355829, upload-time = "2026-06-30T07:42:35.394Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a5/2510e88149abb9966f35b0e775c2549a0a71b39550457f9de6f1b239869e/cython-3.2.8-cp313-cp313-win_amd64.whl", hash = "sha256:0961ba91f59492d1bef974cc6b45993c743162776188bcb79e029b442d5d4fcc", size = 2770884, upload-time = "2026-06-30T07:42:37.315Z" }, - { url = "https://files.pythonhosted.org/packages/ab/46/b491e2eebf00864421fe7b4c2c7d3c2fdd12d16ef8b76c42abd4e571d86b/cython-3.2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c7f4143d0f9fb7b26444e00ebf7c8845f585d56f37b73bd3fb3dac269af8732", size = 3012195, upload-time = "2026-06-30T07:42:39.299Z" }, - { url = "https://files.pythonhosted.org/packages/60/c7/c98115431c92007606efed4220a69ff02ae5a3cbbfc82605ffb6eb85a56b/cython-3.2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f788a2fec204bdd9291d5d542a4856db101dba59f8d83789d3f1eb2895971a0", size = 3227582, upload-time = "2026-06-30T07:42:41.252Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3c/e8675aeb18bf9029d861473fe49f9d612bd738faa3942928c8cba7a40239/cython-3.2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c3a33c2ff250d0277d0ee8b080ba0a0391a00e48af4a96fb09dbebbd1ac4b0e", size = 3368421, upload-time = "2026-06-30T07:42:43.198Z" }, - { url = "https://files.pythonhosted.org/packages/27/f7/4edbf4ca4832e2c5ba5f8a09ab91f347a591d9b00dbf8eec3edce95c7c3f/cython-3.2.8-cp314-cp314-win_amd64.whl", hash = "sha256:89b0fdc2ca0b502afedc4dd4ddbc4f9cb5a135245afacf9483e556e8ad3ada3b", size = 2807626, upload-time = "2026-06-30T07:42:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/92/a2/0f2eaa5076bcaef52567471a54ce02ffd70007bf8688cd054f7aab9bc3b8/cython-3.2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:127bc4039be48c6eebe7f1d68c33d23eb3a0c5ae95e1d730bdd048837751438b", size = 2892527, upload-time = "2026-06-30T07:42:55.45Z" }, - { url = "https://files.pythonhosted.org/packages/a4/08/b5488aef44662e48ac09b42d4cb398207f591c770797036fb1d6fbeb7a52/cython-3.2.8-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e480ae9f195cd29e5ce334e3d434c83dbac0783c0cc88f2407e31ba997724192", size = 3220335, upload-time = "2026-06-30T07:42:57.403Z" }, - { url = "https://files.pythonhosted.org/packages/7c/96/d04a3621045e9fe9c7c5e406a688ee3d6e04a65f545ea7c622ead4b4afd8/cython-3.2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3142407b9d63f233c766e17000e2aac782411bf0409b9adc97cb7c320aecd199", size = 2876481, upload-time = "2026-06-30T07:42:59.406Z" }, - { url = "https://files.pythonhosted.org/packages/71/9a/daa259b638c5eabb8a8c36f203b85b01b5362101ff8fc4ec6ad592d34bb3/cython-3.2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41c118fd91d320cd72af26e29232ff3f1a0a170c47d477c9a176d766067a4718", size = 2999974, upload-time = "2026-06-30T07:43:01.29Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/71cc5b4be5ee4d34c3302b6e7272189106a4072e9890d284d05239d2b645/cython-3.2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6335c6e8737a39734e20d25f89f425bf3274c104fc7efc05aacc7ed1a4858c9d", size = 2897932, upload-time = "2026-06-30T07:43:03.606Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0c/da68b9d3056e90b2060970b50d575cd7fcd1c778e8f23cc467f346e1d471/cython-3.2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1034facc082cb882e1e5beb61e136ae8e282df2eafa11e9771e9b0a15c860801", size = 3235980, upload-time = "2026-06-30T07:43:05.486Z" }, - { url = "https://files.pythonhosted.org/packages/36/0b/d88bc50e66fd1f1160dd2677d9af18273a2fb2f102c086d21f64a5a9c78b/cython-3.2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8896ff6b133f346ebcf22aa23706c4031e8d9d5ae184433dfc67dc1053318b69", size = 3118504, upload-time = "2026-06-30T07:43:07.485Z" }, - { url = "https://files.pythonhosted.org/packages/db/de/511c4364808b3b4036d051a0301b0b142a3ddf8a319bfdbbd474fcfdc879/cython-3.2.8-cp39-abi3-win32.whl", hash = "sha256:3fd6464433d925cba66ae31bf5780c8a469a06da1d109180cffb39ee3c88ae20", size = 2435866, upload-time = "2026-06-30T07:43:09.367Z" }, - { url = "https://files.pythonhosted.org/packages/18/4f/911b2b2a0a02be15829ccbf0c906029a318efbf53d9f8e021e438261c206/cython-3.2.8-cp39-abi3-win_arm64.whl", hash = "sha256:4e9447d9b652396a285cdfbd4f9f0721842c63c6df281720e87dcc4b9ea65af5", size = 2457829, upload-time = "2026-06-30T07:43:11.645Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/31aa63ab719b2e1eea5f200a8a54e2591dc1966a6b75e3de30ef8be9bc2c/cython-3.2.8-py3-none-any.whl", hash = "sha256:f635e113677666de13a2ec2979e9b1d5b90617cdfd1a691d3559be81e2dd6cb9", size = 1258688, upload-time = "2026-06-30T07:41:55.624Z" }, +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/7ffbaab88558e28411715ad35d913cac7d64ee965344c185325da84c4309/cython-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44b7fc417933d65bf31bce00337ff318efa3ea59daed21d10fe842d41e657c08", size = 2998816, upload-time = "2026-07-24T06:22:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/04/69/feed68904f389452494d9e0861c24228c0bda656f9fbd32f9e5b525b2ef4/cython-3.2.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41637c644d7224d2d3170ee312077c2173693f0d4a0da1c82a0cffa3680a42dc", size = 3185802, upload-time = "2026-07-24T06:22:02.831Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0d/a7f094343e5c4925efac60e6dbdcf8602042e7f54c83ae14ccbc8033d2d9/cython-3.2.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f815cd3387bd88ca9eeabc357ce4bdf884eef0ab2949db0fbd5e0b69fe5ee422", size = 3360405, upload-time = "2026-07-24T06:22:04.645Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ba/a5c25226e29cb4f3a2c97d2b29de10e37c8a6b67b3c2f2a8b16ba713b218/cython-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:ddb43637b7749b88644df4319e1c36f767e7fb71a92bb9942558c8de2f1cc5f4", size = 2774714, upload-time = "2026-07-24T06:22:06.953Z" }, + { url = "https://files.pythonhosted.org/packages/3c/69/e5969fa87c7b8833f62fe790e1617a1391112fa55fe12079c41a4553119c/cython-3.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a42e131112072f912b66734088abc946c6bc42cb7454461a6dfdd2d09d3bae", size = 3013040, upload-time = "2026-07-24T06:22:08.9Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/8eaceeec00d4f9cc75e3106397c005952e0d710332c4966215b8a110a522/cython-3.2.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f25b402a6f1eed34af2af57603a9aadcf594dacaced1655e925eb317fba3f337", size = 3231531, upload-time = "2026-07-24T06:22:11.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/a4/1e03a0c115afa9de7b06891721d6489d6a05f78934a6897a0f5a409ee2f5/cython-3.2.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d3cba9c4770cf76370fead8d2ae71bc474f3ef67ae4119e455d025726bcdc1c", size = 3372818, upload-time = "2026-07-24T06:22:12.97Z" }, + { url = "https://files.pythonhosted.org/packages/1b/04/b5c4d76723824d84e3761718c51964f7829335ee1c7c16b69f11b111adf0/cython-3.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:56d95c0674c25f281c6ae8f1d17bd425d6c2818bb304ff781831bb5d00d04b0b", size = 2812223, upload-time = "2026-07-24T06:22:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, + { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, ] [[package]] name = "datasets" -version = "5.0.0" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, @@ -1011,9 +1041,9 @@ dependencies = [ { name = "tqdm" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/85/ce4f780c32f7e36d71257f1c27e8ba898ebe379cb54f211f5f2013f2c219/datasets-5.0.0.tar.gz", hash = "sha256:83dbbbdb07a33b82192b8c419deb18739b138ee2ce1a322d55ce6b100954ec1a", size = 631708, upload-time = "2026-06-05T13:18:26.124Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/5b/836516269d4f618efe621661cfb6f9acc57e6f95265db3efaee48a5ffe04/datasets-5.0.1.tar.gz", hash = "sha256:ce22bb851efd7494f08aad33b940803784434f6e77763d00679a0dc45fcf686a", size = 641498, upload-time = "2026-07-28T11:09:12.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/66/73034ad30b59f13439b75e620989dacba4c047256e358ba7c2e9ec98ea22/datasets-5.0.0-py3-none-any.whl", hash = "sha256:7dd34927a0fd7046e98aad5cb9430e699c373238a15befa7b9bf22b991a7fee6", size = 555084, upload-time = "2026-06-05T13:18:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/98fc6eb83333508ca5f44c52b3e287ea8137a0ad582714e2cbc67a02154b/datasets-5.0.1-py3-none-any.whl", hash = "sha256:9fbf73688f8c18f7529b4fe592abd04015f81d1e58001e4bac73ffb2b39d7cc4", size = 559079, upload-time = "2026-07-28T11:09:10.266Z" }, ] [[package]] @@ -1179,7 +1209,7 @@ dependencies = [ [[package]] name = "fastapi" -version = "0.139.2" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1188,9 +1218,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -1210,11 +1240,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.31.1" +version = "3.32.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/55/1e19b2b56a24a4b94624f7e819e1bb87fa6c5609dbaf621df3aa6568a761/filelock-3.31.1.tar.gz", hash = "sha256:9e0c4e88ebe90833c1beafd3a547ccbc0bf7f491cd3858c3ec7aed63efe02163", size = 196656, upload-time = "2026-07-20T03:14:32.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/61/df1d9db18f188d0ae648956a1decadc0e3b77d0571474370fd01f28a82b1/filelock-3.31.1-py3-none-any.whl", hash = "sha256:9ea33146c780161bf67cb20c7cb26b651566820d65ad8dfdd79422602a2dcfc0", size = 97189, upload-time = "2026-07-20T03:14:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, ] [[package]] @@ -1273,7 +1303,7 @@ source = { git = "https://github.com/deepseek-ai/FlashMLA?rev=nv_dev#b7643bd5452 [[package]] name = "flashinfer-python" -version = "0.6.15" +version = "0.6.16.post3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-tvm-ffi" }, @@ -1293,9 +1323,9 @@ dependencies = [ { name = "torch", marker = "sys_platform == 'never'" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/a5/9d9aa91304fe4c0ee479bd27866dcaa26dc9c9e177d3d24075e34157a5cb/flashinfer_python-0.6.15.tar.gz", hash = "sha256:2a3f1ed47129f9ac9505a26a8f12cadefc0f27d3104fb623ae281032f49eae5f", size = 10286753, upload-time = "2026-07-17T01:22:44.43Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/46/17045cb47b7b93fcb4e5303e398e6b129b86239930ac0875942a83d8b962/flashinfer_python-0.6.16.post3.tar.gz", hash = "sha256:9b146cfcc1454c80f3f99444db48013b3eadaeb32f2a399bd76c9471ecb72f04", size = 11076656, upload-time = "2026-08-08T01:14:16.408Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/a6/d3a1bf32b97f6c2c32aedb0b49fcd45259d9fb8d1131e86327b5672dd1b0/flashinfer_python-0.6.15-py3-none-any.whl", hash = "sha256:da6c339e14db4831ade0d593324f02907d44bd4b86b640faad4727d9e089477b", size = 14949949, upload-time = "2026-07-17T01:22:41.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dc/5367cb601fc9190cc75b4c141f5f7e9a224d1c26a1e983151823e02a25b3/flashinfer_python-0.6.16.post3-py3-none-any.whl", hash = "sha256:caf686b9b079abe1c9d65ab505698bd325e8072de40afd822f2c74f2ac3bc601", size = 15836034, upload-time = "2026-08-08T01:14:13.709Z" }, ] [[package]] @@ -1421,11 +1451,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.4.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] [package.optional-dependencies] @@ -1435,7 +1465,7 @@ http = [ [[package]] name = "google-api-core" -version = "2.32.0" +version = "2.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -1444,34 +1474,34 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/33/00277be1305fd68355d08197f05e22db259c0cff49a10c8590a1869ade9b/google_api_core-2.32.0.tar.gz", hash = "sha256:2b33aad226b19272458c46abfe5c5a38d9531ece0c44502129a1463ce83674ac", size = 177659, upload-time = "2026-07-16T20:36:07.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7c/9be3903e3d45415e8ca493c75f8990a0f6f579d168015d44c379350d0ab0/google_api_core-2.34.0.tar.gz", hash = "sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59", size = 187953, upload-time = "2026-08-06T06:23:58.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/44/5018c5ac1526c98169db98d87a6ff7d5508f5246621c3ee1a046fdd5e0a6/google_api_core-2.32.0-py3-none-any.whl", hash = "sha256:ae1f0d58a6c8869350bf469f8eb3092e7f8c494a942d9525494afb6c162b0904", size = 174198, upload-time = "2026-07-16T20:35:41.865Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c1/a8a92ae1bc4b1a8f804c776d7d3f0c771b78a62c3ad4df1be41b3fd8c767/google_api_core-2.34.0-py3-none-any.whl", hash = "sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de", size = 180545, upload-time = "2026-08-06T06:22:47.502Z" }, ] [[package]] name = "google-auth" -version = "2.56.0" +version = "2.56.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/66/b4ba60005743e01933e22b4f62313e063f7460458b7d8a358427b4930013/google_auth-2.56.0.tar.gz", hash = "sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553", size = 364629, upload-time = "2026-07-13T19:09:57.143Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/4c/fa42116a48bab3f7a143cf5042ecff7df9c8b73f8a376203cd534d1dc966/google_auth-2.56.3.tar.gz", hash = "sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c", size = 367110, upload-time = "2026-08-06T06:24:01.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/7d/cd3e187f14ce832e419e70709bfcc40cb0dc11517d5d03c9d3919bcc3101/google_auth-2.56.0-py3-none-any.whl", hash = "sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0", size = 257976, upload-time = "2026-07-13T19:09:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b3/6117b2f24065cd7e2c4f140e9a193e215f089ca8ba314cf91eb9d0b7fe0a/google_auth-2.56.3-py3-none-any.whl", hash = "sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53", size = 259116, upload-time = "2026-08-06T06:22:51.788Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.75.0" +version = "1.75.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, ] [[package]] @@ -1485,86 +1515,86 @@ wheels = [ [[package]] name = "grpcio" -version = "1.82.1" +version = "1.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, - { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, - { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, - { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, - { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, - { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, - { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, - { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, - { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, - { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, - { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, - { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, - { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, ] [[package]] name = "grpcio-tools" -version = "1.82.1" +version = "1.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/af008a0df6f9ec85ae136f763aed207e68097c952a17443d2c2af9d60a91/grpcio_tools-1.82.1.tar.gz", hash = "sha256:2bd3176ccdbf7cd1f463eb75b7b83544c7d6429f5ca8a0f7f784b76097dac891", size = 6399590, upload-time = "2026-07-08T12:38:15.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/b8/70021ba4ea39ed54f175ae79ac9c71b3104ba965418b416e85e18b661d3a/grpcio_tools-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:1b1ae735ad45f8a01715b0106020803330a68b20b17dcdf51e8b7266af44a9ac", size = 2653283, upload-time = "2026-07-08T12:37:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c5/add9b6f3780aaee6c1463e1295494219fbe849119f7a7eb4968bc677a50d/grpcio_tools-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:e6ce264293507e0a0f2facba230646fd185c18cee14a41bab26bca54b29d6a39", size = 5965914, upload-time = "2026-07-08T12:37:07.985Z" }, - { url = "https://files.pythonhosted.org/packages/1c/76/8849d262571edc9343ff5c2186c8afc61e960ad2b67d9ddc154e02953acf/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75856fb0ba6a574e62b02473b10cb2479356b5db07c39ef8209395105608dc5e", size = 2705355, upload-time = "2026-07-08T12:37:10.279Z" }, - { url = "https://files.pythonhosted.org/packages/69/1f/fc34c4af2464584b31110a8b81e48debb28b0204bbdc6bd5fd625d710c23/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1d7d1f0d0c1fea8bbd6002f7a4ad1eb034b9114f4cf64d6a5d6aaa587725ae02", size = 3033411, upload-time = "2026-07-08T12:37:12.414Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/e42981d84b2e7be1563c6d4fc012330ab6cb42c1f053b8ed81e3e9e5254c/grpcio_tools-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b926e4ba0afb0a69954ef5ffb039b593676edb16d8c750476e65b1de89b535a6", size = 2774501, upload-time = "2026-07-08T12:37:14.401Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f9/1e99a12feb9599077b591ae9814daf85a97ff28fcd57571f08d42c5efff9/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91bd88cf4bd6129620a0a27d051cb1c7346e3da498251c18d9df3061c852de3b", size = 3230020, upload-time = "2026-07-08T12:37:16.528Z" }, - { url = "https://files.pythonhosted.org/packages/b7/88/b82a5eebdf98208256326dec9ef752a3b52e22c8e8ed722cb96e81c0d520/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0972d57773ab2861d39df5e6d3d9e1a1008d53e74f8a5f84dd2932dd907e0ac1", size = 3803155, upload-time = "2026-07-08T12:37:18.706Z" }, - { url = "https://files.pythonhosted.org/packages/78/a5/ce7c35e47ed87a46a66c76c104c11204d8492600fd8411260cac5d9f6253/grpcio_tools-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfd5e337fa40885b82c782968a0d67325a5b769c4e2542cc6fa48133bf6dc97f", size = 3461816, upload-time = "2026-07-08T12:37:20.719Z" }, - { url = "https://files.pythonhosted.org/packages/e5/56/b7fae69b9a9b68df4bdaaaa7ec2e836ba29f811eb2c295bd0014933fd719/grpcio_tools-1.82.1-cp312-cp312-win32.whl", hash = "sha256:518f58639014bf1bcecd9055dc63b6f33d70fd8e7621a15ce7c7d628545b4199", size = 1022473, upload-time = "2026-07-08T12:37:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/b9/81/40c863fa3e84f818dae2f6a58c02b7ef81807f65714f5cefdea3596edf2e/grpcio_tools-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:f28239d935da567af046957b245eab4b1c5f694369a00f0ef2a0a90a63a8ea66", size = 1192176, upload-time = "2026-07-08T12:37:24.352Z" }, - { url = "https://files.pythonhosted.org/packages/53/08/934dd729d3046e4ffd40ff897c26b7391b7b73c21b171c4c52edadc2f933/grpcio_tools-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:fe2e289a95ff818da6e0548ba3d9e24433895b535e1e837ed46900624b1e91c7", size = 2652843, upload-time = "2026-07-08T12:37:26.726Z" }, - { url = "https://files.pythonhosted.org/packages/09/2b/1f4a160a486ac9ed3c6b35a04ab9c48ec9b31141c1c0ff7c27373c0a67ea/grpcio_tools-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:216a476aa5444e66007e53ba0a4c7128f9ad01867ec1ffa788b7c72984a546c3", size = 5963549, upload-time = "2026-07-08T12:37:29.165Z" }, - { url = "https://files.pythonhosted.org/packages/74/2b/8a2675dcb2be98b7cacd09367637063c295aa6008c83c962def12cf47f44/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:92d59cc6232859c760646bb353efd83677e931c171d58eaeaa9451ce41706b58", size = 2705081, upload-time = "2026-07-08T12:37:31.235Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1c/ac011ab4110a2bb37e5af9d6d911183d3cec3fdc178f20477c0582b94d04/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:67e2896338b4299c1363d91856f55349fb9a247d7ec420b7ca021ca1362fb7c6", size = 3033064, upload-time = "2026-07-08T12:37:33.652Z" }, - { url = "https://files.pythonhosted.org/packages/3a/7c/b36f97d0457af255ef5b6ef924b7aa6328b706218e312503b4fbe7056e4b/grpcio_tools-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:452b4880b7f5ca2bbb6fd26e76ac0e10579afa51e589b45ad7562b034f954642", size = 2773651, upload-time = "2026-07-08T12:37:35.789Z" }, - { url = "https://files.pythonhosted.org/packages/8a/23/d084183effc6e4086fc78d318e510a19bbc3d21d85a9b4eb3236f131618e/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57b35422bec9f7b0eda98cfe057b272102d7662884116d335e8fa94fe446bead", size = 3229769, upload-time = "2026-07-08T12:37:37.99Z" }, - { url = "https://files.pythonhosted.org/packages/fd/87/f4084327ff4d743e57f58bbc5eedda04885ea4c7749d9ea07a0284c4e338/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8aa752a4ac0620fd2a427b5bccb772c4c6c9bb497834939481f59579835f0a68", size = 3802527, upload-time = "2026-07-08T12:37:40.665Z" }, - { url = "https://files.pythonhosted.org/packages/65/44/4106351449cfe140d6af39668743eb059c525b1b5dfb37cd4376767bd2a7/grpcio_tools-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:89f9cac1a313c4e72cf83be7e7413f0a34b6c2f0b4e6d8a56288b0bbf4f213ea", size = 3461032, upload-time = "2026-07-08T12:37:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/34/87/60b3be7084be622edff5781d6b82fd24d4bc00f53fe27350824107b3d637/grpcio_tools-1.82.1-cp313-cp313-win32.whl", hash = "sha256:8aa2079a166ef51cecbdfa677ddbfca9d71eb0fcbb3e61dd74c61eb52723d1e9", size = 1022125, upload-time = "2026-07-08T12:37:45.648Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6c/d2460754ab3031d82f6dfd5aea0fdf95ba1004fb56a9a302115da8c4b7ea/grpcio_tools-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:4c00edd39d65b4eafc499b934fb7198788750663d7516718a022d0dd80f4d85c", size = 1191848, upload-time = "2026-07-08T12:37:47.795Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8c/5c2130941fd30d59326fab4c2fe8f8e1c954ebf864c9d2a14d767cc07333/grpcio_tools-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:e6499e7009c38e23f4c9ffc64efa46d3a1ac0c3b01b256e77b9816f7078eb4db", size = 2652840, upload-time = "2026-07-08T12:37:50.264Z" }, - { url = "https://files.pythonhosted.org/packages/33/37/9447cada0b29e3423c38905fcc552ccdaddecac96e6a4ab2ba330e7508c9/grpcio_tools-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:35d79c00a4da740abbbf7fbf1f34151fdca9917885a4a4235d426438a7973aed", size = 5963503, upload-time = "2026-07-08T12:37:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/ce/55/4d4ec2e1064abd14264c3beefba9b46f4c13efad85ad0f1f87eb40ddb2a6/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddd9ddbf43d1a4c472874bed2c491a89be9bf36812a56ed8892f609aebd0a844", size = 2705216, upload-time = "2026-07-08T12:37:55.705Z" }, - { url = "https://files.pythonhosted.org/packages/a8/36/ebd5334dccfe8411487c2feac639bdc275ec263bcd3ec5b620d715ce90ea/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4fa16c11e1c9ad3f35f4545445e217a725359ed235db8ff42c3b40b131ec48b2", size = 3033046, upload-time = "2026-07-08T12:37:57.982Z" }, - { url = "https://files.pythonhosted.org/packages/48/06/69255a28fcb9264db954e8ca6a0eefd692f6efc2a9fef1f0920a42267070/grpcio_tools-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01c4c5333908b2050a14461f5d71170c948628987322b4526e840159b94ffabe", size = 2773832, upload-time = "2026-07-08T12:38:00.428Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/d7895783de780071f90c7ffb36e534fa1468ef2c5a039ee8c2d89478b1b0/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:75eff2a53ec1f00968d7e018de35b997134a4381c5b769713664b2625aac4035", size = 3229939, upload-time = "2026-07-08T12:38:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/86/3f/2a74d4c6396e62332c1d244077775b540e1ad15376cc831f66589f2e0fc3/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bd0352f3b0341afb911c0b9b177b95811e71476b64004b0d38131a4543d42592", size = 3802594, upload-time = "2026-07-08T12:38:05.51Z" }, - { url = "https://files.pythonhosted.org/packages/3b/69/b559cbea6202bca95cec033c4181413e4417759c90480a10cbc7250d4c63/grpcio_tools-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0a838ae62bfd71ea8cdcd28e3a017206572eb185846e18114c82f8e2c9fb98b2", size = 3461304, upload-time = "2026-07-08T12:38:08.059Z" }, - { url = "https://files.pythonhosted.org/packages/33/32/9e4bdb2e6c62b66e70e5e8d4ec7e542caed57976d7a0b2ef65763901d0ca/grpcio_tools-1.82.1-cp314-cp314-win32.whl", hash = "sha256:335393c9f8d3c0fa6c1b3d168002beabc0cd2d6974d409d216b9d9ebe5b33a5a", size = 1045038, upload-time = "2026-07-08T12:38:10.193Z" }, - { url = "https://files.pythonhosted.org/packages/f3/5b/bea2551e5d79f7486bad32163ea6d6655bd1e8d663cb928f100b901f0dd6/grpcio_tools-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:15c067844adca93ed4661bdfd9b176618ef6b7fd83fb381f2746bd8a1e9f6d98", size = 1224194, upload-time = "2026-07-08T12:38:12.439Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c1/b1/50b17b3a2ba8970dbb00b2035a4df218bc6ec88d2d77fc7da4e42e1c7b19/grpcio_tools-1.83.0.tar.gz", hash = "sha256:515907265d14fa9975d0c7723f95a9da01463d7ac607546a03f8741f86a1bb07", size = 6400437, upload-time = "2026-07-23T15:22:18.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/4d/02e4d809d880785a613697e4c0f8134a436ec0142dc3c11989ad1a1c787a/grpcio_tools-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:bd93bbe18c4424805fd2e39854f75d76f80655175621254dc43cb45ec8e91e85", size = 2653283, upload-time = "2026-07-23T15:21:22.973Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/23e8423ac54c3858a5cfbca8a954fa292cab7b8a9a1ee9dc3259b906b763/grpcio_tools-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dc2d370563ee1ee6c1769e49df35ef3f6e75cea8f25acf4ac6e54b335e6f788f", size = 5965938, upload-time = "2026-07-23T15:21:24.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a1/6eb17cf322bfbb76a9b9a8a5ca4a6b27f0af84821145969fc464feedaa0e/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8350e236470700b02bc4ba7f27a8796559630170e6527dda41c0344fbe988e56", size = 2705429, upload-time = "2026-07-23T15:21:26.519Z" }, + { url = "https://files.pythonhosted.org/packages/d4/84/f3c7e5e91e5d40ee792f112260bd329db5381de6f83b21387dd6163ebe51/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b1649f47c4675c1540ad2a77005f4d08392c06202686a0b1bb6b894f96cb75fe", size = 3033412, upload-time = "2026-07-23T15:21:28.183Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/acd6c1800925b0d60f8a670b68cf5bc3566aee60e7cb90178b34253bf53a/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4edc6ba9fdca70bbf585ff6ab5971b8cd6140b4b316df66fd91d168bc1b617", size = 2774500, upload-time = "2026-07-23T15:21:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/36/e6/c7c6f4e25b7344f1fbc5df69606bfd4b9692d4e32d17781c665d3ed45e70/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:762a9f8a4a4a39bda02feebed94efb8d778e0e5a82d0c8f786dce5ddcb950c7f", size = 3229875, upload-time = "2026-07-23T15:21:31.617Z" }, + { url = "https://files.pythonhosted.org/packages/77/fc/9cbdc4606f378a9c2b569c0b6b57f181f97787006be4131a5820d469b70a/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c6c469c928a183f1a99ab26e263fe307347ee7023fa623b55bc778846b2f51b9", size = 3803163, upload-time = "2026-07-23T15:21:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0b/3b754886a02ead1487a967fd11ff13e920218fcf6c8e174f6de0c26dd819/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7980b3ca9dd31c42468c5af8cec97037f83715ea6efbe1b936ecb9c6832ac0f5", size = 3461815, upload-time = "2026-07-23T15:21:34.982Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/4629c154853f6f677299cde014e615084c15ad1d4fedd6845d2e7d354c7e/grpcio_tools-1.83.0-cp312-cp312-win32.whl", hash = "sha256:fd2ff46917f566b3b63dae191d1b05ef2188fe51e756ef321cbdd707ab29dbfb", size = 1022490, upload-time = "2026-07-23T15:21:36.696Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/696b9671e32a693c67299724a1f70f81dfb78aca6a3283ea6a65d54e92b8/grpcio_tools-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:92d2343806b5c21162a57fbaad24fcd8d935ef530f95a0f706a4e2546fdc0662", size = 1192286, upload-time = "2026-07-23T15:21:38.446Z" }, + { url = "https://files.pythonhosted.org/packages/63/2f/a7a4465b2a5b74b479373bf44d86da5840d7d20871764a39fb300e55e093/grpcio_tools-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:3277cfbb7cbbd2d72921fbcd7aba6c8ab1c91a9ab27e8045ac0a0f2e0517cec9", size = 2652845, upload-time = "2026-07-23T15:21:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/ed8ebaae3bd0ecd2387b0fdb3a696bd4b4d4565d18589ee3ba7c6affcbed/grpcio_tools-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1aa9617ce9c2bcbfb8f2fa08e6259e1b3cadaab0316e41f71496847af2f0a664", size = 5963575, upload-time = "2026-07-23T15:21:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/75bddae583077f1374c64e2a87b5924bdbffe162b3855af30c30a0fc8c5c/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad37c786ea92825534466052f5f22f1f29983b1d00ca71ad43e256715a86bba3", size = 2705094, upload-time = "2026-07-23T15:21:43.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/7e1b12b1c5afff4f7d578f3c4eafbb1849f8004ddcc236cd3ff95c8be607/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c82216864d435ecf6f535798d03e9f6b9025a672a2e815715d629aba4ba70349", size = 3033061, upload-time = "2026-07-23T15:21:45.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6d/113291a7aad0c47a1e2ba2375595dfc3c2ab648e0ea05ff3d6f0f89055b3/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04284627655629387b63278591e5efd0aded28d3a08432fe8a8765e4daf2d5b2", size = 2773649, upload-time = "2026-07-23T15:21:47.404Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c2/ce1c6c2475ed5cb0f7c6689bde57e3c105714676227da11b423ff37620a9/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fe627a5248d8e712f5ec3e019420050e61b10600ed241264aefb379a0f1b338", size = 3229788, upload-time = "2026-07-23T15:21:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/c5/53/823fd52c29630398706de400ce7003b03e17ce91df024fc53cde810d2758/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1d5a9a9664d2f4bdda000652e7febf9ab4545a6391c8f05babd52ecb27d7e03e", size = 3802531, upload-time = "2026-07-23T15:21:51.37Z" }, + { url = "https://files.pythonhosted.org/packages/30/85/942ee07caf97b75ead416c6ad5f2fb12b16b8bc92fa3d60bbbea4c06e076/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4bf0e421e1ab5f2cd638de44fc903aed3ba4a2fcb19b93c6f528ff0ec63e3a6a", size = 3461032, upload-time = "2026-07-23T15:21:53.343Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/54cb428ea18912fa9541509fbc2e10f9807d96e88cf435f86e63c540ac25/grpcio_tools-1.83.0-cp313-cp313-win32.whl", hash = "sha256:7b1bd6db403b38addded54866187eba6f9ab9afadf72bb8d0515ed13f0b16c5c", size = 1022116, upload-time = "2026-07-23T15:21:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/2369320766091f6daedb924d133037a9f8b84bfb3e4d02d6ccffcd57b0cd/grpcio_tools-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:d654c645af7cf608a30644bffb8d1ef6b14e8846482c3d0131d0dda91f6fb590", size = 1191934, upload-time = "2026-07-23T15:21:56.862Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/3abb9c200f90110805553ca0e8f7908a0b89485ea99bb271daa37eaafb72/grpcio_tools-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:1ea047ff4bd2bb32fe5268042cb9c9e7bb054e932b52ad756642622f032ef656", size = 2652841, upload-time = "2026-07-23T15:21:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/a2/46/d5beb04f0ffe552e55eaddd3413786e17a1fa68edb2fd8398969c38bc7e8/grpcio_tools-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7a8ac9cb3fbf7a5e4fe59f211e77b4fa4d51279c9f480e6ff98037cf56da1ad8", size = 5963493, upload-time = "2026-07-23T15:22:00.547Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/d8e01ca3281cb59722372c415024a7e70e8a653e70e2075e875394e4f761/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a5fa95fb33a600d2491867a1048f47baa27a830eac01f475043b8ccf63a471eb", size = 2705303, upload-time = "2026-07-23T15:22:02.448Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/75b382d8f116e274ce639ec55a6908dc792627b01d3c47b4f8991701203b/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0ca67941524662e01adea91571bb79df1ac9b4b2641812ef8636e21945119bee", size = 3033047, upload-time = "2026-07-23T15:22:04.445Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9f/200fb55729b735192fded061f53aa37c88cf9f58933cb108e16f5c1fd967/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2a5816a5b6b06b42a6989f02944841c2a8b3daaa7f033dac9267f70078028ef", size = 2773830, upload-time = "2026-07-23T15:22:06.519Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/e585c4e255599ec45f2e07e0f570352158354285afd65ef30ceda97b445e/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f4a83f002895b11c4a862c366d77165825e0064aff14bf2c7453f59f66599b0e", size = 3229907, upload-time = "2026-07-23T15:22:08.358Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/0c098b4ff64d948666b79d0036ce104b8c2209e6f4d1594046044dcfa25d/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1cfee967ae073bc064862971871229248965422f38556096aec76db19d8a8c79", size = 3802600, upload-time = "2026-07-23T15:22:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/249047aab0da8c6b3b2e0156e6868aa0b598973ddf53f59186c43664ff96/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f7e82ee718ae09f879cb832e4517a56691de24383a2da673be184ad8b18e452f", size = 3461307, upload-time = "2026-07-23T15:22:12.33Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/11a228c47acecde2e05715bda4480e5a695690e00776c1db391e5a02c1f7/grpcio_tools-1.83.0-cp314-cp314-win32.whl", hash = "sha256:846fd211ebb72f50d39d3874cc0d616c2b9bcb71db51121ca86af29eec013c74", size = 1045047, upload-time = "2026-07-23T15:22:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/d1f150b2ab3b4ae9932c05104fe1edbcb7fbf505587ea8db99e49341a05f/grpcio_tools-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8c9686b0c19f70b63d8d6cfeff5ad3480bdedecd60f14711fe43950f5397253", size = 1224199, upload-time = "2026-07-23T15:22:16.064Z" }, ] [[package]] @@ -1578,15 +1608,15 @@ wheels = [ [[package]] name = "h2" -version = "4.3.0" +version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, ] [[package]] @@ -1606,26 +1636,26 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, - { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, - { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, - { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, - { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, ] [[package]] @@ -1673,20 +1703,20 @@ http2 = [ [[package]] name = "httpx-aiohttp" -version = "0.1.12" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/2c/b894861cecf030fb45675ea24aa55b5722e97c602a163d872fca66c5a6d8/httpx_aiohttp-0.1.12.tar.gz", hash = "sha256:81feec51fd82c0ecfa0e9aaf1b1a6c2591260d5e2bcbeb7eb0277a78e610df2c", size = 275945, upload-time = "2025-12-12T10:12:15.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/87/3b2df9732a497403e5f4bbf2ec9f25427d53cec797e83070c503649863ef/httpx_aiohttp-0.2.0.tar.gz", hash = "sha256:d4796b981f04734f1d1db9b4d9326ea16bc994f126460b93b69036262cd4a9d8", size = 195714, upload-time = "2026-07-25T07:34:12.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8d/85c9701e9af72ca132a1783e2a54364a90c6da832304416a30fc11196ab2/httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141", size = 6367, upload-time = "2025-12-12T10:12:14.018Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e2/74b6bad3a6d342aee12d8b8d825456c02d21d72319c924326d17444c5ff7/httpx_aiohttp-0.2.0-py3-none-any.whl", hash = "sha256:ccd6eb19ba18805476096e8ef0b369a6beda3955db145a538979eface2fce7ff", size = 9732, upload-time = "2026-07-25T07:34:10.939Z" }, ] [[package]] name = "huggingface-hub" -version = "1.24.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1699,9 +1729,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" }, ] [[package]] @@ -1982,54 +2012,42 @@ wheels = [ [[package]] name = "libcst" -version = "1.8.6" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, - { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, - { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, - { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, - { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, - { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, - { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, - { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, - { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, - { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, - { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, - { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, - { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, - { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, - { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, - { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/bb/d22c37c33dfe18084634f5ef89f8f0749ffe7b6e0ad312722aafd86bbbdb/libcst-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd1a3500c41784075c4946a995d5ad89f68fa0d226b63ff3c4d78f6ea6dd23e5", size = 2043159, upload-time = "2026-07-29T19:24:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/10/b8/2dedef84d72e7271119217503b69ed6dc5d0b2077685e163caae669d9c70/libcst-1.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:611cebd3bbc2014576f4dcc7b845b3c594c96ddc287a3db9b78f22eff156a7d3", size = 2203245, upload-time = "2026-07-29T19:24:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/e02ac2dad647423f947bb11f8322bfdba8ccfdd380e6c7b695add2d1acd4/libcst-1.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8d731abe1307720ea1a52d447555e8443a6d130e0e520243c0634a58f6edbc9d", size = 2255388, upload-time = "2026-07-29T19:24:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/6089a51518cfd2ff40950eb26bcc36951d7b6d4f4213568aa0290265aff7/libcst-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5351d92ca6ac1cc32e097700e1161ad1ceaa4d9b2cca5abadb1e94576b325", size = 2268982, upload-time = "2026-07-29T19:24:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/26881ec466fb70cbc129dca26ccb5a52a0061face2c5822e4b61f00f9699/libcst-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03165a264653bb77f6a11b412ae09c08bdb0c25864f3b8d42b816ac64b9d4b9e", size = 2378174, upload-time = "2026-07-29T19:24:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7a/a4dba5f11faf12a12ffba06d18019987851aab33b590242a602ffd4fb1bd/libcst-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b755ed4a4bc2faee849b54820023137d60d4c199e0a0822f0ff0c1bc49e49b48", size = 2103462, upload-time = "2026-07-29T19:24:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/57cb129093e0d6744b3c914ba6cbbdf51ed1b2c823882936c553a3a0cf1b/libcst-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e50576bad7d56459d9792cd0b0dfe5469dad13646e9a9c0a8b1ba20b269f332", size = 1979825, upload-time = "2026-07-29T19:24:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b1/befc0544283bb3923a928accf79ed685e5a725524bdb3491826670affc07/libcst-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8b9df30f524317b097dc53065b25dda33d6a4cc3c7c8bf4fc83ca7559c58cc0", size = 2043754, upload-time = "2026-07-29T19:24:59.885Z" }, + { url = "https://files.pythonhosted.org/packages/45/50/fef7c172a8457c95894edf5fb04805024899cdfea41fa01b0636587b79e1/libcst-1.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e465a7bc9c2b9533eb9e06d2391f8819f811b5112919d4064c9fb8565aaafa08", size = 2203548, upload-time = "2026-07-29T19:25:01.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/764cd2be1fd99d774fc44039c319dc0ed1d9d9afeaa02759a05121cccd4b/libcst-1.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8504b422c95676a8c27b517e1ac01413ece91bf356865c587ca9bdcd5708a2f7", size = 2255274, upload-time = "2026-07-29T19:25:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/34/a7/474748a27a02fa83e3556b260d5f5236ca48164fa7beffecf3b2cad24dca/libcst-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bcb9f9d4fcfe2ec7a40d2c26e03a538d5d9dc189c38551eb3f94ab661afee7c0", size = 2269126, upload-time = "2026-07-29T19:25:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/655e45363b8cf87b91e41e060b21c89e5316c7ef36eb14a1a498b27bb71e/libcst-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8d671c39a431c309476099b8ec811e412503ec0f4465f6fc907cb51c70e8e6b", size = 2378602, upload-time = "2026-07-29T19:25:06.424Z" }, + { url = "https://files.pythonhosted.org/packages/db/13/6da63f0902ece43bf9d737017251ad7ad06edd9d3c4e1856450403cb4473/libcst-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:4d382fba04077eb556a1ea4295a4482e192aa93639c5a07ba0885841965ea0c0", size = 2103486, upload-time = "2026-07-29T19:25:07.979Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/dccb1a55e38b4e47256a97242d61d2bef5366c744faf88fb087d4fa0f995/libcst-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:a621e261990148c1cfbe26c1798bd2375c131cf358a44a7a4659fc41c1a336e1", size = 1979907, upload-time = "2026-07-29T19:25:09.532Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/4943c71d90975bc59034a057dea346b365c276f308c1d31f5cf2bd85492d/libcst-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eccf4c57d273cdd3fe1c67b72cf9bb1bbd4547aa011824e96ccf5b7136057aa4", size = 2044529, upload-time = "2026-07-29T19:25:11.04Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1168b98c2a0f338be3a44753647baab051feaf6167cc887bf4447f8fd920/libcst-1.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32395244edfe6538e0ea2bf82051d60103d3f54861274805c4fb3745efb70a85", size = 2203519, upload-time = "2026-07-29T19:25:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/2eaa697a80f899bcf2245680bbfcc1e478eb3b3328c21d4b2880bdf00dac/libcst-1.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:444e84c76cd035cd2fe136838c1a524d34b08216521f6f5093df8a6f6cfa5799", size = 2255879, upload-time = "2026-07-29T19:25:14.137Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/793b9fd96dd52d202f5f2b88d7e54f05ebed767d1bb3b32395a1817bf6ab/libcst-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb5d0946f2b4c6711b5d69fe4f833b364f9e7a1a2b08f88b98619dc18975099a", size = 2269807, upload-time = "2026-07-29T19:25:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f4/1bc7aaea03971c45e8a885fed9fc1c73176dbbd00b0296a3cde9529bafd6/libcst-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45808c03528b3ad40b14095348a918e08d98c47b4a125d631cb78e817c0a5b16", size = 2378171, upload-time = "2026-07-29T19:25:17.289Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f7/bc49e367d2bc8817213594dd52cf6b2da2dbbda90b5382d60653999274ac/libcst-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:568288cdbfe3b4ca3ae4852cb0a439ff053dd54c841bc4995bf2b71238b5de40", size = 2177847, upload-time = "2026-07-29T19:25:19.35Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/4d81577a22e6d535d1a3409f3a1c6903e09036f0e17fc47f92169dbc3501/libcst-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:107593af46945593e7825821793393262bc4fa1d3ea24c3ed487b61269bbbdf8", size = 2058134, upload-time = "2026-07-29T19:25:20.769Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7f/c3f3a0e7a1a2adaa76e815e82a7814d6bfe7d49432276bba652248c68d0b/libcst-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f6248cb07444ab9a6733a855737a9febed8b9adca51347019348b09a3ac7dfe9", size = 2036102, upload-time = "2026-07-29T19:25:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/4e/af/2f5543255b2c7d749b966adeccc6bfb1652cf8b425afb913d0f3f025a865/libcst-1.9.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:496c24e0d3240bc7da45dae543aff3f5b6509978c39262d6b84ed2fb999dded2", size = 2194806, upload-time = "2026-07-29T19:25:24.017Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/03f4cddce426d005e208b39ea7b2d456e667cdee0f1891360f0fc8430f20/libcst-1.9.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ea490fa8540503db5f321f0268becab46eb50f710e8cec8041e241fd66f6874f", size = 2246762, upload-time = "2026-07-29T19:25:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/d8/31/9d5fe1e43dc3dbcc74f70f3e0e73fcdd8d84effc1059d8b45974f0d0d2eb/libcst-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50ab94bb2524b419056d4003032b8c67102ac800f8d85b3c4260a01746d94dbb", size = 2259633, upload-time = "2026-07-29T19:25:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2b/6752b28d88c3a19b3bc0b9e1838443b6760d5c862b4f4b37955402659e24/libcst-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a2faaf92500d0226358125630f5aab4758e8aad3f2d70a10892ec3c700781a54", size = 2368043, upload-time = "2026-07-29T19:25:28.344Z" }, + { url = "https://files.pythonhosted.org/packages/e2/94/775825b2637f8ab05694b6a4b3802ae6783b4e799f9b58d2400c7e2d4369/libcst-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c7b548512db25af9c2997a95fa731bd6b6928ecbad6c0915d7482d8bb42d34f", size = 2176432, upload-time = "2026-07-29T19:25:29.921Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/88ad67427c6fd9db929e087912b0a540e5140e5cb77e7ca4170edaac8531/libcst-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:497d5329345f1f5df84e41b0bbd00204b64a2fd30dfe3cfaeaebca633a31e877", size = 2052931, upload-time = "2026-07-29T19:25:31.397Z" }, ] [[package]] @@ -2064,11 +2082,11 @@ dependencies = [ [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -2397,7 +2415,7 @@ test = [ [[package]] name = "megatron-energon" -version = "7.4.0" +version = "7.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "braceexpand" }, @@ -2414,9 +2432,9 @@ dependencies = [ { name = "tqdm" }, { name = "webdataset" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/8a/690e08320622954d347c57270be852cad053798559a8e54810f28f8f6949/megatron_energon-7.4.0.tar.gz", hash = "sha256:df78a42d56dd443e9e1961d899698be2b737010c10ae8747a9428308a6b8e3b1", size = 211145, upload-time = "2026-06-17T10:18:23.881Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/15/1aa34071dbb64c30f56c68b78989e2a3f11ce72583466b98b37a04206ad4/megatron_energon-7.4.1.tar.gz", hash = "sha256:31e5511bf894e235dff35dacbe130fb1283010a132b612c07e3fa841cc37d82c", size = 211270, upload-time = "2026-08-03T12:11:07.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/39/a3696bd65d15b31ce4edf8198c33f8fb553298d5e73b2d7a552db6e58495/megatron_energon-7.4.0-py3-none-any.whl", hash = "sha256:bd0973b7a588539bfceb3b21d78ee28b448ec7a19b01d80fbe10299f149bd94c", size = 286751, upload-time = "2026-06-17T10:18:22.184Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/fcfffd2dbc8523412ac623da5e24096a252ff6342f97e1bddf3cce99e36e/megatron_energon-7.4.1-py3-none-any.whl", hash = "sha256:b2d96756f2825750bf51a2b2c16bd1ddfed8f50414b72bb9da174dd8ddafb7d8", size = 286847, upload-time = "2026-08-03T12:11:05.704Z" }, ] [package.optional-dependencies] @@ -2787,7 +2805,7 @@ wheels = [ [[package]] name = "nltk" -version = "3.10.0" +version = "3.10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -2796,60 +2814,82 @@ dependencies = [ { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/16/24d639531e73cbc6884fb251d116dfe469df9c595e0dcf24668c54d0e8d3/nltk-3.10.2.tar.gz", hash = "sha256:fcfd80fb77931868cea8357573c79838b8abc609942ef9914d1c9f6070d4645c", size = 3101716, upload-time = "2026-08-05T09:56:20.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2b/bf677eb32ca6684b270c0d19ab133c2271e9f3375997e5a8dd2b08e3152d/nltk-3.10.2-py3-none-any.whl", hash = "sha256:2c7ccacb765c5e26b0cb60fb1b57080af522c6924d12a714a243305ba3637412", size = 1725815, upload-time = "2026-08-05T09:56:09.657Z" }, ] [[package]] name = "numpy" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, - { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, - { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, - { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, - { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, - { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, - { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, - { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, - { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, - { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, - { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, - { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, - { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, - { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, - { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, ] [[package]] @@ -3095,7 +3135,7 @@ wheels = [ [[package]] name = "openai" -version = "2.46.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3107,9 +3147,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, + { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, ] [package.optional-dependencies] @@ -3262,63 +3302,57 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pandas" -version = "3.0.3" +version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, - { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, - { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, - { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, - { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, - { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, - { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, - { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, ] [[package]] @@ -3427,11 +3461,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.10.1" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/cd/4f25b2f95b23f5d2c9c1fe43e49841bff5800562149b2666afc09309aa8f/platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695", size = 31678, upload-time = "2026-07-18T03:53:43.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443", size = 22906, upload-time = "2026-07-18T03:53:42.576Z" }, + { url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" }, ] [[package]] @@ -3466,11 +3500,11 @@ wheels = [ [[package]] name = "prometheus-client" -version = "0.25.0" +version = "0.26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, ] [[package]] @@ -3488,14 +3522,14 @@ wheels = [ [[package]] name = "prompt-toolkit" -version = "3.0.52" +version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] [[package]] @@ -3594,14 +3628,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.28.1" +version = "1.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/44/767757fd2cdd4a60d7e4440d9f7b491d6131103d313638d2c03e06c268fb/proto_plus-1.28.1.tar.gz", hash = "sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168", size = 57166, upload-time = "2026-07-08T17:04:02.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/6a/056256feb4bd000869aba5c16cf2aa911572ca2a2feb185f86e457b5171e/proto_plus-1.28.3.tar.gz", hash = "sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9", size = 58051, upload-time = "2026-08-06T06:24:55.581Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/34/2f2b57dbfd145b995a29847a16b0903fce5ef6ad3c7aad740a609c5d3678/proto_plus-1.28.1-py3-none-any.whl", hash = "sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed", size = 50408, upload-time = "2026-07-08T17:03:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/cfee3c50294f55a2f0f9575052dec2c2a48891ad4b1c2a133b05a87026cd/proto_plus-1.28.3-py3-none-any.whl", hash = "sha256:dc76880b8ee951cca002098574376cf71e055f9f16d9ba6570fb8a06f726d281", size = 50795, upload-time = "2026-08-06T06:23:50.653Z" }, ] [[package]] @@ -3730,11 +3764,11 @@ wheels = [ [[package]] name = "pybind11" -version = "3.0.4" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/f0/35145a3c3baffeef55d4b8324caa33abaa8fa56ab345ecd4b2211d09163e/pybind11-3.0.4.tar.gz", hash = "sha256:3286b59c8a774b9ee650169302dd5a4eedc30a8617905a0560dd8ee44775130c", size = 589533, upload-time = "2026-04-19T03:08:15.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f3/95b0f40b31df41dbfe6bb0857419c9442c15839cbac4796f1c26ae0b6081/pybind11-3.1.0.tar.gz", hash = "sha256:a1cc06b524ab3edca51f8ad3895f9c4fa20b8b19283173dff4ae781449dc9639", size = 603746, upload-time = "2026-08-06T23:33:00.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/06/c3a23c9a0263b136c519f033a58d4641e73065fefc7754e9667ec206d992/pybind11-3.0.4-py3-none-any.whl", hash = "sha256:961720ee652da51d531b7b2451a6bd2bc042b0106e6d9baa48ecb7d58034ce63", size = 314166, upload-time = "2026-04-19T03:08:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/33/fd/8762f7ee3e4e4be6d1d846cffb4916dd9bb02b2f800d3603718a0efe494c/pybind11-3.1.0-py3-none-any.whl", hash = "sha256:b8488090f8acffbcb6b5d6a85571a6827a0a2981ffb75e5a0b27b87c4a6b7dd0", size = 319402, upload-time = "2026-08-06T23:32:59.047Z" }, ] [[package]] @@ -3996,15 +4030,15 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -4059,28 +4093,27 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.4.4" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, ] [[package]] name = "python-gitlab" -version = "8.4.0" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "requests-toolbelt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/96/c20b37e7fd86481e1bf2b1922e84b98dc5477fd9aafd30e5c5086f94d922/python_gitlab-8.4.0.tar.gz", hash = "sha256:f36f20ec3f09138f3b12089394941f4dbe5a407021bed71b70a04bbdd37b8a74", size = 410803, upload-time = "2026-05-28T02:43:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/55/8050293b360a29218a15892c2b936e286a69fd4f5d2cf54c7cbe19d34b47/python_gitlab-8.5.0.tar.gz", hash = "sha256:628529ec4ce1f9a7ba2c145b2cf5e4eeca3015418e504b2e6fba70171b6b1b59", size = 411497, upload-time = "2026-07-28T02:04:30.015Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/af/1c3540dbdccb85fca0b0bbc58cdaf514d0a830d7e0302b2059a6c627b172/python_gitlab-8.4.0-py3-none-any.whl", hash = "sha256:102c747d9c107820e215cc5913627388001592a9a3ba1b43fc341a40f6943e72", size = 148242, upload-time = "2026-05-28T02:43:31.505Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/fb547ed2ac318132517e001b74b09d1f17d2c2c681cad1e32a8b84139867/python_gitlab-8.5.0-py3-none-any.whl", hash = "sha256:94228973c54f09eccd30f5160eca91200adc31d6ed0c894221a3865b90f96426", size = 148234, upload-time = "2026-07-28T02:04:28.454Z" }, ] [[package]] @@ -4123,11 +4156,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.2" +version = "2026.3.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, ] [[package]] @@ -4237,7 +4270,7 @@ wheels = [ [[package]] name = "quart" -version = "0.20.0" +version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -4250,9 +4283,9 @@ dependencies = [ { name = "markupsafe" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/a7/d3e1a5f2afac1b20ca65e740bd2686b45b2c8c9de418d851feddeafadaa0/quart-0.21.0.tar.gz", hash = "sha256:ec05a784151386cf87df51f764825d55f3ac402df4f04fd1dc81faca8897ceda", size = 65409, upload-time = "2026-07-23T20:11:36.585Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/a1/05114d779ddad67d45b03faf2de941a74146b37ee1d626a9d193ac4c2153/quart-0.21.0-py3-none-any.whl", hash = "sha256:a21377754d52ee3a9de7f9bff140dc3bb055e2eec8ac187da1789b36cf4c66ca", size = 78863, upload-time = "2026-07-23T20:11:35.162Z" }, ] [[package]] @@ -4612,16 +4645,16 @@ wheels = [ [[package]] name = "s3fs" -version = "2026.4.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiobotocore" }, { name = "aiohttp" }, { name = "fsspec" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/d8/76f3dc1558bdf4494b117a9f7a9cc0a5d9d34edadc9e5d7ceabc5a6a7c37/s3fs-2026.4.0.tar.gz", hash = "sha256:5bdce0abb00b0435ee150807a45fea727451dbc22de4cbc116464f8504ab9d37", size = 85986, upload-time = "2026-04-29T20:52:51.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/00/6677343dc919d6c072bb04d80210afdd22c16838a8d16b3315c122dc728f/s3fs-2026.6.0.tar.gz", hash = "sha256:b28de7082d0a4f72392884bdc497e34a4a1582f675d214c7da0acf6e950a0083", size = 87358, upload-time = "2026-06-16T02:05:48.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a4/9d1ea10ebc9e028a289a72fec84da170689549a8102c8aacfcad26bc5035/s3fs-2026.4.0-py3-none-any.whl", hash = "sha256:de0d2a1f33cdf03831fd2382d278c6e4e31fe57c3bf2f703c61f8aec6b703e2a", size = 32392, upload-time = "2026-04-29T20:52:50.295Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl", hash = "sha256:60576e31bb31193c1f643f32b4c6439548720ea6918ac702e21cd757c80b5db8", size = 32573, upload-time = "2026-06-16T02:05:47.608Z" }, ] [[package]] @@ -4728,24 +4761,24 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.66.0" +version = "2.66.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/ff/670abe04c5072719b5060ed93851d0d69525d60f8f2c5810f8becd58f9c1/sentry_sdk-2.66.0.tar.gz", hash = "sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265", size = 935745, upload-time = "2026-07-16T12:42:04.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/bb/49b10783f29067da2eec179320617e94faf63196609de47aeab3c26c3325/sentry_sdk-2.66.0-py3-none-any.whl", hash = "sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11", size = 504769, upload-time = "2026-07-16T12:42:02.919Z" }, + { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" }, ] [[package]] name = "setuptools" -version = "83.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -4807,11 +4840,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.9" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] @@ -5004,33 +5037,33 @@ wheels = [ [[package]] name = "tensorstore" -version = "0.1.84" +version = "0.1.85" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ml-dtypes" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/18/c8e8b4faffab1a434b6c013d54cf7f5b754a6849429d9dbb718297705796/tensorstore-0.1.84.tar.gz", hash = "sha256:3cb091dfde68600e6d8f03a389ccc92ffa7c0798a0c600d1013c0138d7163e6b", size = 7208048, upload-time = "2026-05-16T06:17:58.448Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/8a/1b5231e965257c3ee7d4615cb49a0fac53a71a1c34b293bcf524bb7c6d13/tensorstore-0.1.84-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:915371fc2c27540e8b69c573b7a06217fb8d161ec231cedfa9f3d264615a326d", size = 16571584, upload-time = "2026-05-16T06:17:13.283Z" }, - { url = "https://files.pythonhosted.org/packages/88/5d/52e52aa00a5ae3ebe1116ca52ac9f47ef98e94f6c4e411649cd3d1bb79cc/tensorstore-0.1.84-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4477eabe26e2f5131f1b1a3444cd9167fe69fabc29579eab8259d218399b9e6b", size = 14905169, upload-time = "2026-05-16T06:17:15.638Z" }, - { url = "https://files.pythonhosted.org/packages/61/36/f88b4bf267902f12cd2ca33aff10fabd6839dd1ce7d51876ebefa98aaf2c/tensorstore-0.1.84-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ace00cf2e45dc5d64fe3a10c2cbef61343915683808a10a3e081233566a7231", size = 19345134, upload-time = "2026-05-16T06:17:17.984Z" }, - { url = "https://files.pythonhosted.org/packages/18/7c/b7b24e10e5cb0213c85204d53fcd60d0568d986ea0001a00a815e14e01e1/tensorstore-0.1.84-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64c8039558d5607b73903948fce058725731df410c5c196cf58b3fc6222395b5", size = 20968745, upload-time = "2026-05-16T06:17:20.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/33ad454a2b667a93b35e74595a351dbf9b8693440bd68665990663b79164/tensorstore-0.1.84-cp312-cp312-win_amd64.whl", hash = "sha256:08e7ec5b35db5d4c4b6a867be8500448f9bd4e0c9d5a52d7f0b460650622baf6", size = 13398458, upload-time = "2026-05-16T06:17:22.701Z" }, - { url = "https://files.pythonhosted.org/packages/e7/27/3c637c0f987866f6fb92cf96ee4d40eee4b5ab699135803ada851f2a56ad/tensorstore-0.1.84-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:9337d96693b4a0e555fbe63bb228e3e2e681a80e4d371f351fb67810f197f74e", size = 16571472, upload-time = "2026-05-16T06:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/41/83/4f3c6ef9bed01f384036c2030b3901cf075bbc8eff6e4529e502f0283ab5/tensorstore-0.1.84-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:028455cccdc05c31f194048cf459a26669b26d38f0516caf9213e7219b1ee79a", size = 14905288, upload-time = "2026-05-16T06:17:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/4d/28/03e46405ba7c616e7a1ec5425a8f4a1b3f4d6ec2be359cb2f248199849e4/tensorstore-0.1.84-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50afb06c57a509091015af6a85da6f483a7f5ad0372284dd95d5513d877336e4", size = 19344890, upload-time = "2026-05-16T06:17:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/82669e70cef67c803852285ba6f59d7e3d102983c0ab4be8269c14756677/tensorstore-0.1.84-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea53a851ea86aad3d99c14a790c85468d6324be14c7ac211f1f0265e8fab707", size = 20968230, upload-time = "2026-05-16T06:17:31.374Z" }, - { url = "https://files.pythonhosted.org/packages/a5/12/97d8ad183e3130e168f2feb860edd68f1b72e57f29268d980f3b70e34cd0/tensorstore-0.1.84-cp313-cp313-win_amd64.whl", hash = "sha256:fe9bf1c7fef69884a91222179550f9b5ba6c1454f9534429221824d9b15c00ec", size = 13398858, upload-time = "2026-05-16T06:17:33.658Z" }, - { url = "https://files.pythonhosted.org/packages/39/bd/fda828e7915ddb61704a6f4568b5b7ce9fe607c33b7535cf51b4fd900b38/tensorstore-0.1.84-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97e767a64415297f019dd1e4b9af6a23d72e126e2a2d05cf41083253abe81428", size = 16576451, upload-time = "2026-05-16T06:17:35.993Z" }, - { url = "https://files.pythonhosted.org/packages/8a/99/2e72fcb19404de43f9412880c542a8ef8651bd30183c85454d6ca14ebe56/tensorstore-0.1.84-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7d01775985fcaa2b0f100349766a953c5086e92e746bf395e936151d4d8f9ac", size = 14907896, upload-time = "2026-05-16T06:17:38.458Z" }, - { url = "https://files.pythonhosted.org/packages/25/d4/9a3964cdfdc5a15df6d0485694de9684c13990a5b0f5d88bfa365e0a2936/tensorstore-0.1.84-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2ff5d5536a8a9b1596c51b9075cc9d40b4c4ea4e6cc03c0480111dbe5d956d", size = 19347021, upload-time = "2026-05-16T06:17:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/43/19/70532cb2bf2f6fc3bf252f850bfb528b26eeb9c30c3cafffb075cbb7c77a/tensorstore-0.1.84-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c9108ae6c29adc90b72ca267ba2b577386c5e410ea2f8e87eabce5ebdad327e", size = 20970345, upload-time = "2026-05-16T06:17:43.874Z" }, - { url = "https://files.pythonhosted.org/packages/4e/22/a523e7576c83a6c35bd1415e5f4530b0f1e448c099d7e22684f55792755c/tensorstore-0.1.84-cp314-cp314-win_amd64.whl", hash = "sha256:4096220f4b9a2411c3751597dd8ced2f671d7a217575613c915191e19d5ea150", size = 13787429, upload-time = "2026-05-16T06:17:46.135Z" }, - { url = "https://files.pythonhosted.org/packages/2b/23/40b0a0a91973431770ac09a8b69039bd7a051169f6707b63a1c51ad36782/tensorstore-0.1.84-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:70284a7225d94adad5989de7f9238638ec9fec2bdd8c0fdb86d567f16d59e615", size = 16652804, upload-time = "2026-05-16T06:17:49.226Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/2ea3b37864adb2e9f6e724c6959ab2b7f56aa4dad01964d4b32adf211e68/tensorstore-0.1.84-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98e10cae5b3fc0828967b8ddf36242b3b26ac8bc79880bc3e36346063259212a", size = 14990904, upload-time = "2026-05-16T06:17:51.464Z" }, - { url = "https://files.pythonhosted.org/packages/ff/26/fe72887d7b91e832bef3033d244c4c548993d1b6fb19177dff3895659c12/tensorstore-0.1.84-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98edbc57d453ba8ebcf0d19bc28e4de6de95922bee2eca0805955b0833b8ce26", size = 19365698, upload-time = "2026-05-16T06:17:53.748Z" }, - { url = "https://files.pythonhosted.org/packages/37/74/35a1d41343f86f6e2ef135e81f6b8107b9f16c777a3e8be9e3fbce541d18/tensorstore-0.1.84-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bd20de85c1b83dd3ca94db24e7bd449bdb055590a1b162b05691f6b81fff00f", size = 20986107, upload-time = "2026-05-16T06:17:56.571Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/26/16/a6530387ed22d07e974af2ef55e34fb19a7251a6911c85a0be029a4dcf9c/tensorstore-0.1.85.tar.gz", hash = "sha256:26698bded0278e98e0988eb8f7d76ad248762afc5f55a38b9122ffd1474e505f", size = 7317948, upload-time = "2026-08-05T15:35:56.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/81/612d3f58009ea1380f9befe2dced9d98c0a0fd608cd3e9d89389f9a04757/tensorstore-0.1.85-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:6b9453f28de853c804339bf882d0bc3eb523c4c55600b764239240bdec4460a8", size = 17015828, upload-time = "2026-08-05T15:35:03.426Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3c/74050e09a490c0cfed6016430226710d29fe4e165e12145368c7de4c6426/tensorstore-0.1.85-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af9189ca9f692bf83ce53e760a8b53fcf46ad0bf365930726faa0e301d0ab147", size = 15411026, upload-time = "2026-08-05T15:35:05.89Z" }, + { url = "https://files.pythonhosted.org/packages/88/7e/a1100ea66dbb028e0ea02f6d2e413e05285534d82d560f1f7d6145603640/tensorstore-0.1.85-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e2f42d1e57df7e07655b819dd07f3de258fde97e39b896a53f089d7d7067e21", size = 19973651, upload-time = "2026-08-05T15:35:09.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/c311f02b82299c6d79e18c7012c8b816141c54721253cbcba1c7c59550d4/tensorstore-0.1.85-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de27f2d6e063050f4283d1fdfe41e6c0ebc6ceacaab10ca2f7e9ecbf35221962", size = 21580735, upload-time = "2026-08-05T15:35:11.727Z" }, + { url = "https://files.pythonhosted.org/packages/88/da/3d66562291808e1edd433704bfdd4f212c1a6c53920293fb5f7296d644a5/tensorstore-0.1.85-cp312-cp312-win_amd64.whl", hash = "sha256:a5421480cd6ab144039c88fc7018ea5cefb8d00038e6dd2470cf776ef1bf45a5", size = 14129526, upload-time = "2026-08-05T15:35:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/29c9c5d21a9fc7cae9e8c3cdaede865619a137921e633b38807085ac8b1f/tensorstore-0.1.85-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:7c6d5689948e5ff4bc220de3e239db3550f5e7c2355c6889746baa2c93f4b3ca", size = 17015970, upload-time = "2026-08-05T15:35:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/89/03/a3d6fd62fb22b58eebc6741d31dfff1e5bc491935881a06d6b180efda6e1/tensorstore-0.1.85-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c0eeea5581a79fdc13b77efd2dfb35ff10ff67ee4f84d3ed2df0a7e250c3e6c", size = 15410397, upload-time = "2026-08-05T15:35:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/15/39/c8aa7ecaa715e722358a2e45f77420646db527897eee1a2e2dbfa511c63c/tensorstore-0.1.85-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5dae40b25cf99543d466d054b99064354055c2813470440fe46503cbaf450a0", size = 19973413, upload-time = "2026-08-05T15:35:23.469Z" }, + { url = "https://files.pythonhosted.org/packages/93/f5/759503078567055731f747e6141bd878d1f706341a9d7a34b9eb6ad0ed50/tensorstore-0.1.85-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3b03f52c8982764133ddd045c05582608d2ad9011b3a20ff832680467697d6d", size = 21580016, upload-time = "2026-08-05T15:35:26.249Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/91a1f9038e2c018f21ea52729ade7d700dde9f01d566b0ed528ae4a7f1d3/tensorstore-0.1.85-cp313-cp313-win_amd64.whl", hash = "sha256:5c7567c8101b3568a5d34f2a5e7235eeb78161a86588d039b5f5658cba6a0b2a", size = 14129625, upload-time = "2026-08-05T15:35:28.928Z" }, + { url = "https://files.pythonhosted.org/packages/91/f2/43f7c95524724e42042fd1e35dc51797277f4b5240fe9093583009a16ef6/tensorstore-0.1.85-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bb4533783df1596e362e0eeb5f8b79be77ea7f6930012701aa75795a61d2bd65", size = 17017251, upload-time = "2026-08-05T15:35:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/d3/37/b00787f8fa0a4f5ade03e112b971b871f1d9bfc19759f22d91c86a68e282/tensorstore-0.1.85-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f486c88b5107157f4819856d9f09d94925096ff167382bfcd0525c089c997bf0", size = 15413971, upload-time = "2026-08-05T15:35:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/1a/17/9cdf2f17db23bc412f39efbe615c163dbeb94121fb47aeff08e72a133f45/tensorstore-0.1.85-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52d4ff5c6db93721a8a3bc7c93e8a00ce2becd5d7b795881c3b71494d8354614", size = 19973625, upload-time = "2026-08-05T15:35:36.772Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f6/557d714bb3ce13cd5115c24906d6732c251040dd4bcc96133fcb6f6a49db/tensorstore-0.1.85-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5398a9eda248eb0ed4fe2dad10fed28287ad14bf2ffc5a6f3022905ac0dadc78", size = 21581093, upload-time = "2026-08-05T15:35:39.88Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/fee81370d763ff0f88c32291d3f1d4eb03cd133723ce5d9cfac8ddc0a489/tensorstore-0.1.85-cp314-cp314-win_amd64.whl", hash = "sha256:07245185b399402d139a9e7e90bd5938a08f5e33867efc91299e97c3e7fa0d0d", size = 14540718, upload-time = "2026-08-05T15:35:42.604Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/9a1beaf29e46b2b527e2130c98fd533729dee6b64f242906ddde1ad79891/tensorstore-0.1.85-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:66d2d85533a2b74b9b0a3b5faf51a19c417e3d592642b49b504cd92a34f00612", size = 17093402, upload-time = "2026-08-05T15:35:45.16Z" }, + { url = "https://files.pythonhosted.org/packages/b5/0d/cbc231244789b634a5796b1bdddc704806c94cdf13cc7ad2d66d8616ef53/tensorstore-0.1.85-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:19a3a809948a96eb44a6da72eef04f8ba44086d52f1e8122b84938890551fa2e", size = 15499976, upload-time = "2026-08-05T15:35:47.733Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e4/c6c573e4b75410bbd8ce541b1222f04fec9a87dff5298ee85b2267d0805b/tensorstore-0.1.85-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1b05a99b568dbf8f807efe307d7832f31cb1db35b3bc4144f3740dc7a04cae", size = 19986971, upload-time = "2026-08-05T15:35:50.451Z" }, + { url = "https://files.pythonhosted.org/packages/66/91/02d93b2d718e33d0c50e06445ab7518b3b8f391dea5c55838f9f9ff9f9cc/tensorstore-0.1.85-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82df9ad8e1df057f38203d12475a2929f36032a5a5224ac584851ed89e64622c", size = 21599959, upload-time = "2026-08-05T15:35:53.617Z" }, ] [[package]] @@ -5154,14 +5187,14 @@ version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, - { name = "filelock", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "fsspec", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jinja2", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "networkx", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "setuptools", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "sympy", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, { name = "triton", marker = "sys_platform == 'never'" }, - { name = "typing-extensions", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "typing-extensions" }, ] [[package]] @@ -5209,14 +5242,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.69.0" +version = "4.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] [[package]] @@ -5270,7 +5303,7 @@ wheels = [ [[package]] name = "typer" -version = "0.27.0" +version = "0.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -5278,9 +5311,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, ] [[package]] @@ -5307,14 +5340,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" }, ] [[package]] @@ -5337,20 +5370,20 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, ] [[package]] name = "virtualenv" -version = "21.6.1" +version = "21.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -5358,9 +5391,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/10/8b7a5454efc032be50c1c5641467dbb5d31314500205212b2aa66d3c8af4/virtualenv-21.7.3.tar.gz", hash = "sha256:5e9e287f5c808070eea3b40403d3368248e64b24f1b60bd9a36e85ac841d2c3e", size = 5525482, upload-time = "2026-08-08T14:43:20.286Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, + { url = "https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl", hash = "sha256:26dfda3c34f29bf1a3ca167426a67658d59979b9954e705aef60a5f724ce1773", size = 5504590, upload-time = "2026-08-08T14:43:18.57Z" }, ] [[package]] @@ -5514,79 +5547,91 @@ wheels = [ [[package]] name = "websockets" -version = "16.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, - { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, - { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, - { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, - { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, - { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, - { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, - { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, - { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, - { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, - { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, - { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, - { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, - { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, - { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, - { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, - { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, - { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, - { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, - { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, ] [[package]] @@ -5618,66 +5663,66 @@ wheels = [ [[package]] name = "wrapt" -version = "2.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, - { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, - { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, - { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, - { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, - { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, - { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, - { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, - { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, - { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, - { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, - { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, - { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, - { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, - { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, - { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, - { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, - { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, - { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, - { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, ] [[package]] From 24bad8e677d22625d86ef2a54c9506b6e4992c93 Mon Sep 17 00:00:00 2001 From: Antoni-Joan Solergibert Date: Mon, 10 Aug 2026 15:13:14 +0200 Subject: [PATCH 234/290] Skip save-side sharding validation via --no-ckpt-load-validate-sharding-integrity (#5554) Signed-off-by: asolergi-nv --- .../dist_checkpointing/strategies/fully_parallel.py | 9 ++++++++- megatron/training/checkpointing.py | 10 +++++++++- megatron/training/config/training_config.py | 8 +++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/megatron/core/dist_checkpointing/strategies/fully_parallel.py b/megatron/core/dist_checkpointing/strategies/fully_parallel.py index b1217cece0d..ca7f28f3cc6 100644 --- a/megatron/core/dist_checkpointing/strategies/fully_parallel.py +++ b/megatron/core/dist_checkpointing/strategies/fully_parallel.py @@ -75,6 +75,7 @@ def __init__( do_cache_distribution: bool = False, backend: str = "torch_dist", version: int = 1, + validate_access_integrity: bool = True, ): """ """ self.base_strategy = strategy @@ -84,6 +85,12 @@ def __init__( self.do_cache_distribution = do_cache_distribution self.backend = backend self.version = version + # When False, skip the first-save sharding-integrity validation (its + # determine_global_metadata all_gather_object). Mirrors the + # validate_access_integrity used by dist_checkpointing.save, so + # --no-ckpt-load-validate-sharding-integrity skips *both* save-side + # validation collectives, not just the one in save_preprocess. + self.validate_access_integrity = validate_access_integrity self.cached_distribution: Optional[ShardDistribution] = None @@ -130,7 +137,7 @@ def apply_saving_parallelization(self, sharded_state_dict: ShardedStateDict) -> distribute_main_replicas_with_precomputed_distribution( sharded_state_dict, self.parallelization_group, precomputed_distribution ) - if self.cached_distribution is None: + if self.cached_distribution is None and self.validate_access_integrity: # First time applying the parallelization validate_sharding_integrity(determine_global_metadata(sharded_state_dict)[1]) if self.do_cache_distribution: diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 84b687b25df..ded7fc4d70f 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -837,8 +837,16 @@ def save_checkpoint( else mpu.get_expert_data_parallel_group() ) save_strategy = FullyParallelSaveStrategyWrapper( - save_strategy, process_group, args.ckpt_assume_constant_structure + save_strategy, + process_group, + args.ckpt_assume_constant_structure, + validate_access_integrity=args.ckpt_load_validate_sharding_integrity, ) + # Allow opting out of save-side sharding validation entirely (even on + # the first save) via --no-ckpt-load-validate-sharding-integrity. This + # skips the world-wide determine_global_metadata all_gather_object. + if not args.ckpt_load_validate_sharding_integrity: + validate_sharding_integrity = False # Store save strategy for future checkpoint saves if checkpointing_context is not None: checkpointing_context['save_strategy'] = save_strategy diff --git a/megatron/training/config/training_config.py b/megatron/training/config/training_config.py index fb5598b8d42..1ba743d5962 100644 --- a/megatron/training/config/training_config.py +++ b/megatron/training/config/training_config.py @@ -564,9 +564,11 @@ class CheckpointConfig: """Assume the checkpoint structure is constant across saves to enable optimizations.""" ckpt_load_validate_sharding_integrity: bool = True - """Whether to validate sharding access integrity when loading a distributed checkpoint. - When True (default), each tensor shard is checked to be accessed exactly once as main - replica by some rank. Disabling skips this validation""" + """Whether to validate sharding access integrity when loading *and saving* a distributed + checkpoint. When True (default), each tensor shard is checked to be accessed exactly once as + main replica by some rank. Disabling skips this validation; on save this also skips the + world-wide determine_global_metadata all_gather_object (otherwise run on the first save of a + job).""" strict_fsdp_dtensor_load: bool = True """Whether to enforce strict loading for FSDP DTensor checkpoints. When False, allows partial loading.""" From fb24e8707c94aebe28fb919e4a6253b600ad0528 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Tue, 11 Aug 2026 01:08:51 +0800 Subject: [PATCH 235/290] feat(gtp): opt-in GTP_remat sharding support for 'moe-latent-proj' (#6383) Signed-off-by: Shiqing Fan --- .../core/generalized_tensor_parallel.md | 65 +++++++++++++------ megatron/core/transformer/moe/moe_layer.py | 9 ++- .../core/transformer/transformer_config.py | 17 +++++ .../models/test_hybrid_moe_model.py | 1 + 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index 288d421253f..831db034d90 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -42,11 +42,12 @@ Both `GTP_remat` collectives are prefetched one step ahead, so they overlap the - [1.7 Scaling](#17-scaling) - [1.8 Native distributed checkpointing (DCP)](#18-native-distributed-checkpointing-dcp) - [2. Usage](#2-usage) - - [2.1 Required flags](#21-required-flags) - - [2.2 High-priority streams (Blackwell and later)](#22-high-priority-streams-blackwell-and-later) - - [2.3 Minimal end-to-end example](#23-minimal-end-to-end-example) - - [2.4 Tuning knobs](#24-tuning-knobs) - - [2.5 FP32-accumulation wgrad reduce-scatter (optional)](#25-fp32-accumulation-wgrad-reduce-scatter-optional) + - [2.1 Knob summary](#21-knob-summary) + - [2.2 Required flags](#22-required-flags) + - [2.3 High-priority streams (Blackwell and later)](#23-high-priority-streams-blackwell-and-later) + - [2.4 Minimal end-to-end example](#24-minimal-end-to-end-example) + - [2.5 Tuning knobs](#25-tuning-knobs) + - [2.6 FP32-accumulation wgrad reduce-scatter (optional)](#26-fp32-accumulation-wgrad-reduce-scatter-optional) - [3. Implementation details](#3-implementation-details) - [3.1 GTP\_remat architecture (Mcore ↔ TE integration)](#31-gtp_remat-architecture-mcore--te-integration) - [What the flags do under the hood](#what-the-flags-do-under-the-hood) @@ -95,7 +96,7 @@ CG compatibility is designed-in from day one, not retrofitted. The entire sync / - **Lazy, one-shot chain linking**: `prefetch_initialized` is flipped during the first fwd (warmup), so the chain-construction Python side-effects never execute inside a captured graph. The link table is buffered and flushed atomically at the second forward. - **DDP hook manual triggering**: `register_grad_accum_hook` stores the DDP hook on the param; `_CudagraphReplayNode.backward` calls it manually after replay (since `AccumulateGrad` hooks are silenced by replay). This is also how the `assert self.grad_reduce_handle is not None` failure from partial-CG + overlap-grad-reduce is resolved. - **Warmup is side-effect-free on `main_grad`**: GTP_remat accumulates wgrad into `main_grad` *inside* the backward (the fusion path returns wgrads as graph outputs instead). Graph capture only *records* ops; it never runs them. But `create_fwd_graph` runs an **eager** warmup fwd+bwd before capturing. That warmup backward executes GTP_remat's `main_grad.add_`. Its deferred cascade adds into a cross-graph `next_w` (another module) from a **stale RS ticket** — the prior backward's wgrad. And `create_cudagraphs()` runs *after* `finalize_model_grads`. So this overwrites the finalized (reduced + per-token-scaled) grads and spikes the step's grad norm. **Fix**: `create_fwd_graph` snapshots the grads its warmup touches — own params + cross-graph `next_w` — via `_backup_grads_before_capture`, then restores them after capture. The bwd graph has no warmup, so it needs none. Bounded to one module's grads. -- **Graph-owned two-stage backward drain**: Stage 1 drains only the all-gathers issued by the current graph and records `bwd_completion_event`, allowing the next backward graph to start. Stage 2 drains that graph's reduce-scatters, accumulates the result into `main_grad`, and releases its persistent wgrad-ring slots. See [§3.5](#cross-graph-backward-reduce-scatter-overlap). +- **Graph-owned two-stage backward drain**: Stage 1 drains only the all-gathers issued by the current graph and records `bwd_completion_event`, allowing the next backward graph to start. Stage 2 drains that graph's reduce-scatters, accumulates the result into `main_grad`, and releases its persistent wgrad-ring slots. See [§3.6](#cross-graph-backward-reduce-scatter-overlap). - **Side-stream registration**: the `(GRAPHED, gtp_remat_group)` ag/rs streams are materialized at runner init (`_register_gtp_side_streams`) so they are captured before the first forward. ### 1.3 Low-precision gather (native FP8 / NVFP4 param) @@ -161,6 +162,7 @@ NVFP4 GTP_remat keeps each shard as a native `NVFP4Tensor` and all-gathers it as > |---|---|---|---| > | **column-parallel** (`linear_qkv`, `linear_fc1`) | `out_features` | `out_features` | same axis → `out_features/(TP × GTP_remat)` | > | **row-parallel** (`linear_proj`, `linear_fc2`) | `in_features` | `out_features` | **perpendicular** → `in_features/TP` × `out_features/GTP_remat` | +> | **duplicated** (`fc1_latent_proj`, `fc2_latent_proj`) | none (weight replicated across TP) | `out_features` | GTP_remat only → `out_features/GTP_remat`; full output reconstructed via AG. Requires `--gtp-remat-opt-in-modules moe_latent_proj`. | - **SP** (sequence-parallel): transparent — GTP_remat operates at weight dim, SP at sequence dim. - **EP** (MoE): `GroupedLinear` with GTP_remat → each routed expert sharded across `EXPERT_GTP_WEIGHT_REMAT_GROUP`, independent of EP. MoE AllToAll (HybridEP/NVLink) runs independently of GTP_remat AG/RS (NCCL/IB). @@ -169,7 +171,7 @@ NVFP4 GTP_remat keeps each shard as a native `NVFP4Tensor` and all-gathers it as ### 1.5 Opt-in, minimally invasive integration - **TE is GTP-agnostic.** Mcore builds the plain TE linear with an already-sharded `out_features` and attaches a `GTPShardedParam` *after* construction; TE dispatches through its generic **`DistributedWeight` protocol** (gates on `is_distributed_weight`) and takes no GTP argument, so there is no framework-level refactor and callers never thread a group (§3.1). -- **Opt-in by linear *class*; sharding stays per-*weight*.** *Which* linears opt in is class-based — GTP_remat wraps the TE classes that resolve a shard group internally (`TEColumnParallelLinear` / `TERowParallelLinear` / `TELayerNormColumnParallelLinear` for dense, `TEGroupedLinear` for routed experts), so upper-level modules thread no `gtp_remat_group`. But materialization and gradient reduction stay at **individual-weight** granularity — each wrapped weight is its own `GTPShardedParam`, gathered/reduce-scattered per-weight, per-call (§1.1). Base `TELinear` (e.g. MoE latent-proj MLPs) and small replicated tensors (LayerNorm γ/β, biases, Mamba `dt_bias`/`A_log`/`D`/`conv1d`, MoE router) **stay full** — the all-gather wouldn't amortize (§3.2 *dense non-GTP_remat* vs *dense GTP_remat*). +- **Opt-in by linear *class*; sharding stays per-*weight*.** Which linears participate is decided per TE class at construction — no `gtp_remat_group` is threaded through upper-level modules. Small tensors (LayerNorm γ/β, biases, Mamba SSM params, MoE router) always stay full; MoE latent-proj MLPs default to full but can be opted in via `--gtp-remat-opt-in-modules moe_latent_proj` when the projection size is large enough. See §3.1 [Class hierarchy](#class-hierarchy-which-linears-shard) for the full per-class breakdown. - **Off is a byte-for-byte no-op.** When the resolved group is `None`/size-1, `_gtp_pre_init` leaves `out_features` unsharded and `_gtp_attach_post_init` short-circuits (as does `wrap_module_params_gtp` for Megatron-local linears); when `gtp_weight_remat_size == 1` the `layers.py` GTP_remat path is skipped entirely. - **Chain setup is one pass.** `classify_gtp_chains(model)` walks `named_parameters()` once at init and sets `chain_id` on every `GTPShardedParam` from the current `cuda_graph_modules` (§3.4). - **Knobs.** `GTPRematConfig.{pad_for_alignment, weight_prefetch, check_param_states}`, plus the debug-name tagger `tag_gtp_params_with_names` for readable link-table output. @@ -179,7 +181,7 @@ NVFP4 GTP_remat keeps each shard as a native `NVFP4Tensor` and all-gathers it as GTP_remat runs under both the standard **Adam** `DistributedOptimizer` and **Muon** (the `LayerWiseDistributedOptimizer`), DCP save/load included: - **Adam** shards optimizer state over the gtp_remat/egtp_remat-excluded replicate group, like any GTP_remat run (§3.2). -- **Muon** keeps matrix params *whole* (Newton–Schulz needs the full 2D weight). A GTP_remat-replicated whole param (e.g. MoE router, latent-proj MLPs) then lands on one checkpoint key shared by all GTP_remat peers, so the LayerWise optimizer folds `gtp_rank` into its `replica_id` — exactly one peer writes (the optimizer-state analog of the model-side fold in §3.3). +- **Muon** keeps matrix params *whole* (Newton–Schulz needs the full 2D weight). A GTP_remat-replicated whole param (e.g. MoE router, latent-proj MLPs by default) then lands on one checkpoint key shared by all GTP_remat peers, so the LayerWise optimizer folds `gtp_rank` into its `replica_id` — exactly one peer writes (the optimizer-state analog of the model-side fold in §3.3). - **Native-FP8 optimizer-state matching (Muon path).** The save-side dequantize (§3.3) hands DCP a *fresh* BF16 tensor, which breaks the id-based optimizer-param → model-`ShardedTensor` match for every native-FP8 GTP_remat weight. The dequantized copy carries a `_gtp_dequant_src` backlink to the live FP8 param, and `_backfill_gtp_sharded_param_map` reuses the model's **own** entry (backlink first, tagged-name second) — preserving its full offsets (expert axes included) and `replica_id`. Only truly-unmatched params (Mamba `in_proj`, a gathered+split factory) take the per-shard rebuild, which refuses expert-parallel params rather than emit EP-colliding shards. Neither path adds a GTP_remat-specific checkpoint format or call site. @@ -215,7 +217,32 @@ See [§3.3 Distributed checkpointing (DCP)](#33-distributed-checkpointing-dcp) f GTP_remat is enabled through two CLI flags on Megatron's training launcher; everything else (process-group construction, parameter slicing, prefetch chain wiring, optimizer routing) is automatic once the flags are set. -### 2.1 Required flags +### 2.1 Knob summary + +The table below covers every GTP-related CLI flag and Python knob. "Required" means GTP either silently breaks or `arguments.py` asserts without it; "Recommended" means it should almost always be set in a real training run; "Optional" means it is off by default and tunable. + +| Flag / knob | Kind | When to set | Default | Details | +|---|---|---|---|---| +| `--tensor-parallel-num-weight-shards` | **Required** | Always, to activate dense GTP | — | Total TP×GTP_remat shards per dense weight; GTP_remat degree = value ÷ TP. Must be ≥ TP and divisible by it. [§2.2](#22-required-flags) | +| `--expert-tensor-parallel-num-weight-shards` | **Required** | MoE models (to shard routed-expert weights) | — | Total ETP×EGTP_remat shards per expert weight; EGTP_remat degree = value ÷ ETP. Independent of dense axis. [§2.2](#22-required-flags) | +| `--gtp-remat-reduce-scatter-with-fp32-accumulation` | **Optional** | BF16 wgrads **and** GTP_remat axis ≥ 4 | off | Replaces the ring RS with an all-to-all + local FP32 sum to eliminate per-hop rounding error. Auto-bypassed at axis size ≤ 2. [§2.6](#26-fp32-accumulation-wgrad-reduce-scatter-optional) | +| `--gtp-remat-opt-in-modules` | **Optional** | MoE models with large `--moe-latent-size` | `[]` | Space-separated list of module tokens to opt in to GTP_remat sharding. Currently supported: `moe_latent_proj` (shards `fc1_latent_proj` / `fc2_latent_proj`; only beneficial when the latent size is large enough to amortize the all-gather). [§1.5](#15-opt-in-minimally-invasive-integration) | +| `--fp8-param-gather` | **Required** | GTP + `--fp8-recipe mxfp8` | off | Gathers native MXFP8 shard directly; without it the grad-buffer reuse path is unavailable and `arguments.py` asserts. Always paired with `--reuse-grad-buf-for-mxfp8-param-ag`. [§1.3](#13-low-precision-gather-native-fp8--nvfp4-param) | +| `--reuse-grad-buf-for-mxfp8-param-ag` | **Required** | GTP + `--fp8-recipe mxfp8` | off | Reuses the grad buffer for the MXFP8 all-gather (MXFP8 cannot map into the contiguous param buffer). Must accompany `--fp8-param-gather`. [§1.3](#13-low-precision-gather-native-fp8--nvfp4-param) | +| `--fp4-param-gather` | **Required** | GTP + `--fp4-format` | off | Gathers native NVFP4 shard directly; without it NVFP4 weights fall back to a BF16 gather that fails the backward GEMM. [§1.3 → GTP + NVFP4](#gtp--nvfp4-native-nvfp4-param) | +| `--high-priority-stream-groups ep gtp_remat expt_gtp_remat tp` | **Recommended** | Blackwell (GB200/GB300) and later | — | Gives GTP_remat comm streams the SM priority needed for AG/RS overlap with compute. Also export `CUDA_GRAPHS_USE_NODE_PRIORITY=1` when using CUDA graphs. [§2.3](#23-high-priority-streams-blackwell-and-later) | + +**Python-only tuning knobs** (via `update_gtp_config`; rarely need changing): + +| Knob | Default | Purpose | +|---|---|---| +| `pad_for_alignment` | auto (16 NVFP4, 32 MXFP8, 16 BF16) | Shard alignment; auto-set by `training.py` based on quantization recipe. | +| `weight_prefetch` | `True` | Disable only to debug the synchronous cold-start path. | +| `async_reduction` | `True` | Async wgrad reduce-scatter; disable for easier debugging. | +| `calculate_per_token_loss` | `False` | Must mirror `config.calculate_per_token_loss` (SUM vs MEAN RS). | +| `graph_wgrad_ring_size` | `2` | Persistent wgrad ring slots per scheduling domain (§3.6). Increase if capture rejects same-key writers. | + +### 2.2 Required flags ```bash # Total number of shards each dense weight (attention, mamba, MLP linears) is split into along @@ -245,7 +272,7 @@ the all-gather reuses the grad buffer. Mechanism: §1.3, §3.1. (`arguments.py` asserts this) — without it NVFP4 weights fall back to a BF16 gather that fails the backward GEMM. Mechanism and mixed-recipe (MXFP8-override) handling: §1.3 → *GTP + NVFP4*. -### 2.2 High-priority streams (Blackwell and later) +### 2.3 High-priority streams (Blackwell and later) Required on GB200 / GB300 so the GTP_remat comm streams get the SM priority needed for AG/RS overlap with compute: @@ -255,7 +282,7 @@ Required on GB200 / GB300 so the GTP_remat comm streams get the SM priority need The launcher also exports `CUDA_GRAPHS_USE_NODE_PRIORITY=1` so captured CUDA graphs respect the inherited stream priority. -### 2.3 Minimal end-to-end example +### 2.4 Minimal end-to-end example ```bash # 4 ranks, TP=2 + GTP_remat=2 across out_features, BF16 weights. @@ -285,7 +312,7 @@ GTP_remat enabled. GTPRematConfig(pad_for_alignment=16, check_param_states=False reduce_scatter_with_fp32_accumulation=False, graph_wgrad_ring_size=2) ``` -### 2.4 Tuning knobs +### 2.5 Tuning knobs Set via `from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG, update_gtp_config`: @@ -295,18 +322,18 @@ update_gtp_config( weight_prefetch=True, # Disable to debug the cold-start path async_reduction=True, # Whether to perform GTP_remat gradient reduction asynchronously calculate_per_token_loss=False, # Mirror config.calculate_per_token_loss (SUM vs MEAN RS) - reduce_scatter_with_fp32_accumulation=False, # wgrad RS: BF16 all-to-all + FP32 sum (§2.5) + reduce_scatter_with_fp32_accumulation=False, # wgrad RS: BF16 all-to-all + FP32 sum (§2.6) graph_wgrad_ring_size=2, # Persistent wgrad slots per graph scheduling domain ) ``` `training.py` auto-tunes `pad_for_alignment` based on the quantization recipe (`--fp4`, `--fp8-recipe=mxfp8`, etc.) before model construction. The other knobs are usually left at defaults. -GTP backward reduce-scatter overlap across local CUDA-graph boundaries is enabled automatically. The ownership and ordering protocol is described in [§3.5](#cross-graph-backward-reduce-scatter-overlap). +GTP backward reduce-scatter overlap across local CUDA-graph boundaries is enabled automatically. The ownership and ordering protocol is described in [§3.6](#cross-graph-backward-reduce-scatter-overlap). > **CUDA-graph warmup under GTP_remat.** When CUDA graphs are enabled, GTP_remat forces a minimum of **2** per-graph warmup steps regardless of `--cuda-graph-warmup-steps` (e.g. a user-set `0` is bumped to `2`): the first warmup builds the weight-prefetch chain and the second exercises the prefetch path before capture. -### 2.5 FP32-accumulation wgrad reduce-scatter (optional) +### 2.6 FP32-accumulation wgrad reduce-scatter (optional) ```bash --gtp-remat-reduce-scatter-with-fp32-accumulation # default: off @@ -372,7 +399,7 @@ The `--*-num-weight-shards` flags flow through five stages, from process groups > **Batch-size arithmetic.** `args.data_parallel_size` is the **replicate degree only** — gtp_remat is *divided out* of it (folded into `total_model_size` at `arguments.py:446`). But data is distributed over the **full DP × gtp_remat axis**, so each gtp_remat peer consumes a *distinct* microbatch and the global sample count is `micro_batch_size × data_parallel_size × gtp_weight_remat_size × num_microbatches`. The training loop therefore **re-applies `gtp_weight_remat_size`** to close the gap: *multiplied back in* for the LR-scheduler `increment` and the logged `batch_size`, *divided back out* to recover `eval_num_microbatches`. Without this it would read as a double-count — it is not. -2. **Per-class sharding.** `extensions/transformer_engine.py` decides *per linear class* whether to shard, so **no `gtp_remat_group` is threaded through the module APIs** (attention, Mamba, MLP, embedding, MTP). Dense wrappers resolve the group via `utils.get_gtp_weight_remat_group(...)`; `TEGroupedLinear` uses `pg_collection.expt_gtp_remat`. Group `None`/size-1 → left full; otherwise `_gtp_pre_init` pre-shards `out_features` and `_gtp_attach_post_init` makes the shard a **`GTPShardedParam`** (the `DistributedWeight` implementer; native FP8/NVFP4 by reclass, BF16 by re-register). Base `te.Linear` (MoE latent projections) gets no group and stays full → see [Class hierarchy](#class-hierarchy-which-linears-shard). +2. **Per-class sharding.** `extensions/transformer_engine.py` decides *per linear class* whether to shard, so **no `gtp_remat_group` is threaded through the module APIs** (attention, Mamba, MLP, embedding, MTP). Dense wrappers resolve the group via `utils.get_gtp_weight_remat_group(...)`; `TEGroupedLinear` uses `pg_collection.expt_gtp_remat`. Group `None`/size-1 → left full; otherwise `_gtp_pre_init` pre-shards `out_features` and `_gtp_attach_post_init` makes the shard a **`GTPShardedParam`** (the `DistributedWeight` implementer; native FP8/NVFP4 by reclass, BF16 by re-register). Base `te.Linear` (MoE latent projections) receives a group only when `--gtp-remat-opt-in-modules moe_latent_proj` is set; otherwise it stays full → see [Class hierarchy](#class-hierarchy-which-linears-shard). 3. **Gradients (DDP).** GTP_remat shards are ordinary DDP params in the usual dense/expert buffers, reduced over the **replicate** group. The gtp_remat axis is completed separately: **GTP shards by their reduce-scatter, replicated params by an all-reduce** in `finalize_model_grads` (mean-vs-sum per `calculate_per_token_loss`) → see §3.2. @@ -382,7 +409,7 @@ The `--*-num-weight-shards` flags flow through five stages, from process groups #### Class hierarchy: which linears shard -The figure visualizes the per-class split from the list above: green = resolves a GTP_remat group and shards, red = base `TELinear` (MoE latent projections) that stays full. Dashed arrows are *builds* (module → leaf); solid arrows are *inherits* (leaf → TE primitive). +The figure visualizes the per-class split from the list above: green = resolves a GTP_remat group and shards, red = base `TELinear` (MoE latent projections, full by default; opt-in via `--gtp-remat-opt-in-modules moe_latent_proj`). Dashed arrows are *builds* (module → leaf); solid arrows are *inherits* (leaf → TE primitive). ![GTP_remat class hierarchy — which TE linear classes shard](../../images/generalized_tensor_parallel/0628_gtp_remat_class_hierarchy.png) @@ -479,7 +506,7 @@ The DP collective only covers the replicate axis; the gtp_remat axis is complete - **Default (mean) path** decouples gradient scaling from the gtp_remat degree: the DP `1/replicate` mean × the reduce-scatter `1/gtp_remat` mean (sharded weights) — or × the finalize AVG (replicated params) — equals the exact full mean, independent of the gtp_remat axis size. - **`--gtp-remat-reduce-scatter-with-fp32-accumulation` swaps the collective, not the scaling** - — this table applies unchanged (§2.5). + — this table applies unchanged (§2.6). - **Per-token-loss path** must SUM over gtp_remat (like the DP axis): `total_global_tokens` already counts the gtp_remat peers' distinct tokens, so the single `÷ total_global_tokens` does all normalization. A `1/gtp_remat` mean here would shrink every gtp_remat gradient by `1/gtp_remat` (grad-norm mismatch + divergence), so the reduce-scatter mean and finalize AVG are both gated on `not calculate_per_token_loss`. > **`average_in_collective` must be off (the default).** The default-path scaling is a *pre-scale* applied before a SUM collective. `average_in_collective=True` instead uses NCCL AVG over the collective's own (replicate) group, which interacts incorrectly with the gtp_remat completion. Asserted via `ProcessGroupCollection.is_gtp_remat_active` in both `arguments.py` (training) and `DistributedDataParallel.__init__` (direct megatron-core users). (Independently, `calculate_per_token_loss` already forbids `average_in_collective`.) @@ -709,7 +736,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_tp_gtp.py` | GTP_remat composed with tensor parallelism (`tp_group × gtp_remat_group`). | | `test_moe_egtp.py` | EGTP_remat on MoE routed-expert weights. | | `test_gtp_loss_correctness.py` | End-to-end: GTP_remat per-step loss trajectory matches a no-GTP_remat baseline. | -| `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. Also the fp32-accumulation reduce-scatter (§2.5): gtp_remat-axis and DDP-axis parity, plus the size-2 bypass. | +| `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. Also the fp32-accumulation reduce-scatter (§2.6): gtp_remat-axis and DDP-axis parity, plus the size-2 bypass. | | `test_gtp_cudagraph_grad.py` | Capture-step grad-norm guard (§1.2): `_backup_grads_before_capture`/`_restore_grads_after_capture` keep a graph capture from clobbering finalized `main_grad` (own params + cross-graph `next_w`, incl. routed-expert `weight_list`). | | `test_gtp_partial_cg.py` | Four-layer partial-CG loss and eager-vs-replay grad-norm parity with two-slot ring reuse across independently replayed graphs (§3.5). | | `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | diff --git a/megatron/core/transformer/moe/moe_layer.py b/megatron/core/transformer/moe/moe_layer.py index 48e78775a84..6624727faab 100644 --- a/megatron/core/transformer/moe/moe_layer.py +++ b/megatron/core/transformer/moe/moe_layer.py @@ -11,7 +11,7 @@ from megatron.core import tensor_parallel, utils from megatron.core.extensions.transformer_engine import HAVE_TE from megatron.core.inference.utils import InferenceMode -from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.process_groups_config import ProcessGroupCollection, resolve_gtp_remat_group from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.moe.moe_utils import ( MoECudaGraphPartialCaptureSignal, @@ -270,6 +270,11 @@ def __init__( linear_cls = InferenceLinear else: linear_cls = TELinear + gtp_remat_group = ( + resolve_gtp_remat_group(pg_collection, is_expert=False) + if "moe_latent_proj" in self.config.gtp_remat_opt_in_modules + else None + ) self.fc1_latent_proj = linear_cls( self.config.hidden_size, self.config.moe_latent_size, @@ -281,6 +286,7 @@ def __init__( skip_weight_param_allocation=False, is_expert=False, name=(name + ".fc1_latent_proj") if name is not None else None, + gtp_remat_group=gtp_remat_group, ) self.fc2_latent_proj = linear_cls( self.config.moe_latent_size, @@ -293,6 +299,7 @@ def __init__( skip_weight_param_allocation=False, is_expert=False, name=(name + ".fc2_latent_proj") if name is not None else None, + gtp_remat_group=gtp_remat_group, ) # Initialize token dispatcher diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 66cfaded213..8dfe3506be1 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -923,6 +923,15 @@ class TransformerConfig(ModelParallelConfig): moe_latent_size: Optional[int] = None """Latent projection dimension for MoE. If None, MoE latent projections are not used.""" + gtp_remat_opt_in_modules: list[str] = field(default_factory=list) + """Extra modules to apply GTP_remat weight sharding to, beyond the default set (attention, + Mamba, MLP, expert linears, embeddings). Allowed values: + + - ``"moe_latent_proj"`` — shard ``fc1_latent_proj`` / ``fc2_latent_proj`` (MoE latent + projections, ``parallel_mode="duplicated"``). Only beneficial when ``moe_latent_size`` + is large enough for the all-gather to amortize. + """ + moe_flex_dispatcher_num_sms: Optional[int] = None """Number of SMs for the flex token dispatcher's dispatch/combine communication, for all backends (deepep, hybridep, ncclep). None lets each backend use its own default. Unifies the @@ -1927,6 +1936,14 @@ def __post_init__(self): "fused_group_mlp offloads the whole fused grouped MLP and cannot be " f"combined with expert_fc1 or moe_act. Remove: {moe_partial_offload}" ) + if self.gtp_remat_opt_in_modules: + _allowed_gtp_remat_opt_in_modules = {"moe_latent_proj"} + invalid = set(self.gtp_remat_opt_in_modules) - _allowed_gtp_remat_opt_in_modules + assert not invalid, ( + f"Invalid choices for gtp_remat_opt_in_modules: {invalid}. " + f"Allowed modules are: {_allowed_gtp_remat_opt_in_modules}" + ) + if self.moe_paged_stash: if self.cpu_offloading: raise ValueError("moe_paged_stash cannot be enabled with cpu_offloading.") diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index eb871568046..2c7efa793d3 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -134,6 +134,7 @@ "fused_residual_rmsnorm": False, "fused_single_qkv_rope": False, "gated_linear_unit": False, + "gtp_remat_opt_in_modules": [], "gtp_weight_remat_size": 1, "glu_linear_offset": 0.0, "grad_scale_func": None, From db1ea81fade819e3749f7c75954c46b2a570275a Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene Date: Mon, 10 Aug 2026 14:29:14 -0500 Subject: [PATCH 236/290] Support RL eval-only runs (#6043) Signed-off-by: Teodor-Dumitru Ene Co-authored-by: Jon Barker --- megatron/training/training.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/megatron/training/training.py b/megatron/training/training.py index acdd727b82d..a64325cce40 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -1412,7 +1412,7 @@ def pretrain( ) print_rank_0(f'[RLProfiler] Profiling enabled, output: {profile_dir}') - if not cfg_container.validation.skip_train or args.perform_rl_step: + if not cfg_container.validation.skip_train or (args.perform_rl_step and args.do_train): if cfg_container.validation.skip_train: print_rank_0('RL inference-only mode (--skip-train --perform-rl-step) ...') else: @@ -1458,6 +1458,7 @@ def pretrain( print_rank_0('skipping training (--skip-train is on) ...') iteration = args.iteration + args.curr_iteration = iteration if args.do_valid: prefix = f'iteration {iteration} on validation set' @@ -4599,7 +4600,7 @@ def build_train_valid_test_data_loaders(build_train_valid_test_datasets_provider test_dataloader = None do_train = (args.train_iters or 0) > 0 do_valid = (args.full_validation or args.eval_iters > 0) - do_test = (args.full_validation or args.eval_iters > 0) + do_test = False else: # Build datasets. From 894928a732491ddfdc99d88ccc999be4946a2d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Mon, 10 Aug 2026 21:48:03 +0200 Subject: [PATCH 237/290] fix(ci): redact sensitive environment values from test output (#6406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../shell_test_utils/_run_training.sh | 25 +++++- .../shell_test_utils/run_ci_test.sh | 11 ++- .../test_training_script_secret_redaction.py | 84 +++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/test_utils/test_training_script_secret_redaction.py diff --git a/tests/functional_tests/shell_test_utils/_run_training.sh b/tests/functional_tests/shell_test_utils/_run_training.sh index 0ef6a9abcad..e304eb7795c 100644 --- a/tests/functional_tests/shell_test_utils/_run_training.sh +++ b/tests/functional_tests/shell_test_utils/_run_training.sh @@ -8,6 +8,23 @@ set -euxo pipefail +is_sensitive_env_name() { + local name="${1^^}" + [[ "$name" == *KEY* || "$name" == *TOKEN* || "$name" == *API* ]] +} + +export_env_assignment() { + local key="$1" + local value="$2" + + export "$key"="$value" + if is_sensitive_env_name "$key"; then + printf '%s=\n' "$key" + else + printf '%s=%s\n' "$key" "$value" + fi +} + set +x for ARGUMENT in "$@"; do KEY=$(echo $ARGUMENT | cut -f1 -d=) @@ -15,8 +32,7 @@ for ARGUMENT in "$@"; do KEY_LENGTH=${#KEY} VALUE="${ARGUMENT:$KEY_LENGTH+1}" - export "$KEY"="$VALUE" - echo "$KEY=$VALUE" + export_env_assignment "$KEY" "$VALUE" done set -x @@ -56,6 +72,7 @@ TRAINING_PARAMS_PATH="$TRAINING_PARAMS_PATH.tmp" set -x # Pull env vars to export +set +x ENV_VARS=$(/usr/local/bin/yq '... comments="" | .ENV_VARS | to_entries | .[] | [.key + "=" + .value] | join(" ")' "$TRAINING_PARAMS_PATH") while IFS= read -r ARGUMENT; do KEY=$(echo $ARGUMENT | cut -f1 -d=) @@ -63,9 +80,9 @@ while IFS= read -r ARGUMENT; do KEY_LENGTH=${#KEY} VALUE="${ARGUMENT:$KEY_LENGTH+1}" - export "$KEY"="$VALUE" - echo "$KEY=$VALUE" + export_env_assignment "$KEY" "$VALUE" done <<<"$ENV_VARS" +set -x # Run before script BEFORE_SCRIPT=$(cat "$TRAINING_PARAMS_PATH" | /usr/local/bin/yq '.BEFORE_SCRIPT') diff --git a/tests/functional_tests/shell_test_utils/run_ci_test.sh b/tests/functional_tests/shell_test_utils/run_ci_test.sh index ffd8360f34d..b58c37d7e23 100644 --- a/tests/functional_tests/shell_test_utils/run_ci_test.sh +++ b/tests/functional_tests/shell_test_utils/run_ci_test.sh @@ -2,6 +2,11 @@ set -exo pipefail +is_sensitive_env_name() { + local name="${1^^}" + [[ "$name" == *KEY* || "$name" == *TOKEN* || "$name" == *API* ]] +} + # Increase soft limit for number of open files to match hard limit ulimit -Sn $(ulimit -Hn) @@ -25,7 +30,11 @@ for ARGUMENT in "$@"; do # Properly quote the value to preserve spaces and special characters export "$KEY"="$(eval echo $VALUE)" - echo "$KEY=$VALUE" + if is_sensitive_env_name "$KEY"; then + printf '%s=\n' "$KEY" + else + printf '%s=%s\n' "$KEY" "$VALUE" + fi done set -x diff --git a/tests/test_utils/test_training_script_secret_redaction.py b/tests/test_utils/test_training_script_secret_redaction.py new file mode 100644 index 00000000000..2ae73379555 --- /dev/null +++ b/tests/test_utils/test_training_script_secret_redaction.py @@ -0,0 +1,84 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import os +import subprocess +from pathlib import Path + +RUN_CI_TEST = Path("tests/functional_tests/shell_test_utils/run_ci_test.sh") +RUN_TRAINING = Path("tests/functional_tests/shell_test_utils/_run_training.sh") +SENSITIVE_ASSIGNMENTS = { + "WANDB_API_KEY": "wandb-value-must-not-appear", + "CI_JOB_TOKEN": "token-value-must-not-appear", + "INTERNAL_API_URL": "api-value-must-not-appear", +} + + +def assert_sensitive_values_redacted(result): + for name, value in SENSITIVE_ASSIGNMENTS.items(): + assert value not in result.stdout + assert value not in result.stderr + assert f"{name}=" in result.stdout + + +def test_training_wrappers_redact_sensitive_arguments_from_output(): + sensitive_args = [f"{name}={value}" for name, value in SENSITIVE_ASSIGNMENTS.items()] + + for script in (RUN_CI_TEST, RUN_TRAINING): + result = subprocess.run( + ["bash", str(script), *sensitive_args, "VISIBLE_ENV=visible-value"], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 # Intentionally omit required training arguments. + assert_sensitive_values_redacted(result) + assert "VISIBLE_ENV=visible-value" in result.stdout + + +def test_run_training_redacts_sensitive_values_from_model_config(tmp_path): + fake_yq = tmp_path / "yq" + fake_yq.write_text("""#!/bin/bash +if [[ "$1" == *".ENV_VARS"* ]]; then + printf 'WANDB_API_KEY=%s\\nCI_JOB_TOKEN=%s\\nINTERNAL_API_URL=%s\\nVISIBLE_ENV=visible-value\\n' \ + "$TEST_KEY_VALUE" "$TEST_TOKEN_VALUE" "$TEST_API_VALUE" +elif [[ "$1" == ".BEFORE_SCRIPT" ]]; then + printf 'exit 0\\n' +else + printf 'null\\n' +fi +""") + fake_yq.chmod(0o755) + + script = tmp_path / "run_training.sh" + script.write_text(RUN_TRAINING.read_text().replace("/usr/local/bin/yq", str(fake_yq))) + config = tmp_path / "model_config.yaml" + config.write_text("ENV_VARS: {}\n") + + required_args = { + "TRAINING_SCRIPT_PATH": "unused.py", + "TRAINING_PARAMS_PATH": str(config), + "OUTPUT_PATH": str(tmp_path / "output"), + "TENSORBOARD_PATH": str(tmp_path / "tensorboard"), + "CHECKPOINT_SAVE_PATH": str(tmp_path / "save"), + "CHECKPOINT_LOAD_PATH": str(tmp_path / "load"), + "DATA_PATH": str(tmp_path / "data"), + "RUN_NUMBER": "1", + "REPEAT": "1", + } + result = subprocess.run( + ["bash", str(script), *(f"{key}={value}" for key, value in required_args.items())], + check=False, + capture_output=True, + env={ + **os.environ, + "TEST_KEY_VALUE": SENSITIVE_ASSIGNMENTS["WANDB_API_KEY"], + "TEST_TOKEN_VALUE": SENSITIVE_ASSIGNMENTS["CI_JOB_TOKEN"], + "TEST_API_VALUE": SENSITIVE_ASSIGNMENTS["INTERNAL_API_URL"], + }, + text=True, + ) + + assert result.returncode == 0 + assert_sensitive_values_redacted(result) + assert "VISIBLE_ENV=visible-value" in result.stdout From fa8319589e6cd10aafa7a314e537c50617396044 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Mon, 10 Aug 2026 13:32:30 -0700 Subject: [PATCH 238/290] Add --muon-use-syrk option for Triton SYRK kernel in Newton-Schulz (#6381) Signed-off-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 --- .../core/optimizer/emerging_optimizers.py | 29 +++++++++++++- megatron/core/optimizer/optimizer_config.py | 3 ++ megatron/core/utils.py | 34 +++++++++++++++++ megatron/training/arguments.py | 3 ++ tests/unit_tests/test_emerging_optimizers.py | 38 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/megatron/core/optimizer/emerging_optimizers.py b/megatron/core/optimizer/emerging_optimizers.py index 53ac956b35c..99d8605fba2 100644 --- a/megatron/core/optimizer/emerging_optimizers.py +++ b/megatron/core/optimizer/emerging_optimizers.py @@ -17,7 +17,13 @@ from torch.optim.optimizer import ParamsT from megatron.core.process_groups_config import ProcessGroupCollection -from megatron.core.utils import get_pg_rank, get_pg_size, log_single_rank +from megatron.core.utils import ( + get_emerging_optimizers_version, + get_pg_rank, + get_pg_size, + is_emerging_optimizers_min_version, + log_single_rank, +) from .optimizer_config import ParamKey, ParamPredicate @@ -43,6 +49,12 @@ logger = logging.getLogger(__name__) +# newton_schulz_tp() gained the use_syrk kwarg in emerging_optimizers 0.4.0. Earlier releases +# expose use_syrk on the non-TP newton_schulz() only, so 0.3.x still rejects it here. Spelled +# ".dev0" so pre-release builds of that line are accepted too, matching how the TE minimums +# elsewhere in the tree are written. +_SYRK_MIN_EO_VERSION = "0.4.0.dev0" + def get_supported_coefficient_types() -> tuple[str, ...]: """Return the coefficient types supported by the installed emerging_optimizers. @@ -178,9 +190,16 @@ def __init__( extra_scale_factor: float = 1.0, pg_collection: Optional[ProcessGroupCollection] = None, tp_mode: Literal["blockwise", "duplicated", "distributed"] = "duplicated", + use_syrk: bool = False, ) -> None: if num_ns_steps < 1: raise ValueError(f"num_ns_steps must be at least 1, got {num_ns_steps}") + if use_syrk and not is_emerging_optimizers_min_version(_SYRK_MIN_EO_VERSION): + raise ValueError( + f"use_syrk requires emerging_optimizers >= {_SYRK_MIN_EO_VERSION}, but " + f"{get_emerging_optimizers_version()} is installed. Upgrade " + "emerging_optimizers or drop --muon-use-syrk." + ) def scaled_orthogonalize_fn( grad: torch.Tensor, @@ -197,6 +216,9 @@ def scaled_orthogonalize_fn( size = [grad.size(-2), grad.size(-1)] if partition_dim is not None: size[partition_dim] *= get_pg_size(tp_group) + # Only forward the kwarg when enabled; older emerging_optimizers do not + # accept it at all, and __init__ has already rejected use_syrk on those. + ns_kwargs = {"use_syrk": True} if use_syrk else {} orth_grad = newton_schulz_tp( grad, steps=num_ns_steps, @@ -204,6 +226,7 @@ def scaled_orthogonalize_fn( tp_group=tp_group, partition_dim=partition_dim, tp_mode="duplicated" if tp_mode == "blockwise" else tp_mode, + **ns_kwargs, ) scale_factor = get_muon_scale_factor(size[0], size[1], mode=scale_mode) return orth_grad * scale_factor * extra_scale_factor @@ -353,6 +376,8 @@ class TensorParallelAdaptiveMuon(TensorParallelMuon, AdaptiveMuon): extra_scale_factor: The additional scale factor to use for the update. pg_collection: Process group collection for distributed training. tp_mode: Tensor parallel mode ("blockwise", "duplicated", or "distributed"). + use_syrk: Whether to use the Triton SYRK kernel for the Gram matrix in + Newton-Schulz. Requires emerging_optimizers >= 0.4.0. moment2_method: Method for second moment accumulation ("adamuon" or "normuon"). beta2: The exponential decay rate for second moment. eps: Small constant for numerical stability. @@ -376,6 +401,7 @@ def __init__( extra_scale_factor: float = 1.0, pg_collection: Optional[ProcessGroupCollection] = None, tp_mode: Literal["blockwise", "duplicated", "distributed"] = "duplicated", + use_syrk: bool = False, moment2_method: Literal["adamuon", "normuon"] = "adamuon", beta2: float = 0.95, eps: float = 1e-8, @@ -398,6 +424,7 @@ def __init__( extra_scale_factor=extra_scale_factor, pg_collection=pg_collection, tp_mode=tp_mode, + use_syrk=use_syrk, ) self.moment2_method = moment2_method diff --git a/megatron/core/optimizer/optimizer_config.py b/megatron/core/optimizer/optimizer_config.py index 24f9a032c47..20045108c89 100644 --- a/megatron/core/optimizer/optimizer_config.py +++ b/megatron/core/optimizer/optimizer_config.py @@ -282,6 +282,9 @@ class OptimizerConfig: muon_tp_mode: str = "blockwise" """How to perform NS calculation for tensor parallel weights. Defaults to "blockwise".""" + muon_use_syrk: bool = False + """Use the Triton SYRK kernel for the Gram matrix in Newton-Schulz iteration.""" + muon_extra_scale_factor: float = 1.0 """Additional scale factor for the muon update.""" diff --git a/megatron/core/utils.py b/megatron/core/utils.py index 72373e9ac3b..c29700eb709 100644 --- a/megatron/core/utils.py +++ b/megatron/core/utils.py @@ -73,6 +73,7 @@ _flashinfer_version = None _mamba_ssm_version = None _causal_conv1d_version = None +_emerging_optimizers_version = None _Wrapped = TypeVar('_Wrapped', bound=Callable) @@ -487,6 +488,39 @@ def is_flashinfer_min_version(version, check_equality=True): return flashinfer_version > PkgVersion(version) +def get_emerging_optimizers_version(): + """Get emerging_optimizers version from __version__; if not available use pip's. Use caching.""" + if not HAVE_PACKAGING: + raise ImportError( + "packaging is not installed. Please install it with `pip install packaging`." + ) + + def get_emerging_optimizers_version_str(): + import emerging_optimizers + + if hasattr(emerging_optimizers, "__version__"): + return str(emerging_optimizers.__version__) + else: + # The distribution name is hyphenated even though the module is not. + return version("emerging-optimizers") + + global _emerging_optimizers_version + if _emerging_optimizers_version is None: + _emerging_optimizers_version = PkgVersion(get_emerging_optimizers_version_str()) + return _emerging_optimizers_version + + +def is_emerging_optimizers_min_version(version, check_equality=True): + """Check if minimum version of `emerging_optimizers` is installed.""" + if not HAVE_PACKAGING: + raise ImportError( + "packaging is not installed. Please install it with `pip install packaging`." + ) + if check_equality: + return get_emerging_optimizers_version() >= PkgVersion(version) + return get_emerging_optimizers_version() > PkgVersion(version) + + _VALID_DSA_KERNEL_BACKENDS = ("none", "tilelang", "cudnn") diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index d4f9eb9c0de..5bdce87c0ed 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2537,6 +2537,9 @@ def _add_regularization_args(parser): group.add_argument('--muon-tp-mode', type=str, default='blockwise', choices=['blockwise', 'duplicated', 'distributed'], help='How to perform NS calculation for tensor model parallel weights') + group.add_argument('--muon-use-syrk', action='store_true', + help='Use the Triton SYRK kernel for the Gram matrix ' + 'in Newton-Schulz iteration.') group.add_argument('--muon-extra-scale-factor', type=float, default=1.0, help='Additional scale factor for the muon update') group.add_argument('--muon-scalar-optimizer', type=str, default='adam', diff --git a/tests/unit_tests/test_emerging_optimizers.py b/tests/unit_tests/test_emerging_optimizers.py index e3b9f666fb2..bb8919f4c72 100644 --- a/tests/unit_tests/test_emerging_optimizers.py +++ b/tests/unit_tests/test_emerging_optimizers.py @@ -1758,3 +1758,41 @@ def test_lion_optimizer_multi_layer_net(): params_updated += 1 assert params_updated > 0, "At least some parameters should be updated after optimizer step" + + +# =========================================================================== +# use_syrk version gate +# =========================================================================== + + +@pytest.mark.parametrize("optimizer_cls", [TensorParallelMuon, TensorParallelAdaptiveMuon]) +def test_muon_use_syrk_rejected_on_old_emerging_optimizers(monkeypatch, optimizer_cls): + """use_syrk must raise on emerging_optimizers < 0.4.0 rather than silently falling back. + + Covers TensorParallelAdaptiveMuon too, since it forwards use_syrk through + TensorParallelMuon.__init__ and that forwarding is what applies the gate to both. + """ + import megatron.core.optimizer.emerging_optimizers as eo_module + + monkeypatch.setattr(eo_module, "is_emerging_optimizers_min_version", lambda _version: False) + monkeypatch.setattr(eo_module, "get_emerging_optimizers_version", lambda: "0.2.0") + + model = torch.nn.Linear(60, 30, bias=False, dtype=torch.float32, device='cuda') + with pytest.raises(ValueError, match="use_syrk requires emerging_optimizers"): + optimizer_cls( + params=[model.weight], lr=0.01, pg_collection=None, tp_mode="duplicated", use_syrk=True + ) + + +@pytest.mark.parametrize("optimizer_cls", [TensorParallelMuon, TensorParallelAdaptiveMuon]) +def test_muon_use_syrk_default_off_ignores_version(monkeypatch, optimizer_cls): + """The gate only fires when use_syrk is requested; the default path stays version-agnostic.""" + import megatron.core.optimizer.emerging_optimizers as eo_module + + monkeypatch.setattr(eo_module, "is_emerging_optimizers_min_version", lambda _version: False) + + model = torch.nn.Linear(60, 30, bias=False, dtype=torch.float32, device='cuda') + optimizer = optimizer_cls( + params=[model.weight], lr=0.01, pg_collection=None, tp_mode="duplicated" + ) + assert optimizer is not None From 7d08fcabaadd8636581111f0d2865ef60382d9cc Mon Sep 17 00:00:00 2001 From: Antoni-Joan Solergibert Date: Mon, 10 Aug 2026 22:41:44 +0200 Subject: [PATCH 239/290] Add 1-node GB200 functional test for checkpoint load with load_optim=False (#6347) Signed-off-by: asolergi-nv --- .../golden_values_dev_dgx_gb200.json | 190 ++++++++++++++++++ .../model_config.yaml | 167 +++++++++++++++ .../recipes/gb200/nemotron-1node.yaml | 75 +++++++ 3 files changed, 432 insertions(+) create mode 100644 tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/golden_values_dev_dgx_gb200.json create mode 100644 tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml create mode 100644 tests/test_utils/recipes/gb200/nemotron-1node.yaml diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/golden_values_dev_dgx_gb200.json new file mode 100644 index 00000000000..80e381de4cf --- /dev/null +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/golden_values_dev_dgx_gb200.json @@ -0,0 +1,190 @@ +{ + "lm loss": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 6.73463, + "2": 6.75258, + "3": 6.94339, + "4": 7.10578, + "5": 7.04152, + "6": 6.89676, + "7": 6.55094, + "8": 6.73391, + "9": 6.82793, + "10": 6.61451, + "11": 6.61577, + "12": 6.36458, + "13": 6.58196, + "14": 6.30857, + "15": 6.02791, + "16": 6.25202, + "17": 6.70549, + "18": 7.04081, + "19": 6.87104, + "20": 6.80226 + } + }, + "num-zeros": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 41574424.0, + "2": 40866352.0, + "3": 41109720.0, + "4": 39958088.0, + "5": 40232688.0, + "6": 39298128.0, + "7": 38833600.0, + "8": 38997864.0, + "9": 36716064.0, + "10": 36583692.0, + "11": 35971540.0, + "12": 36313716.0, + "13": 35446016.0, + "14": 35045664.0, + "15": 34122316.0, + "16": 33854192.0, + "17": 33248644.0, + "18": 32408952.0, + "19": 32442504.0, + "20": 32759794.0 + } + }, + "mem-allocated-bytes": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 2150477824.0, + "2": 2150481408.0, + "3": 2150481408.0, + "4": 2150481408.0, + "5": 2150481408.0, + "6": 2150481408.0, + "7": 2150481408.0, + "8": 2150481408.0, + "9": 2150481408.0, + "10": 2150481408.0, + "11": 2150481408.0, + "12": 2150481408.0, + "13": 2150481408.0, + "14": 2150481408.0, + "15": 2150481408.0, + "16": 2150481408.0, + "17": 2150481408.0, + "18": 2150481408.0, + "19": 2150481408.0, + "20": 2150481408.0 + } + }, + "mem-max-allocated-bytes": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 6330623488.0, + "2": 6931712512.0, + "3": 6948292096.0, + "4": 6948292096.0, + "5": 6948292096.0, + "6": 6948292096.0, + "7": 6953188864.0, + "8": 6953188864.0, + "9": 6953188864.0, + "10": 6953188864.0, + "11": 6953188864.0, + "12": 6953188864.0, + "13": 6953188864.0, + "14": 6953188864.0, + "15": 6953188864.0, + "16": 6953188864.0, + "17": 6953188864.0, + "18": 6953188864.0, + "19": 6953188864.0, + "20": 6953188864.0 + } + }, + "mtp_1 loss": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 7.04846, + "2": 7.05258, + "3": 7.2243, + "4": 7.41523, + "5": 7.32119, + "6": 7.18303, + "7": 6.81504, + "8": 7.04759, + "9": 7.13946, + "10": 6.89996, + "11": 6.93183, + "12": 6.6725, + "13": 6.83364, + "14": 6.59629, + "15": 6.31277, + "16": 6.50048, + "17": 7.03556, + "18": 7.35246, + "19": 7.2095, + "20": 7.10924 + } + }, + "mtp_2 loss": { + "start_step": 1, + "end_step": 20, + "step_interval": 1, + "values": { + "1": 7.065, + "2": 7.07318, + "3": 7.23823, + "4": 7.42932, + "5": 7.33195, + "6": 7.20438, + "7": 6.83547, + "8": 7.07029, + "9": 7.15759, + "10": 6.91906, + "11": 6.94668, + "12": 6.694, + "13": 6.84687, + "14": 6.61255, + "15": 6.32224, + "16": 6.51333, + "17": 7.05428, + "18": 7.3698, + "19": 7.22892, + "20": 7.12733 + } + }, + "iteration-time": { + "start_step": 2, + "end_step": 20, + "step_interval": 1, + "values": { + "2": 51.46, + "3": 0.76026, + "4": 0.74884, + "5": 0.72507, + "6": 0.71553, + "7": 0.72443, + "8": 0.70733, + "9": 0.71905, + "10": 0.69802, + "11": 0.78199, + "12": 0.71092, + "13": 0.73925, + "14": 0.71338, + "15": 0.69999, + "16": 0.70744, + "17": 0.71716, + "18": 0.70879, + "19": 0.71, + "20": 0.73052 + } + } +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml new file mode 100644 index 00000000000..10d94d27f03 --- /dev/null +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml @@ -0,0 +1,167 @@ +ENV_VARS: + NCCL_NVLS_ENABLE: 0 + PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True" + TORCH_NCCL_AVOID_RECORD_STREAMS: 1 + TORCH_NCCL_HIGH_PRIORITY: 1 + CUDA_DEVICE_MAX_CONNECTIONS: 32 + NVTE_BWD_LAYERNORM_SM_MARGIN: 20 + NVTE_FWD_LAYERNORM_SM_MARGIN: 20 + NVTE_NORM_BWD_USE_CUDNN: 1 + NVTE_NORM_FWD_USE_CUDNN: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 1 + NON_DETERMINSTIC_RESULTS: 1 + +# Loads the frozen checkpoint from ${CHECKPOINT_LOAD_PATH} in a single run. +TEST_TYPE: frozen-start + +MODEL_ARGS: + # Distributed topology: 1 node x 4 GB200 GPUs. + --tensor-model-parallel-size: 1 + --pipeline-model-parallel-size: 1 + --context-parallel-size: 1 + --expert-model-parallel-size: 4 + --expert-tensor-parallel-size: 1 + --data-parallel-sharding-strategy: optim_grads_params + --use-distributed-optimizer: true + --overlap-grad-reduce: true + --overlap-param-gather: true + + # Functional-test training budget. + --micro-batch-size: 2 + --global-batch-size: 32 + --train-iters: 20 + --manual-gc: true + --manual-gc-interval: 100 + --cross-entropy-loss-fusion: true + # Bridge uses TE here, but current MCore rejects TE cross-entropy fusion. + --cross-entropy-fusion-impl: native + --attention-backend: fused + --te-rng-tracker: true + + # Nemotron 3.5 Lightning architecture, scaled down to fit a single node. + # The 6:6:2 Mamba/MoE/attention ratio mirrors the 23:23:6 of the 52-layer model. + --spec: "[megatron.core.models.hybrid.hybrid_layer_specs hybrid_stack_spec]" + --hybrid-layer-pattern: MEMEM*EMEMEM*E/*E/*E + --num-layers: 14 + --hidden-size: 768 + --ffn-hidden-size: 512 + --moe-ffn-hidden-size: 512 + --num-attention-heads: 8 + --group-query-attention: true + --num-query-groups: 2 + --kv-channels: 64 + --mamba-num-heads: 16 + --mamba-head-dim: 64 + --mamba-state-dim: 128 + --mamba-num-groups: 8 + --position-embedding-type: none + --normalization: RMSNorm + --norm-epsilon: 1.0e-5 + --untie-embeddings-and-output-weights: true + --disable-bias-linear: true + --squared-relu: true + --use-fused-weighted-squared-relu: true + --init-method-std: 0.0173 + --make-vocab-size-divisible-by: 128 + --transformer-impl: transformer_engine + + # Mixture of experts. alltoall keeps the test portable across GB200 hosts; + # the 2-node nightly variant uses the HybridEP flex dispatcher instead. + --num-experts: 32 + --moe-router-topk: 4 + --moe-router-topk-scaling-factor: 2.5 + --moe-router-num-groups: 1 + --moe-router-group-topk: 1 + --moe-router-score-function: sigmoid + --moe-router-enable-expert-bias: true + --moe-router-dtype: fp32 + --moe-router-load-balancing-type: seq_aux_loss + --moe-aux-loss-coeff: 1.0e-4 + --moe-shared-expert-intermediate-size: 1024 + --moe-token-dispatcher-type: alltoall + --moe-grouped-gemm: true + --moe-permute-fusion: true + + # Lightning multi-token prediction. The unified pattern above carries both depths. + --mtp-num-layers: 2 + --mtp-use-repeated-layer: true + --mtp-loss-scaling-factor: 0.3 + + # the_pile shard00 + GPT-2 BPE, the same corpus the other 1-node GB200 tests + # use, so it is already staged at ${DATA_PATH} on the CI runner. + --seq-length: 2048 + --max-position-embeddings: 2048 + --data-path: ${DATA_PATH}/text/the_pile/shard00/my-gpt3_00_text_document + --vocab-file: ${DATA_PATH}/text/the_pile/shard00/bpe/vocab.json + --merge-file: ${DATA_PATH}/text/the_pile/shard00/bpe/merges.txt + --split: 949,50,1 + --data-cache-path: ${DATA_CACHE_PATH} + --dataloader-type: single + --num-workers: 2 + --no-create-attention-mask-in-dataloader: true + + # BF16 compute and BF16 gradient reduction with FP32 optimizer state. + --bf16: true + --grad-reduce-in-bf16: true + --main-grads-dtype: fp32 + --main-params-dtype: fp32 + --exp-avg-dtype: fp32 + --exp-avg-sq-dtype: fp32 + + # Adam and cosine schedule inherited from Bridge. + --optimizer: adam + --lr: 1.6e-3 + --min-lr: 1.6e-5 + --lr-decay-style: cosine + --lr-decay-iters: 39735 + --lr-warmup-iters: 333 + --lr-warmup-init: 0.0 + --lr-wsd-decay-style: minus_sqrt + --adam-beta1: 0.9 + --adam-beta2: 0.95 + --adam-eps: 1.0e-8 + --weight-decay: 0.1 + --clip-grad: 1.0 + --override-opt-param-scheduler: true + --attention-dropout: 0.0 + --hidden-dropout: 0.0 + + # The point of this test: adopt the frozen checkpoint's weights but start the + # optimizer from scratch, the way SFT / post-training loads a pretrained model. + # --finetune resets the iteration counter to 0 so the run logs steps 1..20; + # --no-load-optim is what actually drops the optimizer state. + --load: ${CHECKPOINT_LOAD_PATH}/model/nemotron3_5_lightning_1n4g/v1 + --no-load-optim: true + --no-load-rng: true + --finetune: true + --save: ${CHECKPOINT_SAVE_PATH} + --save-interval: 50 + --ckpt-format: torch_dist + --ckpt-assume-constant-structure: true + --ckpt-fully-parallel-load: true + --dist-ckpt-strictness: log_all + + # Validation and functional metrics. eval-interval < train-iters so the + # evaluation loop actually runs during the test. + --eval-interval: 10 + --eval-iters: 5 + --log-interval: 1 + --log-num-zeros-in-grad: true + --log-validation-ppl-to-tensorboard: true + --log-timers-to-tensorboard: true + --log-memory-to-tensorboard: true + --tensorboard-dir: ${TENSORBOARD_PATH} + --timing-log-level: 0 + --seed: 1234 + +# iteration-time is deliberately not compared: it varies ~4% run-to-run against +# a 5% tolerance, and this is a correctness test, not a perf test. +METRICS: + - lm loss + - mtp_1 loss + - mtp_2 loss + - num-zeros + - mem-allocated-bytes + - mem-max-allocated-bytes + +LAUNCHER: ft_launcher diff --git a/tests/test_utils/recipes/gb200/nemotron-1node.yaml b/tests/test_utils/recipes/gb200/nemotron-1node.yaml new file mode 100644 index 00000000000..ee61a7246cd --- /dev/null +++ b/tests/test_utils/recipes/gb200/nemotron-1node.yaml @@ -0,0 +1,75 @@ +type: basic +format_version: 1 +maintainers: [mcore] +loggers: [stdout] +spec: + name: "{test_case}_{environment}_{platforms}" + model: nemotron + build: mcore-pyt-{environment} + nodes: 1 + gpus: 4 + n_repeat: 5 + platforms: dgx_gb200 + script_setup: | + set -euo pipefail + unset https_proxy + umask 077 + printf '%s\n' "machine gitlab-master.nvidia.com login okoenig password $RO_API_TOKEN" >> /root/.netrc + trap 'rm -f /root/.netrc; unset RO_API_TOKEN' EXIT + + # Checkout latest + cd /opt + rm -rf /opt/megatron-lm; mkdir megatron-lm; cd megatron-lm + git init + git remote add origin $MCORE_REPO + git fetch origin '+refs/merge-requests/*:refs/remotes/merge-requests/*' + git fetch origin $MCORE_MR_COMMIT + git checkout $MCORE_MR_COMMIT + git rev-parse HEAD + + # Checkout backwards-ref + cd /opt + rm -rf /opt/megatron-lm-legacy; mkdir megatron-lm-legacy; cd megatron-lm-legacy + git init + git remote add origin $MCORE_REPO + git fetch origin $MCORE_BACKWARDS_COMMIT + git checkout $MCORE_BACKWARDS_COMMIT + git rev-parse HEAD + rm -rf megatron; cp -a /opt/megatron-lm/megatron ./ + rm -f /root/.netrc + unset RO_API_TOKEN + script: |- + rm -f /root/.netrc + unset RO_API_TOKEN + set -euo pipefail + : "${{RUN_ID:=pr-$$}}" + cd /opt/megatron-lm + + export GPUS_PER_NODE={gpus} + + # DATA_PATH resolves the_pile shard00 corpus and its GPT-2 BPE files; the + # frozen checkpoint is read from + # ${{CHECKPOINT_LOAD_PATH}}/model/nemotron3_5_lightning_1n4g/v1. + ARGUMENTS=( + "DATA_PATH=/mnt/artifacts" + "DATA_CACHE_PATH={assets_dir}/cache" + "OUTPUT_PATH={assets_dir}" + "TENSORBOARD_PATH={assets_dir}/tensorboard" + "CHECKPOINT_SAVE_PATH={artifacts_dir}/checkpoints" + "CHECKPOINT_LOAD_PATH=/mnt/artifacts" + "TRAINING_SCRIPT_PATH=pretrain_hybrid.py" + "TRAINING_PARAMS_PATH=./tests/functional_tests/test_cases/{model}/{test_case}/model_config.yaml" + "GOLDEN_VALUES_PATH=./tests/functional_tests/test_cases/{model}/{test_case}/golden_values_{environment}_{platforms}.json" + "N_REPEAT={n_repeat}" + "ENABLE_LIGHTWEIGHT_MODE=${{ENABLE_LIGHTWEIGHT_MODE:-}}" + "RECORD_CHECKPOINTS=${{RECORD_CHECKPOINTS:-}}" + ) + + bash ./tests/functional_tests/shell_test_utils/run_ci_test.sh "${{ARGUMENTS[@]}}" + +products: + - test_case: [nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G] + products: + - environment: [dev] + scope: [mr-github] + platforms: [dgx_gb200] From 86e928a97f15804124d4116bcb1bbb2b22f8fb11 Mon Sep 17 00:00:00 2001 From: Deyu Fu Date: Tue, 11 Aug 2026 05:24:21 +0800 Subject: [PATCH 240/290] Selective-FP32 runtime and native-FP32 optimizer checkpoint lifecycle (#5929) Signed-off-by: Deyu Fu Signed-off-by: Hongxiao Bai Co-authored-by: Hongxiao Bai --- megatron/core/fp8_utils.py | 27 +++++++ .../cpu_offloading/hybrid_optimizer.py | 7 +- megatron/core/optimizer/optimizer.py | 40 +++++++++- megatron/core/transformer/module.py | 42 +++++++++- .../dist_checkpointing/test_optimizer.py | 80 +++++++++++++++++++ tests/unit_tests/test_fp8_utils.py | 25 ++++++ .../test_optimizer_cpu_offloading.py | 60 ++++++++++++++ tests/unit_tests/transformer/test_module.py | 17 +++- 8 files changed, 289 insertions(+), 9 deletions(-) diff --git a/megatron/core/fp8_utils.py b/megatron/core/fp8_utils.py index 895d46e9b3d..fc79cc9a7db 100644 --- a/megatron/core/fp8_utils.py +++ b/megatron/core/fp8_utils.py @@ -871,6 +871,29 @@ def get_fp8_context(config: TransformerConfig, layer_no: int = -1, is_init: bool return fp8_context + def get_fp8_disabled_context(config: TransformerConfig, is_init: bool = False): + """Return a context manager that disables TE quantization. + + Use this around submodule construction or execution that must stay in a higher + precision while its enclosing module uses an FP8 or FP4 context. + + Args: + config: Transformer configuration that controls quantization. + is_init: Whether to disable the parameter-initialization context instead of + the forward autocast context. + + Returns: + A disabled TE quantization context when quantization is active, otherwise a + no-op context. + """ + if is_init: + if not (config.fp8_param or config.fp4_param): + return nullcontext() + return transformer_engine.pytorch.fp8_model_init(enabled=False) + if not (config.fp8 or config.fp4): + return nullcontext() + return transformer_engine.pytorch.fp8_autocast(enabled=False) + else: def get_fp8_recipe(config: TransformerConfig): @@ -881,6 +904,10 @@ def get_fp8_context(config: TransformerConfig, layer_no: int = -1, is_init: bool """Returns dummy fp8 context manager since TE is not available.""" return nullcontext() + def get_fp8_disabled_context(config: TransformerConfig, is_init: bool = False): + """Return a no-op context manager since TE is not available.""" + return nullcontext() + if HAVE_TE: from transformer_engine.pytorch.fp8 import FP8GlobalStateManager diff --git a/megatron/core/optimizer/cpu_offloading/hybrid_optimizer.py b/megatron/core/optimizer/cpu_offloading/hybrid_optimizer.py index c87ccd5ff31..45bf910f84d 100644 --- a/megatron/core/optimizer/cpu_offloading/hybrid_optimizer.py +++ b/megatron/core/optimizer/cpu_offloading/hybrid_optimizer.py @@ -370,8 +370,11 @@ def _update_fp32_params_by_new_state(self): if not self.param_update_in_fp32: return for param, v in self.state.items(): - fp32_param = self.param_to_fp32_param[param] - fp32_param.data.copy_(v["master_param"]) + # Native FP32 params do not need a separate master parameter and are + # intentionally absent from param_to_fp32_param. + fp32_param = self.param_to_fp32_param.get(param) + if fp32_param is not None: + fp32_param.data.copy_(v["master_param"]) def update_fp32_param_by_new_param(self): """ diff --git a/megatron/core/optimizer/optimizer.py b/megatron/core/optimizer/optimizer.py index dac16f4a2ee..5252f6b86eb 100644 --- a/megatron/core/optimizer/optimizer.py +++ b/megatron/core/optimizer/optimizer.py @@ -1147,8 +1147,26 @@ def sharded_state_dict( state_dict = self.state_dict() + # Optimizer state ids enumerate the inner optimizer params: the fp32 main + # copies of float16 params, native fp32 params, and any frozen params, + # interleaved in the original param-group order. Map each fp32 main copy + # back to its model-side param; all other params already are model params. + main_param_id_to_model_param = { + id(main_param): model_param + for model_group, main_group in zip( + self.float16_groups, self.fp32_from_float16_groups, strict=True + ) + for model_param, main_param in zip(model_group, main_group, strict=True) + } + + def model_params_in_optimizer_order(): + for param in chain.from_iterable( + inner_group['params'] for inner_group in self.optimizer.param_groups + ): + yield main_param_id_to_model_param.get(id(param), param) + id_to_sharded_param_map = get_param_id_to_sharded_param_map( - model_sharded_state_dict, chain.from_iterable(g for g in self.float16_groups) + model_sharded_state_dict, model_params_in_optimizer_order() ) _backfill_gtp_sharded_param_map( @@ -1159,6 +1177,20 @@ def sharded_state_dict( assert len(state_dict['fp32_from_fp16_params']) == len( state_dict['optimizer']['param_groups'] ) + # State ids of the fp32 main copies only, skipping native fp32 and frozen params. + float16_param_ids_per_group = [] + for state_group, inner_group in zip( + state_dict['optimizer']['param_groups'], self.optimizer.param_groups, strict=True + ): + float16_param_ids_per_group.append( + [ + param_id + for param_id, param in zip( + state_group['params'], inner_group['params'], strict=True + ) + if id(param) in main_param_id_to_model_param + ] + ) state_dict['fp32_from_fp16_params'] = [ [ make_sharded_optimizer_tensor( @@ -1166,10 +1198,10 @@ def sharded_state_dict( fp32_param, prefix=f'optimizer.state.fp32_param', ) - for param_id, fp32_param in zip(state_group['params'], fp32_group) + for param_id, fp32_param in zip(param_ids, fp32_group, strict=True) ] - for fp32_group, state_group in zip( - state_dict['fp32_from_fp16_params'], state_dict['optimizer']['param_groups'] + for fp32_group, param_ids in zip( + state_dict['fp32_from_fp16_params'], float16_param_ids_per_group, strict=True ) ] diff --git a/megatron/core/transformer/module.py b/megatron/core/transformer/module.py index 558b1b07a15..bf28600a1aa 100644 --- a/megatron/core/transformer/module.py +++ b/megatron/core/transformer/module.py @@ -433,6 +433,40 @@ def float_conversion(val): return conversion_helper(val, float_conversion) +def mark_keep_in_fp32(tensor: torch.Tensor) -> torch.Tensor: + """Mark a parameter or buffer so that ``Float16Module`` keeps it in FP32. + + Args: + tensor: The parameter or buffer to mark. + + Returns: + The same tensor, for call-site convenience. + """ + tensor.keep_in_fp32 = True + return tensor + + +def convert_module_to_dtype_except_fp32_marked( + module: torch.nn.Module, dtype: torch.dtype +) -> torch.nn.Module: + """Cast floating-point parameters and buffers except those marked to stay in FP32. + + Args: + module: The module to convert in place. + dtype: The target floating-point dtype. + + Returns: + The converted module. + """ + return module._apply( + lambda tensor: ( + tensor.to(dtype) + if tensor.is_floating_point() and not getattr(tensor, 'keep_in_fp32', False) + else tensor + ) + ) + + class Float16Module(MegatronModule): """Float 16 Module. @@ -455,13 +489,17 @@ def __init__(self, config: TransformerConfig, module: torch.nn.Module): self.pg_collection = getattr(module, 'pg_collection', None) if self.fp16: - self.add_module('module', module.half()) + self.add_module( + 'module', convert_module_to_dtype_except_fp32_marked(module, torch.half) + ) def float16_convertor(val): return val.half() elif self.bf16: - self.add_module('module', module.bfloat16()) + self.add_module( + 'module', convert_module_to_dtype_except_fp32_marked(module, torch.bfloat16) + ) def float16_convertor(val): return val.bfloat16() diff --git a/tests/unit_tests/dist_checkpointing/test_optimizer.py b/tests/unit_tests/dist_checkpointing/test_optimizer.py index f93e09a43b7..7c319e0a14a 100644 --- a/tests/unit_tests/dist_checkpointing/test_optimizer.py +++ b/tests/unit_tests/dist_checkpointing/test_optimizer.py @@ -79,6 +79,27 @@ def sharded_state_dict(self): return sharded_state_dict +class NativeFp32Model(torch.nn.Module): + """Parameters for an interleaved trainable/frozen BF16 and FP32 group.""" + + def __init__(self): + super().__init__() + self.pre = torch.nn.Linear(8, 8, bias=False) + self.frozen = torch.nn.Linear(8, 8, bias=False) + self.frozen.weight.requires_grad_(False) + self.gate = torch.nn.Parameter(torch.zeros(24, dtype=torch.float32)) + self.post = torch.nn.Linear(8, 8, bias=False) + self.config = TransformerConfig( + hidden_size=8, num_attention_heads=1, num_layers=1, bf16=True + ) + + def sharded_state_dict(self): + return { + key: ShardedTensor.from_rank_offsets(key, value) + for key, value in self.state_dict(keep_vars=True).items() + } + + class SwigluFactoryModel(torch.nn.Module): def __init__(self, pp_separate_model: bool = False): super().__init__() @@ -238,6 +259,65 @@ def test_optimizer_params(self, tmp_path_dist_ckpt): ] ) + def test_float16_optimizer_with_native_fp32_and_frozen_params(self): + """Native FP32 and frozen param ids must not shift BF16 checkpoint state.""" + from megatron.core.optimizer import OptimizerConfig + from megatron.core.optimizer.optimizer import Float16OptimizerWithFloat16Params + from megatron.core.transformer.module import ( + convert_module_to_dtype_except_fp32_marked, + mark_keep_in_fp32, + ) + + Utils.initialize_model_parallel(1, 1) + model = NativeFp32Model().cuda() + model.gate = mark_keep_in_fp32(model.gate) + convert_module_to_dtype_except_fp32_marked(model, torch.bfloat16) + assert model.pre.weight.dtype == torch.bfloat16 + assert model.frozen.weight.dtype == torch.bfloat16 + assert not model.frozen.weight.requires_grad + assert model.gate.dtype == torch.float32 + assert model.post.weight.dtype == torch.bfloat16 + + # Use an explicit trainable BF16/frozen BF16/FP32/trainable BF16 order. + # Module.parameters() would yield the root gate before child parameters. + ordered_params = [model.pre.weight, model.frozen.weight, model.gate, model.post.weight] + for param in ordered_params: + if param.requires_grad: + param.grad = torch.zeros_like(param) + inner_optim = Adam(ordered_params) + inner_optim.step() + + optim = Float16OptimizerWithFloat16Params( + inner_optim, + OptimizerConfig(optimizer='adam', lr=1e-4, bf16=True), + None, + lambda opt, cfg: None, + ) + sharded_state_dict = optim.sharded_state_dict(model.sharded_state_dict()) + + # FP32 main copies pair with the BF16 params only, in optimizer order. + fp32_params = sharded_state_dict['fp32_from_fp16_params'][0] + assert [(sharded.key, tuple(sharded.data.shape)) for sharded in fp32_params] == [ + ('optimizer.state.fp32_param.pre.weight', (8, 8)), + ('optimizer.state.fp32_param.post.weight', (8, 8)), + ] + + # The frozen parameter has neither optimizer state nor an fp32 main copy. + state = sharded_state_dict['optimizer']['state'] + assert 1 not in state + + # Per-param state maps every trainable param, including native FP32, to the right key. + expected = {0: ('pre.weight', (8, 8)), 2: ('gate', (24,)), 3: ('post.weight', (8, 8))} + for param_id, (model_key, shape) in expected.items(): + for state_key in ('exp_avg', 'exp_avg_sq'): + sharded = state[param_id][state_key] + assert sharded.key == f'optimizer.state.{state_key}.{model_key}', sharded.key + assert tuple(sharded.data.shape) == shape, ( + param_id, + sharded.key, + sharded.data.shape, + ) + def initialize_pp_agnostic_model(pre_process=True, post_process=True, seed=0, **config_kwargs): torch.manual_seed(seed) diff --git a/tests/unit_tests/test_fp8_utils.py b/tests/unit_tests/test_fp8_utils.py index dc65d541455..2fc55962ae8 100644 --- a/tests/unit_tests/test_fp8_utils.py +++ b/tests/unit_tests/test_fp8_utils.py @@ -23,6 +23,31 @@ reason_for_no_mxfp8 = "MXFP8 requires Transformer Engine and device arch >= 10" +@pytest.mark.skipif(not fp8_utils.HAVE_TE, reason="Transformer Engine is not installed") +@pytest.mark.parametrize( + ("is_init", "config_values", "te_helper"), + [ + ( + False, + {"fp8": "hybrid", "fp4": None, "fp8_param": False, "fp4_param": False}, + "fp8_autocast", + ), + (True, {"fp8": None, "fp4": None, "fp8_param": True, "fp4_param": False}, "fp8_model_init"), + ], +) +def test_get_fp8_disabled_context_uses_disabled_te_context(is_init, config_values, te_helper): + config = Mock(**config_values) + disabled_context = Mock() + + with patch.object( + fp8_utils.transformer_engine.pytorch, te_helper, return_value=disabled_context + ) as te_context: + result = fp8_utils.get_fp8_disabled_context(config, is_init=is_init) + + assert result is disabled_context + te_context.assert_called_once_with(enabled=False) + + class MockTELinear(nn.Module): """Mock TE Linear module for testing.""" diff --git a/tests/unit_tests/test_optimizer_cpu_offloading.py b/tests/unit_tests/test_optimizer_cpu_offloading.py index 33febbb3eb0..379acc9dbda 100644 --- a/tests/unit_tests/test_optimizer_cpu_offloading.py +++ b/tests/unit_tests/test_optimizer_cpu_offloading.py @@ -17,6 +17,20 @@ from torch.optim import Adam as GPUAdam from megatron.core.optimizer.cpu_offloading import HybridDeviceOptimizer +from megatron.core.transformer.module import ( + convert_module_to_dtype_except_fp32_marked, + mark_keep_in_fp32, +) + + +class Fp32MarkedToyNet(nn.Module): + def __init__(self): + super().__init__() + self.proj = nn.Linear(4, 4, bias=False) + self.scale = mark_keep_in_fp32(nn.Parameter(torch.ones(4))) + + def forward(self, x): + return self.proj(x) * self.scale class Net(nn.Module): @@ -71,6 +85,52 @@ def setup_seed(seed): torch.backends.cudnn.benchmark = False # Disable auto-tuner for reproducibility +def test_load_state_dict_with_native_fp32_param(): + """Round-trip state for a BF16 toy net with a parameter marked to stay in FP32.""" + model = Fp32MarkedToyNet().cuda() + convert_module_to_dtype_except_fp32_marked(model, torch.bfloat16) + assert model.proj.weight.dtype == torch.bfloat16 + assert model.scale.dtype == torch.float32 + + optimizer = HybridDeviceOptimizer( + model.parameters(), + offload_fraction=1.0, + cpu_optimizer_cls=Adam, + gpu_optimizer_cls=GPUAdam, + param_update_in_fp32=True, + overlap_cpu_optimizer_d2h_h2d=False, + lr=1e-3, + ) + inputs = torch.ones(2, 4, device="cuda", dtype=torch.bfloat16) + model(inputs).sum().backward() + optimizer.step() + + restored_model = Fp32MarkedToyNet().cuda() + convert_module_to_dtype_except_fp32_marked(restored_model, torch.bfloat16) + restored_model.load_state_dict(model.state_dict()) + restored_optimizer = HybridDeviceOptimizer( + restored_model.parameters(), + offload_fraction=1.0, + cpu_optimizer_cls=Adam, + gpu_optimizer_cls=GPUAdam, + param_update_in_fp32=True, + overlap_cpu_optimizer_d2h_h2d=False, + lr=1e-3, + ) + restored_optimizer.load_state_dict(optimizer.state_dict()) + + assert set(restored_optimizer.state) == set(restored_model.parameters()) + assert restored_model.proj.weight in restored_optimizer.param_to_fp32_param + assert restored_model.scale not in restored_optimizer.param_to_fp32_param + assert torch.equal( + restored_optimizer.param_to_fp32_param[restored_model.proj.weight], + optimizer.param_to_fp32_param[model.proj.weight], + ) + + restored_model(inputs).sum().backward() + restored_optimizer.step() + + @pytest.mark.skipif( torch.__version__ < '2.3.0', reason=( diff --git a/tests/unit_tests/transformer/test_module.py b/tests/unit_tests/transformer/test_module.py index 92f15b2f46d..5faf6c81ef1 100644 --- a/tests/unit_tests/transformer/test_module.py +++ b/tests/unit_tests/transformer/test_module.py @@ -4,7 +4,7 @@ import torch from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.module import Float16Module, MegatronModule +from megatron.core.transformer.module import Float16Module, MegatronModule, mark_keep_in_fp32 from megatron.core.transformer.transformer_config import TransformerConfig from tests.unit_tests.test_utilities import Utils @@ -163,3 +163,18 @@ def test_bf16_module(self): x = torch.ones((2, 2)).cuda() # inputs are converted to bf16 then outputs are converted to fp32 assert bf16_module(x).dtype == torch.float32 + + @pytest.mark.parametrize( + ('precision', 'dtype'), [('fp16', torch.float16), ('bf16', torch.bfloat16)] + ) + def test_keep_in_fp32_params(self, precision, dtype): + transformer_config = self.transformer_config + megatron_module = self.megatron_module + megatron_module.fp32_param = mark_keep_in_fp32( + torch.nn.Parameter(torch.zeros(4, dtype=torch.float32, device='cuda')) + ) + setattr(transformer_config, precision, True) + float16_module = Float16Module(config=transformer_config, module=megatron_module) + + assert float16_module.module.linear.weight.dtype == dtype + assert float16_module.module.fp32_param.dtype == torch.float32 From 67a355e01556de65c640f7aef264c0eb7942c881 Mon Sep 17 00:00:00 2001 From: JavaZero <71128095+JavaZeroo@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:44:43 +0800 Subject: [PATCH 241/290] Update docstring for moe_router_topk_scaling_factor (#2178) Signed-off-by: JavaZeroo <2487163254@qq.com> Co-authored-by: Guihong Li --- megatron/core/transformer/transformer_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 8dfe3506be1..de7f6f3fd35 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -781,8 +781,8 @@ class TransformerConfig(ModelParallelConfig): By default, softmax is done after top-k.""" moe_router_topk_scaling_factor: Optional[float] = None - """Scaling factor for routing score in top-k selection, only works when moe_router_pre_softmax - enabled. Defaults to None, which means no scaling.""" + """Scaling factor for routing score in top-k selection. Defaults to None, which means no + scaling.""" moe_router_score_function: Literal['softmax', 'sigmoid', 'sqrtsoftplus'] = "softmax" """Score function for MoE routing. Can be "softmax", "sigmoid" or "sqrtsoftplus".""" From 105445d1ba4e7877578396dea0599a4b8721c33d Mon Sep 17 00:00:00 2001 From: Antoni-Joan Solergibert Date: Tue, 11 Aug 2026 01:59:09 +0200 Subject: [PATCH 242/290] Add --ckpt-drop-redundant-extra-state to skip persisting redundant TE _extra_state (#5552) Signed-off-by: asolergi-nv --- megatron/training/arguments.py | 11 ++ megatron/training/checkpointing.py | 93 +++++++++++++- .../test_drop_redundant_extra_state.py | 120 ++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/dist_checkpointing/test_drop_redundant_extra_state.py diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 5bdce87c0ed..e54f8a22252 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -2896,6 +2896,17 @@ def _add_checkpointing_args(parser): group.add_argument('--ckpt-fully-parallel-save', action='store_true', dest='ckpt_fully_parallel_save_deprecated', help='Deprecated: see --no-ckpt-fully-parallel-save.') + group.add_argument('--ckpt-drop-redundant-extra-state', action='store_true', + default=False, + help='Drop TE `_extra_state` artifacts that carry no ' + 'irreplaceable state (no FP8, or block/current FP8 ' + 'scaling) from the distributed checkpoint, keeping them ' + 'local instead of writing them. Only delayed-scaling ' + '`_extra_state` (amax history + scale) is ever needed and ' + 'it is always persisted regardless of this flag. By ' + 'default (flag off) all `_extra_state` are persisted, ' + 'preserving the previous behavior. Older checkpoints load ' + 'unchanged either way.') return parser diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index ded7fc4d70f..88559933f97 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -25,7 +25,8 @@ from torch.distributed.checkpoint import FileSystemReader, default_planner from megatron.core import dist_checkpointing, mpu, tensor_parallel -from megatron.core.dist_checkpointing.mapping import ShardedObject +from megatron.core.dist_checkpointing.dict_utils import dict_list_map_inplace +from megatron.core.dist_checkpointing.mapping import LocalNonpersistentObject, ShardedObject from megatron.core.dist_checkpointing.strategies.async_utils import _disable_gc from megatron.core.dist_checkpointing.strategies.fully_parallel import ( FullyParallelLoadStrategyWrapper, @@ -1434,9 +1435,99 @@ def generate_state_dict( if not args.no_save_rng and rng_state: state_dict['rng_state'] = rng_state + # Optionally avoid persisting TE `_extra_state` artifacts that carry no + # irreplaceable state (see `_localize_redundant_extra_states`). Opt-in via + # --ckpt-drop-redundant-extra-state (off by default, preserving prior behavior). + # Applied here so the SAME decision is used for both the save state dict and + # the load *request* (`generate_state_dict` is the shared chokepoint), which + # keeps the two symmetric and backward compatible. + if args.ckpt_format == "torch_dist" and getattr( + args, "ckpt_drop_redundant_extra_state", False + ): + _localize_redundant_extra_states(state_dict) + return state_dict +# Byte markers of the dict keys that TE `get_extra_state` writes ONLY under +# `if recipe.delayed():` (see TransformerEngine `module/base.py`). Their presence +# in the serialized payload is a robust, unpickle-free signal that the +# `_extra_state` carries persistent delayed-scaling state (amax history + scale). +_FP8_PERSISTENT_EXTRA_STATE_MARKERS = (b"amax_history_fwd", b"scale_fwd", b"scale_bwd") + + +def _fp8_extra_state_is_persistent(data) -> bool: + """Whether a TE `_extra_state` payload must be checkpointed. + + A `_extra_state` is worth persisting only when it carries state that cannot + be reconstructed from the run config at model-build time, i.e. the + delayed-scaling FP8 amax history + scale. Concretely: + + * ``None`` / empty ``uint8`` tensor -> FP8 disabled -> NOT persistent + * non-empty, no delayed-scaling key -> block/current scaling (NVFP4, MXFP8, + Float8CurrentScaling): recipe + scalars only, all config-derived + -> NOT persistent + * non-empty, has ``scale_fwd`` / ``amax_history_fwd`` / ``scale_bwd`` + -> delayed scaling -> persistent + + The check operates on the raw serialized bytes (the uint8 tensor that + ``get_extra_state`` returns) so it needs no unpickling and no TE import. + Anything unrecognized defaults to persistent, so real state is never + silently dropped. + """ + if data is None: + return False + if isinstance(data, torch.Tensor): + if data.numel() == 0: + return False + try: + raw = data.detach().cpu().contiguous().numpy().tobytes() + except Exception: + return True + return any(marker in raw for marker in _FP8_PERSISTENT_EXTRA_STATE_MARKERS) + # Unknown payload type: keep it persistent to be safe. + return True + + +def _localize_redundant_extra_states(state_dict): + """Rewrite non-persistent TE `_extra_state` ShardedObjects as local objects. + + For checkpoints with no FP8, or with block/current FP8 scaling (NVFP4, + MXFP8, Float8CurrentScaling), every `_extra_state` is either empty or a + recipe-only blob that the freshly-built model reproduces from config — see + `_fp8_extra_state_is_persistent`. Such artifacts are pure overhead: in a + large MoE model they account for the overwhelming majority of the + checkpoint's ShardedObjects (e.g. ~50k on the 55B hybrid-MoE run). + + Wrapping them in ``LocalNonpersistentObject`` (instead of ``ShardedObject``) + means, via the dist-checkpointing pipeline: + + * SAVE: ``save_preprocess`` drops them, so they are never written to disk. + * LOAD: ``load_preprocess`` unwraps them back into the loaded state dict with + the local (freshly-built) value, so the module's ``_extra_state`` key is + still present (strict ``load_state_dict`` stays happy) and + ``set_extra_state`` no-ops on the empty/recipe payload. + + Because this runs in ``generate_state_dict`` (shared by save and the load + request), the decision is symmetric: dropped keys are never *requested*, so + loading an older checkpoint that still stores them simply does not read them + (they are neither "missing" nor "unexpected" in the request) — backward + compatible. Delayed-scaling `_extra_state` stays a ``ShardedObject`` and is + saved/loaded exactly as before. + """ + + def _maybe_localize(value): + if ( + isinstance(value, ShardedObject) + and value.key.endswith("_extra_state") + and not _fp8_extra_state_is_persistent(value.data) + ): + return LocalNonpersistentObject(value.data) + return value + + dict_list_map_inplace(_maybe_localize, state_dict) + + def preprocess_fsdp_dtensor_state_dict(args, raw_state_dict, model): state_dict = raw_state_dict.copy() handle_fp8_extra_state_case(state_dict['model']) diff --git a/tests/unit_tests/dist_checkpointing/test_drop_redundant_extra_state.py b/tests/unit_tests/dist_checkpointing/test_drop_redundant_extra_state.py new file mode 100644 index 00000000000..ed542cc4f8b --- /dev/null +++ b/tests/unit_tests/dist_checkpointing/test_drop_redundant_extra_state.py @@ -0,0 +1,120 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. + +"""Unit tests for the ``--ckpt-drop-redundant-extra-state`` optimization. + +These cover the two pure-logic helpers that decide which TE ``_extra_state`` +artifacts are redundant and rewrite them as local (non-persistent) objects: +``_fp8_extra_state_is_persistent`` and ``_localize_redundant_extra_states``. + +The logic operates on the raw serialized ``_extra_state`` bytes, so it is +exercised here with TE-format payloads built the same way TE's +``get_extra_state`` does (a ``torch.save`` of a small dict into a ``uint8`` +tensor). This needs no FP8 hardware, so it runs identically on FP8-capable CI +(H100 / GB200) and elsewhere. +""" + +import io + +import pytest +import torch + +from megatron.core.dist_checkpointing.mapping import ( + LocalNonpersistentObject, + ShardedObject, + ShardedTensor, +) +from megatron.training.checkpointing import ( + _fp8_extra_state_is_persistent, + _localize_redundant_extra_states, +) + + +def _te_like_extra_state(payload: dict) -> torch.Tensor: + """Serialize ``payload`` the way TE's ``get_extra_state`` does: a + ``torch.save`` of a dict into a ``uint8`` byte tensor.""" + buf = io.BytesIO() + torch.save(payload, buf) + return torch.frombuffer(bytearray(buf.getvalue()), dtype=torch.uint8) + + +# A delayed-scaling FP8 payload carries amax history + scale buffers -> persistent. +_DELAYED = { + "amax_history_fwd": torch.zeros(3), + "scale_fwd": torch.ones(1), + "scale_bwd": torch.ones(1), +} +# Block / current scaling (NVFP4, MXFP8, Float8CurrentScaling) only stores +# recipe + config-derived scalars, none of the delayed-scaling markers. +_RECIPE_ONLY = {"recipe": "Float8CurrentScaling", "global_steps": 7} + + +class TestFp8ExtraStateIsPersistent: + def test_none_is_not_persistent(self): + assert _fp8_extra_state_is_persistent(None) is False + + def test_empty_tensor_is_not_persistent(self): + # FP8 disabled: get_extra_state returns an empty uint8 tensor. + assert _fp8_extra_state_is_persistent(torch.empty(0, dtype=torch.uint8)) is False + + def test_recipe_only_is_not_persistent(self): + # Block / current scaling: no delayed-scaling markers in the payload. + assert _fp8_extra_state_is_persistent(_te_like_extra_state(_RECIPE_ONLY)) is False + + @pytest.mark.parametrize("marker", ["amax_history_fwd", "scale_fwd", "scale_bwd"]) + def test_delayed_scaling_markers_are_persistent(self, marker): + # Any one delayed-scaling marker is enough to keep the payload. + assert ( + _fp8_extra_state_is_persistent(_te_like_extra_state({marker: torch.zeros(2)})) is True + ) + + def test_full_delayed_payload_is_persistent(self): + assert _fp8_extra_state_is_persistent(_te_like_extra_state(_DELAYED)) is True + + def test_unknown_payload_defaults_to_persistent(self): + # Anything that is not a tensor is kept, so real state is never dropped. + assert _fp8_extra_state_is_persistent({"not": "a tensor"}) is True + + +class TestLocalizeRedundantExtraStates: + def _sharded_object(self, key, data): + return ShardedObject(key, data, (1,), (0,)) + + def test_localizes_only_redundant_extra_states(self): + empty_es = self._sharded_object("decoder.0.self_attention._extra_state", None) + recipe_es = self._sharded_object( + "decoder.0.mlp._extra_state", _te_like_extra_state(_RECIPE_ONLY) + ) + delayed_es = self._sharded_object( + "decoder.1.mlp._extra_state", _te_like_extra_state(_DELAYED) + ) + # A ShardedObject that is NOT an _extra_state must be left alone. + other_obj = self._sharded_object("decoder.0.metadata", {"foo": "bar"}) + weight = ShardedTensor.from_rank_offsets("decoder.0.weight", torch.zeros(4), replica_id=0) + + state_dict = { + "empty_es": empty_es, + "recipe_es": recipe_es, + "delayed_es": delayed_es, + "other_obj": other_obj, + "weight": weight, + } + _localize_redundant_extra_states(state_dict) + + # Redundant (empty / recipe-only) _extra_state -> dropped on save. + assert isinstance(state_dict["empty_es"], LocalNonpersistentObject) + assert isinstance(state_dict["recipe_es"], LocalNonpersistentObject) + # The wrapped value is preserved so load can restore the key locally. + assert state_dict["recipe_es"].unwrap() is recipe_es.data + # Delayed-scaling _extra_state stays a ShardedObject (still checkpointed). + assert state_dict["delayed_es"] is delayed_es + # Non-_extra_state objects and tensors are untouched. + assert state_dict["other_obj"] is other_obj + assert state_dict["weight"] is weight + + def test_noop_when_no_redundant_extra_states(self): + delayed_es = self._sharded_object( + "decoder.0.mlp._extra_state", _te_like_extra_state(_DELAYED) + ) + state_dict = {"delayed_es": delayed_es} + _localize_redundant_extra_states(state_dict) + assert state_dict["delayed_es"] is delayed_es From 0c1fc4c5c91d03c085900da1fed3cbb3dda9ce84 Mon Sep 17 00:00:00 2001 From: Xuanteng Huang <44627253+xuantengh@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:23:06 +0800 Subject: [PATCH 243/290] Bump FLA to 0.5.1 and integrate GDN2 kernels (#5765) Signed-off-by: Xuanteng Huang --- ...rimental_attention_variant_module_specs.py | 66 ++- megatron/core/models/hybrid/hybrid_block.py | 10 +- megatron/core/ssm/gated_delta_net/__init__.py | 13 +- megatron/core/ssm/gated_delta_net/common.py | 288 ++++++----- megatron/core/ssm/gated_delta_net/gdn.py | 191 +++++-- megatron/core/ssm/gated_delta_net/gdn2.py | 475 ++++++++++++++++++ .../core/transformer/transformer_config.py | 43 +- megatron/training/training.py | 27 +- pyproject.toml | 2 +- ...rimental_attention_variant_module_specs.py | 11 +- tests/unit_tests/ssm/test_gated_delta_net.py | 205 ++++++-- uv.lock | 17 +- 12 files changed, 1070 insertions(+), 278 deletions(-) create mode 100644 megatron/core/ssm/gated_delta_net/gdn2.py diff --git a/megatron/core/models/gpt/experimental_attention_variant_module_specs.py b/megatron/core/models/gpt/experimental_attention_variant_module_specs.py index a76fe6e3a23..87c63a126bb 100644 --- a/megatron/core/models/gpt/experimental_attention_variant_module_specs.py +++ b/megatron/core/models/gpt/experimental_attention_variant_module_specs.py @@ -1,10 +1,11 @@ # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +import warnings from typing import List, Optional from megatron.core.fusions.fused_bias_dropout import get_bias_dropout_add from megatron.core.models.backends import BackendSpecProvider -from megatron.core.ssm.gated_delta_net import GatedDeltaNet, GatedDeltaNetSubmodules +from megatron.core.ssm.gated_delta_net import GatedDeltaNet, GatedDeltaNet2, GatedDeltaNetSubmodules from megatron.core.transformer.enums import AttnMaskType, LayerType from megatron.core.transformer.experimental_attention_variant.absorbed_mla import ( AbsorbedMLASelfAttention, @@ -52,6 +53,17 @@ HAVE_KITCHEN = False +########## +# Experimental Attention Variant Names +########## + +# Canonical ``experimental_attention_variant`` names served by the gated delta net family. +GDN_ATTENTION_VARIANTS = ("gdn", "gdn2") + +# Deprecated ``experimental_attention_variant`` spellings mapped to their canonical name. +_DEPRECATED_ATTENTION_VARIANT_ALIASES = {"gated_delta_net": "gdn"} + + ########## # Experimental Attention Variant Module Specs ########## @@ -66,8 +78,12 @@ def get_gated_delta_net_module_spec( backend = _get_backend_spec_provider(config=config) rms_norm = config.normalization == "RMSNorm" + # gdn2 reuses the GDN submodules and spec structure with the GatedDeltaNet2 module. + gdn_module = ( + GatedDeltaNet2 if config.experimental_attention_variant == "gdn2" else GatedDeltaNet + ) attention = ModuleSpec( - module=GatedDeltaNet, + module=gdn_module, submodules=GatedDeltaNetSubmodules( in_proj=backend.column_parallel_layer_norm_linear(), out_norm=backend.layer_norm(rms_norm=rms_norm, for_qk=False), @@ -138,7 +154,7 @@ def get_experimental_attention_variant_module_spec( if backend is None: backend = _get_backend_spec_provider(config=config) - if config.experimental_attention_variant == "gated_delta_net": + if is_gated_delta_net_variant(config.experimental_attention_variant): return get_gated_delta_net_module_spec(config=config, backend=backend) elif config.experimental_attention_variant == "dsa": return get_dsa_module_spec_for_backend(config=config, backend=backend) @@ -330,10 +346,50 @@ def get_transformer_block_with_experimental_attention_variant_spec( ########## +def normalize_experimental_attention_variant( + experimental_attention_variant: Optional[str], +) -> Optional[str]: + """Resolve a deprecated ``experimental_attention_variant`` spelling to its canonical name. + + ``gated_delta_net`` is the deprecated spelling of ``gdn``. Passing it emits a + ``DeprecationWarning`` and returns the canonical name so that every downstream + consumer only has to handle ``gdn``. + + Args: + experimental_attention_variant: The configured variant name, possibly a + deprecated alias. + + Returns: + The canonical variant name, or the argument unchanged when it is not an alias. + """ + canonical = _DEPRECATED_ATTENTION_VARIANT_ALIASES.get(experimental_attention_variant) + if canonical is None: + return experimental_attention_variant + + warnings.warn( + f"experimental_attention_variant='{experimental_attention_variant}' is deprecated " + f"and will be removed in a future release. Use '{canonical}' instead.", + DeprecationWarning, + stacklevel=2, + ) + return canonical + + +def is_gated_delta_net_variant(experimental_attention_variant: Optional[str]) -> bool: + """Check if the experimental attention variant is served by a gated delta net layer. + + Accepts the deprecated ``gated_delta_net`` spelling without warning; use + :func:`normalize_experimental_attention_variant` to emit the deprecation notice. + """ + canonical = _DEPRECATED_ATTENTION_VARIANT_ALIASES.get( + experimental_attention_variant, experimental_attention_variant + ) + return canonical in GDN_ATTENTION_VARIANTS + + def is_linear_attention_variant(experimental_attention_variant: Optional[str]) -> bool: """Check if the experimental attention variant is a linear attention variant.""" - linear_attention_variants = ["gated_delta_net"] - return experimental_attention_variant in linear_attention_variants + return is_gated_delta_net_variant(experimental_attention_variant) def _validate_dsa_index_share_pipeline_split(config: TransformerConfig, local_layer_ids) -> None: diff --git a/megatron/core/models/hybrid/hybrid_block.py b/megatron/core/models/hybrid/hybrid_block.py index 0042cbea010..49cc938eb02 100644 --- a/megatron/core/models/hybrid/hybrid_block.py +++ b/megatron/core/models/hybrid/hybrid_block.py @@ -191,8 +191,16 @@ def __init__( name=(name + f".layers.{i}") if name is not None else None, ) elif layer_type == LayerSymbols.GDN: + gdn_layer_spec = submodules.gdn_layer + if self.config.experimental_attention_variant == "gdn2": + # 'G' layers build the GDN2 variant when the gdn2 experimental + # attention variant is selected. + from megatron.core.ssm.gated_delta_net import GatedDeltaNet2 + + gdn_layer_spec = copy.deepcopy(gdn_layer_spec) + gdn_layer_spec.submodules.self_attention.module = GatedDeltaNet2 layer = build_module( - submodules.gdn_layer, + gdn_layer_spec, config=self.config, layer_number=layer_number, pg_collection=pg_collection, diff --git a/megatron/core/ssm/gated_delta_net/__init__.py b/megatron/core/ssm/gated_delta_net/__init__.py index 6514f7b3a87..e5a5b4e6a9f 100644 --- a/megatron/core/ssm/gated_delta_net/__init__.py +++ b/megatron/core/ssm/gated_delta_net/__init__.py @@ -15,19 +15,28 @@ l2norm, tensor_a2a_cp2hp, tensor_a2a_hp2cp, - torch_chunk_gated_delta_rule, ) -from megatron.core.ssm.gated_delta_net.gdn import GatedDeltaNet +from megatron.core.ssm.gated_delta_net.gdn import GatedDeltaNet, torch_chunk_gated_delta_rule +from megatron.core.ssm.gated_delta_net.gdn2 import ( + HAVE_FLA_GDN2, + GatedDeltaNet2, + chunk_gdn2, + torch_chunk_gdn2, +) __all__ = [ "HAVE_FLA", + "HAVE_FLA_GDN2", "GatedDeltaNet", + "GatedDeltaNet2", "GatedDeltaNetSubmodules", "causal_conv1d", "chunk_gated_delta_rule", + "chunk_gdn2", "get_parameter_local_cp", "l2norm", "tensor_a2a_cp2hp", "tensor_a2a_hp2cp", "torch_chunk_gated_delta_rule", + "torch_chunk_gdn2", ] diff --git a/megatron/core/ssm/gated_delta_net/common.py b/megatron/core/ssm/gated_delta_net/common.py index 7ddaf6c3a1b..6802cfa3ded 100644 --- a/megatron/core/ssm/gated_delta_net/common.py +++ b/megatron/core/ssm/gated_delta_net/common.py @@ -69,7 +69,10 @@ class GatedDeltaNetSubmodules: class GatedDeltaRuleInterface(Protocol): """ - Unified typing protocol for GDN core computation interfaces. + Unified typing protocol for linear attention interfaces, compliant to upstream FLA interfaces. + + Only ``q``/``k``/``v``/``g`` are common to every kernel, and only as keywords: each + variant inserts its own gates after ``g`` (e.g., ``beta`` for GDN, ``b``/``w`` for GDN2). """ def __call__( @@ -78,6 +81,7 @@ def __call__( k: torch.Tensor, v: torch.Tensor, g: torch.Tensor, + *, scale: float | None = None, initial_state: torch.Tensor | None = None, output_final_state: bool = False, @@ -296,18 +300,26 @@ def _setup_variant_attrs(self): """ raise NotImplementedError + def _reset_dt_bias(self): + """Initialize ``dt_bias``. Called from ``reset_parameters`` under the RNG tracker. + + Defaults to ones; variants whose kernel expects a different step-size + parametrization override this. + """ + torch.ones( + self.dt_bias_dim, + dtype=self.config.params_dtype, + device=torch.cuda.current_device(), + out=self.dt_bias.data, + ) + def reset_parameters(self): """Reset the parameters.""" if self.config.perform_initialization: with get_cuda_rng_tracker().fork(): if self.conv_init is not None: nn.init.uniform_(self.conv1d.weight, -self.conv_init, self.conv_init) - torch.ones( - self.dt_bias_dim, - dtype=self.config.params_dtype, - device=torch.cuda.current_device(), - out=self.dt_bias.data, - ) + self._reset_dt_bias() A = torch.empty( self.A_log.shape[0], dtype=self.config.params_dtype, @@ -348,23 +360,9 @@ def _gated_norm_and_a2a( norm_out_hp = norm_out_hp.reshape(batch, seq_len, -1) norm_out_hp = norm_out_hp.transpose(0, 1).contiguous() - # CP all to all: HP to CP - if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': - if self.cp_size > 1: - norm_out_hp = norm_out_hp.index_select(0, thd_cp_a2a_inv) - norm_out = tensor_a2a_hp2cp( - norm_out_hp, - seq_dim=0, - head_dim=-1, - cp_group=self.pg_collection.cp, - redo_attention_load_balancing=False, - ) - else: - norm_out = tensor_a2a_hp2cp( - norm_out_hp, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp - ) - - return norm_out + return a2a_hp_to_cp( + norm_out_hp, self.cp_size, self.pg_collection.cp, packed_seq_params, thd_cp_a2a_inv + ) @jit_fuser def _apply_gated_norm(self, x, gate): @@ -383,17 +381,23 @@ def _prepare_input_for_gated_delta_rule( self, qkv: torch.Tensor, gate: torch.Tensor, + A_log_local_cp: torch.Tensor, + dt_bias_local_cp: torch.Tensor, batch: int, seq_len: int, *gate_feats: tuple[torch.Tensor], - ) -> tuple[torch.Tensor, ...]: + ) -> dict[str, torch.Tensor]: """ - Prepare the query, key, value, gate, and variant gate-feature tensors for the - gated delta rule kernels. + Prepare all gated delta rule kernel inputs. - Fuses split, reshape, L2 norm, repeat_interleave, and contiguous operations. - ``gate_feats`` holds the variant-specific in_proj sections, which are returned - contiguous for the decay/gating computation in ``forward``. + Fuses split, reshape, L2 norm, decay/gate activations, repeat_interleave, and + contiguous operations. ``gate_feats`` holds the variant-specific in_proj + sections, which ``_compute_gates`` turns into the decay and gating tensors. + + Returns: + (dict[str, Tensor]): Kernel inputs keyed by kernel argument name (``q``, + ``k``, ``v``, ``g``, plus the variant-specific gates), and the output + gate (z) tensor under the ``gate`` key, which is not a kernel input. """ # Split qkv into query_key and value query_key, value = torch.split( @@ -415,35 +419,49 @@ def _prepare_input_for_gated_delta_rule( query, key = torch.split(query_key, [split_size, split_size], dim=2) # Expand query and key if needed (grouped query attention) - if self.num_value_heads // self.num_key_heads > 1: - repeat_factor = self.num_value_heads // self.num_key_heads + repeat_factor = self.num_value_heads // self.num_key_heads + if repeat_factor > 1: query = query.repeat_interleave(repeat_factor, dim=2) key = key.repeat_interleave(repeat_factor, dim=2) - # Make all tensors contiguous - query = query.contiguous() - key = key.contiguous() - value = value.contiguous() - gate = gate.contiguous() - gate_feats = tuple(t.contiguous() for t in gate_feats) - - return query, key, value, gate, *gate_feats + g, variant_kernel_inputs = self._compute_gates( + A_log_local_cp, dt_bias_local_cp, batch, seq_len, *gate_feats + ) - @jit_fuser - def _compute_g_and_beta( + kernel_inputs = { + "q": query.contiguous(), + "k": key.contiguous(), + "v": value.contiguous(), + "g": g.contiguous(), + "gate": gate.contiguous(), + **variant_kernel_inputs, + } + return kernel_inputs + + def _compute_gates( self, A_log_local_cp: torch.Tensor, dt_bias_local_cp: torch.Tensor, - alpha: torch.Tensor, - beta: torch.Tensor, - ) -> tuple[torch.Tensor, torch.Tensor]: + batch: int, + seq_len: int, + *gate_feats: tuple[torch.Tensor], + ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: """ - Compute g (decay) and beta (sigmoid) for gated delta rule. - Fuses exp, softplus, mul, neg, and sigmoid operations. + Compute the log-decay ``g`` and the variant-specific kernel inputs. + + Args: + A_log_local_cp: CP-local slice of ``A_log``. + dt_bias_local_cp: CP-local slice of ``dt_bias``. + batch: Batch size. + seq_len: Sequence length. + gate_feats: The variant-specific in_proj output sections (everything after + the qkv and output-gate sections, in ``feat_dim_split`` order). + + Returns: + (tuple[Tensor, dict[str, Tensor]]): The log-decay ``g`` and a dict of the + remaining variant-specific kernel inputs keyed by kernel argument name. """ - g = -A_log_local_cp.exp() * F.softplus(alpha.float() + dt_bias_local_cp) # In fp32 - beta = beta.sigmoid() - return g, beta + raise NotImplementedError def _resolve_cu_seqlens( self, cu_seqlens_padded, cu_seqlens_actual, total_seq_len, name, cp_size: int = 1 @@ -795,105 +813,85 @@ def tensor_a2a_hp2cp( return tensor -#################### -# Torch native gated delta rule -#################### -def torch_chunk_gated_delta_rule( - q, - k, - v, - g, - beta, - chunk_size=64, - initial_state=None, - output_final_state=False, - use_qk_l2norm_in_kernel=False, - cu_seqlens=None, +def a2a_cp_to_hp( + qkvzba: torch.Tensor, + in_proj_split_sections: tuple[int, ...], + cp_size: int, + cp_group: torch.distributed.ProcessGroup, + cu_seqlens_q: torch.Tensor | None, + seq_len: int, + packed_seq_params: PackedSeqParams | None, ) -> tuple[torch.Tensor, torch.Tensor | None]: - # pylint: disable=line-too-long - ''' - Torch-native implementation of chunked gated delta rule for deterministic mode. - Need this because FLA is not deterministic. + """Run GDN context-parallel to hidden-parallel A2A and return its inverse context. - Reference: https://github.com/huggingface/transformers/blob/144c8ce2809a2e21914017652700e1ecb450501e/src/transformers/models/qwen3_next/modeling_qwen3_next.py#L470-L547 - ''' + Args: + qkvzba: in_proj output in sbhd format, sharded along the sequence dim over CP. + in_proj_split_sections: per-section sizes of the in_proj output, local to this + TP rank, used to build the pre-a2a head permutation. + cp_size: context-parallel world size. + cp_group: context-parallel process group. + cu_seqlens_q: cumulative sequence lengths, required for the ``thd`` path. + seq_len: global (unsharded) sequence length. + packed_seq_params: packed-sequence params; the ``thd`` path is taken when its + ``qkv_format`` is ``'thd'``. - assert ( - cu_seqlens is None - ), "cu_seqlens is not supported for torch_chunk_gated_delta_rule for now." - - query, key, value = q, k, v - initial_dtype = query.dtype - if use_qk_l2norm_in_kernel: - query = l2norm(query, dim=-1, eps=1e-6) - key = l2norm(key, dim=-1, eps=1e-6) - query, key, value, beta, g = [ - x.transpose(1, 2).contiguous().to(torch.float32) for x in (query, key, value, beta, g) - ] - - batch_size, num_heads, sequence_length, k_head_dim = key.shape - v_head_dim = value.shape[-1] - pad_size = (chunk_size - sequence_length % chunk_size) % chunk_size - query = F.pad(query, (0, 0, 0, pad_size)) - key = F.pad(key, (0, 0, 0, pad_size)) - value = F.pad(value, (0, 0, 0, pad_size)) - beta = F.pad(beta, (0, pad_size)) - g = F.pad(g, (0, pad_size)) - total_sequence_length = sequence_length + pad_size - scale = 1 / (query.shape[-1] ** 0.5) - query = query * scale - - v_beta = value * beta.unsqueeze(-1) - k_beta = key * beta.unsqueeze(-1) - # reshape to chunks - query, key, value, k_beta, v_beta = [ - x.reshape(x.shape[0], x.shape[1], -1, chunk_size, x.shape[-1]) - for x in (query, key, value, k_beta, v_beta) - ] - g = g.reshape(g.shape[0], g.shape[1], -1, chunk_size) - mask = torch.triu( - torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device), diagonal=0 - ) + Returns: + The hidden-parallel tensor and the sequence-dim inverse permutation to hand to + :func:`a2a_hp_to_cp` (``None`` outside the ``thd`` + CP>1 case). + """ + if cp_size > 1: + # Pre-permute head dim so a single unsectioned a2a is equivalent to per-section a2a. + head_perm = _build_head_perm_for_split_sections( + in_proj_split_sections, cp_size, qkvzba.device + ) + qkvzba = qkvzba.index_select(-1, head_perm) - # chunk decay - g = g.cumsum(dim=-1) - decay_mask = ((g.unsqueeze(-1) - g.unsqueeze(-2)).tril().exp().float()).tril() - attn = -((k_beta @ key.transpose(-1, -2)) * decay_mask).masked_fill(mask, 0) - for i in range(1, chunk_size): - row = attn[..., i, :i].clone() - sub = attn[..., :i, :i].clone() - attn[..., i, :i] = row + (row.unsqueeze(-1) * sub).sum(-2) - attn = attn + torch.eye(chunk_size, dtype=attn.dtype, device=attn.device) - value = attn @ v_beta - k_cumdecay = attn @ (k_beta * g.exp().unsqueeze(-1)) - last_recurrent_state = ( - torch.zeros(batch_size, num_heads, k_head_dim, v_head_dim).to(value) - if initial_state is None - else initial_state.to(value) - ) - core_attn_out = torch.zeros_like(value) - mask = torch.triu( - torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device), diagonal=1 - ) + thd_cp_a2a_inv = None + if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': + qkvzba = tensor_a2a_cp2hp( + qkvzba, seq_dim=0, head_dim=-1, cp_group=cp_group, undo_attention_load_balancing=False + ) + if cp_size > 1: + # Permute at the seq dim so that a single unsectioned a2a + # is equivalent to per-sequence a2a. + # This also folds the ``_undo_attention_load_balancing`` step. + thd_cp_a2a_idx, thd_cp_a2a_inv = _build_thd_cp_a2a_perm(cu_seqlens_q, cp_size, seq_len) + qkvzba = qkvzba.index_select(0, thd_cp_a2a_idx) + else: + qkvzba = tensor_a2a_cp2hp(qkvzba, seq_dim=0, head_dim=-1, cp_group=cp_group) + + return qkvzba, thd_cp_a2a_inv + + +def a2a_hp_to_cp( + norm_out: torch.Tensor, + cp_size: int, + cp_group: torch.distributed.ProcessGroup, + packed_seq_params: PackedSeqParams | None, + thd_cp_a2a_inv: torch.Tensor | None, +) -> torch.Tensor: + """Run GDN hidden-parallel to context-parallel A2A using CP-to-HP context. - # for each chunk - for i in range(0, total_sequence_length // chunk_size): - q_i, k_i, v_i = query[:, :, i], key[:, :, i], value[:, :, i] - attn = (q_i @ k_i.transpose(-1, -2) * decay_mask[:, :, i]).masked_fill_(mask, 0) - v_prime = (k_cumdecay[:, :, i]) @ last_recurrent_state - v_new = v_i - v_prime - attn_inter = (q_i * g[:, :, i, :, None].exp()) @ last_recurrent_state - core_attn_out[:, :, i] = attn_inter + attn @ v_new - last_recurrent_state = ( - last_recurrent_state * g[:, :, i, -1, None, None].exp() - + (k_i * (g[:, :, i, -1, None] - g[:, :, i]).exp()[..., None]).transpose(-1, -2) @ v_new + Args: + norm_out: gated-norm output in sbhd format, sharded along the head dim over CP. + cp_size: context-parallel world size. + cp_group: context-parallel process group. + packed_seq_params: packed-sequence params; the ``thd`` path is taken when its + ``qkv_format`` is ``'thd'``. + thd_cp_a2a_inv: sequence-dim inverse permutation returned by + :func:`a2a_cp_to_hp`, required on the ``thd`` path when ``cp_size > 1``. + + Returns: + The context-parallel tensor, matching the layout of the GDN module input. + """ + if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': + if cp_size > 1: + assert thd_cp_a2a_inv is not None + norm_out = norm_out.index_select(0, thd_cp_a2a_inv) + norm_out = tensor_a2a_hp2cp( + norm_out, seq_dim=0, head_dim=-1, cp_group=cp_group, redo_attention_load_balancing=False ) + else: + norm_out = tensor_a2a_hp2cp(norm_out, seq_dim=0, head_dim=-1, cp_group=cp_group) - if not output_final_state: - last_recurrent_state = None - core_attn_out = core_attn_out.reshape( - core_attn_out.shape[0], core_attn_out.shape[1], -1, core_attn_out.shape[-1] - ) - core_attn_out = core_attn_out[:, :, :sequence_length] - core_attn_out = core_attn_out.transpose(1, 2).contiguous().to(initial_dtype) - return core_attn_out, last_recurrent_state + return norm_out diff --git a/megatron/core/ssm/gated_delta_net/gdn.py b/megatron/core/ssm/gated_delta_net/gdn.py index 65d9dc7df0a..a28478218de 100644 --- a/megatron/core/ssm/gated_delta_net/gdn.py +++ b/megatron/core/ssm/gated_delta_net/gdn.py @@ -13,16 +13,15 @@ from megatron.core import tensor_parallel from megatron.core.inference.contexts import BaseInferenceContext +from megatron.core.jit import jit_fuser from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.ssm.gated_delta_net.common import ( - _build_head_perm_for_split_sections, - _build_thd_cp_a2a_perm, _GDNBase, + a2a_cp_to_hp, causal_conv1d, chunk_gated_delta_rule, get_parameter_local_cp, - tensor_a2a_cp2hp, - torch_chunk_gated_delta_rule, + l2norm, ) from megatron.core.utils import deprecate_inference_params, nvtx_range_pop, nvtx_range_push @@ -61,6 +60,22 @@ def _setup_variant_attrs(self): else: self.gated_delta_rule = chunk_gated_delta_rule + @jit_fuser + def _compute_gates( + self, + A_log_local_cp: torch.Tensor, + dt_bias_local_cp: torch.Tensor, + batch: int, + seq_len: int, + *gate_feats: tuple[torch.Tensor], + ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: + """Compute the per-head log-decay g and the write strength beta.""" + # ``gate_feats`` arrives in ``in_proj_split_names`` order: beta, then alpha. + beta, alpha = gate_feats + g = -A_log_local_cp.exp() * F.softplus(alpha.float() + dt_bias_local_cp) # In fp32 + beta = beta.sigmoid() + return g, {"beta": beta.contiguous()} + def forward( self, hidden_states: torch.Tensor, @@ -131,37 +146,15 @@ def forward( qkvzba, _ = self.in_proj(hidden_states) nvtx_range_pop(suffix="in_proj") - # CP All to All: CP to HP - if self.cp_size > 1: - # # Pre-permute head dim so a single unsectioned a2a is equivalent to per-section a2a. - head_perm = _build_head_perm_for_split_sections( - self.in_proj_split_sections, - self.pg_collection.cp.size(), - torch.cuda.current_device(), - ) - qkvzba = qkvzba.index_select(-1, head_perm) - - thd_cp_a2a_idx, thd_cp_a2a_inv = None, None - if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': - qkvzba = tensor_a2a_cp2hp( - qkvzba, - seq_dim=0, - head_dim=-1, - cp_group=self.pg_collection.cp, - undo_attention_load_balancing=False, - ) - if self.cp_size > 1: - # Permute at the seq dim so that a single unsectioned a2a - # is equivalent to per-sequence a2a. - # This also folds the ``_undo_attention_load_balancing`` step. - thd_cp_a2a_idx, thd_cp_a2a_inv = _build_thd_cp_a2a_perm( - cu_seqlens_q, self.cp_size, seq_len - ) - qkvzba = qkvzba.index_select(0, thd_cp_a2a_idx) - else: - qkvzba = tensor_a2a_cp2hp( - qkvzba, seq_dim=0, head_dim=-1, cp_group=self.pg_collection.cp - ) + qkvzba, thd_cp_a2a_inv = a2a_cp_to_hp( + qkvzba, + self.in_proj_split_sections, + self.cp_size, + self.pg_collection.cp, + cu_seqlens_q, + seq_len, + packed_seq_params, + ) # Transpose: s b x --> b s x # From sbhd to bshd format @@ -227,24 +220,17 @@ def forward( self.dt_bias, dim=0, cp_group=self.pg_collection.cp ) - # Prepare QKV tensors (split, reshape, L2 norm, repeat_interleave, contiguous) + # Prepare all kernel inputs (split, reshape, L2 norm, gates, contiguous) nvtx_range_push(suffix="prepare_input_for_gated_delta_rule") - query, key, value, gate, beta, alpha = self._prepare_input_for_gated_delta_rule( - qkv, gate, batch, seq_len, beta, alpha + kernel_inputs = self._prepare_input_for_gated_delta_rule( + qkv, gate, A_log_local_cp, dt_bias_local_cp, batch, seq_len, beta, alpha ) + gate = kernel_inputs.pop("gate") nvtx_range_pop(suffix="prepare_input_for_gated_delta_rule") - nvtx_range_push(suffix="g_and_beta") - g, beta = self._compute_g_and_beta(A_log_local_cp, dt_bias_local_cp, alpha, beta) - nvtx_range_pop(suffix="g_and_beta") - nvtx_range_push(suffix="gated_delta_rule") core_attn_out, _ = self.gated_delta_rule( - query, - key, - value, - g=g, - beta=beta, + **kernel_inputs, initial_state=None, output_final_state=False, use_qk_l2norm_in_kernel=False, @@ -276,3 +262,114 @@ def forward( self.norm_out_checkpoint.discard_output_and_register_recompute(out) return out, out_bias + + +#################### +# Torch native gated delta rule +#################### +def torch_chunk_gated_delta_rule( + q, + k, + v, + g, + beta, + scale=None, + chunk_size=64, + initial_state=None, + output_final_state=False, + use_qk_l2norm_in_kernel=False, + cu_seqlens=None, + **kwargs, +) -> tuple[torch.Tensor, torch.Tensor | None]: + # pylint: disable=line-too-long + ''' + Torch-native implementation of chunked gated delta rule for deterministic mode. + Need this because FLA is not deterministic. + + ``scale`` defaults to ``1 / sqrt(K)``, matching the FLA kernel. Extra keyword + arguments are accepted and ignored so this stays interchangeable with the FLA + kernel, which takes several options this implementation does not model. + + Reference: https://github.com/huggingface/transformers/blob/144c8ce2809a2e21914017652700e1ecb450501e/src/transformers/models/qwen3_next/modeling_qwen3_next.py#L470-L547 + ''' + + assert ( + cu_seqlens is None + ), "cu_seqlens is not supported for torch_chunk_gated_delta_rule for now." + + query, key, value = q, k, v + initial_dtype = query.dtype + if use_qk_l2norm_in_kernel: + query = l2norm(query, dim=-1, eps=1e-6) + key = l2norm(key, dim=-1, eps=1e-6) + query, key, value, beta, g = [ + x.transpose(1, 2).contiguous().to(torch.float32) for x in (query, key, value, beta, g) + ] + + batch_size, num_heads, sequence_length, k_head_dim = key.shape + v_head_dim = value.shape[-1] + pad_size = (chunk_size - sequence_length % chunk_size) % chunk_size + query = F.pad(query, (0, 0, 0, pad_size)) + key = F.pad(key, (0, 0, 0, pad_size)) + value = F.pad(value, (0, 0, 0, pad_size)) + beta = F.pad(beta, (0, pad_size)) + g = F.pad(g, (0, pad_size)) + total_sequence_length = sequence_length + pad_size + if scale is None: + scale = 1 / (query.shape[-1] ** 0.5) + query = query * scale + + v_beta = value * beta.unsqueeze(-1) + k_beta = key * beta.unsqueeze(-1) + # reshape to chunks + query, key, value, k_beta, v_beta = [ + x.reshape(x.shape[0], x.shape[1], -1, chunk_size, x.shape[-1]) + for x in (query, key, value, k_beta, v_beta) + ] + g = g.reshape(g.shape[0], g.shape[1], -1, chunk_size) + mask = torch.triu( + torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device), diagonal=0 + ) + + # chunk decay + g = g.cumsum(dim=-1) + decay_mask = ((g.unsqueeze(-1) - g.unsqueeze(-2)).tril().exp().float()).tril() + attn = -((k_beta @ key.transpose(-1, -2)) * decay_mask).masked_fill(mask, 0) + for i in range(1, chunk_size): + row = attn[..., i, :i].clone() + sub = attn[..., :i, :i].clone() + attn[..., i, :i] = row + (row.unsqueeze(-1) * sub).sum(-2) + attn = attn + torch.eye(chunk_size, dtype=attn.dtype, device=attn.device) + value = attn @ v_beta + k_cumdecay = attn @ (k_beta * g.exp().unsqueeze(-1)) + last_recurrent_state = ( + torch.zeros(batch_size, num_heads, k_head_dim, v_head_dim).to(value) + if initial_state is None + else initial_state.to(value) + ) + core_attn_out = torch.zeros_like(value) + mask = torch.triu( + torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device), diagonal=1 + ) + + # for each chunk + for i in range(0, total_sequence_length // chunk_size): + q_i, k_i, v_i = query[:, :, i], key[:, :, i], value[:, :, i] + attn = (q_i @ k_i.transpose(-1, -2) * decay_mask[:, :, i]).masked_fill_(mask, 0) + v_prime = (k_cumdecay[:, :, i]) @ last_recurrent_state + v_new = v_i - v_prime + attn_inter = (q_i * g[:, :, i, :, None].exp()) @ last_recurrent_state + core_attn_out[:, :, i] = attn_inter + attn @ v_new + last_recurrent_state = ( + last_recurrent_state * g[:, :, i, -1, None, None].exp() + + (k_i * (g[:, :, i, -1, None] - g[:, :, i]).exp()[..., None]).transpose(-1, -2) @ v_new + ) + + if not output_final_state: + last_recurrent_state = None + core_attn_out = core_attn_out.reshape( + core_attn_out.shape[0], core_attn_out.shape[1], -1, core_attn_out.shape[-1] + ) + core_attn_out = core_attn_out[:, :, :sequence_length] + core_attn_out = core_attn_out.transpose(1, 2).contiguous().to(initial_dtype) + return core_attn_out, last_recurrent_state diff --git a/megatron/core/ssm/gated_delta_net/gdn2.py b/megatron/core/ssm/gated_delta_net/gdn2.py new file mode 100644 index 00000000000..62ad3ef0f32 --- /dev/null +++ b/megatron/core/ssm/gated_delta_net/gdn2.py @@ -0,0 +1,475 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2025, Songlin Yang, Jan Kautz, Ali Hatamizadeh. + +# Some of this code was adopted from https://github.com/huggingface/transformers +# This source code is licensed under the Apache license found in the +# LICENSE file in the root directory of this source tree. + +import logging +import math +from functools import partial + +import torch +import torch.nn.functional as F + +from megatron.core import tensor_parallel +from megatron.core.inference.contexts import BaseInferenceContext +from megatron.core.jit import jit_fuser +from megatron.core.packed_seq_params import PackedSeqParams +from megatron.core.ssm.gated_delta_net.common import ( + _GDNBase, + a2a_cp_to_hp, + causal_conv1d, + get_parameter_local_cp, + l2norm, +) +from megatron.core.utils import deprecate_inference_params, nvtx_range_pop, nvtx_range_push + +try: + # The GDN2 kernel is only available in flash-linear-attention >= 0.5.1. + from fla.ops.gdn2.chunk import chunk_gdn2 + + HAVE_FLA_GDN2 = True +except ImportError: + chunk_gdn2 = None + HAVE_FLA_GDN2 = False + +logger = logging.getLogger(__name__) + + +class GatedDeltaNet2(_GDNBase): + """GDN2 (Gated DeltaNet-2) layer class. + + GDN2 replaces GDN's per-head scalar decay and write strength with channel-wise + gates, decoupling erase and write: + + S_t = (I - k_t (b_t * k_t)^T) Diag(exp(g_t)) S_{t-1} + k_t (w_t * v_t)^T + + where ``g_t`` is a per-key-channel log-decay, ``b_t`` (in R^{d_k}) is the + channel-wise erase gate, and ``w_t`` (in R^{d_v}) is the channel-wise write gate. + Reference: "Gated DeltaNet-2: Decoupling Erase and Write in Linear Attention" + (https://github.com/NVlabs/GatedDeltaNet-2). + + Note: unlike the GDN2 reference implementation, which uses low-rank decay and + output-gate projections, all GDN2 projections are fused full-rank into the single + column-parallel in_proj for TP/CP/SP simplicity. + + The layer takes input with size [s, b, h] and returns output of the same size. + """ + + def _setup_variant_attrs(self): + """Set the GDN2 in_proj sizing, split tables, gate parameter dims, and kernel.""" + assert ( + chunk_gdn2 is not None or self.config.deterministic_mode + ), "GDN2 requires flash-linear-attention >= 0.5.1 with the fla.ops.gdn2 kernel." + + # f (decay pre-activation), b (erase gate), w (write gate), on top of the + # q/k/v/z sections the base class already accounts for. + # TODO: for now, output gate is forced for GDN2. + # We may remove this restriction in the future. + self.in_proj_extra_dim = self.qk_dim * 2 + self.v_dim + + # Per-section sizes (and names) of the in_proj output, local to this TP rank. + # Used for the CP head permutation (pre-a2a), for splitting the projection + # output (post-a2a), and for the sharded checkpoint split of in_proj.weight. + self.in_proj_split_names = ["query", "key", "value", "z", "f", "b", "w"] + self.in_proj_split_sections = ( + self.qk_dim_local_tp, # q + self.qk_dim_local_tp, # k + self.v_dim_local_tp, # v + self.v_dim_local_tp, # gate (z) + self.qk_dim_local_tp, # f (decay pre-activation) + self.qk_dim_local_tp, # b (erase gate) + self.v_dim_local_tp, # w (write gate) + ) + self.feat_dim_split = ( + (self.qk_dim_local_tp * 2 + self.v_dim_local_tp) // self.cp_size, # qkv + self.v_dim_local_tp // self.cp_size, # gate (z) + self.qk_dim_local_tp // self.cp_size, # f + self.qk_dim_local_tp // self.cp_size, # b + self.v_dim_local_tp // self.cp_size, # w + ) + + # Time step projection (discretization): per-key-channel dt_bias and + # per-key-head A_log, following the GDN2 reference implementation. + self.dt_bias_dim = self.qk_dim_local_tp + self.a_log_dim = self.num_k_heads_local_tp + + if self.config.deterministic_mode: + self.gated_delta_rule = torch_chunk_gdn2 + else: + self.gated_delta_rule = chunk_gdn2 + + def _reset_dt_bias(self): + """Softplus-inverse init of dt_bias. + + Initializes so the initial per-channel step size lands in [1e-3, 0.1], + following the GDN2 reference implementation. + """ + dt = torch.exp( + torch.rand( + self.dt_bias.shape[0], dtype=torch.float32, device=torch.cuda.current_device() + ) + * (math.log(0.1) - math.log(0.001)) + + math.log(0.001) + ).clamp(min=1e-4) + inv_dt = dt + torch.log(-torch.expm1(-dt)) + self.dt_bias.data.copy_(inv_dt) + + @jit_fuser + def _compute_gates( + self, + A_log_local_cp: torch.Tensor, + dt_bias_local_cp: torch.Tensor, + batch: int, + seq_len: int, + *gate_feats: tuple[torch.Tensor, ...], + ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: + """Compute the per-channel log-decay g and the erase/write gates b/w.""" + f, b, w = gate_feats + # Channel-wise log-decay, computed in fp32 for numerical stability. A_log is a + # per-key-head rate broadcast over the head's key channels; dt_bias is per-channel. + g = -A_log_local_cp.float().exp().repeat_interleave(self.key_head_dim) * F.softplus( + f.float() + dt_bias_local_cp + ) + g = g.reshape(batch, seq_len, -1, self.key_head_dim) + + # Channel-wise erase (key axis) and write (value axis) gates, squashed to [0, 1] + b = b.sigmoid().reshape(batch, seq_len, -1, self.key_head_dim) + w = w.sigmoid().reshape(batch, seq_len, -1, self.value_head_dim) + + # Expand key-side gates across value-head groups (grouped value attention) + repeat_factor = self.num_value_heads // self.num_key_heads + if repeat_factor > 1: + g = g.repeat_interleave(repeat_factor, dim=2) + b = b.repeat_interleave(repeat_factor, dim=2) + + return g, {"b": b.contiguous(), "w": w.contiguous()} + + def forward( + self, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor, + inference_context: BaseInferenceContext | None = None, + packed_seq_params: PackedSeqParams | None = None, + sequence_len_offset: int | None = None, + *, + inference_params: BaseInferenceContext | None = None, + **kwargs, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + """ + Perform a forward pass through the GDN2 module. + + Return: + (tuple[torch.Tensor, torch.Tensor]) GDN2 output and bias. + """ + + inference_context = deprecate_inference_params(inference_context, inference_params) + + seq_len, batch, _ = hidden_states.shape + seq_len = seq_len * self.sp_size * self.cp_size + + if inference_context is not None: + assert ( + inference_context.is_static_batching() + ), "GDN2 does not currently support dynamic inference batching." + assert not self.config.sequence_parallel + # TODO: support inference + raise NotImplementedError("GDN2 does not support inference for now.") + + if packed_seq_params is not None and packed_seq_params.qkv_format == 'thd': + assert batch == 1, "Packed sequence expects batch dimension to be 1" + assert ( + not self.config.deterministic_mode + ), "Packed sequence does not support deterministic mode." + + # Resolve cu_seqlens with alignment padding handling. + cu_seqlens_q = self._resolve_cu_seqlens( + packed_seq_params.cu_seqlens_q_padded, + packed_seq_params.cu_seqlens_q, + seq_len, + "cu_seqlens_q", + cp_size=self.cp_size, + ) + cu_seqlens_kv = self._resolve_cu_seqlens( + packed_seq_params.cu_seqlens_kv_padded, + packed_seq_params.cu_seqlens_kv, + seq_len, + "cu_seqlens_kv", + cp_size=self.cp_size, + ) + assert torch.equal(cu_seqlens_q, cu_seqlens_kv), ( + "Currently only support cu_seqlens_q equals to cu_seqlens_kv, " + f"but got {cu_seqlens_q=} and {cu_seqlens_kv=}" + ) + num_packed_seqs = cu_seqlens_q.shape[0] - 1 + assert num_packed_seqs > 0, ( + "Number of packed sequences must be greater than 0, " + f"but got {cu_seqlens_q=} and {cu_seqlens_kv=}" + ) + else: + cu_seqlens_q = None + cu_seqlens_kv = None + + # Input projection + nvtx_range_push(suffix="in_proj") + qkvzfbw, _ = self.in_proj(hidden_states) + nvtx_range_pop(suffix="in_proj") + + qkvzfbw, thd_cp_a2a_inv = a2a_cp_to_hp( + qkvzfbw, + self.in_proj_split_sections, + self.cp_size, + self.pg_collection.cp, + cu_seqlens_q, + seq_len, + packed_seq_params, + ) + + # Transpose: s b x --> b s x + # From sbhd to bshd format + qkvzfbw = qkvzfbw.transpose(0, 1) + + # Split the tensor into q/k/v, gate (z), and the GDN2 gate features f, b, w + qkv, gate, f, b, w = torch.split(qkvzfbw, self.feat_dim_split, dim=-1) + gate = gate.reshape(batch, seq_len, -1, self.value_head_dim) + + # Convolution on qkv + nvtx_range_push(suffix="conv1d") + seq_len = qkv.shape[1] + qkv_channels_split_sections = [ + self.qk_dim_local_tp, + self.qk_dim_local_tp, + self.v_dim_local_tp, + ] + conv1d_weight = get_parameter_local_cp( + self.conv1d.weight, + dim=0, + cp_group=self.pg_collection.cp, + split_sections=qkv_channels_split_sections, + ) + conv1d_bias = ( + get_parameter_local_cp( + self.conv1d.bias, + dim=0, + cp_group=self.pg_collection.cp, + split_sections=qkv_channels_split_sections, + ) + if self.conv_bias + else None + ) + if self.config.deterministic_mode: + qkv = qkv.transpose(1, 2).contiguous() # b, s, d -> b, d, s + conv_out = F.conv1d( + input=qkv, # Torch-native only accept [b, d, s] format input + weight=conv1d_weight, + bias=conv1d_bias, + stride=self.conv1d.stride, + padding=self.conv1d.padding, + dilation=self.conv1d.dilation, + groups=self.conv_dim_local_tp // self.cp_size, + ) + qkv = self.act_fn(conv_out[..., :seq_len]) + qkv = qkv.transpose(1, 2) # b, d, s -> b, s, d + else: + assert self.activation in ["silu", "swish"] + qkv, _ = causal_conv1d( + x=qkv, # FLA conv1d accepts [b, s, d] format input + weight=conv1d_weight.squeeze(1), # d, 1, w -> d, w + bias=conv1d_bias, + activation=self.activation, + initial_state=None, + output_final_state=False, + cu_seqlens=cu_seqlens_q, + ) + nvtx_range_pop(suffix="conv1d") + + A_log_local_cp = get_parameter_local_cp(self.A_log, dim=0, cp_group=self.pg_collection.cp) + dt_bias_local_cp = get_parameter_local_cp( + self.dt_bias, dim=0, cp_group=self.pg_collection.cp + ) + + # Prepare all kernel inputs (split, reshape, L2 norm, gates, contiguous) + nvtx_range_push(suffix="prepare_input_for_gated_delta_rule") + kernel_inputs = self._prepare_input_for_gated_delta_rule( + qkv, gate, A_log_local_cp, dt_bias_local_cp, batch, seq_len, f, b, w + ) + gate = kernel_inputs.pop("gate") + nvtx_range_pop(suffix="prepare_input_for_gated_delta_rule") + + nvtx_range_push(suffix="gated_delta_rule") + core_attn_out, _ = self.gated_delta_rule( + **kernel_inputs, + initial_state=None, + output_final_state=False, + use_qk_l2norm_in_kernel=False, + cu_seqlens=cu_seqlens_q, + ) + nvtx_range_pop(suffix="gated_delta_rule") + + if self.recompute_norm_out: + self.norm_out_checkpoint = tensor_parallel.CheckpointWithoutOutput() + norm_func = partial( + self._gated_norm_and_a2a, + thd_cp_a2a_inv=thd_cp_a2a_inv, + batch=batch, + seq_len=seq_len, + packed_seq_params=packed_seq_params, + ) + norm_out = self.norm_out_checkpoint.checkpoint(norm_func, core_attn_out, gate) + else: + norm_out = self._gated_norm_and_a2a( + core_attn_out, gate, thd_cp_a2a_inv, batch, seq_len, packed_seq_params + ) + + # Output projection + nvtx_range_push(suffix="out_proj") + out, out_bias = self.out_proj(norm_out) + nvtx_range_pop(suffix="out_proj") + + if self.recompute_norm_out: + self.norm_out_checkpoint.discard_output_and_register_recompute(out) + + return out, out_bias + + +#################### +# Torch native gated delta rule 2 +#################### +def torch_chunk_gdn2( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + g: torch.Tensor, + b: torch.Tensor, + w: torch.Tensor, + scale: float | None = None, + chunk_size: int = 64, + initial_state: torch.Tensor | None = None, + output_final_state: bool = False, + use_qk_l2norm_in_kernel: bool = False, + cu_seqlens: torch.LongTensor | None = None, + **kwargs, +) -> tuple[torch.Tensor, torch.Tensor | None]: + r"""Torch-native chunkwise Gated Delta Rule-2, for deterministic mode. + + Args: + q: queries of shape ``[B, T, H, K]``. + k: keys of shape ``[B, T, H, K]``. + v: values of shape ``[B, T, H, V]``. + g: channel-wise log-decay of shape ``[B, T, H, K]``. + b: channel-wise erase gate of shape ``[B, T, H, K]``. + w: channel-wise write gate of shape ``[B, T, H, V]``. + scale: attention scale. Defaults to ``1 / sqrt(K)``. + chunk_size: chunk length of the WY schedule. + initial_state: optional ``[B, H, K, V]`` initial state. + output_final_state: whether to also return the final recurrent state. + use_qk_l2norm_in_kernel: L2-normalize q and k here rather than in the caller. + cu_seqlens: packed-sequence offsets; unsupported, must be ``None``. + kwargs: accepted and ignored, so this stays interchangeable with the FLA + kernel, which takes several options this implementation does not model. + + Returns: + (tuple[Tensor, Tensor | None]): output of shape ``[B, T, H, V]`` and the + final state, or ``None`` when ``output_final_state`` is ``False``. + """ + assert cu_seqlens is None, "cu_seqlens is not supported for torch_chunk_gdn2 for now." + + initial_dtype = q.dtype + if use_qk_l2norm_in_kernel: + q = l2norm(q, dim=-1, eps=1e-6) + k = l2norm(k, dim=-1, eps=1e-6) + + # b s h d -> b h s d, and compute the whole recurrence in fp32 + query, key, value, g, b, w = [ + x.transpose(1, 2).contiguous().to(torch.float32) for x in (q, k, v, g, b, w) + ] + + batch_size, num_heads, sequence_length, k_head_dim = key.shape + v_head_dim = value.shape[-1] + pad_size = (chunk_size - sequence_length % chunk_size) % chunk_size + # Zero padding is inert: it leaves the erase/write rows empty and, because the + # padded log-decay is 0, leaves the chunk's cumulative decay at its last real value. + query, key, value, g, b, w = [ + F.pad(x, (0, 0, 0, pad_size)) for x in (query, key, value, g, b, w) + ] + total_sequence_length = sequence_length + pad_size + if scale is None: + scale = 1 / (k_head_dim**0.5) + query = query * scale + + # reshape to chunks + query, key, value, g, b, w = [ + x.reshape(x.shape[0], x.shape[1], -1, chunk_size, x.shape[-1]) + for x in (query, key, value, g, b, w) + ] + + # Channel-wise cumulative log-decay within each chunk. + g = g.cumsum(dim=-2) + decay = g.exp() + + # The pairwise decay exp(G_r - G_j) is carried on the operands, as + # exp(G_r - c) * exp(c - G_j) for any per-channel c. Centering on half the + # chunk's total decay halves the exponent range each operand has to represent, + # which keeps exp() in fp32 range for roughly twice the decay strength. + center = g[..., -1:, :] * 0.5 + decay_centered = (g - center).exp() + inv_decay_centered = (center - g).exp() + + erase = decay * b * key # E = exp(G) * b * k + erase_centered = decay_centered * b * key + key_inv_decay = key * inv_decay_centered # Khat = exp(c - G) * k + write = w * value # Z = w * v + + # T = (I + A)^{-1} with A = tril(E @ Khat^T, -1), by forward substitution. + mask = torch.triu( + torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device), diagonal=0 + ) + attn = -(erase_centered @ key_inv_decay.transpose(-1, -2)).masked_fill(mask, 0) + for i in range(1, chunk_size): + row = attn[..., i, :i].clone() + sub = attn[..., :i, :i].clone() + attn[..., i, :i] = row + (row.unsqueeze(-1) * sub).sum(-2) + attn = attn + torch.eye(chunk_size, dtype=attn.dtype, device=attn.device) + + write = attn @ write # T @ Z + k_cumdecay = attn @ erase # T @ E + + last_recurrent_state = ( + torch.zeros(batch_size, num_heads, k_head_dim, v_head_dim).to(value) + if initial_state is None + else initial_state.to(value) + ) + core_attn_out = torch.zeros_like(write) + mask = torch.triu( + torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device), diagonal=1 + ) + query_decay = query * decay # Qtilde = exp(G) * q, exact: multiplies the incoming state + query_decay_centered = query * decay_centered # centered: only used pairwise against Khat + + # for each chunk + for i in range(0, total_sequence_length // chunk_size): + attn_i = query_decay_centered[:, :, i] @ key_inv_decay[:, :, i].transpose(-1, -2) + attn_i = attn_i.masked_fill_(mask, 0) + # U = T @ (Z - E @ S), the chunk's delta residuals against the incoming state + u_i = write[:, :, i] - k_cumdecay[:, :, i] @ last_recurrent_state + attn_inter = query_decay[:, :, i] @ last_recurrent_state + core_attn_out[:, :, i] = attn_inter + attn_i @ u_i + # Carry the state across the chunk: decay it by the chunk total, then add + # the delta residuals mapped back through the keys. exp(G_C - G) <= 1, so this + # ratio needs no centering. + g_chunk = g[:, :, i, -1:] # G_C, the chunk's total log-decay, [b, h, 1, k] + key_bar = key[:, :, i] * (g_chunk - g[:, :, i]).exp() + last_recurrent_state = ( + last_recurrent_state * g_chunk.squeeze(-2).unsqueeze(-1).exp() + + key_bar.transpose(-1, -2) @ u_i + ) + + if not output_final_state: + last_recurrent_state = None + core_attn_out = core_attn_out.reshape( + core_attn_out.shape[0], core_attn_out.shape[1], -1, core_attn_out.shape[-1] + ) + core_attn_out = core_attn_out[:, :, :sequence_length] + core_attn_out = core_attn_out.transpose(1, 2).contiguous().to(initial_dtype) + return core_attn_out, last_recurrent_state diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index de7f6f3fd35..7fa433221f1 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -291,8 +291,15 @@ class TransformerConfig(ModelParallelConfig): #################### # attention variant #################### - experimental_attention_variant: Optional[Literal['gated_delta_net', 'dsa']] = None - """Type of attention variant to use. Currently support gated_delta_net and dsa.""" + experimental_attention_variant: Optional[Literal['gdn', 'gdn2', 'dsa', 'gated_delta_net']] = ( + None + ) + """Type of attention variant to use. Currently support gdn, gdn2 and dsa. + gdn2 selects the GDN2 (Gated DeltaNet-2) variant of the gated delta net layer, with + channel-wise decay, erase and write gates; it requires flash-linear-attention >= 0.5.1. + Both gdn and gdn2 also select the layer built for the hybrid layer pattern symbol 'G'. + 'gated_delta_net' is a deprecated alias of 'gdn': it is normalized to 'gdn' in + __post_init__ and emits a DeprecationWarning.""" experimental_attention_variant_loss_scale_func: Optional[Callable[[torch.Tensor], None]] = None """Optional hook for experimental attention variants to receive the main loss scale.""" @@ -1307,6 +1314,19 @@ def __post_init__(self): """ super().__post_init__() + # Resolve deprecated attention variant spellings up front so that every consumer + # downstream only has to handle the canonical names. Imported lazily because the + # spec module imports this one. + from megatron.core.models.gpt.experimental_attention_variant_module_specs import ( + is_gated_delta_net_variant, + normalize_experimental_attention_variant, + ) + + if self.experimental_attention_variant is not None: + self.experimental_attention_variant = normalize_experimental_attention_variant( + self.experimental_attention_variant + ) + # When fp32 residual connections are enabled, pipeline parallel communication must # use fp32 to match the dtype of the residual stream between pipeline stages. if self.fp32_residual_connection and self.pipeline_dtype is not None: @@ -1353,10 +1373,14 @@ def __post_init__(self): f"tensor_model_parallel_size ({self.tensor_model_parallel_size})." ) - if self.experimental_attention_variant == "gated_delta_net": - assert ( - self.linear_attention_freq is not None - ), f"linear_attention_freq must be set for linear gated_delta_net." + if is_gated_delta_net_variant(self.experimental_attention_variant): + # gdn2 may also be enabled for GDN layers built via the hybrid layer pattern + # symbol 'G', where linear_attention_freq is unused; the GPT experimental + # attention route raises a clear error downstream if it is missing. + if self.experimental_attention_variant == "gdn": + assert ( + self.linear_attention_freq is not None + ), "linear_attention_freq must be set for linear gdn." # Check required parameters assert ( @@ -1827,13 +1851,12 @@ def __post_init__(self): "multi_latent_attention." ) - if ( - "gdn_norm_out" in self.recompute_modules - and self.experimental_attention_variant != "gated_delta_net" + if "gdn_norm_out" in self.recompute_modules and ( + not is_gated_delta_net_variant(self.experimental_attention_variant) ): raise ValueError( "gdn_norm_out in recompute_modules is only supported with " - "experimental_attention_variant='gated_delta_net'." + "experimental_attention_variant='gdn' or 'gdn2'." ) if "core_attn" in self.recompute_modules: diff --git a/megatron/training/training.py b/megatron/training/training.py index a64325cce40..51c177fc07d 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -60,6 +60,7 @@ from megatron.core.inference.symmetric_memory import SymmetricMemoryManager from megatron.core.inference.unified_memory import create_unified_mempool from megatron.core.models.gpt.experimental_attention_variant_module_specs import ( + is_gated_delta_net_variant, is_linear_attention_variant, ) from megatron.core.msc_utils import maybe_msc @@ -495,14 +496,20 @@ def mamba_layer_flops(total_tokens, hidden_size, state_dim=16, def gdn_layer_flops(total_tokens, hidden_size, qk_head_dim=128, v_head_dim=128, num_qk_heads=16, num_v_heads=32, - conv_kernel_dim=4): + conv_kernel_dim=4, use_gdn2=False): """Calculate FLOPs for a Gated Delta Net (GDN) layer.""" qk_dim = qk_head_dim * num_qk_heads v_dim = v_head_dim * num_v_heads + if use_gdn2: + # GDN2 in_proj: hidden_size -> (4*qk_dim + 3*v_dim) for q, k, v, z, f, b, w + in_proj_dim = 4 * qk_dim + 3 * v_dim + else: + # GDN in_proj: hidden_size -> (2*qk_dim + 2*v_dim + 2*num_v_heads) + in_proj_dim = 2 * qk_dim + 2 * v_dim + 2 * num_v_heads return ( 2 * total_tokens * ( - # in_proj: hidden_size -> (2*qk_dim + 2*v_dim + 2*num_v_heads) - hidden_size * (2 * qk_dim + 2 * v_dim + 2 * num_v_heads) + # in_proj + hidden_size * in_proj_dim # conv1d + conv_kernel_dim * (2 * qk_dim + v_dim) # gated delta rule: KK^T, VK^T, S(a(I-bKK^T)), and SQ @@ -524,7 +531,7 @@ def hybrid_flops(total_tokens, seqlen_squared_sum, hidden_size, moe_ffn_hidden_size=2048, shared_expert_ffn_hidden_size=2048, num_experts_routed_to=1, gdn_qk_head_dim=128, gdn_v_head_dim=128, gdn_num_qk_heads=16, gdn_num_v_heads=32, - gdn_conv_kernel_dim=4, + gdn_conv_kernel_dim=4, gdn_use_gdn2=False, vocab_size=256000, mtp_num_layers=0): """Calculate total FLOPs for the hybrid model.""" flops_fwd = ( @@ -542,7 +549,7 @@ def hybrid_flops(total_tokens, seqlen_squared_sum, hidden_size, num_gdn_layers * gdn_layer_flops(total_tokens, hidden_size, gdn_qk_head_dim, gdn_v_head_dim, gdn_num_qk_heads, gdn_num_v_heads, - gdn_conv_kernel_dim) + + gdn_conv_kernel_dim, gdn_use_gdn2) + (2 * total_tokens * hidden_size * vocab_size * (1 + mtp_num_layers)) # logits computation ) return flops_fwd * 3 @@ -740,7 +747,7 @@ def transformer_flops(): num_linear_attention_layers = sum(linear_attention_pattern) num_standard_attention_layers = num_layers - num_linear_attention_layers - if args.experimental_attention_variant == "gated_delta_net": + if is_gated_delta_net_variant(args.experimental_attention_variant): # Calculate the FLOPs for the gated delta net attention. qk_head_dim = args.linear_key_head_dim v_head_dim = args.linear_value_head_dim @@ -748,13 +755,18 @@ def transformer_flops(): num_v_heads = args.linear_num_value_heads qk_dim = qk_head_dim * num_qk_heads v_dim = v_head_dim * num_v_heads + if args.experimental_attention_variant == "gdn2": + # GDN2 in_proj: q, k, v, z, f, b, w + in_proj_dim = 4 * qk_dim + 3 * v_dim + else: + in_proj_dim = 2 * qk_dim + 2 * v_dim + 2 * num_v_heads linear_self_attn_term = ( forward_backward_expansion_factor * fma_expansion_factor * ( ## in proj args.hidden_size - * (2 * qk_dim + 2 * v_dim + 2 * num_v_heads) + * in_proj_dim ## conv1d + args.linear_conv_kernel_dim * (2 * qk_dim + v_dim) @@ -895,6 +907,7 @@ def transformer_flops(): gdn_num_qk_heads=args.linear_num_key_heads or 16, gdn_num_v_heads=args.linear_num_value_heads or 32, gdn_conv_kernel_dim=args.linear_conv_kernel_dim or 4, + gdn_use_gdn2=(args.experimental_attention_variant == "gdn2"), vocab_size=args.padded_vocab_size, mtp_num_layers=mtp_num_layers, ) diff --git a/pyproject.toml b/pyproject.toml index 49d014a0489..26c0fbc33be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ dev = [ "tensorstore~=0.1,!=0.1.46,!=0.1.72", "multi-storage-client~=0.50", "opentelemetry-api~=1.33.1", - "flash-linear-attention~=0.4.0", + "flash-linear-attention==0.5.1", "megatron-energon[av_decode]~=7.0", "av", "flashinfer-python>=0.5.0,<0.7.0", diff --git a/tests/unit_tests/models/test_experimental_attention_variant_module_specs.py b/tests/unit_tests/models/test_experimental_attention_variant_module_specs.py index 0a454b5d7ff..d9c4c14b1f7 100644 --- a/tests/unit_tests/models/test_experimental_attention_variant_module_specs.py +++ b/tests/unit_tests/models/test_experimental_attention_variant_module_specs.py @@ -106,7 +106,14 @@ def _fn(variant): @pytest.mark.parametrize( "variant, expected", - [("gated_delta_net", True), ("dsa", False), (None, False), ("some_unknown_variant", False)], + [ + ("gdn", True), + ("gdn2", True), + ("gated_delta_net", True), + ("dsa", False), + (None, False), + ("some_unknown_variant", False), + ], ) def test_variants(self, variant, expected): """Validate linear-attention variant classification across supported and unsupported names.""" @@ -412,6 +419,8 @@ class TestGetExperimentalAttentionVariantModuleSpec: @pytest.mark.parametrize( "variant, target_fn", [ + ("gdn", "get_gated_delta_net_module_spec"), + ("gdn2", "get_gated_delta_net_module_spec"), ("gated_delta_net", "get_gated_delta_net_module_spec"), ("dsa", "get_dsa_module_spec_for_backend"), ], diff --git a/tests/unit_tests/ssm/test_gated_delta_net.py b/tests/unit_tests/ssm/test_gated_delta_net.py index 7cd2eb5e104..1e762ba5bbb 100644 --- a/tests/unit_tests/ssm/test_gated_delta_net.py +++ b/tests/unit_tests/ssm/test_gated_delta_net.py @@ -13,13 +13,20 @@ get_transformer_block_with_experimental_attention_variant_spec, ) from megatron.core.process_groups_config import ProcessGroupCollection -from megatron.core.ssm.gated_delta_net import GatedDeltaNet +from megatron.core.ssm.gated_delta_net import ( + HAVE_FLA, + HAVE_FLA_GDN2, + GatedDeltaNet, + GatedDeltaNet2, + chunk_gdn2, + torch_chunk_gated_delta_rule, + torch_chunk_gdn2, +) from megatron.core.ssm.gated_delta_net.common import ( _build_head_perm_for_split_sections, _build_thd_cp_a2a_perm, tensor_a2a_cp2hp, tensor_a2a_hp2cp, - torch_chunk_gated_delta_rule, ) from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed from megatron.core.transformer import TransformerConfig @@ -30,13 +37,6 @@ make_test_packed_seq_params_with_padding, ) -try: - import fla - - HAVE_FLA = True -except ImportError: - HAVE_FLA = False - # https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html#nccl-multi-rank-gpu-enable # NVLS doesn't support one single GPU to be shared by multiple ranks, so disable this in test os.environ.update({"NCCL_NVLS_ENABLE": "0"}) @@ -54,6 +54,7 @@ def _unpack_sequence(x: torch.Tensor, cu_seqlens: torch.Tensor, dim=1) -> list[t return unpacked_x +@pytest.mark.parametrize("use_gdn2", [False, True], ids=["gdn", "gdn2"]) @pytest.mark.parametrize( ("tp_size", "sp", "cp_size"), [(1, False, 1), (2, False, 1), (2, True, 1), (1, False, 2), (2, False, 2), (2, True, 2)], @@ -63,7 +64,10 @@ def _unpack_sequence(x: torch.Tensor, cu_seqlens: torch.Tensor, dim=1) -> list[t class TestGatedDeltaNet: @pytest.fixture(scope='function', autouse=True) - def setup_method(self, tp_size, sp, cp_size): + def setup_method(self, tp_size, sp, cp_size, use_gdn2): + if use_gdn2 and not HAVE_FLA_GDN2: + pytest.skip("FLA with GDN2 support is not installed.") + # Initialize parallel and random seed Utils.initialize_model_parallel( tensor_model_parallel_size=tp_size, @@ -74,6 +78,7 @@ def setup_method(self, tp_size, sp, cp_size): self.tp_size = tp_size self.cp_size = cp_size self.sp_size = tp_size if sp else 1 + self.use_gdn2 = use_gdn2 # Get TP and CP process groups from device mesh tp_group = parallel_state.get_tensor_model_parallel_group() @@ -99,17 +104,15 @@ def setup_method(self, tp_size, sp, cp_size): tensor_model_parallel_size=tp_size, sequence_parallel=sp, context_parallel_size=cp_size, - experimental_attention_variant="gated_delta_net", + experimental_attention_variant="gdn2" if use_gdn2 else "gated_delta_net", linear_attention_freq=[1], transformer_impl="transformer_engine", ) - gdn_submodules = get_experimental_attention_variant_module_spec( - config=self.transformer_config - ).submodules + gdn_spec = get_experimental_attention_variant_module_spec(config=self.transformer_config) - self.gdn = GatedDeltaNet( + self.gdn = gdn_spec.module( self.transformer_config, - submodules=gdn_submodules, + submodules=gdn_spec.submodules, layer_number=1, bias=False, conv_bias=False, @@ -158,12 +161,10 @@ def test_selective_recompute_norm_out(self): pg_collection = ProcessGroupCollection(tp=tp_group, cp=cp_group) def build_gdn(config): - gdn_submodules = get_experimental_attention_variant_module_spec( - config=config - ).submodules - gdn = GatedDeltaNet( + gdn_spec = get_experimental_attention_variant_module_spec(config=config) + gdn = gdn_spec.module( config, - submodules=gdn_submodules, + submodules=gdn_spec.submodules, layer_number=1, bias=False, conv_bias=False, @@ -241,16 +242,14 @@ def test_deterministic_mode(self): det_config = copy.deepcopy(self.transformer_config) det_config.deterministic_mode = True - gdn_submodules = get_experimental_attention_variant_module_spec( - config=det_config - ).submodules + gdn_spec = get_experimental_attention_variant_module_spec(config=det_config) model_parallel_cuda_manual_seed(42) torch.manual_seed(42) gdn = ( - GatedDeltaNet( + gdn_spec.module( det_config, - submodules=gdn_submodules, + submodules=gdn_spec.submodules, layer_number=1, bias=False, conv_bias=False, @@ -263,8 +262,13 @@ def test_deterministic_mode(self): .bfloat16() ) - # deterministic_mode must select the torch-native kernel, not FLA. - assert gdn.gated_delta_rule is torch_chunk_gated_delta_rule + # deterministic_mode must select the variant's torch-native kernel, not FLA. + if self.use_gdn2: + assert isinstance(gdn, GatedDeltaNet2) + assert gdn.gated_delta_rule is torch_chunk_gdn2 + else: + assert isinstance(gdn, GatedDeltaNet) + assert gdn.gated_delta_rule is torch_chunk_gated_delta_rule micro_batch_size = 2 seq_length = 64 @@ -275,20 +279,20 @@ def test_deterministic_mode(self): dtype=torch.bfloat16, ) - def run(): + def run(module): hidden_states = base_input.clone().requires_grad_(True) - output, _ = gdn(hidden_states, None) + output, _ = module(hidden_states, None) output.float().sum().backward() grads = { name: param.grad.detach().clone() - for name, param in gdn.named_parameters() + for name, param in module.named_parameters() if param.grad is not None } - gdn.zero_grad(set_to_none=True) + module.zero_grad(set_to_none=True) return output.detach().clone(), grads, hidden_states.grad.detach().clone() - out1, grads1, input_grad1 = run() - out2, grads2, input_grad2 = run() + out1, grads1, input_grad1 = run(gdn) + out2, grads2, input_grad2 = run(gdn) rank = torch.distributed.get_rank() assert torch.equal(out1, out2), f"Output not reproducible ({rank=})" @@ -301,9 +305,17 @@ def run(): def test_module_construction(self): gdn = self.gdn - assert gdn.in_proj_dim == 2 * gdn.qk_dim + 2 * gdn.v_dim + 2 * gdn.num_value_heads - assert gdn.A_log.shape == (gdn.num_value_heads // self.tp_size,) - assert gdn.dt_bias.shape == (gdn.num_value_heads // self.tp_size,) + if self.use_gdn2: + assert isinstance(gdn, GatedDeltaNet2) + assert gdn.gated_delta_rule is chunk_gdn2 + assert gdn.in_proj_dim == 4 * gdn.qk_dim + 3 * gdn.v_dim + assert gdn.A_log.shape == (gdn.num_key_heads // self.tp_size,) + assert gdn.dt_bias.shape == (gdn.qk_dim // self.tp_size,) + else: + assert isinstance(gdn, GatedDeltaNet) + assert gdn.in_proj_dim == 2 * gdn.qk_dim + 2 * gdn.v_dim + 2 * gdn.num_value_heads + assert gdn.A_log.shape == (gdn.num_value_heads // self.tp_size,) + assert gdn.dt_bias.shape == (gdn.num_value_heads // self.tp_size,) def test_jit_compiled_helpers(self): import torch._dynamo @@ -329,34 +341,72 @@ def test_jit_compiled_helpers(self): device=device, dtype=torch.bfloat16, ) - gate_feats = ( - torch.randn(batch, seq_len, num_v_heads_local, device=device, dtype=torch.bfloat16), - torch.randn(batch, seq_len, num_v_heads_local, device=device, dtype=torch.bfloat16), - ) # beta, alpha + if self.use_gdn2: + gate_feats = ( + torch.randn(batch, seq_len, qk_dim_local, device=device, dtype=torch.bfloat16), + torch.randn(batch, seq_len, qk_dim_local, device=device, dtype=torch.bfloat16), + torch.randn(batch, seq_len, v_dim_local, device=device, dtype=torch.bfloat16), + ) # f, b, w + A_log_mock = torch.randn(num_k_heads_local, device=device, dtype=torch.bfloat16) + dt_bias_mock = torch.randn(qk_dim_local, device=device, dtype=torch.bfloat16) + expected_keys = {"q", "k", "v", "g", "b", "w"} + else: + gate_feats = ( + torch.randn(batch, seq_len, num_v_heads_local, device=device, dtype=torch.bfloat16), + torch.randn(batch, seq_len, num_v_heads_local, device=device, dtype=torch.bfloat16), + ) # beta, alpha + A_log_mock = torch.randn(num_v_heads_local, device=device, dtype=torch.bfloat16) + dt_bias_mock = torch.randn(num_v_heads_local, device=device, dtype=torch.bfloat16) + expected_keys = {"q", "k", "v", "g", "beta"} # Disable dynamo so coverage.py can trace through the method bodies, # which are normally wrapped by @jit_fuser (torch.compile). with torch._dynamo.config.patch(disable=True): - query, key, value, gate_out, *gate_feats_out = gdn._prepare_input_for_gated_delta_rule( - qkv, gate, batch, seq_len, *gate_feats + kernel_inputs = gdn._prepare_input_for_gated_delta_rule( + qkv, gate, A_log_mock, dt_bias_mock, batch, seq_len, *gate_feats ) + # The output gate (z) rides along under "gate" and is popped by forward before + # the kernel call; everything else is passed straight through as kernel kwargs. + gate_out = kernel_inputs.pop("gate") + assert set(kernel_inputs) == expected_keys + + query, key, value, g = (kernel_inputs[k] for k in ("q", "k", "v", "g")) assert query.shape == (batch, seq_len, num_v_heads_local, gdn.key_head_dim) assert key.shape == (batch, seq_len, num_v_heads_local, gdn.key_head_dim) assert value.shape == (batch, seq_len, num_v_heads_local, gdn.value_head_dim) - for t in (query, key, value, gate_out, *gate_feats_out): + assert gate_out.shape == (batch, seq_len, num_v_heads_local, gdn.value_head_dim) + for t in (query, key, value, gate_out, *kernel_inputs.values()): assert t.is_contiguous() - # The variant gate features (beta, alpha) pass through with shapes intact - beta_out, alpha_out = gate_feats_out - assert beta_out.shape == (batch, seq_len, num_v_heads_local) - assert alpha_out.shape == (batch, seq_len, num_v_heads_local) + if self.use_gdn2: + # Per-channel decay and erase/write gates squashed to [0, 1] + b, w = kernel_inputs["b"], kernel_inputs["w"] + assert g.shape == (batch, seq_len, num_v_heads_local, gdn.key_head_dim) + assert b.shape == (batch, seq_len, num_v_heads_local, gdn.key_head_dim) + assert w.shape == (batch, seq_len, num_v_heads_local, gdn.value_head_dim) + assert (g <= 0).all() + assert (b >= 0).all() and (b <= 1).all() + assert (w >= 0).all() and (w <= 1).all() + else: + # Per-head decay and write strength beta + beta = kernel_inputs["beta"] + assert g.shape == (batch, seq_len, num_v_heads_local) + assert beta.shape == (batch, seq_len, num_v_heads_local) + assert (g <= 0).all() + assert (beta >= 0).all() and (beta <= 1).all() def test_gpu_forward_thd_correctness(self): if self.sp_size > 1: pytest.skip("Sequence parallel is not supported for this test case.") - atol, rtol = 3e-4, 3e-4 + if self.use_gdn2: + # FLA uses different kernels for SBHD and THD: + # https://github.com/fla-org/flash-linear-attention/blob/ebf3a0cff2be3e6f2b2f99820b8fe4e28855ced0/fla/ops/gdn2/chunk_intra.py#L40-L53 + # so we relax the error bound here + atol, rtol = 1e-2, 1e-2 + else: + atol, rtol = 3e-4, 3e-4 # Input shape sequence_length = 32 @@ -399,7 +449,12 @@ def test_gpu_forward_thd_padding_correctness(self): if self.sp_size > 1: pytest.skip("Sequence parallel is not supported for this test case.") - atol, rtol = 3e-4, 3e-4 + if self.use_gdn2: + # See test_gpu_forward_thd_correctness: varlen vs batched kernel paths only + # match up to bf16 ULP-level differences for GDN2. + atol, rtol = 1e-2, 1e-2 + else: + atol, rtol = 3e-4, 3e-4 sequence_length = 32 micro_batch_size = 4 @@ -555,8 +610,58 @@ def test_parallel_gated_delta_net_correctness(tmp_path_dist_ckpt, sequence_packi ) +@pytest.mark.parametrize("sequence_packing", [False, True]) +@pytest.mark.parametrize( + ("tp", "sp", "cp"), + [(4, True, 1), (1, False, 2), (2, True, 2)], # TP w/ SP # CP # TP w/ SP + CP +) +@pytest.mark.skipif(not HAVE_FLA_GDN2, reason="FLA with GDN2 support is not installed.") +def test_parallel_gated_delta_net2_correctness(tmp_path_dist_ckpt, sequence_packing, tp, sp, cp): + transformer_config = TransformerConfig( + hidden_size=128, + linear_conv_kernel_dim=2, + linear_key_head_dim=32, + linear_value_head_dim=32, + linear_num_key_heads=4, + linear_num_value_heads=8, + num_layers=1, + normalization="RMSNorm", + use_cpu_initialization=True, + layernorm_zero_centered_gamma=True, + num_attention_heads=8, + activation_func=F.silu, + bf16=True, + experimental_attention_variant="gdn2", + linear_attention_freq=[1], + transformer_impl="transformer_engine", + ) + + transformer_layer_spec = get_transformer_block_with_experimental_attention_variant_spec( + config=transformer_config, vp_stage=None, pp_rank=0 + ) + + atol = rtol = 3e-2 if cp > 1 else 2e-2 + _test_parallel_attention_correctness( + transformer_config=transformer_config, + transformer_layer_spec=transformer_layer_spec, + tmp_path_dist_ckpt=tmp_path_dist_ckpt, + atol=atol, + rtol=rtol, + tp=tp, + sp=sp, + cp=cp, + seed=42, + sequence_length=512, + micro_batch_size=2, + sequence_packing=sequence_packing, + ) + + @pytest.mark.parametrize("cp_size", [2, 4], scope="class") @pytest.mark.internal +@pytest.mark.skip( + "Used to verify the correctness of the fused THD AllToAll implementation, locally validated thus no need to run on CI." +) class TestFusedThdAllToAll: """Verify fused 1 AllToAll + permute matches the per-sequence, per-channel loop in GDN.""" diff --git a/uv.lock b/uv.lock index 9630ea2b0b2..3fc0c7b0308 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -1258,15 +1258,14 @@ wheels = [ [[package]] name = "fla-core" -version = "0.4.2" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "einops" }, - { name = "torch", marker = "sys_platform == 'never'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/f9/9e05c48f92b1388a8a357141eb557ed0dd6d4bb936e1d05d35f01976657f/fla_core-0.4.2.tar.gz", hash = "sha256:e9fef6fcdf122029f9feb7dccfeb85eb9650e6aabc72d2a65b36558e9c590edd", size = 377722, upload-time = "2026-03-12T14:45:46.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/62/99e149f19a447ce809d7f4fa64ae61c073da337505b9db7f389502470820/fla_core-0.5.1.tar.gz", hash = "sha256:7f3cf56edfbaa9115f4937d1181372e5c7b11809ad8eb2e411fffc3caf729f48", size = 498800, upload-time = "2026-06-18T18:17:15.377Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/36/3c303f92bafea7c3f97d68bbb83d18cc42e30cd0bfb1b7cfe589360f11d6/fla_core-0.4.2-py3-none-any.whl", hash = "sha256:cba3db29380002da3cbfc0db94d6efac19aaf528900d19c05c2765e8f3cc485b", size = 510239, upload-time = "2026-03-12T14:45:43.708Z" }, + { url = "https://files.pythonhosted.org/packages/ce/78/a55ee7a62515dcb9220770dd99dfe59ac6599da8af84ad1d20f9f407df4d/fla_core-0.5.1-py3-none-any.whl", hash = "sha256:02150d34aa1e37f6b8ed9b2feec5d29af93680573e5077ed981238179c11fb06", size = 702955, upload-time = "2026-06-18T18:17:12.229Z" }, ] [[package]] @@ -1285,15 +1284,15 @@ wheels = [ [[package]] name = "flash-linear-attention" -version = "0.4.2" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fla-core" }, { name = "transformers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/cb/46cc27a829a10b308927c5dbc99176906a021bb0770253699e93f3cd81a0/flash_linear_attention-0.4.2.tar.gz", hash = "sha256:f97c01ebe7cf390323af07dd3fb65ade07da16724339bf70c78607bc0c007c34", size = 148464, upload-time = "2026-03-12T14:45:46.945Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/e8/8f115be585a046795e4a1f7ade727889219bb66b8d96cc668b8c9e437c0e/flash_linear_attention-0.5.1.tar.gz", hash = "sha256:8840fd4c37de8b0612dc8fd493867f3d330672ba2f17c024a2ce37239634e247", size = 221733, upload-time = "2026-06-18T18:17:16.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/ee/a3cba17965482b35c4990af90bad108e82c32edcb59911c37f318b5f4198/flash_linear_attention-0.4.2-py3-none-any.whl", hash = "sha256:c08be006ce4dbe1be81f54938ee8e6fc7968cfba397c8d06c7669e97b8c44c0d", size = 284661, upload-time = "2026-03-12T14:45:44.905Z" }, + { url = "https://files.pythonhosted.org/packages/68/3c/5819fb19dc071302ca818616a4e64d4454e1f1193929eb8365c8d38e9052/flash_linear_attention-0.5.1-py3-none-any.whl", hash = "sha256:9022862f0a238752372c81290694b8b2ed1cb2d13fc40d340ffeeb554d6bee5c", size = 403096, upload-time = "2026-06-18T18:17:14.025Z" }, ] [[package]] @@ -2322,7 +2321,7 @@ requires-dist = [ { name = "emerging-optimizers", marker = "extra == 'dev'", git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git?rev=v0.2.0" }, { name = "fast-hadamard-transform", marker = "extra == 'dev'", git = "https://github.com/Dao-AILab/fast-hadamard-transform.git?rev=f134af63deb2df17e1171a9ec1ea4a7d8604d5ca" }, { name = "fastapi", marker = "extra == 'dev'", specifier = "~=0.50" }, - { name = "flash-linear-attention", marker = "extra == 'dev'", specifier = "~=0.4.0" }, + { name = "flash-linear-attention", marker = "extra == 'dev'", specifier = "==0.5.1" }, { name = "flashinfer-python", marker = "extra == 'dev'", specifier = ">=0.5.0,<0.7.0" }, { name = "flask-restful", marker = "extra == 'mlm'" }, { name = "flask-restful", marker = "extra == 'training'" }, From 78901d8a71b92ed19e3e31e00815e6bde558e9de Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Mon, 10 Aug 2026 17:48:54 -0700 Subject: [PATCH 244/290] Add construction-scoped MFSDP v2 fully shard options (#6127) Signed-off-by: Jingyue Wu --- .../megatron_fsdp/experimental/fully_shard.py | 15 +++++++------- .../src/megatron_fsdp/experimental/module.py | 10 +++++++--- .../experimental/parameter_group.py | 8 ++++---- .../distributed/mfsdp_v2/test_fully_shard.py | 20 +++++++------------ .../mfsdp_v2/test_symmetric_memory.py | 14 +++++-------- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py index 02a242feec3..a5dab7de9d3 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/fully_shard.py @@ -27,11 +27,13 @@ from .module import FsdpContext, FsdpModule from .placement import MeshAxis, Placements -_FSDP_CONTEXT = ContextVar[FsdpContext | None]("megatron_fsdp_context", default=None) +_FSDP_CONTEXT = ContextVar[FsdpContext | None]("mfsdp_context", default=None) @contextmanager -def fully_shard_context(device: torch.device | None = None) -> Iterator[FsdpContext]: +def fully_shard_context( + device: torch.device | None = None, *, use_symmetric_memory: bool = False +) -> Iterator[FsdpContext]: """Construct FSDP modules that share runtime streams and prefetch orders. Independent roots are ordered by their root-level ``fully_shard`` calls. @@ -40,6 +42,8 @@ def fully_shard_context(device: torch.device | None = None) -> Iterator[FsdpCont Args: device: CUDA device on which to create communication streams. Defaults to the current CUDA device. + use_symmetric_memory: Allocate communication staging buffers from PyTorch's + NCCL symmetric-memory pool. """ if _FSDP_CONTEXT.get() is not None: raise RuntimeError("fully_shard_context does not support nesting.") @@ -48,7 +52,7 @@ def fully_shard_context(device: torch.device | None = None) -> Iterator[FsdpCont if device.type != "cuda": raise ValueError(f"fully_shard_context requires a CUDA device, got {device}.") - context = FsdpContext(device=device) + context = FsdpContext(device=device, use_symmetric_memory=use_symmetric_memory) token = _FSDP_CONTEXT.set(context) try: yield context @@ -65,7 +69,6 @@ def fully_shard( mesh: DeviceMesh, placements: Placements, mixed_precision_policy: MixedPrecisionPolicy | None = None, - use_symm_mem: bool = False, grad_divisor: int = 1, ) -> None: """Apply FSDP to a module in place. @@ -79,8 +82,6 @@ def fully_shard( placements: Parameter, gradient, and optimizer placements. mixed_precision_policy: Optional precision policy. Defaults to FP32 main weights and parameter-dtype main gradients. - use_symm_mem: Allocate all-gather and reduce-scatter staging buffers from - PyTorch's NCCL symmetric-memory pool. grad_divisor: Additional divisor applied to the reduced gradient, on top of the averaging the mesh already performs. Defaults to 1, which is correct whenever each mesh rank contributes exactly one term to the gradient. @@ -117,8 +118,8 @@ def fully_shard( mesh=mesh, placements=placements, mixed_precision_policy=mixed_precision_policy, - use_symm_mem=use_symm_mem, grad_divisor=grad_divisor, + use_symmetric_memory=context.use_symmetric_memory, ) except Exception: module.__class__ = original_cls diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 895c9963e26..101d247ce09 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -43,19 +43,23 @@ class FsdpContext: # unnecessary because it can be detected when ``model_weight``, after syncing # from ``main_weight``, has placements different from ``Placements.optimizer``. is_last_microbatch: bool + use_symmetric_memory: bool # Static orders used to drive all-gather prefetch. We may want to switch to # capturing runtime order if static module order proves too fragile. Each # FsdpModule tracks its own materialized state via ``FsdpModule._unshard_event``. forward_order: IndexedOrder["FsdpModule"] backward_order: IndexedOrder["FsdpModule"] - def __init__(self, device: torch.device) -> None: + def __init__(self, device: torch.device, use_symmetric_memory: bool = False) -> None: """Create rank-local runtime state for FSDP modules on ``device``. Args: device: Device on which this context schedules communication. + use_symmetric_memory: Whether modules constructed in this context allocate + communication staging buffers from PyTorch's NCCL symmetric-memory pool. """ self.is_last_microbatch = True + self.use_symmetric_memory = use_symmetric_memory self.forward_order = IndexedOrder() self.backward_order = IndexedOrder() # Construction-only; empty after finalization. @@ -154,8 +158,8 @@ def __init__( mesh: DeviceMesh, placements: Placements, mixed_precision_policy: MixedPrecisionPolicy, - use_symm_mem: bool = False, grad_divisor: int = 1, + use_symmetric_memory: bool = False, ) -> None: """Initialize FSDP runtime state on an already-constructed module.""" self._context = context @@ -177,8 +181,8 @@ def __init__( placements=placements, mixed_precision_policy=mixed_precision_policy, reduce_scatter_stream=context.reduce_scatter_stream, - use_symm_mem=use_symm_mem, grad_divisor=grad_divisor, + use_symmetric_memory=use_symmetric_memory, ) for group_parameters in _group_parameters(owned_parameters) ] diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 6728af5691e..32241d8f4ea 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -77,8 +77,8 @@ def __init__( placements: Placements, mixed_precision_policy: MixedPrecisionPolicy, reduce_scatter_stream: torch.cuda.Stream, - use_symm_mem: bool = False, grad_divisor: int = 1, + use_symmetric_memory: bool = False, ) -> None: """Create persistent sharded buffers for a group of parameters. @@ -89,14 +89,14 @@ def __init__( placements: Parameter, gradient, and optimizer placements. mixed_precision_policy: Precision policy for main weights and gradients. reduce_scatter_stream: Stream on which to allocate the main-gradient buffer. - use_symm_mem: Allocate communication staging buffers from PyTorch's + use_symmetric_memory: Allocate communication staging buffers from PyTorch's NCCL symmetric-memory pool. grad_divisor: Additional divisor applied on top of the mesh-size averaging. See ``fully_shard``. """ if not parameters: raise ValueError("FsdpParameterGroup requires at least one parameter.") - if use_symm_mem and not hasattr(symm_mem, "is_symm_mem_tensor"): + if use_symmetric_memory and not hasattr(symm_mem, "is_symm_mem_tensor"): raise RuntimeError("Symmetric-memory MFSDP requires PyTorch 2.12 or later.") parameter_to_fqns: dict[nn.Parameter, list[str]] = {} @@ -135,7 +135,7 @@ def __init__( placements=main_weight_placements, ) - if use_symm_mem: + if use_symmetric_memory: # PyTorch caches this in C++ and returns early when the backend is already NCCL. symm_mem.set_backend("NCCL") self._symm_mem_pool = symm_mem.get_mem_pool(self.main_weight.device) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 863becf8b77..413f4ea9a36 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -594,8 +594,8 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): ) -@pytest.mark.parametrize("use_symm_mem", [False, True], ids=["default", "symmetric_memory"]) -def test_overlaps_communication_and_compute(distributed_setup, use_symm_mem): +@pytest.mark.parametrize("use_symmetric_memory", [False, True], ids=["default", "symmetric_memory"]) +def test_overlaps_communication_and_compute(distributed_setup, use_symmetric_memory): """Forward and backward communication should overlap GEMM compute.""" world_size = distributed_setup.world_size device = distributed_setup.device @@ -620,7 +620,7 @@ def test_overlaps_communication_and_compute(distributed_setup, use_symm_mem): if not dist.is_initialized(): dist.init_process_group(backend="nccl") - if use_symm_mem: + if use_symmetric_memory: # Dedicated communicator with NCCL's zero-CTA policy. cta_policy is a # per-communicator property, so scoping it to this group leaves the rest of the # bucket on default-CTA symmetric-memory kernels (test_symmetric_memory.py asserts @@ -642,15 +642,9 @@ def test_overlaps_communication_and_compute(distributed_setup, use_symm_mem): model = MultiChildModel(dim=dim, num_children=num_children).to(device=device, dtype=dtype) placements = _flat_placements() policy = MixedPrecisionPolicy(main_params_dtype=dtype, main_grads_dtype=dtype) - with fully_shard_context(device=device): + with fully_shard_context(device=device, use_symmetric_memory=use_symmetric_memory): for layer in model.layers: - fully_shard( - layer, - mesh=mesh, - placements=placements, - mixed_precision_policy=policy, - use_symm_mem=use_symm_mem, - ) + fully_shard(layer, mesh=mesh, placements=placements, mixed_precision_policy=policy) x = torch.randn(4096, dim, device=device, dtype=dtype, requires_grad=True) @@ -683,7 +677,7 @@ def train_one_iteration() -> None: # Each child layer does a forward and a backward all-gather and one # reduce-scatter. Zero-CTA moves the all-gather to copy-engine memcpys, so it # should not emit all-gather kernels. - expected_allgather_kernel_count = 0 if use_symm_mem else 2 * num_children + expected_allgather_kernel_count = 0 if use_symmetric_memory else 2 * num_children assert len(allgather_kernels) == expected_allgather_kernel_count, ( f"Expected {expected_allgather_kernel_count} all-gather kernels, got " f"{len(allgather_kernels)}: {[kernel.name for kernel in allgather_kernels]}" @@ -712,7 +706,7 @@ def train_one_iteration() -> None: ) expected_allgather_overlap = 2 * (num_children - 1) expected_reduce_scatter_overlap = num_children - 1 - if not use_symm_mem: + if not use_symmetric_memory: assert allgather_overlap_count >= expected_allgather_overlap, ( f"Expected at least {expected_allgather_overlap} all-gathers to " f"overlap compute, got {allgather_overlap_count}/{len(allgather_kernels)}." diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py index 6ae3f05a95b..aaebf6267f1 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_symmetric_memory.py @@ -59,24 +59,22 @@ def test_fully_shard_symmetric_memory_matches_default_and_profiles_nccl( mesh = init_device_mesh(device.type, (world_size,)) num_training_steps = 5 - def train(use_symm_mem: bool) -> list[torch.Tensor]: + def train(use_symmetric_memory: bool) -> list[torch.Tensor]: torch.manual_seed(1234) model = TinyModel().to(device=device, dtype=torch.bfloat16) mixed_precision_policy = MixedPrecisionPolicy(main_params_dtype=torch.float32) - with fully_shard_context(device=device): + with fully_shard_context(device=device, use_symmetric_memory=use_symmetric_memory): fully_shard( model.fc1, mesh=mesh, placements=_flat_placements(), mixed_precision_policy=mixed_precision_policy, - use_symm_mem=use_symm_mem, ) fully_shard( model.fc2, mesh=mesh, placements=_flat_placements(), mixed_precision_policy=mixed_precision_policy, - use_symm_mem=use_symm_mem, ) optimizer = torch.optim.SGD(model.parameters(), lr=0.05, foreach=False) @@ -101,11 +99,11 @@ def train(use_symm_mem: bool) -> list[torch.Tensor]: return losses with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof_without_symm_mem: - losses_without_symm_mem = train(use_symm_mem=False) + losses_without_symm_mem = train(use_symmetric_memory=False) torch.cuda.synchronize() with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof_with_symm_mem: - losses_with_symm_mem = train(use_symm_mem=True) + losses_with_symm_mem = train(use_symmetric_memory=True) torch.cuda.synchronize() torch.testing.assert_close( @@ -188,20 +186,18 @@ def test_fully_shard_zero_cta_moves_all_gather_to_copy_engine(distributed_setup) num_training_steps = 5 model = TinyModel().to(device=device, dtype=torch.bfloat16) mixed_precision_policy = MixedPrecisionPolicy(main_params_dtype=torch.float32) - with fully_shard_context(device=device): + with fully_shard_context(device=device, use_symmetric_memory=True): fully_shard( model.fc1, mesh=mesh, placements=_flat_placements(), mixed_precision_policy=mixed_precision_policy, - use_symm_mem=True, ) fully_shard( model.fc2, mesh=mesh, placements=_flat_placements(), mixed_precision_policy=mixed_precision_policy, - use_symm_mem=True, ) optimizer = torch.optim.SGD(model.parameters(), lr=0.05, foreach=False) x = torch.randn(2, _HIDDEN, device=device, dtype=torch.bfloat16) From 5e86dfafcadede2ae14bc2846b3992e4a40b5a8e Mon Sep 17 00:00:00 2001 From: Jenny Chen Date: Tue, 11 Aug 2026 02:10:51 -0400 Subject: [PATCH 245/290] Support explicit Hugging Face finetune data files in finetune.sh (#6413) Signed-off-by: Jennifer Chen --- examples/post_training/modelopt/finetune.py | 137 ++++++++++++++++- examples/post_training/modelopt/utils.py | 42 +++-- megatron/post_training/arguments.py | 8 + .../test_modelopt_finetune_dataset.py | 145 ++++++++++++++++++ 4 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 tests/unit_tests/post_training/test_modelopt_finetune_dataset.py diff --git a/examples/post_training/modelopt/finetune.py b/examples/post_training/modelopt/finetune.py index f44650388df..c0d026db0c5 100755 --- a/examples/post_training/modelopt/finetune.py +++ b/examples/post_training/modelopt/finetune.py @@ -5,7 +5,7 @@ import os import sys from functools import partial -from typing import Any, Dict +from typing import Any, Dict, List sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))) @@ -95,6 +95,93 @@ def _wildcard_get(cls, directory: Dict[str, Any], name: str, default_value=None) break return ret + @staticmethod + def _infer_data_files_builder(data_files: List[str]) -> str: + extensions = {os.path.splitext(filename)[1].lower() for filename in data_files} + if extensions <= {".json", ".jsonl"}: + return "json" + if extensions == {".parquet"}: + return "parquet" + raise ValueError( + "--finetune-data-files must contain only .parquet or .json/.jsonl " + "files of a single format." + ) + + @staticmethod + def _materialize_data_files(hf_dataset: str, data_files: List[str]) -> List[str]: + from huggingface_hub import hf_hub_download + + resolved_files = [] + for filename in data_files: + if any(char in filename for char in "*?["): + raise ValueError( + "--finetune-data-files requires explicit files; list each file instead of " + "using a wildcard." + ) + + local_filename = filename + if not os.path.isabs(filename) and os.path.isdir(hf_dataset): + local_filename = os.path.join(hf_dataset, filename) + + if os.path.isfile(local_filename): + resolved_files.append(local_filename) + elif os.path.exists(hf_dataset): + raise FileNotFoundError("Dataset file not found: {}".format(local_filename)) + else: + resolved_files.append( + hf_hub_download( + repo_id=hf_dataset, + filename=filename, + repo_type="dataset", + token=os.environ.get("HF_TOKEN", None), + ) + ) + return resolved_files + + @staticmethod + def _load_parquet_data_files(data_files: List[str]): + import pyarrow as pa + import pyarrow.parquet as pq + + tables = [] + for filename in data_files: + # Some published HF parquet shards contain newer `datasets` feature + # metadata than the runtime parser understands. Dropping that metadata + # lets `datasets.Dataset` infer equivalent features from Arrow schema. + tables.append( + pq.read_table(filename, memory_map=True).replace_schema_metadata(None) + ) + + table = tables[0] if len(tables) == 1 else pa.concat_tables(tables) + return datasets.Dataset(table) + + @staticmethod + def _load_dataset_synchronized(load_dataset_func): + if not torch.distributed.is_available() or not torch.distributed.is_initialized(): + return load_dataset_func() + + if torch.distributed.get_rank() == 0: + raw_samples = load_dataset_func() + torch.distributed.barrier() + if torch.distributed.get_rank() != 0: + raw_samples = load_dataset_func() + torch.distributed.barrier() + return raw_samples + + @staticmethod + def _normalize_input_ids(input_ids) -> List[int]: + if isinstance(input_ids, transformers.BatchEncoding): + input_ids = input_ids["input_ids"] + if isinstance(input_ids, torch.Tensor): + input_ids = input_ids.tolist() + if input_ids and isinstance(input_ids[0], list): + if len(input_ids) != 1: + raise ValueError( + "Expected one tokenized chat sample but got {}".format(len(input_ids)) + ) + input_ids = input_ids[0] + return input_ids + def __init__( self, num_packed_samples: int, @@ -103,6 +190,8 @@ def __init__( seq_length: int, num_shards: int = 1, shard_index: int = 0, + hf_dataset_split: str = "train", + hf_dataset_data_files: List[str] | None = None, ): """A simple dataset implementation for supervised fine-tuning. @@ -119,12 +208,16 @@ def __init__( seq_length: max sequence length num_shards: number of shards for distributed training shard_index: shard index for distributed training + hf_dataset_split: Hugging Face dataset split for explicit files + hf_dataset_data_files: optional explicit Hugging Face dataset files """ if not isinstance(tokenizer, transformers.PreTrainedTokenizerBase): raise ValueError("SFTDataset only supports transformers.PreTrainedTokenizerBase!") self.num_packed_samples = num_packed_samples self.hf_dataset = hf_dataset + self.hf_dataset_split = hf_dataset_split + self.hf_dataset_data_files = hf_dataset_data_files self.tokenizer = tokenizer self.seq_length = seq_length self.data_transformation = lambda data: data @@ -139,10 +232,39 @@ def __init__( REMOVE_THINK_CHAT_TEMPLATE, "" ) - hf_dataset_kwargs = SFTDataset.hf_dataset_to_kwargs.get( - self.hf_dataset, {"split": "train"} + hf_dataset_kwargs = dict( + SFTDataset.hf_dataset_to_kwargs.get(self.hf_dataset, {"split": "train"}) ) - self._raw_samples = datasets.load_dataset(self.hf_dataset, token=os.environ.get("HF_TOKEN", None), **hf_dataset_kwargs) + if self.hf_dataset_data_files: + data_files_builder = SFTDataset._infer_data_files_builder( + self.hf_dataset_data_files + ) + data_files = SFTDataset._load_dataset_synchronized( + lambda: SFTDataset._materialize_data_files( + self.hf_dataset, self.hf_dataset_data_files + ) + ) + if data_files_builder == "parquet": + self._raw_samples = SFTDataset._load_dataset_synchronized( + lambda: SFTDataset._load_parquet_data_files(data_files) + ) + else: + hf_dataset_kwargs = { + "split": self.hf_dataset_split, + "data_files": {self.hf_dataset_split: data_files}, + } + self._raw_samples = SFTDataset._load_dataset_synchronized( + lambda: datasets.load_dataset( + data_files_builder, + token=os.environ.get("HF_TOKEN", None), + **hf_dataset_kwargs, + ) + ) + else: + self._raw_samples = datasets.load_dataset( + self.hf_dataset, token=os.environ.get("HF_TOKEN", None), **hf_dataset_kwargs + ) + self._raw_samples = self._raw_samples.shard( num_shards=self.num_shards, index=shard_index ) @@ -261,7 +383,10 @@ def _process_example(self, example: Dict[str, Any]): return None # We always add eos between samples for training purpose. - input_ids = self.tokenizer.apply_chat_template(example) + input_ids = SFTDataset._normalize_input_ids( + self.tokenizer.apply_chat_template(example) + ) + current_loss_mask = [1] * len(input_ids) input_ids = input_ids + [get_eos_token_id(self.tokenizer)] current_loss_mask += [0] @@ -335,6 +460,8 @@ def train_valid_test_sft_datasets_provider(train_val_test_num_samples): else: kwargs = { "hf_dataset": args.finetune_hf_dataset, + "hf_dataset_split": args.finetune_data_split, + "hf_dataset_data_files": args.finetune_data_files, "tokenizer": hf_tokenizer, "seq_length": args.seq_length, # Optional kwargs diff --git a/examples/post_training/modelopt/utils.py b/examples/post_training/modelopt/utils.py index 512b640fa9c..dae58c9e72c 100644 --- a/examples/post_training/modelopt/utils.py +++ b/examples/post_training/modelopt/utils.py @@ -39,16 +39,38 @@ def get_eos_token_id(hf_tokenizer=None): if hf_tokenizer is None: hf_tokenizer = get_hf_tokenizer() - if hf_tokenizer.eos_token == "<|eot_id|>": - return 128001 - if hf_tokenizer.eos_token == "<|eot|>": - return 200001 - if hf_tokenizer.eos_token == "<|im_end|>": - return 151643 - if hf_tokenizer.eos_token == "<|return|>": - return 199999 - - return hf_tokenizer.eos_token_id + tokenizer_size = None + try: + tokenizer_size = len(hf_tokenizer) + except TypeError: + tokenizer_size = getattr(hf_tokenizer, "vocab_size", None) + + def _valid_token_id(token_id): + return token_id is not None and token_id >= 0 and ( + tokenizer_size is None or token_id < tokenizer_size + ) + + known_chat_eos_ids = { + "<|eot_id|>": 128001, + "<|eot|>": 200001, + "<|im_end|>": 151643, + "<|return|>": 199999, + } + if hf_tokenizer.eos_token in known_chat_eos_ids: + converted_id = hf_tokenizer.convert_tokens_to_ids(hf_tokenizer.eos_token) + if _valid_token_id(converted_id): + return converted_id + mapped_id = known_chat_eos_ids[hf_tokenizer.eos_token] + if _valid_token_id(mapped_id): + return mapped_id + + if _valid_token_id(hf_tokenizer.eos_token_id): + return hf_tokenizer.eos_token_id + raise ValueError( + "Tokenizer EOS token id {} is outside the tokenizer vocabulary.".format( + hf_tokenizer.eos_token_id + ) + ) def build_lm_batch( diff --git a/megatron/post_training/arguments.py b/megatron/post_training/arguments.py index 8fd41269877..2b1442b08ec 100644 --- a/megatron/post_training/arguments.py +++ b/megatron/post_training/arguments.py @@ -93,6 +93,14 @@ def add_modelopt_args(parser): group.add_argument( "--finetune-data-split", type=str, default="train", help="HF dataset split used for finetuning." ) + group.add_argument( + "--finetune-data-files", + type=str, + nargs="+", + default=None, + help="Optional explicit files from an HF dataset repository used for finetuning. " + "Listing files avoids preparing unrelated splits of large datasets.", + ) # MTP / base train-target selection for QAD and MTP QAT. group.add_argument( diff --git a/tests/unit_tests/post_training/test_modelopt_finetune_dataset.py b/tests/unit_tests/post_training/test_modelopt_finetune_dataset.py new file mode 100644 index 00000000000..036e4a4cb83 --- /dev/null +++ b/tests/unit_tests/post_training/test_modelopt_finetune_dataset.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Unit tests for ModelOpt Hugging Face finetuning dataset helpers.""" + +import sys +from argparse import ArgumentParser +from pathlib import Path + +import pytest +import torch + +pytest.importorskip("datasets") +pytest.importorskip("modelopt") +pa = pytest.importorskip("pyarrow") +pq = pytest.importorskip("pyarrow.parquet") +transformers = pytest.importorskip("transformers") + +_MODEL_OPT_EXAMPLE_DIR = Path(__file__).parents[3] / "examples" / "post_training" / "modelopt" +sys.path.insert(0, str(_MODEL_OPT_EXAMPLE_DIR)) + +from finetune import SFTDataset +from utils import get_eos_token_id + +from megatron.post_training.arguments import add_modelopt_args + + +class _Tokenizer: + eos_token = "<|return|>" + eos_token_id = 3 + + def __init__(self, converted_id): + self.converted_id = converted_id + + def __len__(self): + return 10 + + def convert_tokens_to_ids(self, _token): + return self.converted_id + + +def test_finetune_data_files_argument_accepts_multiple_files(): + parser = ArgumentParser() + add_modelopt_args(parser) + + args = parser.parse_args( + [ + "--finetune-data-split", + "chat", + "--finetune-data-files", + "data/chat-00000.parquet", + "data/chat-00001.parquet", + ] + ) + + assert args.finetune_data_split == "chat" + assert args.finetune_data_files == ["data/chat-00000.parquet", "data/chat-00001.parquet"] + + +def test_materialize_data_files_resolves_local_dataset_directory(tmp_path): + data_file = tmp_path / "train.jsonl" + data_file.write_text('{"messages": []}\n') + + resolved = SFTDataset._materialize_data_files(str(tmp_path), ["train.jsonl"]) + + assert resolved == [str(data_file)] + + +def test_materialize_data_files_downloads_explicit_hub_file(monkeypatch): + calls = [] + + def _download(**kwargs): + calls.append(kwargs) + return "/cache/chat.parquet" + + monkeypatch.setattr("huggingface_hub.hf_hub_download", _download) + + resolved = SFTDataset._materialize_data_files( + "nvidia/Nemotron-Post-Training-Dataset-v2", ["data/chat.parquet"] + ) + + assert resolved == ["/cache/chat.parquet"] + assert calls == [ + { + "repo_id": "nvidia/Nemotron-Post-Training-Dataset-v2", + "filename": "data/chat.parquet", + "repo_type": "dataset", + "token": None, + } + ] + + +def test_materialize_data_files_rejects_wildcards(): + with pytest.raises(ValueError, match="explicit files"): + SFTDataset._materialize_data_files("org/dataset", ["data/chat-*.parquet"]) + + +def test_infer_data_files_builder_rejects_mixed_formats(): + with pytest.raises(ValueError, match="single format"): + SFTDataset._infer_data_files_builder(["train.jsonl", "train.parquet"]) + + +def test_load_parquet_data_files_ignores_huggingface_schema_metadata(tmp_path): + data_file = tmp_path / "train.parquet" + table = pa.table({"text": ["sample"]}).replace_schema_metadata( + {b"huggingface": b"unsupported feature metadata"} + ) + pq.write_table(table, data_file) + + dataset = SFTDataset._load_parquet_data_files([str(data_file)]) + + assert dataset["text"] == ["sample"] + + +@pytest.mark.parametrize( + ("input_ids", "expected"), + [ + ([1, 2], [1, 2]), + ([[1, 2]], [1, 2]), + (torch.tensor([[1, 2]]), [1, 2]), + (transformers.BatchEncoding({"input_ids": [[1, 2]]}), [1, 2]), + ], +) +def test_normalize_input_ids(input_ids, expected): + assert SFTDataset._normalize_input_ids(input_ids) == expected + + +def test_normalize_input_ids_rejects_batches(): + with pytest.raises(ValueError, match="one tokenized chat sample"): + SFTDataset._normalize_input_ids([[1, 2], [3, 4]]) + + +def test_get_eos_token_id_prefers_valid_converted_id(): + assert get_eos_token_id(_Tokenizer(converted_id=7)) == 7 + + +def test_get_eos_token_id_falls_back_from_out_of_vocab_chat_id(): + assert get_eos_token_id(_Tokenizer(converted_id=199999)) == 3 + + +def test_get_eos_token_id_rejects_out_of_vocab_fallback(): + tokenizer = _Tokenizer(converted_id=199999) + tokenizer.eos_token_id = 10 + + with pytest.raises(ValueError, match="outside the tokenizer vocabulary"): + get_eos_token_id(tokenizer) From d7d7a6a7e2cdcfed8f9c9d5e553a2c6f02c953ca Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Mon, 10 Aug 2026 23:30:18 -0700 Subject: [PATCH 246/290] Add megatron.core.telemetry base layer and observability guide (#6415) Signed-off-by: Deepak Narayanan --- .../user-guide/observability/configuration.md | 142 ++++++++++ docs/user-guide/observability/extending.md | 169 ++++++++++++ docs/user-guide/observability/index.md | 87 ++++++ docs/user-guide/observability/metrics.md | 107 ++++++++ .../observability/pipeline-parallel.md | 158 +++++++++++ docs/user-guide/observability/span-groups.md | 156 +++++++++++ megatron/core/telemetry/README.md | 60 ++++ megatron/core/telemetry/__init__.py | 12 + megatron/core/telemetry/fallbacks.py | 45 +++ megatron/core/telemetry/span_groups.py | 153 +++++++++++ megatron/core/telemetry/training_metrics.py | 114 ++++++++ tests/unit_tests/telemetry/__init__.py | 1 + tests/unit_tests/telemetry/test_fallbacks.py | 114 ++++++++ .../unit_tests/telemetry/test_span_groups.py | 166 ++++++++++++ .../telemetry/test_training_metrics.py | 256 ++++++++++++++++++ 15 files changed, 1740 insertions(+) create mode 100644 docs/user-guide/observability/configuration.md create mode 100644 docs/user-guide/observability/extending.md create mode 100644 docs/user-guide/observability/index.md create mode 100644 docs/user-guide/observability/metrics.md create mode 100644 docs/user-guide/observability/pipeline-parallel.md create mode 100644 docs/user-guide/observability/span-groups.md create mode 100644 megatron/core/telemetry/README.md create mode 100644 megatron/core/telemetry/__init__.py create mode 100644 megatron/core/telemetry/fallbacks.py create mode 100644 megatron/core/telemetry/span_groups.py create mode 100644 megatron/core/telemetry/training_metrics.py create mode 100644 tests/unit_tests/telemetry/__init__.py create mode 100644 tests/unit_tests/telemetry/test_fallbacks.py create mode 100644 tests/unit_tests/telemetry/test_span_groups.py create mode 100644 tests/unit_tests/telemetry/test_training_metrics.py diff --git a/docs/user-guide/observability/configuration.md b/docs/user-guide/observability/configuration.md new file mode 100644 index 00000000000..cf967cc6856 --- /dev/null +++ b/docs/user-guide/observability/configuration.md @@ -0,0 +1,142 @@ + + +# Configuration + +## CLI flags + +| Flag | Type | Description | +|---|---|---| +| `--otel-enabled` | flag | Enable OTel telemetry | +| `--otel-service-name NAME` | string | Override `OTEL_SERVICE_NAME` | +| `--otel-span-groups SPEC` | string | Comma-separated span-group spec (see [Span Groups](span-groups.md)) | + +These flags are processed in `megatron/training/global_vars.py:_set_telemetry()` and override the corresponding env vars. + +## Megatron-specific environment variables + +Each `MEGATRON_OTEL_*` variable is an **alias** for the corresponding [`NemoLensConfig` field](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/configuration.md) with `NEMO_LENS_*` as fallback — they are not independent settings. Setting `MEGATRON_OTEL_ENABLED=1` is equivalent to setting `NEMO_LENS_ENABLED=1`; they refer to the same underlying config. The prefix/fallback model lets Megatron scope its own env vars while still inheriting lens defaults from a shared environment. + +| Variable | Default | Description | +|---|---|---| +| `MEGATRON_OTEL_ENABLED` | `0` | Master toggle; must be set to `1` to activate | +| `MEGATRON_OTEL_RANK_STRATEGY` | `single_rank` | `single_rank`, `all_ranks`, `sampled`, `first_rank_per_node`, or any name registered via `register_rank_strategy()` | +| `MEGATRON_OTEL_EXPORT_RANK` | `-1` | For `single_rank`: which rank exports. `-1` = last rank | +| `MEGATRON_OTEL_EXPORT_SAMPLE_RATE` | `1.0` | For `sampled`: fraction in `[0.0, 1.0]` | +| `MEGATRON_OTEL_SAMPLING_STRATEGY` | (empty) | `rank_aware` or any name registered via `register_sampling_strategy()`. Empty leaves the OTel SDK default sampler in place. | +| `MEGATRON_OTEL_TRACES_ENABLED` | `1` | Enable trace spans | +| `MEGATRON_OTEL_METRICS_ENABLED` | `1` | Enable metrics instruments | +| `MEGATRON_OTEL_LOGS_ENABLED` | `0` | Enable OTel log bridge | +| `MEGATRON_OTEL_SPAN_GROUPS` | `default` | Span granularity spec (see [Span Groups](span-groups.md)) | +| `MEGATRON_OTEL_EXPORTER` | `otlp` | Exporter backend: `otlp` or `console` | +| `NEMO_LENS_RUN_ID` | (auto) | Unique run identifier. Auto-detected from `SLURM_JOB_ID` or generated UUID | +| `NEMO_LENS_USER_ID` | (empty) | Optional user/team label | + +For the full config model, field semantics, and validation rules, see +[lens: configuration](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/configuration.md). + +## Rank strategy + +Controls which ranks actually send telemetry. Four strategies are available: `single_rank` (default), `all_ranks`, `sampled`, and `first_rank_per_node`, configured via `MEGATRON_OTEL_RANK_STRATEGY` above. + +See [lens: sampling](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/sampling.md) for detailed semantics, when to use each, and how they compose with OTel SDK samplers. + +## Standard OTel SDK variables + +All standard OTel SDK env vars are honoured by the SDK directly: + +| Variable | Example | +|---|---| +| `OTEL_SERVICE_NAME` | `megatron-training` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` or `http/protobuf` | +| `OTEL_EXPORTER_OTLP_HEADERS` | `Authorization=Bearer ` | +| `OTEL_TRACES_SAMPLER` | `parentbased_traceidratio` | +| `OTEL_TRACES_SAMPLER_ARG` | `0.1` | + +## Run Identification + +Each training run is automatically assigned a unique `nemo.run.id` resource attribute that flows to all backends. + +**Priority order:** + +1. `NEMO_LENS_RUN_ID` env var (explicit, highest priority) +2. `SLURM_JOB_ID` env var (auto-detected on SLURM clusters) +3. Auto-generated 12-character UUID (fallback) + +All ranks in a distributed job share the same `run_id`. Each rank gets a unique `service.instance.id` of `{run_id}-rank{rank}`. + +Filter by `nemo.run.id` in Jaeger, Grafana, Kibana to isolate a specific run. + +## Resource attributes + +Megatron's `_set_telemetry()` sets training-config attributes on the OTel `Resource` so they appear as Jaeger "Process" tags across every span in the run: + +| Attribute | Megatron source | +|---|---| +| `dl.local_rank` | `args.local_rank` | +| `dl.tensor_parallel.size` | `args.tensor_model_parallel_size` | +| `dl.pipeline_parallel.size` | `args.pipeline_model_parallel_size` | +| `dl.data_parallel.size` | `args.data_parallel_size` | +| `dl.batch_size` | `args.global_batch_size` | +| `dl.sequence_length` | `args.seq_length` | +| `megatron.num_layers` | `args.num_layers` | +| `megatron.hidden_size` | `args.hidden_size` | +| `megatron.num_attention_heads` | `args.num_attention_heads` | +| `megatron.train_iters` | `args.train_iters` | +| `megatron.micro_batch_size` | `args.micro_batch_size` | +| `megatron.ckpt_format` | `args.ckpt_format` | +| `megatron.precision` | `fp16` / `bf16` / `fp32` | + +Plus auto-detected attributes from lens's [resource detection](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/resources.md): hostname, PID, GPU count, SLURM metadata, Kubernetes metadata. + +## Typical configurations + +### Local development with console exporter + +```bash +export MEGATRON_OTEL_ENABLED=1 +export MEGATRON_OTEL_EXPORTER=console +python examples/run_simple_mcore_train_loop.py +``` + +Spans and metrics print to stdout. + +### Local collector + +Point Megatron at an OTLP endpoint on localhost — an OpenTelemetry Collector, or +a backend such as Jaeger that accepts OTLP directly: + +```bash +export MEGATRON_OTEL_ENABLED=1 +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +torchrun --nproc_per_node=8 pretrain_gpt.py ... +``` + +For a local stack to send this to, see +[lens: sending telemetry to a backend](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/observability/backends.md). + +### Production with remote collector + +```bash +export MEGATRON_OTEL_ENABLED=1 +export MEGATRON_OTEL_SPAN_GROUPS=default +export OTEL_EXPORTER_OTLP_ENDPOINT=http://:4317 +export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " +python pretrain_gpt.py ... +``` + +### Per-step granularity with trace sampling + +```bash +export MEGATRON_OTEL_ENABLED=1 +export MEGATRON_OTEL_SPAN_GROUPS=per_step +export OTEL_TRACES_SAMPLER=parentbased_traceidratio +export OTEL_TRACES_SAMPLER_ARG=0.1 # keep 10% of traces +``` diff --git a/docs/user-guide/observability/extending.md b/docs/user-guide/observability/extending.md new file mode 100644 index 00000000000..e53963e4f9b --- /dev/null +++ b/docs/user-guide/observability/extending.md @@ -0,0 +1,169 @@ + + +# Extending Instrumentation + +To add new spans or metrics to Megatron code, use the instrumentation primitives from `nemo.lens`. The primitives themselves are documented in +[lens: instrumentation](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/instrumentation.md). +This page covers Megatron conventions. + +## Adding a custom span + +### Simple block — `span_cm` + +```python +from megatron.training.global_vars import get_telemetry +from nemo.lens.helpers import span_cm + +telemetry = get_telemetry() +if telemetry is not None: + with span_cm("megatron.my_custom_op", tracer=telemetry.tracer, param_count=1e9): + ... # your code +``` + +`span_cm` always creates a span when telemetry is active — good for cold paths. + +### Group-gated block — `managed_span` + +For hot paths where you want minimal cost when the group is disabled: + +```python +from megatron.core.telemetry.span_groups import MegatronSpanGroup +from nemo.lens.helpers import managed_span + +with managed_span(MegatronSpanGroup.STEP, "megatron.my_custom_step", + iteration=iteration) as span: + result = do_work() + if span is not None: + span.set_attribute("megatron.my_custom.result", result) +``` + +`managed_span` yields `None` when the group is disabled; the body still runs. Check `if span is not None` before setting attributes. + +### Fallback pattern + +Every import of lens in Megatron code must use the try/except fallback idiom so the code runs when lens isn't installed: + +```python +try: + from nemo.lens.helpers import managed_span as _otel_managed_span + from nemo.lens.state import is_span_group_enabled as _otel_sg_enabled +except ImportError: + from megatron.core.telemetry.fallbacks import managed_span as _otel_managed_span + from megatron.core.telemetry.fallbacks import is_span_group_enabled as _otel_sg_enabled +``` + +`megatron/core/telemetry/fallbacks.py` re-exports from `nemo.lens.fallbacks` when lens is installed, otherwise provides inline no-ops. + +See [lens: optional dependency](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/design/optional-dependency.md). + +## Naming conventions + +| Kind | Convention | Example | +|---|---|---| +| Span name | `megatron..` | `megatron.train_step`, `megatron.microbatch.forward` | +| Span attribute | `megatron.` for Megatron-specific, `dl.` for shared | `megatron.iteration`, `dl.rank` | +| Resource attribute | `megatron.` for Megatron-specific, `dl.` for shared, standard for host/SLURM/K8s | `megatron.num_layers`, `dl.tensor_parallel.size`, `host.name` | +| Metric name | `megatron..` | `megatron.training.loss` | + +Use the constants in `nemo.lens.semconv` when a name is shared across consumers (`DL_RANK`, `NEMO_RUN_ID`, etc.). For Megatron-specific names, hard-coded strings are fine — they're short and grep-able. + +## Choosing a span group + +When adding a new span, decide which group it belongs to: + +- **Always want it in production?** → `job` (very rare outside setup spans) +- **Once per iteration?** → `step` +- **Inside the forward/backward?** → `forward_backward` or `microbatch` +- **Inside the optimizer?** → `optimizer` +- **Related to checkpointing?** → `checkpoint` +- **Related to evaluation?** → `evaluate` +- **Cross-rank communication?** → `communication` +- **Inference request path?** → `inference` + +Don't invent a new group unless no existing group fits — new groups add to `MegatronSpanGroup` and require preset updates. + +## Adding a new span group + +If you do need a new group: + +1. Edit `megatron/core/telemetry/span_groups.py`: + + ```python + class MegatronSpanGroup(SpanGroup): + # ... existing groups ... + MY_NEW_GROUP = "my_new_group" + + ALL_GROUPS = frozenset([*SpanGroup.ALL_GROUPS, ..., MY_NEW_GROUP]) + + _PRESETS = { + "default": frozenset([SpanGroup.JOB, SpanGroup.CHECKPOINT, SpanGroup.EVALUATE, + INFERENCE]), # typically don't add new groups to default + "per_step": frozenset([...]), # add to per_step if it's per-iteration + "all": ALL_GROUPS, # always in all + } + ``` + +2. Document it in [Span Groups](span-groups.md) with the spans it controls and typical frequency. + +3. Update dashboard queries if the group introduces new metric labels. + +## Adding a metric + +For domain-specific metrics, add a module under `megatron/core/telemetry/` following the pattern in `megatron/core/telemetry/training_metrics.py`: + +```python +# megatron/core/telemetry/my_domain_metrics.py +import weakref +from opentelemetry import metrics + +_INSTRUMENTS: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + +def _get_instruments(meter: metrics.Meter) -> dict: + instruments = _INSTRUMENTS.get(meter) + if instruments is None: + instruments = { + "my_new_metric": meter.create_histogram( + name="megatron.training.my_new_metric_ms", + unit="ms", + description="...", + ), + } + _INSTRUMENTS[meter] = instruments + return instruments + +def record_training_metrics(meter, *, my_new_value_ms=None, ...): + i = _get_instruments(meter) + if my_new_value_ms is not None: + i["my_new_metric"].record(my_new_value_ms) +``` + +Call `record_training_metrics(meter=handle.meter, my_new_value_ms=42.0)` only on the export rank (check `handle.is_exporting`). + +See [lens: metrics](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/metrics.md) for the pattern rationale. + +## Testing new instrumentation + +Megatron's telemetry tests live at `tests/unit_tests/telemetry/` and use the fixture pattern from lens's `conftest.py` (global OTel state reset before/after each test). + +When adding a span: + +1. Add a test in `tests/unit_tests/telemetry/` that asserts the span is emitted when its group is enabled and absent when disabled. +2. Use `InMemorySpanExporter` (from lens's `conftest.py`, shared via `sys.path` or a test utility) to capture spans. +3. Assert on span name, attributes, and parent relationships. + +See [lens: testing](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/developer/testing.md) for fixture patterns. + +## When not to add instrumentation + +- **Inside a tight inner loop** (per-token, per-parameter). Even `managed_span`'s frozenset lookup adds up over trillions of invocations. +- **On code that runs on all ranks with unbounded cardinality**. If the span attribute includes something like a tensor shape with high variance, you get cardinality explosion at the backend. +- **As a replacement for logging**. Structured logs belong in logs (and can be correlated via the [log bridge](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/logging-bridge.md)). Spans describe bounded operations, not every interesting event. + +When in doubt, start with a coarse span at the boundary of the subsystem, not a fine-grained one at every internal call. diff --git a/docs/user-guide/observability/index.md b/docs/user-guide/observability/index.md new file mode 100644 index 00000000000..e9239e0a692 --- /dev/null +++ b/docs/user-guide/observability/index.md @@ -0,0 +1,87 @@ +--- +orphan: true +--- + + + +# Observability + +Megatron-LM is instrumented with [OpenTelemetry](https://opentelemetry.io/) via the [`nemo-lens`](https://github.com/NVIDIA-NeMo/Lens) library, emitting **traces** at training-framework boundaries and **metrics** for loss, throughput, and gradient norm. + +Telemetry exports to any OTLP-compatible backend (Jaeger, Grafana Tempo, W&B Weave, Honeycomb, Datadog, ...). + +```{note} +These pages are being landed ahead of the instrumentation they describe. The +span-group and metric definitions in `megatron/core/telemetry/` are available +now; the `--otel-*` CLI flags, the `MEGATRON_OTEL_*` environment variables, and +the call-site spans arrive with +[#6385](https://github.com/NVIDIA/Megatron-LM/pull/6385). Until then, treat this +section as a description of the intended interface rather than of what a +released Megatron-LM does. +``` + +## What's in this section + +```{toctree} +:maxdepth: 1 + +configuration +span-groups +metrics +pipeline-parallel +extending +``` + +## Scope + +This documentation covers **Megatron-specific** usage: CLI flags, environment variables, span names, metric names, and the pipeline-parallel trace correlation integration. + +For general concepts — span groups, instrumentation primitives, configuration model, custom exporters, resource detection — see the [lens documentation](https://github.com/NVIDIA-NeMo/Lens). This section links to lens docs when relevant rather than duplicating content. + +## Quick start + +```bash +export MEGATRON_OTEL_ENABLED=1 +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +export MEGATRON_OTEL_SPAN_GROUPS=default # coarse-grained; safe for production + +torchrun --nproc_per_node=8 pretrain_gpt.py ... +``` + +With `default` span groups, Megatron emits a handful of coarse spans per iteration and a steady stream of training metrics. Switch to `per_step` for profiling individual steps, or `all` for fine-grained debugging. + +## What gets instrumented + +| Subsystem | File | Spans | +|---|---|---| +| Training loop | `megatron/training/training.py` | `megatron.pretrain`, `megatron.train`, `megatron.train_step`, `megatron.forward_backward`, `megatron.optimizer_step` | +| Pipeline schedules | `megatron/core/pipeline_parallel/schedules.py` | `megatron.microbatch.forward`, `megatron.microbatch.backward`, `megatron.pp.recv_forward.linked` | +| P2P communication | `megatron/core/pipeline_parallel/p2p_communication.py` | `megatron.p2p.{send,recv}_{forward,backward}` | +| Gradient sync (DDP) | `megatron/core/distributed/distributed_data_parallel.py` | `megatron.grad_sync.{start,finish}` | +| Checkpointing | `megatron/training/checkpointing.py` | `megatron.save_checkpoint.*`, `megatron.load_checkpoint.*` | +| Model init | `megatron/training/training.py` | `megatron.model_init` | +| Evaluation | `megatron/training/training.py` | `megatron.evaluate`, `megatron.evaluate.step` | +| Inference server | `megatron/core/inference/text_generation_server/` | `text_completion {model}` (GenAI semconv) | + +Each span is tagged with a **span group** that controls whether it's emitted at runtime. See [Span Groups](span-groups.md). + +## What gets exported + +- **Traces**: Jaeger / Tempo / Honeycomb / etc. via OTLP. +- **Metrics**: Prometheus via the OTel Collector, or direct OTLP to Grafana Mimir / Datadog / etc. +- **Logs** (optional): via the OTel log bridge when `MEGATRON_OTEL_LOGS_ENABLED=1` — correlates `logging` records with the active span's trace ID. + +By default, only **one rank** exports (the last rank). For multi-rank telemetry, see [Configuration — Rank strategy](configuration.md#rank-strategy). + +## Related + +- Lens configuration model and env vars: [lens: configuration](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/configuration.md) +- Instrumentation primitives (`managed_span`, `trace_fn`, `span_cm`): [lens: instrumentation](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/instrumentation.md) +- Sending telemetry to a backend: [lens: backends](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/observability/backends.md) diff --git a/docs/user-guide/observability/metrics.md b/docs/user-guide/observability/metrics.md new file mode 100644 index 00000000000..998d64005f8 --- /dev/null +++ b/docs/user-guide/observability/metrics.md @@ -0,0 +1,107 @@ + + +# Metrics + +Megatron emits two namespaces of metrics: training metrics (`megatron.training.*`) and inference metrics (`gen_ai.*`, following OTel GenAI semantic conventions). + +All metrics are emitted **only on the export rank** (`is_exporting = True`). Non-exporting ranks don't create metric instruments. + +For the general instrument pattern (weak-reference caching, None-skipping), see +[lens: metrics](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/metrics.md). + +## Training metrics (`megatron.training.*`) + +Training has no OTel standard, so Megatron uses a project-specific namespace. Emission is tied to `--log-interval` (same cadence as TensorBoard and W&B loggers). + +| Metric | Type | Unit | Description | +|---|---|---|---| +| `megatron.training.step_duration_ms` | Histogram | ms | Duration of one training step in milliseconds | +| `megatron.training.loss` | Gauge | — | Training loss (last value per log interval) | +| `megatron.training.throughput_tflops` | Gauge | TFLOP/s | Training throughput in TFLOP/s/GPU | +| `megatron.training.tokens_per_sec` | Gauge | tokens/s | Training throughput in tokens per second | +| `megatron.training.grad_norm` | Gauge | — | Global gradient norm | +| `megatron.training.skipped_iters` | Counter | — | Optimizer steps skipped (NaN/inf loss) | +| `megatron.training.learning_rate` | Gauge | — | Current learning rate | +| `megatron.training.memory_allocated_gb` | Gauge | GB | Peak GPU memory allocated | + +Loss, throughput, grad norm, and learning rate are **Gauges** (point-in-time value), not Histograms — this produces a Prometheus `gauge` which is semantically correct for a value that changes every log interval. + +### Emission site + +`megatron/training/training.py` calls `record_training_metrics()` from `megatron.core.telemetry.training_metrics` every `--log-interval` iterations. The instrument module caches per-Meter instruments using `WeakKeyDictionary` to avoid leaking on re-init. + +## Inference metrics (GenAI) + +Follows the [OTel GenAI metrics spec](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/). Emitted from `megatron/core/inference/text_generation_server/`. + +| Metric | Type | Unit | Description | +|---|---|---|---| +| `gen_ai.server.request.duration` | Histogram | s | End-to-end request latency | +| `gen_ai.client.token.usage` | Histogram | `{token}` | Tokens per request, split by `gen_ai.token.type` | + +All data points carry the required GenAI attributes: +- `gen_ai.operation.name = "text_completion"` +- `gen_ai.provider.name = "megatron"` +- `gen_ai.request.model = ` + +`gen_ai.client.token.usage` additionally has a `gen_ai.token.type` label with values `"input"` or `"output"` — query them separately in Prometheus/Grafana. + +## Prometheus metric names + +The OTel SDK may append a unit suffix when exporting to Prometheus. + +| OTel instrument name | Prometheus metric (example) | +|---|---| +| `megatron.training.loss` | `megatron_training_loss` (Gauge) | +| `megatron.training.step_duration_ms` | `megatron_training_step_duration_ms_milliseconds` | +| `megatron.training.throughput_tflops` | `megatron_training_throughput_tflops` (Gauge) | +| `megatron.training.tokens_per_sec` | `megatron_training_tokens_per_sec` (Gauge) | +| `megatron.training.skipped_iters` | `megatron_training_skipped_iters_total` | +| `gen_ai.server.request.duration` | `gen_ai_server_request_duration_seconds` | +| `gen_ai.client.token.usage` | `gen_ai_client_token_usage_bucket` (+ `gen_ai_token_type` label) | + +Dashboards use regex patterns (e.g. `{__name__=~"megatron_training_loss.*"}`) to match regardless of suffix. If a panel shows "No data", use **Explore → Prometheus → Metrics browser** to discover exact names on your SDK version. + +## Filtering across runs + +Metrics carry the `nemo.run.id` resource attribute on every data point. Use it to filter in Grafana: + +``` +{nemo_run_id="", __name__=~"megatron_training_.*"} +``` + +Or to compare two runs: + +``` +{nemo_run_id=~"run-a|run-b", __name__="megatron_training_loss"} +``` + +## Metric vs span attribute + +A recurring pitfall: putting training loss on a span attribute instead of a metric. + +- **Loss** changes every iteration. Put it on `megatron.training.loss` metric. Prometheus stores each value; Grafana plots the series. +- **Iteration number** is categorical context for a specific span. Put it on `megatron.iteration` span attribute. Jaeger uses it for filtering. + +Don't do it the other way. Loss on a span attribute produces no useful time series in Jaeger; it's wasted data. Iteration on a metric label produces one metric series per iteration — unbounded cardinality explosion. + +See [lens: metrics — Metric vs span attribute vs resource attribute](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/metrics.md#metrics-vs-span-attributes-vs-resource-attributes). + +## Adding custom metrics + +To add Megatron-specific metrics, add a new file under `megatron/core/telemetry/` following the pattern in `megatron/core/telemetry/training_metrics.py`: + +1. Declare a `WeakKeyDictionary` for per-Meter instrument caching. +2. Implement `_get__instruments(meter)` that creates and caches instruments. +3. Implement `record__metrics(meter, **kwargs)` that records only non-`None` values. + +Use `megatron..` naming for application-specific metrics, reserving the shared `dl.*` and `gen_ai.*` namespaces for cross-consumer or standard metrics. + +See the existing `nemo.lens.instruments.inference` as a template. diff --git a/docs/user-guide/observability/pipeline-parallel.md b/docs/user-guide/observability/pipeline-parallel.md new file mode 100644 index 00000000000..57793bd2e10 --- /dev/null +++ b/docs/user-guide/observability/pipeline-parallel.md @@ -0,0 +1,158 @@ + + +# Pipeline-Parallel Trace Correlation + +When pipeline parallel size > 1, each rank normally creates an independent trace per iteration — making cross-stage causality invisible in Jaeger. Megatron solves this by broadcasting rank 0's trace context to all ranks and linking each stage's receive operation to that context. + +The generic primitives (`broadcast_trace_context`, `create_linked_span`) live in lens; see +[lens: distributed tracing](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/distributed-tracing.md) +for how they work. + +This page covers Megatron's specific integration. + +## The problem + +Without correlation: + +``` +Rank 0 trace (trace_id: AAA): + megatron.train_step + megatron.microbatch.forward [microbatch 0] + megatron.p2p.send_forward + +Rank 1 trace (trace_id: BBB): # different trace! + megatron.p2p.recv_forward + megatron.microbatch.forward [microbatch 0] +``` + +Rank 0 sends a tensor to rank 1, but there's nothing in the trace that shows this relationship. Jaeger treats them as unrelated activities. + +## The solution + +At the start of every `megatron.train_step`, rank 0's trace context is broadcast to all ranks via `torch.distributed`. Each non-first pipeline stage then emits a `megatron.pp.recv_forward.linked` span with an **OTel Link** (not parent-child) to the broadcast context. + +### After integration + +``` +Rank 0 trace (trace_id: ABC): + megatron.train_step + megatron.microbatch.forward [microbatch 0] + megatron.p2p.send_forward + +Rank 1 trace (same trace_id: ABC): + megatron.pp.recv_forward.linked [LINK to rank 0's train_step context] + megatron.p2p.recv_forward + megatron.microbatch.forward [microbatch 0] + +Rank 2 trace (same trace_id: ABC): + megatron.pp.recv_forward.linked [LINK to rank 0's train_step context] + ... +``` + +All PP stages of a given step share a single `trace_id`. Each stage's `megatron.pp.recv_forward.linked` shows a link back to the broadcast context — visible in Jaeger's span detail panel. + +## Where the integration lives + +### Broadcast — `megatron/training/training.py` + +Inside the main training loop, just before the `megatron.train_step` managed_span opens: + +```python +# Broadcast rank 0's trace context to all PP ranks. +# Collective — all ranks must participate. +_pp_carrier = None +try: + from megatron.core import parallel_state + if (torch.distributed.is_initialized() + and parallel_state.get_pipeline_model_parallel_world_size() > 1 + and get_telemetry() is not None): + from nemo.lens.distributed import broadcast_trace_context + from nemo.lens.state import set_pp_trace_carrier + _pp_carrier = broadcast_trace_context( + rank=torch.distributed.get_rank(), src_rank=0 + ) + set_pp_trace_carrier(_pp_carrier) +except Exception: + _pp_carrier = None +``` + +### Linked spans — `megatron/core/pipeline_parallel/schedules.py` + +Inside `forward_backward_pipelining_without_interleaving`, wrapping the warmup and pre-1F1B `recv_forward` calls: + +```python +# OTel: create linked span to sender's trace context on recv_forward. +_recv_link_span = None +if (not is_pp_first_stage(p2p_communicator.pp_group) + and _otel_sg_enabled('communication') + and _otel_create_linked_span is not None): + _carrier = _otel_get_pp_carrier() + if _carrier is not None: + from opentelemetry import trace as _otel_trace_mod + _recv_link_span = _otel_create_linked_span( + _otel_trace_mod.get_tracer('nemo.lens'), + 'megatron.pp.recv_forward.linked', + remote_carrier=_carrier, + **{'dl.pipeline_parallel.rank': rank, 'dl.microbatch_id': i}, + ) +input_tensor = p2p_communicator.recv_forward(...) +if _recv_link_span is not None: + _recv_link_span.end() +``` + +## What's instrumented vs what isn't + +| recv site | Instrumented? | +|---|---| +| Warmup `recv_forward` (lines ~2166 in `schedules.py`) | Yes | +| Pre-1F1B `recv_forward` (line ~2196) | Yes | +| Steady-state 1F1B `send_forward_recv_backward` | No — high frequency; instrumenting every hop would dominate signal | +| Cooldown `recv_backward` | No — redundant with warmup coverage | + +The warmup and pre-1F1B receives establish the pipeline structure for Jaeger viewing. Steady-state and cooldown receives are skipped to keep the per-step span count bounded — the link from rank N's warmup recv to rank 0's context is enough to visualise the pipeline wave. + +## Cost + +- `broadcast_trace_context`: one `torch.distributed.broadcast` of a small carrier payload (length int64 + ~200 bytes), runs once per step (not per microbatch). +- `megatron.pp.recv_forward.linked` spans: only created when the `communication` span group is enabled AND PP > 1 AND telemetry was initialised. + +Does not scale with number of microbatches — one broadcast per step, a handful of linked spans per step. + +## Collective correctness + +`broadcast_trace_context` is a **collective operation** — every rank must call it or the job deadlocks. The gate uses `get_telemetry() is not None`, which is uniformly true or false across ranks (telemetry was initialised in `_set_telemetry` on all ranks, regardless of whether they export). + +Do not gate on `handle.is_exporting` — that differs per rank and would cause some ranks to call broadcast while others skip it. + +## Disabling + +Set `MEGATRON_OTEL_SPAN_GROUPS=default` (which excludes `communication`) to skip the linked spans. The broadcast still happens (cheap), but the spans aren't emitted. + +Alternatively, run with PP size 1 — the broadcast is gated on PP > 1 and won't fire. + +To disable both: `MEGATRON_OTEL_ENABLED=0` — `get_telemetry()` returns `None`, the broadcast is skipped entirely. + +## Viewing in Jaeger + +1. Open the Jaeger UI (`:16686` in the local stack). +2. Search for traces with `service.name=megatron-lm` and `nemo.run.id=`. +3. Click a `megatron.train_step` trace. +4. In the waterfall, find a `megatron.pp.recv_forward.linked` span (only present on non-first PP ranks). +5. Click the span → the detail panel shows a "Links" section with a clickable reference to the upstream span context. + +Jaeger renders the link as a visible reference rather than a parent-child edge. This is the correct representation: the stages are concurrent, not hierarchical. + +## Why links instead of parent-child + +Parent-child implies sequential dependency: "the parent was running, spawned this child, then continued." Pipeline-parallel stages run concurrently — stage 1 doing forward on microbatch N doesn't "spawn" stage 2's forward on microbatch N-1; they happen at the same time with a tensor exchange between them. + +Links model "these are related" without implying temporal ordering. This is the correct shape for concurrent distributed work. + +See [lens: distributed tracing](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/distributed-tracing.md) for a deeper discussion. diff --git a/docs/user-guide/observability/span-groups.md b/docs/user-guide/observability/span-groups.md new file mode 100644 index 00000000000..49bd1cab725 --- /dev/null +++ b/docs/user-guide/observability/span-groups.md @@ -0,0 +1,156 @@ + + +# Span Groups + +Span granularity in Megatron is controlled by the `MEGATRON_OTEL_SPAN_GROUPS` env var (or `--otel-span-groups` CLI flag). The spec accepts preset keywords, individual group names, or a mix. + +For the general span-group mechanism see +[lens: span groups](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/user-guide/span-groups.md). +This page covers Megatron's extensions and the complete span hierarchy. + +## Preset keywords + +| Preset | Groups included | Relative cost | +|---|---|---| +| `default` | `job`, `checkpoint`, `evaluate`, `inference` | Lowest — safe for production | +| `per_step` | `default` + `model_init`, `load_checkpoint`, `step`, `forward_backward`, `optimizer`, `communication`, `data_loading` | Moderate — use with sampling | +| `all` | everything including `microbatch`, `layer`, `activation_offload` | Highest — dev/debug only | + +## `MegatronSpanGroup` + +Defined in `megatron/core/telemetry/span_groups.py`. Extends lens's base `SpanGroup` with Megatron-specific groups: + +| Group | Spans emitted | Typical frequency | +|---|---|---| +| `job` | `megatron.pretrain`, `megatron.train` | once per job | +| `checkpoint` | `megatron.save_checkpoint`, `megatron.save_checkpoint.state_dict`, `megatron.save_checkpoint.io_write` | every checkpoint | +| `evaluate` | `megatron.evaluate`, `megatron.evaluate.step` | every eval interval | +| `model_init` | `megatron.model_init` | once at startup | +| `load_checkpoint` | `megatron.load_checkpoint`, `megatron.load_checkpoint.io_read` | once at startup | +| `step` | `megatron.train_step` | every iteration | +| `forward_backward` | `megatron.forward_backward` | every iteration | +| `optimizer` | `megatron.optimizer_step` | every iteration | +| `microbatch` | `megatron.microbatch.forward`, `megatron.microbatch.backward` | every microbatch | +| `layer` | `megatron.layer.forward`, `megatron.layer.self_attention`, `megatron.layer.mlp` | every layer per microbatch | +| `communication` | `megatron.p2p.{recv,send}_{forward,backward}`, `megatron.grad_sync.{start,finish}`, `megatron.pp.recv_forward.linked` | every iteration | +| `activation_offload` | `megatron.activation.offload`, `megatron.activation.reload` | every microbatch | +| `data_loading` | (reserved for future use) | every iteration | +| `inference` | `text_completion {model}` | every inference request | + +## Examples + +```bash +# Coarse spans only — default +MEGATRON_OTEL_SPAN_GROUPS=default + +# Include per-step spans +MEGATRON_OTEL_SPAN_GROUPS=per_step + +# Default + microbatch only (skip step/optimizer groups) +MEGATRON_OTEL_SPAN_GROUPS=default,microbatch + +# Everything +MEGATRON_OTEL_SPAN_GROUPS=all +``` + +## Span hierarchy + +The full tree of spans Megatron can emit, with the controlling span group shown per span: + +``` +megatron.pretrain # job + ├── megatron.model_init # model_init + ├── megatron.load_checkpoint # load_checkpoint + │ └── megatron.load_checkpoint.io_read # load_checkpoint + └── megatron.train # job + ├── megatron.train_step # step + │ ├── megatron.forward_backward # forward_backward + │ │ ├── megatron.microbatch.forward # microbatch (×N) + │ │ │ └── megatron.layer.forward # layer (×L per microbatch) + │ │ │ ├── megatron.layer.self_attention + │ │ │ └── megatron.layer.mlp + │ │ ├── megatron.microbatch.backward # microbatch (×N) + │ │ ├── megatron.pp.recv_forward.linked # communication — link to sender's context (PP > 1) + │ │ ├── megatron.p2p.recv_forward # communication + │ │ ├── megatron.p2p.send_forward # communication + │ │ ├── megatron.p2p.recv_backward # communication + │ │ ├── megatron.p2p.send_backward # communication + │ │ ├── megatron.activation.offload # activation_offload + │ │ └── megatron.activation.reload # activation_offload + │ ├── megatron.grad_sync.start # communication + │ ├── megatron.grad_sync.finish # communication + │ └── megatron.optimizer_step # optimizer + ├── megatron.save_checkpoint # checkpoint + │ ├── megatron.save_checkpoint.state_dict # checkpoint + │ └── megatron.save_checkpoint.io_write # checkpoint + └── megatron.evaluate # evaluate + └── megatron.evaluate.step # evaluate (×N) + +text_completion {model} # inference (GenAI semconv) +``` + +## Span attributes + +Key Megatron-specific span attributes: + +| Attribute | Type | Set on | +|---|---|---| +| `megatron.model_type` | str | `megatron.pretrain` | +| `megatron.train_iters` | int | `megatron.pretrain`, `megatron.train` | +| `megatron.global_batch_size` | int | `megatron.pretrain` | +| `megatron.iteration` | int | `megatron.train_step`, `megatron.save_checkpoint` | +| `megatron.loss` | float | `megatron.train_step` | +| `megatron.grad_norm` | float | `megatron.train_step`, `megatron.optimizer_step` | +| `megatron.num_microbatches` | int | `megatron.forward_backward` | +| `megatron.microbatch_id` | int | `megatron.microbatch.forward` | +| `megatron.eval_iters` | int | `megatron.evaluate` | +| `megatron.update_successful` | bool | `megatron.optimizer_step` | +| `dl.pipeline_parallel.rank` | int | `megatron.pp.recv_forward.linked` | +| `dl.microbatch_id` | int | `megatron.pp.recv_forward.linked` (warmup only) | + +## Granularity guidance + +| Span groups | Relative cost | Recommendation | +|---|---|---| +| Disabled (`MEGATRON_OTEL_ENABLED=0`) | None | Default for smoke tests | +| `default` | Lowest | Safe for all production runs | +| `per_step` | Moderate | Use with `OTEL_TRACES_SAMPLER` | +| `all` (includes microbatch, layer) | Highest | Development / profiling only | + +Non-exporting ranks have `frozenset()` span groups — `is_span_group_enabled()` returns `False` everywhere, so **no span objects are created at all**. The disabled path is a frozenset lookup followed by an immediate return, not a no-op span that still allocates. See [lens: architecture](https://github.com/NVIDIA-NeMo/Lens/blob/main/docs/design/architecture.md). + +## Inference spans (GenAI semconv) + +The inference server (`MegatronGenerate`) emits spans that follow the [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/). + +### Span name + +``` +text_completion {gen_ai.request.model} +``` + +e.g. `text_completion gpt` or `text_completion llama`. + +### Span attributes + +| Attribute | Requirement | Value / source | +|---|---|---| +| `gen_ai.operation.name` | Required | `"text_completion"` | +| `gen_ai.provider.name` | Required | `"megatron"` | +| `gen_ai.request.model` | Conditionally Required | `args.model_type` | +| `gen_ai.request.max_tokens` | Recommended | `tokens_to_generate` from request | +| `gen_ai.request.temperature` | Recommended | `temperature` from request | +| `gen_ai.request.top_k` | Recommended | `top_k` if > 0 | +| `gen_ai.request.top_p` | Recommended | `top_p` if > 0.0 | +| `gen_ai.request.seed` | Recommended | `random_seed` if ≥ 0 | +| `gen_ai.usage.output_tokens` | Recommended | token count from response | +| `error.type` | Conditionally Required | set on error by `span_cm` | + +W3C TraceContext is extracted from incoming HTTP headers so upstream callers can propagate trace context into the Megatron span. diff --git a/megatron/core/telemetry/README.md b/megatron/core/telemetry/README.md new file mode 100644 index 00000000000..9df8183b020 --- /dev/null +++ b/megatron/core/telemetry/README.md @@ -0,0 +1,60 @@ +# Megatron-LM OpenTelemetry Instrumentation + +This module holds the building blocks for Megatron's OpenTelemetry integration, +built on top of [`nemo-lens`](https://github.com/NVIDIA-NeMo/Lens). + +Once the call sites are instrumented, Megatron emits **traces** at training +framework boundaries (training loop, checkpointing, evaluation, P2P +communication, pipeline parallel stages, inference) and **metrics** (loss, +throughput, gradient norm) that export to any OTLP-compatible backend. + +## Contents + +``` +megatron/core/telemetry/ +├── span_groups.py — MegatronSpanGroup: Megatron-specific span groups. +├── training_metrics.py — OTel instruments for the training loop. +├── fallbacks.py — No-op shims for when nemo-lens is not installed. +└── __init__.py +``` + +Resource detection and the instrumentation primitives themselves live in +`nemo-lens`. This module is a thin integration layer. + +## Optional dependencies + +Nothing here requires `nemo-lens` or `opentelemetry` to import. Both are +optional: when neither is installed, `fallbacks` supplies no-op decorators and +context managers, `span_groups` falls back to a local `SpanGroup` stub, and +`record_training_metrics()` returns immediately. Call sites can therefore import +from this module unconditionally. + +To pull in the real implementations, install +[`nemo-lens`](https://github.com/NVIDIA-NeMo/Lens) alongside Megatron. A +`megatron-core[otel]` extra that does this for you arrives with the +call-site instrumentation. + +## Documentation + +`docs/user-guide/observability/` holds the full Observability guide: + +| Topic | Doc | +|---|---| +| Overview | [index.md](../../../docs/user-guide/observability/index.md) | +| Configuration (env vars, CLI flags) | [configuration.md](../../../docs/user-guide/observability/configuration.md) | +| Span groups and span hierarchy | [span-groups.md](../../../docs/user-guide/observability/span-groups.md) | +| Training and inference metrics | [metrics.md](../../../docs/user-guide/observability/metrics.md) | +| Pipeline-parallel trace correlation | [pipeline-parallel.md](../../../docs/user-guide/observability/pipeline-parallel.md) | +| Adding new instrumentation | [extending.md](../../../docs/user-guide/observability/extending.md) | + +For the generic `nemo-lens` documentation (configuration model, instrumentation +primitives, custom exporters, design decisions), see the lens docs at +. + +## Status + +This module is the base layer. The call-site instrumentation (training loop, +checkpointing, pipeline schedules, inference server), the `otel` install extra, +the `--otel-*` CLI flags, the `MEGATRON_OTEL_*` environment variables, and the +`docs/index.md` toctree entry that publishes the guide above land separately; +see . diff --git a/megatron/core/telemetry/__init__.py b/megatron/core/telemetry/__init__.py new file mode 100644 index 00000000000..6ab24deb692 --- /dev/null +++ b/megatron/core/telemetry/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Megatron-LM telemetry helpers. + +When ``nemo-lens`` is installed, the real implementations are used. +Otherwise, no-op fallbacks from ``fallbacks`` keep everything working. + +Submodules: + fallbacks — No-op stubs matching the nemo.lens API. + span_groups — SpanGroup / MegatronSpanGroup constants and presets. + training_metrics — OTel metric recording for the training loop. +""" diff --git a/megatron/core/telemetry/fallbacks.py b/megatron/core/telemetry/fallbacks.py new file mode 100644 index 00000000000..07b7b5b288e --- /dev/null +++ b/megatron/core/telemetry/fallbacks.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""No-op fallbacks for when nemo-lens is not installed. + +When nemo-lens IS installed, re-exports from nemo.lens.fallbacks for +consistency. When it is NOT installed, provides identical local no-ops. +""" + +try: + # pylint: disable=unused-import + from nemo.lens.fallbacks import ( # noqa: F401 + is_span_group_enabled, + managed_span, + safe_set_span_attributes, + span_cm, + trace_fn, + ) +except ImportError: + from contextlib import contextmanager + + def trace_fn(group, name, tracer=None): + """No-op decorator — returns the function unchanged.""" + + def decorator(func): + return func + + return decorator + + @contextmanager + def managed_span(group, name, tracer=None, **attributes): + """No-op context manager — yields None.""" + yield None + + def is_span_group_enabled(group): + """Always returns False when nemo-lens is not installed.""" + return False + + def safe_set_span_attributes(span, attributes, redact_keys=None): + """No-op.""" + pass + + @contextmanager + def span_cm(name, tracer=None, record_exception=True, **attributes): + """No-op context manager — yields None.""" + yield None diff --git a/megatron/core/telemetry/span_groups.py b/megatron/core/telemetry/span_groups.py new file mode 100644 index 00000000000..74ad02775a3 --- /dev/null +++ b/megatron/core/telemetry/span_groups.py @@ -0,0 +1,153 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Span group definitions for Megatron-LM telemetry. + +Tries to import the real ``SpanGroup`` from ``nemo.lens``. When nemo-lens +is not installed, a minimal stub is provided so that ``MegatronSpanGroup`` +constants are always available. +""" + +from typing import ClassVar, Final + +try: + from nemo.lens.groups import SpanGroup +except ImportError: + + class SpanGroup: + """Minimal stub when nemo-lens is not installed.""" + + JOB = "job" + CHECKPOINT = "checkpoint" + EVALUATE = "evaluate" + MODEL_INIT = "model_init" + LOAD_CHECKPOINT = "load_checkpoint" + STEP = "step" + FORWARD_BACKWARD = "forward_backward" + OPTIMIZER = "optimizer" + + ALL_GROUPS: Final[frozenset] = frozenset( + [ + JOB, + CHECKPOINT, + EVALUATE, + MODEL_INIT, + LOAD_CHECKPOINT, + STEP, + FORWARD_BACKWARD, + OPTIMIZER, + ] + ) + + _PRESETS: ClassVar[dict] = { + "default": frozenset([JOB, CHECKPOINT, EVALUATE]), + "per_step": frozenset( + [ + JOB, + CHECKPOINT, + EVALUATE, + MODEL_INIT, + LOAD_CHECKPOINT, + STEP, + FORWARD_BACKWARD, + OPTIMIZER, + ] + ), + "profiling": ALL_GROUPS, + "all": ALL_GROUPS, + } + + @classmethod + def resolve(cls, spec: str) -> frozenset: + """Always raises; resolving a span-group spec needs nemo-lens.""" + raise RuntimeError( + "SpanGroup.resolve() requires nemo-lens to be installed. " + "Install it with: pip install nemo-lens" + ) + + +class MegatronSpanGroup(SpanGroup): + """Span groups for Megatron-LM instrumentation. + + Extends the shared groups with Megatron-specific fine-grained groups. + """ + + # ------------------------------------------------------------------ # + # Fine-grained (included in "per_step" or "all") + # ------------------------------------------------------------------ # + + MICROBATCH = "microbatch" + """Per-microbatch forward/backward spans.""" + + LAYER = "layer" + """Per-transformer-layer forward (attention + MLP breakdown).""" + + COMMUNICATION = "communication" + """P2P send/recv and gradient AllReduce/ReduceScatter.""" + + ACTIVATION_OFFLOAD = "activation_offload" + """GPU<->CPU activation offload/reload spans.""" + + DATA_LOADING = "data_loading" + """Data loading and batch preparation.""" + + FIRST_ITERATION = "first_iteration" + """The first training iteration actually executed in this process (post + checkpoint-resume, post iteration-skip) — not necessarily iteration 1, and + distinct from the per-step STEP span since it captures one-off warmup + costs (compilation, CUDA graph capture, prefetch) absent from steady-state + iterations.""" + + TRACE_REGION = "trace_region" + """Shadows every perfetto-native ``trace_region(...)`` marker with a lens + span (see megatron.core.perfetto_trace) — ~85 checkpoint/dataset/load + sub-phase markers, covered without per-site instrumentation. Verbose and + fine-grained: deliberately NOT in the ``per_step`` preset (only ``all``); + opt in explicitly, e.g. ``--otel-span-groups per_step,trace_region``.""" + + # ------------------------------------------------------------------ # + # Inference + # ------------------------------------------------------------------ # + + INFERENCE = "inference" + """Inference server request spans.""" + + # ------------------------------------------------------------------ # + # All groups and presets + # ------------------------------------------------------------------ # + + ALL_GROUPS: Final[frozenset] = SpanGroup.ALL_GROUPS | frozenset( + [ + MICROBATCH, + LAYER, + COMMUNICATION, + ACTIVATION_OFFLOAD, + DATA_LOADING, + FIRST_ITERATION, + TRACE_REGION, + INFERENCE, + ] + ) + + _PRESETS: ClassVar[dict] = { + "default": frozenset( + [SpanGroup.JOB, SpanGroup.CHECKPOINT, SpanGroup.EVALUATE, FIRST_ITERATION, INFERENCE] + ), + "per_step": frozenset( + [ + SpanGroup.JOB, + SpanGroup.CHECKPOINT, + SpanGroup.EVALUATE, + SpanGroup.MODEL_INIT, + SpanGroup.LOAD_CHECKPOINT, + SpanGroup.STEP, + SpanGroup.FORWARD_BACKWARD, + SpanGroup.OPTIMIZER, + COMMUNICATION, + DATA_LOADING, + FIRST_ITERATION, + INFERENCE, + ] + ), + "profiling": ALL_GROUPS, + "all": ALL_GROUPS, + } diff --git a/megatron/core/telemetry/training_metrics.py b/megatron/core/telemetry/training_metrics.py new file mode 100644 index 00000000000..3b1e619b70e --- /dev/null +++ b/megatron/core/telemetry/training_metrics.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Training metric instruments (megatron.training.* namespace). + +Self-contained copy of the metric recording logic from nemo.lens so that +Megatron-LM can emit training metrics without a hard dependency on nemo-lens. +""" + +from __future__ import annotations + +import logging +import weakref + +# Metric name constants (mirrors nemo.lens.semconv). +MEGATRON_TRAINING_STEP_DURATION_MS = "megatron.training.step_duration_ms" +MEGATRON_TRAINING_LOSS = "megatron.training.loss" +MEGATRON_TRAINING_THROUGHPUT_TFLOPS = "megatron.training.throughput_tflops" +MEGATRON_TRAINING_GRAD_NORM = "megatron.training.grad_norm" +MEGATRON_TRAINING_SKIPPED_ITERS = "megatron.training.skipped_iters" +MEGATRON_TRAINING_LEARNING_RATE = "megatron.training.learning_rate" +MEGATRON_TRAINING_TOKENS_PER_SEC = "megatron.training.tokens_per_sec" +MEGATRON_TRAINING_MEMORY_ALLOCATED_GB = "megatron.training.memory_allocated_gb" + +try: + from opentelemetry import metrics +except ImportError: + metrics = None + +_logger = logging.getLogger(__name__) +_TRAINING_INSTRUMENTS: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + + +def _get_training_instruments(meter) -> dict: + instruments = _TRAINING_INSTRUMENTS.get(meter) + if instruments is None: + instruments = { + "step_duration_ms": meter.create_histogram( + name=MEGATRON_TRAINING_STEP_DURATION_MS, + unit="ms", + description="Duration of one training step in milliseconds.", + ), + "loss": meter.create_gauge( + name=MEGATRON_TRAINING_LOSS, description="Training loss value at each log interval." + ), + "throughput_tflops": meter.create_gauge( + name=MEGATRON_TRAINING_THROUGHPUT_TFLOPS, + description="Training throughput in TFLOP/s/GPU.", + ), + "grad_norm": meter.create_gauge( + name=MEGATRON_TRAINING_GRAD_NORM, description="Global gradient norm." + ), + "skipped_iters": meter.create_counter( + name=MEGATRON_TRAINING_SKIPPED_ITERS, + description="Number of training iterations skipped.", + ), + "learning_rate": meter.create_gauge( + name=MEGATRON_TRAINING_LEARNING_RATE, description="Current learning rate." + ), + "tokens_per_sec": meter.create_gauge( + name=MEGATRON_TRAINING_TOKENS_PER_SEC, + description="Training throughput in tokens/second.", + ), + "memory_allocated_gb": meter.create_gauge( + name=MEGATRON_TRAINING_MEMORY_ALLOCATED_GB, + description="Peak GPU memory allocated in GB.", + ), + } + _TRAINING_INSTRUMENTS[meter] = instruments + return instruments + + +def record_training_metrics( + meter, + step_duration_ms: float | None = None, + loss: float | None = None, + throughput_tflops: float | None = None, + grad_norm: float | None = None, + skipped_iters: int | None = None, + learning_rate: float | None = None, + tokens_per_sec: float | None = None, + memory_allocated_gb: float | None = None, +) -> None: + """Record training metrics to the OTel meter. + + All arguments are optional; ``None`` values are silently skipped. + Safe to call when telemetry is disabled (meter is no-op). + + If ``opentelemetry`` is not installed, this function is a no-op. + """ + if metrics is None: + return + + try: + instruments = _get_training_instruments(meter) + except Exception: + _logger.warning("Failed to create training metric instruments", exc_info=True) + return + + if step_duration_ms is not None: + instruments["step_duration_ms"].record(step_duration_ms) + if loss is not None: + instruments["loss"].set(loss) + if throughput_tflops is not None: + instruments["throughput_tflops"].set(throughput_tflops) + if grad_norm is not None: + instruments["grad_norm"].set(float(grad_norm)) + if skipped_iters is not None and skipped_iters > 0: + instruments["skipped_iters"].add(skipped_iters) + if learning_rate is not None: + instruments["learning_rate"].set(learning_rate) + if tokens_per_sec is not None: + instruments["tokens_per_sec"].set(tokens_per_sec) + if memory_allocated_gb is not None: + instruments["memory_allocated_gb"].set(memory_allocated_gb) diff --git a/tests/unit_tests/telemetry/__init__.py b/tests/unit_tests/telemetry/__init__.py new file mode 100644 index 00000000000..57f9c727234 --- /dev/null +++ b/tests/unit_tests/telemetry/__init__.py @@ -0,0 +1 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. diff --git a/tests/unit_tests/telemetry/test_fallbacks.py b/tests/unit_tests/telemetry/test_fallbacks.py new file mode 100644 index 00000000000..27635a64eaa --- /dev/null +++ b/tests/unit_tests/telemetry/test_fallbacks.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for ``megatron.core.telemetry.fallbacks``. + +Call sites import these five names unconditionally, so they must exist and be +callable whether or not ``nemo-lens`` is installed. The no-op behaviour +assertions only hold for the local stubs, so they skip when lens is present. +""" + +import pytest + +from megatron.core.telemetry import fallbacks + +try: + import nemo.lens # noqa: F401 + + HAVE_NEMO_LENS = True +except ImportError: + HAVE_NEMO_LENS = False + +requires_no_lens = pytest.mark.skipif( + HAVE_NEMO_LENS, reason="local no-op stubs are only used without nemo-lens" +) + + +class TestPublicSurface: + """Holds regardless of which implementation was imported.""" + + @pytest.mark.parametrize( + "name", + [ + "trace_fn", + "managed_span", + "is_span_group_enabled", + "safe_set_span_attributes", + "span_cm", + ], + ) + def test_name_is_exported_and_callable(self, name): + assert callable(getattr(fallbacks, name)) + + +@requires_no_lens +class TestTraceFn: + def test_returns_the_function_unchanged(self): + def original(a, b): + return a + b + + decorated = fallbacks.trace_fn("job", "megatron.train")(original) + assert decorated is original + + def test_decorated_function_still_works(self): + @fallbacks.trace_fn("job", "megatron.train") + def add(a, b): + return a + b + + assert add(2, 3) == 5 + + def test_accepts_an_explicit_tracer(self): + def original(): + return None + + assert fallbacks.trace_fn("job", "megatron.train", tracer=object())(original) is original + + +@requires_no_lens +class TestManagedSpan: + def test_yields_none(self): + with fallbacks.managed_span("job", "megatron.train") as span: + assert span is None + + def test_accepts_arbitrary_attributes(self): + with fallbacks.managed_span("job", "megatron.train", iteration=7, rank=0) as span: + assert span is None + + def test_propagates_exceptions_from_the_body(self): + with pytest.raises(ValueError, match="boom"): + with fallbacks.managed_span("job", "megatron.train"): + raise ValueError("boom") + + +@requires_no_lens +class TestSpanCm: + def test_yields_none(self): + with fallbacks.span_cm("megatron.train") as span: + assert span is None + + def test_accepts_record_exception_and_attributes(self): + with fallbacks.span_cm("megatron.train", record_exception=False, rank=3) as span: + assert span is None + + def test_propagates_exceptions_from_the_body(self): + with pytest.raises(ValueError, match="boom"): + with fallbacks.span_cm("megatron.train"): + raise ValueError("boom") + + +@requires_no_lens +class TestIsSpanGroupEnabled: + @pytest.mark.parametrize("group", ["job", "step", "microbatch", "not_a_real_group"]) + def test_always_false(self, group): + """Every group is off, so gated instrumentation stays dormant.""" + assert fallbacks.is_span_group_enabled(group) is False + + +@requires_no_lens +class TestSafeSetSpanAttributes: + def test_accepts_a_none_span(self): + assert fallbacks.safe_set_span_attributes(None, {"iteration": 7}) is None + + def test_accepts_redact_keys(self): + assert ( + fallbacks.safe_set_span_attributes(None, {"token": "x"}, redact_keys=["token"]) is None + ) diff --git a/tests/unit_tests/telemetry/test_span_groups.py b/tests/unit_tests/telemetry/test_span_groups.py new file mode 100644 index 00000000000..c1f7fbc7323 --- /dev/null +++ b/tests/unit_tests/telemetry/test_span_groups.py @@ -0,0 +1,166 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for ``megatron.core.telemetry.span_groups``. + +These run whether or not ``nemo-lens`` is installed. Assertions that depend on +which ``SpanGroup`` base class was picked up are gated on ``HAVE_NEMO_LENS``. +""" + +import pytest + +from megatron.core.telemetry.span_groups import MegatronSpanGroup, SpanGroup + +try: + import nemo.lens # noqa: F401 + + HAVE_NEMO_LENS = True +except ImportError: + HAVE_NEMO_LENS = False + +# The groups MegatronSpanGroup adds on top of the shared lens groups. +MEGATRON_ONLY_GROUPS = frozenset( + [ + MegatronSpanGroup.MICROBATCH, + MegatronSpanGroup.LAYER, + MegatronSpanGroup.COMMUNICATION, + MegatronSpanGroup.ACTIVATION_OFFLOAD, + MegatronSpanGroup.DATA_LOADING, + MegatronSpanGroup.FIRST_ITERATION, + MegatronSpanGroup.TRACE_REGION, + MegatronSpanGroup.INFERENCE, + ] +) + + +class TestGroupConstants: + """The group names are part of the user-facing --otel-span-groups spec.""" + + @pytest.mark.parametrize( + "attribute,expected", + [ + ("MICROBATCH", "microbatch"), + ("LAYER", "layer"), + ("COMMUNICATION", "communication"), + ("ACTIVATION_OFFLOAD", "activation_offload"), + ("DATA_LOADING", "data_loading"), + ("FIRST_ITERATION", "first_iteration"), + ("TRACE_REGION", "trace_region"), + ("INFERENCE", "inference"), + ], + ) + def test_group_name(self, attribute, expected): + assert getattr(MegatronSpanGroup, attribute) == expected + + def test_group_names_are_unique(self): + assert len(MEGATRON_ONLY_GROUPS) == 8 + + def test_megatron_groups_do_not_collide_with_base_groups(self): + assert not (MEGATRON_ONLY_GROUPS & SpanGroup.ALL_GROUPS) + + def test_inherits_base_groups(self): + assert MegatronSpanGroup.JOB == SpanGroup.JOB + assert MegatronSpanGroup.CHECKPOINT == SpanGroup.CHECKPOINT + assert MegatronSpanGroup.STEP == SpanGroup.STEP + + +class TestAllGroups: + def test_is_a_frozenset(self): + assert isinstance(MegatronSpanGroup.ALL_GROUPS, frozenset) + + def test_extends_base_all_groups(self): + assert SpanGroup.ALL_GROUPS <= MegatronSpanGroup.ALL_GROUPS + + def test_contains_every_megatron_group(self): + assert MEGATRON_ONLY_GROUPS <= MegatronSpanGroup.ALL_GROUPS + + def test_is_exactly_base_plus_megatron(self): + assert MegatronSpanGroup.ALL_GROUPS == SpanGroup.ALL_GROUPS | MEGATRON_ONLY_GROUPS + + +class TestPresets: + """Presets must nest: default subset of per_step subset of all.""" + + def test_expected_presets_exist(self): + assert set(MegatronSpanGroup._PRESETS) == {"default", "per_step", "profiling", "all"} + + def test_every_preset_is_a_subset_of_all_groups(self): + for name, groups in MegatronSpanGroup._PRESETS.items(): + assert groups <= MegatronSpanGroup.ALL_GROUPS, f"preset {name!r} has unknown groups" + + def test_default_is_a_subset_of_per_step(self): + assert MegatronSpanGroup._PRESETS["default"] <= MegatronSpanGroup._PRESETS["per_step"] + + def test_all_and_profiling_are_every_group(self): + assert MegatronSpanGroup._PRESETS["all"] == MegatronSpanGroup.ALL_GROUPS + assert MegatronSpanGroup._PRESETS["profiling"] == MegatronSpanGroup.ALL_GROUPS + + def test_default_is_coarse_grained(self): + """`default` is the production preset; it must not carry per-step cost.""" + default = MegatronSpanGroup._PRESETS["default"] + assert MegatronSpanGroup.JOB in default + assert MegatronSpanGroup.CHECKPOINT in default + assert MegatronSpanGroup.EVALUATE in default + assert MegatronSpanGroup.FIRST_ITERATION in default + assert MegatronSpanGroup.INFERENCE in default + assert MegatronSpanGroup.STEP not in default + assert MegatronSpanGroup.FORWARD_BACKWARD not in default + assert MegatronSpanGroup.MICROBATCH not in default + + def test_per_step_adds_step_level_groups(self): + per_step = MegatronSpanGroup._PRESETS["per_step"] + assert MegatronSpanGroup.STEP in per_step + assert MegatronSpanGroup.FORWARD_BACKWARD in per_step + assert MegatronSpanGroup.OPTIMIZER in per_step + assert MegatronSpanGroup.MODEL_INIT in per_step + assert MegatronSpanGroup.LOAD_CHECKPOINT in per_step + assert MegatronSpanGroup.COMMUNICATION in per_step + assert MegatronSpanGroup.DATA_LOADING in per_step + + @pytest.mark.parametrize( + "group", + [ + MegatronSpanGroup.MICROBATCH, + MegatronSpanGroup.LAYER, + MegatronSpanGroup.ACTIVATION_OFFLOAD, + MegatronSpanGroup.TRACE_REGION, + ], + ) + def test_verbose_groups_are_opt_in_only(self, group): + """These emit per-layer or per-marker spans; `all` only.""" + assert group not in MegatronSpanGroup._PRESETS["per_step"] + assert group in MegatronSpanGroup._PRESETS["all"] + + def test_presets_do_not_shadow_the_base_class(self): + """Subclassing must not leave MegatronSpanGroup resolving base presets.""" + assert MegatronSpanGroup._PRESETS is not SpanGroup._PRESETS + + +class TestResolve: + @pytest.mark.skipif(HAVE_NEMO_LENS, reason="stub SpanGroup is only used without nemo-lens") + def test_resolve_without_nemo_lens_raises(self): + with pytest.raises(RuntimeError, match="nemo-lens"): + MegatronSpanGroup.resolve("default") + + @pytest.mark.skipif(not HAVE_NEMO_LENS, reason="requires nemo-lens") + def test_resolve_with_nemo_lens_returns_the_preset(self): + assert MegatronSpanGroup.resolve("default") == MegatronSpanGroup._PRESETS["default"] + assert MegatronSpanGroup.resolve("all") == MegatronSpanGroup.ALL_GROUPS + + +@pytest.mark.skipif(HAVE_NEMO_LENS, reason="stub SpanGroup is only used without nemo-lens") +class TestStubFallback: + """Without nemo-lens the local stub must still expose the shared groups.""" + + def test_stub_defines_the_shared_groups(self): + assert SpanGroup.ALL_GROUPS == frozenset( + [ + "job", + "checkpoint", + "evaluate", + "model_init", + "load_checkpoint", + "step", + "forward_backward", + "optimizer", + ] + ) diff --git a/tests/unit_tests/telemetry/test_training_metrics.py b/tests/unit_tests/telemetry/test_training_metrics.py new file mode 100644 index 00000000000..5a5f929bdb9 --- /dev/null +++ b/tests/unit_tests/telemetry/test_training_metrics.py @@ -0,0 +1,256 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Unit tests for ``megatron.core.telemetry.training_metrics``. + +The tests drive ``record_training_metrics`` with a fake meter rather than a real +OTel ``MeterProvider``, so they need neither ``opentelemetry`` nor ``nemo-lens`` +and they can assert exactly which instrument received which value. +""" + +import gc + +import pytest + +from megatron.core.telemetry import training_metrics +from megatron.core.telemetry.training_metrics import record_training_metrics + +# Argument name -> (instrument kind, exported metric name). +INSTRUMENTS = { + "step_duration_ms": ("histogram", training_metrics.MEGATRON_TRAINING_STEP_DURATION_MS), + "loss": ("gauge", training_metrics.MEGATRON_TRAINING_LOSS), + "throughput_tflops": ("gauge", training_metrics.MEGATRON_TRAINING_THROUGHPUT_TFLOPS), + "grad_norm": ("gauge", training_metrics.MEGATRON_TRAINING_GRAD_NORM), + "skipped_iters": ("counter", training_metrics.MEGATRON_TRAINING_SKIPPED_ITERS), + "learning_rate": ("gauge", training_metrics.MEGATRON_TRAINING_LEARNING_RATE), + "tokens_per_sec": ("gauge", training_metrics.MEGATRON_TRAINING_TOKENS_PER_SEC), + "memory_allocated_gb": ("gauge", training_metrics.MEGATRON_TRAINING_MEMORY_ALLOCATED_GB), +} + + +class FakeInstrument: + """Accepts all three OTel write calls and remembers what it was given.""" + + def __init__(self, kind, name): + self.kind = kind + self.name = name + self.values = [] + + def record(self, value): + self.values.append(value) + + def set(self, value): + self.values.append(value) + + def add(self, value): + self.values.append(value) + + +class FakeMeter: + """Stands in for an OTel ``Meter``; must be weak-referenceable.""" + + def __init__(self): + self.instruments = {} + self.create_calls = 0 + + def _create(self, kind, name): + self.create_calls += 1 + instrument = FakeInstrument(kind, name) + self.instruments[name] = instrument + return instrument + + def create_histogram(self, name, unit=None, description=None): + return self._create("histogram", name) + + def create_gauge(self, name, unit=None, description=None): + return self._create("gauge", name) + + def create_counter(self, name, unit=None, description=None): + return self._create("counter", name) + + +class BrokenMeter: + """Every instrument factory raises, as an SDK misconfiguration would.""" + + def create_histogram(self, *args, **kwargs): + raise RuntimeError("meter is broken") + + create_gauge = create_histogram + create_counter = create_histogram + + +@pytest.fixture(autouse=True) +def clear_instrument_cache(): + """The instrument cache is module-global; keep tests independent.""" + training_metrics._TRAINING_INSTRUMENTS.clear() + yield + training_metrics._TRAINING_INSTRUMENTS.clear() + + +@pytest.fixture(autouse=True) +def otel_available(monkeypatch): + """Exercise the recording path even when ``opentelemetry`` is absent.""" + if training_metrics.metrics is None: + monkeypatch.setattr(training_metrics, "metrics", object()) + + +@pytest.fixture +def meter(): + return FakeMeter() + + +class TestMetricNames: + """These strings are the queryable metric names; changing one is breaking.""" + + @pytest.mark.parametrize("kind_and_name", INSTRUMENTS.values(), ids=list(INSTRUMENTS)) + def test_name_is_namespaced(self, kind_and_name): + _, name = kind_and_name + assert name.startswith("megatron.training.") + + def test_names_are_unique(self): + names = [name for _, name in INSTRUMENTS.values()] + assert len(set(names)) == len(names) + + +class TestInstrumentCreation: + def test_creates_every_instrument_with_the_right_kind(self, meter): + record_training_metrics(meter, loss=1.0) + + for kind, name in INSTRUMENTS.values(): + assert name in meter.instruments, f"{name} was never created" + assert meter.instruments[name].kind == kind + + def test_creates_exactly_the_expected_instruments(self, meter): + record_training_metrics(meter, loss=1.0) + + assert set(meter.instruments) == {name for _, name in INSTRUMENTS.values()} + + def test_instruments_are_created_once_per_meter(self, meter): + record_training_metrics(meter, loss=1.0) + creates_after_first_call = meter.create_calls + + record_training_metrics(meter, loss=2.0) + record_training_metrics(meter, loss=3.0) + + assert meter.create_calls == creates_after_first_call + + def test_each_meter_gets_its_own_instruments(self): + first, second = FakeMeter(), FakeMeter() + record_training_metrics(first, loss=1.0) + record_training_metrics(second, loss=2.0) + + loss_name = training_metrics.MEGATRON_TRAINING_LOSS + assert first.instruments[loss_name] is not second.instruments[loss_name] + assert first.instruments[loss_name].values == [1.0] + assert second.instruments[loss_name].values == [2.0] + + def test_cache_does_not_keep_the_meter_alive(self): + """The cache is weak-keyed so re-init does not leak meters. + + The meter is built here rather than taken from the fixture, which would + hold the only reference that stops it being collected. + """ + meter = FakeMeter() + record_training_metrics(meter, loss=1.0) + assert len(training_metrics._TRAINING_INSTRUMENTS) == 1 + + del meter + gc.collect() + assert len(training_metrics._TRAINING_INSTRUMENTS) == 0 + + +class TestRecording: + def test_records_every_metric(self, meter): + record_training_metrics( + meter, + step_duration_ms=123.5, + loss=2.75, + throughput_tflops=410.0, + grad_norm=0.9, + skipped_iters=2, + learning_rate=1e-4, + tokens_per_sec=50000.0, + memory_allocated_gb=64.25, + ) + + expected = { + training_metrics.MEGATRON_TRAINING_STEP_DURATION_MS: 123.5, + training_metrics.MEGATRON_TRAINING_LOSS: 2.75, + training_metrics.MEGATRON_TRAINING_THROUGHPUT_TFLOPS: 410.0, + training_metrics.MEGATRON_TRAINING_GRAD_NORM: 0.9, + training_metrics.MEGATRON_TRAINING_SKIPPED_ITERS: 2, + training_metrics.MEGATRON_TRAINING_LEARNING_RATE: 1e-4, + training_metrics.MEGATRON_TRAINING_TOKENS_PER_SEC: 50000.0, + training_metrics.MEGATRON_TRAINING_MEMORY_ALLOCATED_GB: 64.25, + } + for name, value in expected.items(): + assert meter.instruments[name].values == [value], name + + def test_records_nothing_when_all_values_are_none(self, meter): + record_training_metrics(meter) + + assert all(not instrument.values for instrument in meter.instruments.values()) + + @pytest.mark.parametrize("argument", list(INSTRUMENTS)) + def test_records_one_metric_in_isolation(self, meter, argument): + record_training_metrics(meter, **{argument: 1}) + + _, recorded_name = INSTRUMENTS[argument] + for name, instrument in meter.instruments.items(): + assert instrument.values == ([1] if name == recorded_name else []), name + + def test_accumulates_across_calls(self, meter): + record_training_metrics(meter, loss=1.0) + record_training_metrics(meter, loss=0.5) + + assert meter.instruments[training_metrics.MEGATRON_TRAINING_LOSS].values == [1.0, 0.5] + + def test_grad_norm_is_coerced_to_float(self, meter): + """Callers pass a torch scalar; the SDK only accepts a plain float.""" + + class ScalarTensor: + def __float__(self): + return 1.5 + + record_training_metrics(meter, grad_norm=ScalarTensor()) + + recorded = meter.instruments[training_metrics.MEGATRON_TRAINING_GRAD_NORM].values + assert recorded == [1.5] + assert type(recorded[0]) is float + + @pytest.mark.parametrize("value,expected", [(0, []), (1, [1]), (5, [5])]) + def test_skipped_iters_only_counts_when_positive(self, meter, value, expected): + """Adding zero to a counter is a pointless export.""" + record_training_metrics(meter, skipped_iters=value) + + assert ( + meter.instruments[training_metrics.MEGATRON_TRAINING_SKIPPED_ITERS].values == expected + ) + + @pytest.mark.parametrize("argument", ["loss", "grad_norm", "learning_rate"]) + def test_zero_is_recorded_for_non_counter_metrics(self, meter, argument): + """Zero loss is a real observation, unlike zero skipped iterations.""" + record_training_metrics(meter, **{argument: 0.0}) + + _, name = INSTRUMENTS[argument] + assert meter.instruments[name].values == [0.0] + + +class TestFailureHandling: + def test_instrument_creation_failure_is_swallowed(self, caplog): + """Telemetry must never take down the training loop.""" + record_training_metrics(BrokenMeter(), loss=1.0) + + assert "Failed to create training metric instruments" in caplog.text + + def test_a_broken_meter_is_not_cached(self): + record_training_metrics(BrokenMeter(), loss=1.0) + + assert len(training_metrics._TRAINING_INSTRUMENTS) == 0 + + def test_no_op_without_opentelemetry(self, meter, monkeypatch): + monkeypatch.setattr(training_metrics, "metrics", None) + + record_training_metrics(meter, loss=1.0) + + assert meter.create_calls == 0 + assert len(training_metrics._TRAINING_INSTRUMENTS) == 0 From 8971d19dda0f2bda87783353dde93a5134422a96 Mon Sep 17 00:00:00 2001 From: Fei Wu <33940270+YangFei1990@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:30:12 -0700 Subject: [PATCH 247/290] [NCCL EP] Support eager mode and overflow handling (#6229) Signed-off-by: YangFei1990 --- megatron/core/transformer/moe/fused_a2a.py | 35 ++- megatron/core/transformer/moe/paged_stash.py | 88 +++++- .../core/transformer/moe/token_dispatcher.py | 197 +++++++++---- .../core/transformer/transformer_config.py | 29 +- .../a2a_overlap/test_schedule_layer_1f1b.py | 4 +- tests/unit_tests/a2a_overlap/utils.py | 11 +- .../models/test_hybrid_moe_model.py | 1 - .../transformer/moe/test_paged_stashing.py | 269 +++++++++++++++++- .../transformer/moe/test_token_dispatcher.py | 24 +- .../transformer/test_cuda_graphs.py | 2 - 10 files changed, 523 insertions(+), 137 deletions(-) diff --git a/megatron/core/transformer/moe/fused_a2a.py b/megatron/core/transformer/moe/fused_a2a.py index 444760655a6..46b5548df57 100644 --- a/megatron/core/transformer/moe/fused_a2a.py +++ b/megatron/core/transformer/moe/fused_a2a.py @@ -620,6 +620,7 @@ def ensure_nccl_ep_bootstrapped( max_tokens_per_rank, recv_capacity_per_rank, hidden_dim, + num_topk, num_sms=0, zero_copy=False, ): @@ -635,9 +636,13 @@ def ensure_nccl_ep_bootstrapped( num_experts (int): Total experts across ``ep_group`` (global, not per-rank). max_tokens_per_rank (int): Upper bound on local input tokens per forward. Must be even (NCCL EP requires ``num_tokens_per_rank * inner_dim % 4 == 0``). - recv_capacity_per_rank (int): Per-rank receive-buffer capacity in tokens. Must be - ``>= max_tokens_per_rank``; runtime overflow hard-traps (no soft drop). + recv_capacity_per_rank (int, optional): Per-rank receive-buffer capacity in tokens. Must + be ``>= max_tokens_per_rank``. ``None`` selects eager mode, where TE sizes the + receive buffer per step from the actual received-token count. hidden_dim (int): Token hidden size. + num_topk (int): Per-token top-k over ``ep_group``; sizes NCCL EP's internal buffers. + This is the same TP-scaled top-k used for the receive-capacity budget, not the + raw ``moe_router_topk``. num_sms (int): SM cap passed to TE as ``max_num_sms`` (0 lets TE/NCCL choose). """ if not HAVE_TE_EP: @@ -645,7 +650,7 @@ def ensure_nccl_ep_bootstrapped( "transformer_engine.pytorch.ep is unavailable. The 'ncclep' flex dispatcher backend " "requires a TransformerEngine build with NCCL EP support (NVTE_BUILD_WITH_NCCL_EP=1)." ) - if te_ep._BOOTSTRAPPED: # reuse TE's own one-time guard; no parallel state to drift + if is_nccl_ep_bootstrapped(): # reuse TE's own one-time guard; no parallel state to drift return te_ep.ep_bootstrap( ep_group, @@ -653,11 +658,22 @@ def ensure_nccl_ep_bootstrapped( max_tokens_per_rank=max_tokens_per_rank, recv_capacity_per_rank=recv_capacity_per_rank, hidden_dim=hidden_dim, + num_topk=num_topk, max_num_sms=num_sms, zero_copy=zero_copy, + drop_on_overflow=recv_capacity_per_rank is not None, ) +def is_nccl_ep_bootstrapped() -> bool: + """Whether TE's process-wide NCCL EP context is live. is_ep_bootstrapped is TE 3321.""" + if not HAVE_TE_EP: + return False + if hasattr(te_ep, "is_ep_bootstrapped"): + return te_ep.is_ep_bootstrapped() + return te_ep._BOOTSTRAPPED + + def nccl_ep_finalize(): """Tear down the NCCL EP context. Idempotent; safe when never bootstrapped. @@ -717,12 +733,13 @@ def nccl_ep_dispatch( Returns: tuple: ``(recv_tokens, tokens_per_expert, dispatched_probs)``: - * ``recv_tokens``: packed received tokens ``[recv_capacity_per_rank, hidden]``, - grouped by local expert (no separate compaction step). - * ``tokens_per_expert``: ``int32`` ``[num_local_experts]`` device tensor of + * ``recv_tokens``: packed received tokens ``[recv_rows, hidden]``, grouped by local + expert (no separate compaction step). ``recv_rows`` is + ``recv_capacity_per_rank``, or this step's received-token count in eager mode. + * ``tokens_per_expert``: ``int64`` ``[num_local_experts]`` device tensor of received counts per local expert (feeds grouped GEMM as group sizes; alignment-padded, == actual when ``alignment=0``). - * ``dispatched_probs``: ``float32`` ``[recv_capacity_per_rank]`` per-slot + * ``dispatched_probs``: ``float32`` ``[recv_rows]`` per-slot weights; apply them in the expert MLP (combine is called unweighted). ``tokens_per_expert`` is non-differentiable. @@ -742,8 +759,8 @@ def nccl_ep_combine(buffer, expert_out, num_local_tokens=None, grad_out=None): Args: buffer (te_ep.EpBuffer): The TE EP buffer for this combine. - expert_out (torch.Tensor): Expert outputs ``[recv_capacity_per_rank, hidden]``, - already weighted. + expert_out (torch.Tensor): Expert outputs ``[recv_rows, hidden]`` (the row count + ``nccl_ep_dispatch`` returned), already weighted. num_local_tokens (int): Rows of the result (local token count for this forward). When None, TE uses ``buffer.max_tokens_per_rank``. grad_out (torch.Tensor, optional): caller-owned symm buffer the backward scatters the diff --git a/megatron/core/transformer/moe/paged_stash.py b/megatron/core/transformer/moe/paged_stash.py index 5bef3d321b5..c38aac11bd2 100644 --- a/megatron/core/transformer/moe/paged_stash.py +++ b/megatron/core/transformer/moe/paged_stash.py @@ -14,10 +14,15 @@ paged_stash_copy_kernel, paged_stash_pop_kernel, ) +from megatron.core.transformer.moe.token_dispatcher import nccl_ep_release_context from megatron.core.utils import get_attr_wrapped_model logger = logging.getLogger(__name__) +# One retry only, and it is enough: prepare_for_rerun clears the capacity factor (dropless, so no +# receive budget to exceed) and disables paged stashing, so the retry cannot fail either way. +_MAX_RERUN_ATTEMPTS = 2 + SCALE_INV_BLOCK_SIZE = 32 @@ -976,6 +981,11 @@ def __init__(self, config, copy_main_params, model, optimizer, forward_backward_ self.optimizer = optimizer self.forward_backward_func = forward_backward_func self.moe_layers = [] + # Peak per-rank receive capacity the last over-budget step needed, and the + # moe_expert_rank_capacity_factor that would have covered it, if the backend reports + # them (NCCL EP only). Both set by check_moe_overflow. + self._required_recv_capacity = None + self._required_capacity_factor = None # TransformerConfig objects that must stay in sync for moe_paged_stash: the training # loop `config` (schedules / paged_stash_reset) plus each VP chunk's GPT root config # (GPTModel.forward). MoE mlps use the same config reference as that root, so we do @@ -1082,10 +1092,41 @@ def check_moe_overflow(self): dim=0, ) torch.distributed.all_reduce(flags, op=torch.distributed.ReduceOp.SUM) - return flags[0].item(), flags[1].item(), flags[2].item() + stash_overflow_ranks, overbudget_ranks, host_spill_ranks = ( + flags[0].item(), + flags[1].item(), + flags[2].item(), + ) + + # Second all_reduce only on the failure path, which has already synced: the happy path + # keeps its single collective. Backends that do not report a required capacity (HybridEP) + # leave this None. + self._required_recv_capacity = None + self._required_capacity_factor = None + if overbudget_ranks > 0: + per_layer = [mlp.token_dispatcher.check_required_capacity() for mlp in self.moe_layers] + per_layer = [r for r in per_layer if r is not None] + if per_layer: + required = torch.cat(per_layer).max().reshape(1) + torch.distributed.all_reduce(required, op=torch.distributed.ReduceOp.MAX) + self._required_recv_capacity = int(required.item()) + comm_manager = self.moe_layers[0].token_dispatcher._comm_manager + denominator = getattr(comm_manager, '_max_tokens_per_rank', 0) * getattr( + comm_manager, 'router_topk', 0 + ) + # Track the updated cap factor to log for user + if denominator: + self._required_capacity_factor = self._required_recv_capacity / denominator + + return stash_overflow_ranks, overbudget_ranks, host_spill_ranks def prepare_for_rerun(self, is_training=True): - """Prepare for rerun""" + """Prepare for rerun: go dropless, disable paged stashing, and reset grads/graph. + + One path for both overflow kinds. Clearing the capacity factor sends HybridEP dropless + and ncclEP into eager mode, neither of which can overflow a receive budget, and paged + stashing is off, so the retry cannot fail the same way twice. + """ log_single_rank( logger, logging.INFO, @@ -1099,6 +1140,18 @@ def prepare_for_rerun(self, is_training=True): ): mlp.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor = None mlp.token_dispatcher.reset_over_budget() + mlp.token_dispatcher.invalidate_ep_bootstrap() + # Record the peak the dropped step needed while it is still valid + if self._required_recv_capacity is not None: + for mlp in self.moe_layers: + mlp.token_dispatcher.grow_ep_recv_capacity(self._required_recv_capacity) + log_single_rank( + logger, + logging.INFO, + f"NCCL EP: grew the receive capacity to {self._required_recv_capacity} tokens " + "per rank after the token drop; set moe_expert_rank_capacity_factor accordingly " + "to avoid the rerun cost.", + ) if self.stash_manager.overflow is not None: self.stash_manager.overflow.zero_() if self.stash_manager.host_spill is not None: @@ -1136,6 +1189,8 @@ def _try_copy_main_params(opt): stage='training' if is_training else 'validation' ) + nccl_ep_release_context() + # Only drop page buffers on training fallback. Validation uses forward_only=True, so # paged_stash_reset disables the stash manager and eval forward never reads/writes the # large page buffers—freeing them here saves almost nothing. If we released on eval, @@ -1182,9 +1237,6 @@ def __call__(self, *args, **kwargs): saved_moe_paged_stash = self.config.moe_paged_stash num_tries = 0 while True: - assert ( - num_tries < 2 - ), f"PagedStashRunner: num_tries {num_tries} exceeded max attempts!!!" num_tries += 1 data_iterator, data_list = self.data_read( data_iterator, model, training, num_microbatches @@ -1214,17 +1266,34 @@ def __call__(self, *args, **kwargs): mlp.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor = ( mlp.token_dispatcher.config.moe_expert_rank_capacity_factor ) + # Only after an actual rerun: rebootstrap NCCL EP + if num_tries > 1: + for mlp in self.moe_layers: + mlp.token_dispatcher.invalidate_ep_bootstrap() + nccl_ep_release_context() self._set_moe_paged_stash_all(saved_moe_paged_stash) break # Overflow or over-budget: prepare_for_rerun clears capacity factor and paged stash. if overbudget_ranks > 0: + needed = ( + f" Peak receive capacity needed was {self._required_recv_capacity} tokens " + "per rank." + if self._required_recv_capacity is not None + else "" + ) + factor = ( + f" Set moe_expert_rank_capacity_factor >= " + f"{self._required_capacity_factor:.2f} to avoid the rerun; it is not " + "carried across a restart." + if self._required_capacity_factor is not None + else " Consider increasing moe_expert_rank_capacity_factor." + ) log_single_rank( logger, logging.INFO, "Paged stash: token drop during MoE token dispatch (over budget) " - f"on {overbudget_ranks} rank(s). " - "Consider increasing moe_expert_rank_capacity_factor.", + f"on {overbudget_ranks} rank(s).{needed}{factor}", ) if stash_overflow_ranks > 0: log_single_rank( @@ -1235,5 +1304,10 @@ def __call__(self, *args, **kwargs): "Consider increasing moe_paged_stash_buffer_size_factor_cuda or " "moe_paged_stash_buffer_size_factor_cpu.", ) + # Give up before rebuilding anything: the retry has already run, so another + # prepare_for_rerun would tear down and regrow the EP context on the way out. + assert ( + num_tries < _MAX_RERUN_ATTEMPTS + ), f"PagedStashRunner: num_tries {num_tries} exceeded max attempts!!!" self.prepare_for_rerun(is_training=training) return result diff --git a/megatron/core/transformer/moe/token_dispatcher.py b/megatron/core/transformer/moe/token_dispatcher.py index 256f38cc6fb..8fc4acedfef 100644 --- a/megatron/core/transformer/moe/token_dispatcher.py +++ b/megatron/core/transformer/moe/token_dispatcher.py @@ -27,8 +27,10 @@ fused_dispatch, hybrid_ep_combine, hybrid_ep_dispatch, + is_nccl_ep_bootstrapped, nccl_ep_combine, nccl_ep_dispatch, + nccl_ep_finalize, new_nccl_ep_buffer, set_deepep_num_sms, ) @@ -1473,12 +1475,16 @@ class _NCCLEPManager(_DispatchManager): (1) setup_metadata(): reconstruct topk indices/probs from the routing map (like DeepEP). (2) dispatch(): TE ep_dispatch permutes tokens to expert-major layout and performs the all-to-all in one step, returning a packed receive buffer + per-expert counts. - (3) get_permuted_hidden_states_by_experts(): the receive buffer is already expert-major, - so this only narrows it to the valid (sum of per-expert counts) rows for the experts. - (4) get_restored_hidden_states_by_experts(): re-expand the expert output back into the - static receive-capacity buffer that TE ep_combine writes from. + (3) get_permuted_hidden_states_by_experts(): pass-through; the receive buffer is already + expert-major and is exactly what the experts consume. + (4) get_restored_hidden_states_by_experts(): pass-through; the expert output is already what + TE ep_combine reads from. (5) combine(): TE ep_combine scatters expert outputs back to the original tokens. + moe_expert_rank_capacity_factor selects the mode: set for static shapes (fixed receive + buffer, CUDA-graph capturable, needs the fused grouped GEMM), unset for eager (TE sizes the + receive buffer per step from the actual received-token count). + The TE NCCL EP context (a single EpBuffer) and the process-wide bootstrap are created lazily on the first dispatch, when the local token count is known. """ @@ -1527,51 +1533,59 @@ def __init__( self.hidden_dim = config.moe_latent_size or config.hidden_size # Per-expert packing alignment for the receive buffer (grouped-GEMM tile) self.alignment = get_align_size_for_quantization(config) - self.rank_capacity_factor = config.moe_expert_rank_capacity_factor - self.static_shape = config.moe_ncclep_static_shape + self.moe_expert_rank_capacity_factor = config.moe_expert_rank_capacity_factor + self.eager = self.moe_expert_rank_capacity_factor is None self.zero_copy = config.moe_ncclep_zero_copy self._zc_quant = self.zero_copy and bool(config.fp8 or config.fp4) - if self.zero_copy and not self.static_shape: - raise ValueError( - "moe_ncclep_zero_copy requires moe_ncclep_static_shape " - "(fixed [recv_capacity, hidden] symm buffers)." + # Grown by grow_recv_capacity() after an overflow, to the peak the dropped step needed. + self._recv_capacity_override = None + if not self.eager: + # Accumulated device-side per dispatch, so the happy path never syncs. + self.over_budget = torch.zeros(1, dtype=torch.bool, device='cuda') + self.required_recv = torch.zeros(1, dtype=torch.int64, device='cuda') + + if nccl_ep_dispatch is None: + raise ImportError( + "TransformerEngine NCCL EP is unavailable. The 'ncclep' backend requires a " + "TransformerEngine build with NCCL EP support (NVTE_BUILD_WITH_NCCL_EP=1)." ) - if self.static_shape: - # static shape needs a fused grouped GEMM that consumes ragged per-expert counts on - # device (no host-side split narrowing): moe_grouped_gemm selects the grouped experts - # and use_transformer_engine_op_fuser fuses FC1+act+FC2 over them (fp8/fp4 via the CuTe - # DSL fused grouped MLP, bf16 via the op-fuser GroupedLinear grouped-tensor path). + # [TODO] Add support for drop and pad with the 'ncclep' backend. + if config.moe_pad_expert_input_to_capacity or config.moe_expert_capacity_factor is not None: + raise ValueError("drop and pad is not supported yet with the 'ncclep' backend.") + + if self.eager: + if self.zero_copy: + raise ValueError( + "moe_ncclep_zero_copy requires moe_expert_rank_capacity_factor: the symm-mem " + "buffers are a fixed [recv_capacity, hidden] and cannot be resized per step." + ) + else: + # Static shapes feed the experts the full receive buffer, so the grouped GEMM must + # consume the ragged per-expert counts on device and never read the slack tail: + # moe_grouped_gemm selects the grouped experts and use_transformer_engine_op_fuser + # fuses FC1+act+FC2 over them (fp8/fp4 via the CuTe DSL fused grouped MLP, bf16 via + # the op-fuser GroupedLinear grouped-tensor path). if not (config.use_transformer_engine_op_fuser and config.moe_grouped_gemm): raise ValueError( - "moe_ncclep_static_shape=True requires BOTH use_transformer_engine_op_fuser " - "and moe_grouped_gemm (the fused grouped GEMM over device-side " - "per-expert counts)." + "moe_expert_rank_capacity_factor with the 'ncclep' backend requires BOTH " + "use_transformer_engine_op_fuser and moe_grouped_gemm (the fused grouped GEMM " + "over device-side per-expert counts); unset it to use eager mode instead." ) if config.fp8 or config.fp4: if torch.cuda.get_device_capability()[0] < 10: raise ValueError( - "moe_ncclep_static_shape=True with fp8/fp4 requires an sm100+ (Blackwell+) " - "GPU for the CuTe DSL grouped GEMM; leave it False on older GPUs." + "moe_expert_rank_capacity_factor with the 'ncclep' backend and fp8/fp4 " + "requires an sm100+ (Blackwell+) GPU for the CuTe DSL grouped GEMM; unset " + "it to use eager mode on older GPUs." ) if int(os.environ.get("NVTE_CUTEDSL_FUSED_GROUPED_MLP", "0")) <= 0: raise ValueError( - "moe_ncclep_static_shape=True with fp8/fp4 requires the CuTe DSL grouped " - "GEMM; set NVTE_CUTEDSL_FUSED_GROUPED_MLP=1 (the expert grouped GEMM must " - "consume ragged per-expert counts on device)." + "moe_expert_rank_capacity_factor with the 'ncclep' backend and fp8/fp4 " + "requires the CuTe DSL grouped GEMM; set " + "NVTE_CUTEDSL_FUSED_GROUPED_MLP=1 (the expert grouped GEMM must consume " + "ragged per-expert counts on device)." ) - if nccl_ep_dispatch is None: - raise ImportError( - "TransformerEngine NCCL EP is unavailable. The 'ncclep' backend requires a " - "TransformerEngine build with NCCL EP support (NVTE_BUILD_WITH_NCCL_EP=1)." - ) - if self.rank_capacity_factor is None: - raise ValueError( - "The 'ncclep' backend requires moe_expert_rank_capacity_factor to be set: it " - "sizes the per-rank receive buffer. Exceeding the budget hard-traps, so set it " - "generously." - ) - # Fresh EpBuffer per dispatch, held until the matching combine consumes it. dispatch # and combine share one buffer: handle_mem is the routing table that dispatch writes # and combine reads. Safe because dispatch i / combine i strictly alternate. @@ -1596,22 +1610,38 @@ def setup_metadata(self, routing_map: torch.Tensor, probs: torch.Tensor): self.num_local_tokens = num_tokens def _ensure_bootstrap(self): - """Bootstrap NCCL EP and size the receive buffer on first use (static shapes).""" - if self._bootstrapped: + """ + Bootstrap NCCL EP and size the receive buffer on first use or after paged stash rerun + """ + # A released context always needs a rebuild, and every mode change is paired with one. + if self._bootstrapped and is_nccl_ep_bootstrapped(): return + # PagedStashRunner will set factor to None during rerun to enable eager mode + self.eager = self.moe_expert_rank_capacity_factor is None + # TODO: support eager mode with zero_copy + self.zero_copy = self.config.moe_ncclep_zero_copy and not self.eager + self._zc_quant = self.zero_copy and bool(self.config.fp8 or self.config.fp4) # NCCL EP's HT backend requires max_dispatch_tokens_per_rank to be a multiple of the HT # chunk size (64); ncclEpCreateGroup otherwise fails with "invalid usage". # (nccl_ep device/hybridep_adapter.cu). _HT_TOKENS_PER_CHUNK = 64 + # TODO: support THD/Dynamic CP when different ranks might have different number of tokens self._max_tokens_per_rank = ( (self.num_local_tokens + _HT_TOKENS_PER_CHUNK - 1) // _HT_TOKENS_PER_CHUNK * _HT_TOKENS_PER_CHUNK ) - budget = int(self._max_tokens_per_rank * self.router_topk * self.rank_capacity_factor) - if self.alignment != 0: - budget += -budget % self.alignment - self._recv_capacity = budget + if self.eager: + self._recv_capacity = None + else: + budget = int( + self._max_tokens_per_rank * self.router_topk * self.moe_expert_rank_capacity_factor + ) + if self._recv_capacity_override is not None: + budget = max(budget, self._recv_capacity_override) + if self.alignment != 0: + budget += -budget % self.alignment + self._recv_capacity = budget ensure_nccl_ep_bootstrapped( self.group, @@ -1619,6 +1649,7 @@ def _ensure_bootstrap(self): max_tokens_per_rank=self._max_tokens_per_rank, recv_capacity_per_rank=self._recv_capacity, hidden_dim=self.hidden_dim, + num_topk=self.router_topk, num_sms=( self.config.moe_flex_dispatcher_num_sms if self.config.moe_flex_dispatcher_num_sms is not None @@ -1678,9 +1709,11 @@ def dispatch( # token_indices/token_probs: [num_local_tokens, router_topk] topk_idx = self.token_indices topk_weights = self.token_probs.float() - # hidden_states: [num_local_tokens, H] -> recv_tokens: [recv_capacity_per_rank, H] + # hidden_states: [num_local_tokens, H] -> recv_tokens: [recv_rows, H] # tokens_per_expert: [num_local_experts] - # dispatched_probs: [recv_capacity_per_rank] + # dispatched_probs: [recv_rows] + # recv_rows is the actual number of received tokens in eager mode, otherwise + # recv_capacity_per_rank. recv_tokens, tokens_per_expert, dispatched_probs = nccl_ep_dispatch( self._buffer, hidden_states, @@ -1690,20 +1723,38 @@ def dispatch( recv_topk_weights=_NCCLEPManager._zc_recv_topk_weights_buf, ) self.tokens_per_expert = tokens_per_expert.to(torch.int64) + if not self.eager: + # ep_prepare fills total_recv_tokens from the routing map, before any dropping, so + # it is the capacity this step actually needed. + total_recv_tokens = self._buffer.total_recv_tokens + self.over_budget |= total_recv_tokens > self._recv_capacity + torch.maximum(self.required_recv, total_recv_tokens, out=self.required_recv) # fp8 zero-copy: dispatched_probs aliases the recv_topk_weights symm buffer, which the # next layer's dispatch reuses; copy it out so it stays valid through this layer's backward. # bf16 gets a fresh per-call pool buffer (not shared), so no copy is needed. self.dispatched_probs = dispatched_probs.clone() if self._zc_quant else dispatched_probs return recv_tokens + def grow_recv_capacity(self, new_capacity: int) -> None: + """Raise the static receive budget to ``new_capacity``, the peak a dropped step needed. + + Applied on the success path after a dropless replay, so the restored static budget covers + the routing that overflowed instead of dropping again on the next step. Monotonic. + """ + if self.config.moe_expert_rank_capacity_factor is None: + return + # Overflow means total_recv_tokens > _recv_capacity >= the current override, so the + # peak that triggered it is always strictly larger. Anything else is stale accounting. + assert new_capacity > (self._recv_capacity_override or 0), ( + f"receive capacity must grow on overflow: peak {new_capacity} is not above the " + f"current override {self._recv_capacity_override}" + ) + self._recv_capacity_override = new_capacity + self._buffer = None + self._bootstrapped = False + def get_permuted_hidden_states_by_experts(self, hidden_states: torch.Tensor) -> torch.Tensor: - if self.static_shape: - return hidden_states, self.dispatched_probs - # narrow to the sum(counts) valid (alignment-padded) rows the experts consume. - num_valid = int(self.tokens_per_expert.sum().item()) # sum(counts) = Σ - permuted_hidden = hidden_states[:num_valid] # [recv_capacity_per_rank, H] -> [Σ, H] - permuted_probs = self.dispatched_probs[:num_valid] # [recv_capacity_per_rank] -> [Σ] - return permuted_hidden, permuted_probs + return hidden_states, self.dispatched_probs def get_number_of_tokens_per_expert(self) -> torch.Tensor: ''' @@ -1712,16 +1763,6 @@ def get_number_of_tokens_per_expert(self) -> torch.Tensor: return self.tokens_per_expert def get_restored_hidden_states_by_experts(self, hidden_states: torch.Tensor) -> torch.Tensor: - # TE ep_combine reads from the static [recv_capacity, H] buffer. static_shape=False path the - # experts ran on the narrowed [Σ, H] slice, so re-expand back to recv_capacity; in - # static_shape mode the output is already recv_capacity rows (no-op). Rows beyond the valid - # region map to no token and combine ignores them. - num_valid = hidden_states.shape[0] - pad_rows = self._recv_capacity - num_valid - if pad_rows > 0: - hidden_states = torch.cat( - [hidden_states, hidden_states.new_zeros(pad_rows, hidden_states.shape[-1])], dim=0 - ) return hidden_states def combine( @@ -1730,7 +1771,7 @@ def combine( async_finish: bool = True, allocate_on_comm_stream: bool = True, ) -> torch.Tensor: - # hidden_states: [recv_capacity_per_rank, H] -> [num_local_tokens, H] + # hidden_states: [recv_rows, H] -> [num_local_tokens, H] hidden_states = nccl_ep_combine( self._buffer, hidden_states, @@ -1745,6 +1786,19 @@ def combine( return hidden_states +def nccl_ep_release_context() -> None: + """Release the process-wide NCCL EP context and the shared zero-copy symm buffers. + + Collective, and process-wide rather than per-layer: call it once per rerun transition. + ``_ensure_bootstrap`` rebuilds both lazily, so the symm-buffer rendezvous lands outside + CUDA-graph capture. + """ + _NCCLEPManager._zc_fwd_token_buf = None + _NCCLEPManager._zc_bwd_token_buf = None + _NCCLEPManager._zc_recv_topk_weights_buf = None + nccl_ep_finalize() + + class MoEFlexTokenDispatcher(MoETokenDispatcher): """A flexible token dispatcher that abstracts the underlying tensor and expert parallelism. It uses a single communication group over all TP and EP ranks, @@ -1997,7 +2051,28 @@ def check_over_budget(self): else: return None + def check_required_capacity(self): + """Peak per-rank receive capacity the dispatcher needed, or None if it is not tracked.""" + if hasattr(self._comm_manager, 'required_recv'): + return self._comm_manager.required_recv + else: + return None + + def grow_ep_recv_capacity(self, new_capacity: int) -> None: + """Grow the backend's receive budget to ``new_capacity`` so an overflowed step can be + replayed without dropping. No-op unless the backend supports it.""" + if hasattr(self._comm_manager, 'grow_recv_capacity'): + self._comm_manager.grow_recv_capacity(new_capacity) + + def invalidate_ep_bootstrap(self) -> None: + """Force the backend to re-bootstrap on its next dispatch. Call after changing the mode: + every manager must re-derive it, not just the one that happens to dispatch first.""" + if hasattr(self._comm_manager, '_bootstrapped'): + self._comm_manager._bootstrapped = False + def reset_over_budget(self): """Reset the accumulated over-budget flag on the communication manager.""" if hasattr(self._comm_manager, 'over_budget'): self._comm_manager.over_budget.fill_(0) + if hasattr(self._comm_manager, 'required_recv'): + self._comm_manager.required_recv.fill_(0) diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 7fa433221f1..1b6246613be 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -965,22 +965,12 @@ class TransformerConfig(ModelParallelConfig): moe_hybridep_num_sms_preprocessing: int = 108 """Number of SMs to use for HybridEP preprocessing (metadata scan kernel).""" - moe_ncclep_static_shape: bool = False - """For the 'ncclep' flex dispatcher: feed the experts the full fixed-size receive buffer - instead of narrowing to the (data-dependent) number of received tokens, removing the D2H sync - and dynamic shapes from the dispatch (required for CUDA-graph capture of the MoE A2A and for the - 1F1B EP comm overlap). The fused grouped GEMM consumes the ragged per-expert counts on device - and walks only the received tokens (no slack GEMM, no last-expert padding). This requires the - CuTe DSL / device-offset grouped GEMM, so it is only supported with the fused op - (use_transformer_engine_op_fuser, NVTE_CUTEDSL_FUSED_GROUPED_MLP=1) on sm100+ (Blackwell or - later); the dispatcher asserts this. On older GPUs leave it False (dynamic shape). Defaults to - False (narrow to the received tokens).""" - moe_ncclep_zero_copy: bool = False """For the 'ncclep' flex dispatcher: use the NCCL symmetric-memory zero-copy IO path (ep_bootstrap zero_copy + symm-mem-backed receive/combine buffers) instead of the default HBM - staged-copy path, saving one copy on the wire. Requires moe_ncclep_static_shape and the fused op - (use_transformer_engine_op_fuser). Defaults to False.""" + staged-copy path, saving one copy on the wire. Requires moe_expert_rank_capacity_factor (the + symm-mem buffers are a fixed [recv_capacity, hidden] and cannot be resized per step) and the + fused op (use_transformer_engine_op_fuser). Defaults to False.""" moe_mlp_glu_interleave_size: Optional[int] = None """When set, GLU activations in the MoE grouped MLP layer will use a @@ -994,8 +984,9 @@ class TransformerConfig(ModelParallelConfig): """moe_expert_rank_capacity_factor (float): The capacity factor for each expert rank, i.e. the per-rank token budget. None means no token will be dropped. The default is None. With the 'hybridep' backend, tokens exceeding this budget are dropped. With the 'ncclep' - backend, exceeding the budget is a hard error (TransformerEngine/NCCL traps) — set it - generously.""" + backend, setting it selects static shapes (fixed receive buffer, CUDA-graph capturable) and + leaving it None selects eager mode (receive buffer sized per step from the received-token + count).""" ################## # Context Parallel @@ -2784,14 +2775,6 @@ def _scope_to_str(s): self.moe_token_dispatcher_type == 'flex' and self.moe_flex_dispatcher_backend == 'ncclep' ): - if not self.moe_ncclep_static_shape: - warnings.warn( - 'overlap_moe_expert_parallel_comm with ncclep and ' - 'moe_ncclep_static_shape=False: get_permuted_hidden_states_by_experts ' - 'does a device-to-host sync that serializes the 1F1B overlap (correct, ' - 'but loses the overlap benefit). Set moe_ncclep_static_shape=True for ' - 'the overlapped path (needs the fused op on sm100+).' - ) assert not ( self.fine_grained_activation_offloading and 'expert_fc1' in (self.offload_modules or []) diff --git a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py index fe36e052e03..53e735a35e3 100644 --- a/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py +++ b/tests/unit_tests/a2a_overlap/test_schedule_layer_1f1b.py @@ -489,7 +489,9 @@ def test_transformer_layer_overlap_zero_copy(self): apply_flex_backend_kwargs(extra_kwargs, "flex", "ncclep") extra_kwargs.update( moe_ncclep_zero_copy=True, - moe_ncclep_static_shape=True, + # zero-copy needs the static path; generous factor since the small test token + # counts make routing imbalance high and overflow hard-traps. + moe_expert_rank_capacity_factor=8.0, use_transformer_engine_op_fuser=True, gated_linear_unit=True, activation_func=F.silu, diff --git a/tests/unit_tests/a2a_overlap/utils.py b/tests/unit_tests/a2a_overlap/utils.py index c4d6a2844e1..93655a67b9f 100644 --- a/tests/unit_tests/a2a_overlap/utils.py +++ b/tests/unit_tests/a2a_overlap/utils.py @@ -275,18 +275,13 @@ def get_valid_dispatcher_configs(): def apply_flex_backend_kwargs(extra_kwargs, dispatcher_type, flex_backend): """Wire the dispatcher type + flex backend into a config kwargs dict. - For ncclep, also set moe_expert_rank_capacity_factor: ncclep sizes a per-rank receive buffer - from it and overflow hard-traps, so it must be set (2.0 gives ample headroom at test sizes). + ncclep is left in eager mode (no moe_expert_rank_capacity_factor): the static path needs the + fused grouped GEMM, which these overlap tests do not enable. Tests that do (zero-copy) set the + capacity factor themselves. """ extra_kwargs["moe_token_dispatcher_type"] = dispatcher_type if dispatcher_type == "flex": extra_kwargs["moe_flex_dispatcher_backend"] = flex_backend - if flex_backend == "ncclep": - # ncclep sizes a per-rank receive buffer from this and overflow hard-traps (the - # em_scan_kernel "padded slots > max_recv_tokens_per_rank" device check). These overlap - # tests use small token counts (high routing-imbalance variance), so use a generous - # factor to guarantee no overflow; the staging buffer is tiny at this model size. - extra_kwargs["moe_expert_rank_capacity_factor"] = 8.0 return extra_kwargs diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index 2c7efa793d3..c2c00ae4d0b 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -194,7 +194,6 @@ "moe_latent_size": None, "moe_layer_freq": 1, "moe_layer_recompute": False, - "moe_ncclep_static_shape": False, "moe_ncclep_zero_copy": False, "moe_pad_expert_input_to_capacity": False, "moe_pad_experts_for_cuda_graph_inference": False, diff --git a/tests/unit_tests/transformer/moe/test_paged_stashing.py b/tests/unit_tests/transformer/moe/test_paged_stashing.py index 013e5111f2b..11194c1b487 100644 --- a/tests/unit_tests/transformer/moe/test_paged_stashing.py +++ b/tests/unit_tests/transformer/moe/test_paged_stashing.py @@ -117,7 +117,6 @@ def __init__( add_bias_linear=kwargs.get("add_bias_linear", False), moe_permute_fusion=kwargs.get("moe_permute_fusion", False), moe_flex_dispatcher_backend=kwargs.get("moe_flex_dispatcher_backend", None), - moe_ncclep_static_shape=kwargs.get("moe_ncclep_static_shape", False), moe_ncclep_zero_copy=kwargs.get("moe_ncclep_zero_copy", False), moe_grouped_gemm=kwargs.get("moe_grouped_gemm", False), moe_paged_stash=kwargs.get("moe_paged_stash", False), @@ -131,8 +130,15 @@ def __init__( gated_linear_unit=kwargs.get("gated_linear_unit", False), activation_func=kwargs.get("activation_func", F.gelu), moe_router_force_biased=kwargs.get("moe_router_force_biased", None), - moe_paged_stash_buffer_size_factor_cuda=0.5, - moe_paged_stash_buffer_size_factor_cpu=1.5, + # Shrinking the CUDA factor and zeroing the CPU one (no host-spill fallback) is how + # a test forces a paged-stash overflow: the pool is provisioned once at the + # capture->captured transition, so a small factor overflows on the first real step. + moe_paged_stash_buffer_size_factor_cuda=kwargs.get( + "moe_paged_stash_buffer_size_factor_cuda", 0.5 + ), + moe_paged_stash_buffer_size_factor_cpu=kwargs.get( + "moe_paged_stash_buffer_size_factor_cpu", 1.5 + ), ) self.moe_layers = [self._create_moe_layer(layer_number=i) for i in range(num_layers)] self.moe_layer = self.moe_layers[0] @@ -199,9 +205,28 @@ def is_nccl_ep_zero_copy_available(): def is_nccl_ep_available(): + """NCCL EP built into TE, with the eager/drop-capable ``ep_bootstrap`` signature. + + ``ensure_nccl_ep_bootstrapped`` always passes ``recv_capacity_per_rank`` and + ``drop_on_overflow``, so a TE predating that signature raises TypeError on the first + bootstrap for every ncclEP path -- static as much as eager. Gate on it here so such builds + skip cleanly instead of erroring. ``recv_capacity_per_rank`` must also be *optional*: that + is what makes eager (the over-budget replay) expressible. + """ from megatron.core.transformer.moe.fused_a2a import HAVE_TE_EP - return HAVE_TE_EP + if not HAVE_TE_EP: + return False + + import inspect + + from transformer_engine.pytorch.ep import ep_bootstrap + + params = inspect.signature(ep_bootstrap).parameters + recv_capacity = params.get("recv_capacity_per_rank") + return ( + recv_capacity is not None and recv_capacity.default is None and "drop_on_overflow" in params + ) def _te_grouped_mlp_op_fuser_environment_supported() -> bool: @@ -448,10 +473,11 @@ def test_overload_factor_and_over_budget(self): class TestNcclEpPagedStashing: """Paged stashing with the NCCL EP flex backend in its static-shape path. - ncclep's CUDA-graph / paged-stash path requires moe_ncclep_static_shape=True, which feeds the - experts the full fixed-size recv buffer and is only valid with fp8/fp4 + the CuTe DSL grouped - GEMM (the container always configures mxfp8; NVTE_CUTEDSL_FUSED_GROUPED_MLP=1 must be set in the - environment). This mirrors TestPagedStashing: run the paged-stash path twice and assert the two + ncclep's CUDA-graph / paged-stash path requires moe_expert_rank_capacity_factor, which feeds + the experts the full fixed-size recv buffer and is only valid with fp8/fp4 + the CuTe DSL + grouped GEMM (the container always configures mxfp8; NVTE_CUTEDSL_FUSED_GROUPED_MLP=1 must be + set in the environment). This mirrors TestPagedStashing: run the paged-stash path twice and + assert the two passes agree (a determinism guard for the static ncclep path), plus no paged-stash overflow. """ @@ -461,6 +487,232 @@ def setup_method(self, method): def teardown_method(self, method): Utils.destroy_model_parallel() + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.flaky_in_dev + @pytest.mark.internal + def test_over_budget(self): + """Budget matches _NCCLEPManager._ensure_bootstrap; over_budget matches map-derived load. + + Mirrors TestPagedStashingOverBudget for HybridEP, plus the peak capacity NCCL EP + reports -- HybridEP has no equivalent because it recovers by going dropless and so + never needs to know how much was required. The capacity factor is deliberately below + 1.0: each rank receives num_tokens*topk on average, so 1.0 sits exactly at the mean + and anything under it overflows. + """ + if not is_nccl_ep_available(): + pytest.skip("NCCL EP is not available") + + config.ENABLE_EXPERIMENTAL = True + + container = MoEModelTestContainer( + tp_size=1, + ep_size=4, + pp_size=1, + num_moe_experts=8, + num_layers=4, + moe_router_topk=2, + moe_router_load_balancing_type="aux_loss", + moe_token_dispatcher_type="flex", + moe_permute_fusion=True, + hidden_size=1024, + moe_flex_dispatcher_backend="ncclep", + test_dtype=torch.bfloat16, + moe_grouped_gemm=True, + moe_use_legacy_grouped_gemm=False, + moe_paged_stash=True, + moe_expert_rank_capacity_factor=0.6, + use_transformer_engine_op_fuser=True, + moe_mlp_glu_interleave_size=32, + moe_router_padding_for_quantization=True, + gated_linear_unit=True, + activation_func=F.silu, + ) + + seq_length = 1024 + batch_size = 1 + topk = container.config.moe_router_topk + capacity_factor = container.config.moe_expert_rank_capacity_factor + hidden_states = torch.randn( + (seq_length, batch_size, container.config.hidden_size), dtype=torch.bfloat16 + ) + + num_tokens = seq_length * batch_size * topk + pad_multiple = get_align_size_for_quantization(container.config) + budget = int(num_tokens * capacity_factor) + budget += -budget % pad_multiple + + paged_stash_reset(True, config=container.config) + paged_stash_init_chunk_handler(1, 0) + _forward_backward_all_layers(container, hidden_states) + + # NCCL EP's manager keeps token_probs/token_indices rather than the routing map, and a + # rank's received load depends on every rank's routing, so the HybridEP map-derived + # cross-check does not transfer. Check the device-side accounting against the budget + # instead: required_recv is filled by ep_prepare before any dropping, and over_budget is + # the same comparison made on device, so the two must agree with config arithmetic. + any_over_budget = False + for layer_idx, layer in enumerate(container.moe_layers): + comm = layer.token_dispatcher._comm_manager + over_budget = layer.token_dispatcher.check_over_budget().item() + required = layer.token_dispatcher.check_required_capacity().item() + + assert comm._recv_capacity == budget, ( + f"layer {layer_idx}: dispatcher budget ({comm._recv_capacity}) != expected " + f"({budget}) for capacity factor {capacity_factor}" + ) + assert required > 0, f"layer {layer_idx}: required capacity was never recorded" + assert over_budget == (required > budget), ( + f"layer {layer_idx}: over_budget={over_budget} disagrees with required " + f"({required}) vs budget ({budget})" + ) + any_over_budget |= over_budget + + assert any_over_budget, ( + f"no layer exceeded budget {budget} at capacity factor {capacity_factor}; " + "the test is not exercising overflow" + ) + + # Leave a clean slate. The EP context is process-wide and ep_bootstrap refuses a second + # call, so a later test would otherwise reuse this capacity. Drop the layers before + # finalizing and force a collection: this container has no __del__, so its EpBuffers + # would otherwise be freed at an arbitrary later point -- inside the next test, against + # a context that has since been re-bootstrapped. + import gc + + from megatron.core.transformer.moe.token_dispatcher import nccl_ep_release_context + + del container + gc.collect() + nccl_ep_release_context() + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.flaky_in_dev + @pytest.mark.internal + @pytest.mark.parametrize("zero_copy", [False, True]) + def test_over_budget_recovery(self, zero_copy): + """Degrade an over-budget step to a dropless replay, then restore the grown static budget.""" + if not is_nccl_ep_available(): + pytest.skip("NCCL EP is not available") + if zero_copy and not is_nccl_ep_zero_copy_available(): + pytest.skip("NCCL EP zero-copy TE API is not available") + + from megatron.core.transformer.moe.token_dispatcher import nccl_ep_release_context + + config.ENABLE_EXPERIMENTAL = True + + container = MoEModelTestContainer( + tp_size=1, + ep_size=4, + pp_size=1, + num_moe_experts=8, + num_layers=4, + moe_router_topk=2, + moe_router_load_balancing_type="aux_loss", + moe_token_dispatcher_type="flex", + moe_permute_fusion=True, + hidden_size=1024, + moe_flex_dispatcher_backend="ncclep", + test_dtype=torch.bfloat16, + moe_grouped_gemm=True, + moe_use_legacy_grouped_gemm=False, + moe_paged_stash=True, + moe_expert_rank_capacity_factor=0.6, + moe_ncclep_zero_copy=zero_copy, + use_transformer_engine_op_fuser=True, + moe_mlp_glu_interleave_size=32, + moe_router_padding_for_quantization=True, + gated_linear_unit=True, + activation_func=F.silu, + ) + + seq_length = 1024 + batch_size = 1 + hidden_states = torch.randn( + (seq_length, batch_size, container.config.hidden_size), dtype=torch.bfloat16 + ) + + def run(): + paged_stash_reset(True, config=container.config) + paged_stash_init_chunk_handler(1, 0) + out, _, _, _ = _forward_backward_all_layers(container, hidden_states) + container.zero_grad() + torch.cuda.synchronize() + return out + + # 1. Undersized budget: the step drops tokens and reports it. + out_dropped = run() + over_1 = [l.token_dispatcher.check_over_budget().item() for l in container.moe_layers] + req_1 = [l.token_dispatcher.check_required_capacity().item() for l in container.moe_layers] + + required_t = torch.tensor([max(req_1)], dtype=torch.int64, device="cuda") + torch.distributed.all_reduce(required_t, op=torch.distributed.ReduceOp.MAX) + required = int(required_t.item()) + budget_before = container.moe_layers[0].token_dispatcher._comm_manager._recv_capacity + + # 2. prepare_for_rerun: clear the capacity factor (-> eager, which has no budget to + # exceed), record the peak to grow to, release the EP context, replay dropless. + for layer in container.moe_layers: + layer.token_dispatcher.reset_over_budget() + layer.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor = None + layer.token_dispatcher.grow_ep_recv_capacity(required) + layer.token_dispatcher.invalidate_ep_bootstrap() + nccl_ep_release_context() + + out_replay = run() + eager_2 = [l.token_dispatcher._comm_manager.eager for l in container.moe_layers] + zc_2 = [l.token_dispatcher._comm_manager.zero_copy for l in container.moe_layers] + + dropped_finite = bool(torch.isfinite(out_dropped).all()) + replay_finite = bool(torch.isfinite(out_replay).all()) + # atol=0 so the comparison is purely relative: these activations are ~1e-15, and + # allclose's default atol=1e-8 would call any result "close", including a completely + # wrong one. + dropped_differs = not torch.allclose(out_dropped, out_replay, rtol=1e-2, atol=0) + + # 3. Success branch: restore the capacity factor -> static returns at the grown budget. + for layer in container.moe_layers: + layer.token_dispatcher.reset_over_budget() + layer.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor = ( + container.config.moe_expert_rank_capacity_factor + ) + layer.token_dispatcher.invalidate_ep_bootstrap() + nccl_ep_release_context() + + out_restored = run() + eager_3 = [l.token_dispatcher._comm_manager.eager for l in container.moe_layers] + zc_3 = [l.token_dispatcher._comm_manager.zero_copy for l in container.moe_layers] + caps_3 = [l.token_dispatcher._comm_manager._recv_capacity for l in container.moe_layers] + over_3 = [l.token_dispatcher.check_over_budget().item() for l in container.moe_layers] + nccl_ep_release_context() + + assert required > budget_before, ( + f"nothing exceeded budget {budget_before} at capacity factor " + f"{container.config.moe_expert_rank_capacity_factor}; not exercising overflow" + ) + assert all(eager_2), f"replay did not degrade to eager: {eager_2}" + assert not any(zc_2), f"replay must drop zero-copy while eager: {zc_2}" + assert replay_finite, "eager replay produced non-finite values" + assert ( + dropped_finite + ), "the dropped step produced non-finite values; dropped tokens must contribute 0" + assert not any(eager_3), f"restore did not return to static: {eager_3}" + assert all( + z == zero_copy for z in zc_3 + ), f"restore did not return zero_copy to {zero_copy}: {zc_3}" + assert all( + c >= required > budget_before for c in caps_3 + ), f"budget did not grow: {budget_before} -> {caps_3}, observed peak {required}" + assert not any(over_3), f"still over budget after growing to {required}: {over_3}" + # Only ranks that actually dropped can differ: overflow is per-rank, so a rank whose + # experts stayed under budget legitimately reproduces the same output. + if any(over_1): + assert dropped_differs, ( + "this rank was over budget, so the dropped step must differ from the dropless " + "replay" + ) + # The correctness check: two different execution modes must agree + torch.testing.assert_close(out_restored, out_replay, rtol=1e-2, atol=0) + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") # NCCL EP static-shape paged stashing aborts in dev CI with a pybind11 GIL dec_ref failure. @pytest.mark.flaky_in_dev @@ -489,7 +741,6 @@ def test_forward_backward_4_layers(self, zero_copy): moe_permute_fusion=True, hidden_size=1024, moe_flex_dispatcher_backend="ncclep", - moe_ncclep_static_shape=True, test_dtype=torch.bfloat16, moe_grouped_gemm=True, moe_use_legacy_grouped_gemm=False, diff --git a/tests/unit_tests/transformer/moe/test_token_dispatcher.py b/tests/unit_tests/transformer/moe/test_token_dispatcher.py index 8034ec345aa..ea74d57f760 100644 --- a/tests/unit_tests/transformer/moe/test_token_dispatcher.py +++ b/tests/unit_tests/transformer/moe/test_token_dispatcher.py @@ -101,7 +101,6 @@ def __init__( moe_permute_fusion=kwargs.get("moe_permute_fusion", False), moe_flex_dispatcher_backend=kwargs.get("moe_flex_dispatcher_backend", None), moe_expert_rank_capacity_factor=kwargs.get("moe_expert_rank_capacity_factor", None), - moe_ncclep_static_shape=kwargs.get("moe_ncclep_static_shape", False), moe_ncclep_zero_copy=kwargs.get("moe_ncclep_zero_copy", False), use_transformer_engine_op_fuser=kwargs.get("use_transformer_engine_op_fuser", False), gated_linear_unit=kwargs.get("gated_linear_unit", False), @@ -155,7 +154,7 @@ def dispatcher_dropless_test(self): probs, indices = apply_module(moe_layer.router)(hidden_states) probs = torch.ones_like(probs) / moe_layer.router.topk - (permuted_local_hidden_states, tokens_per_expert, permuted_probs) = token_permutation( + permuted_local_hidden_states, tokens_per_expert, permuted_probs = token_permutation( moe_layer.token_dispatcher, hidden_states, probs, indices ) @@ -244,7 +243,7 @@ def dispatcher_capacity_test(self): restored_hidden_states_answer = hidden_states * local_probss.sum(dim=1).unsqueeze(1) restored_hidden_states_answer = restored_hidden_states_answer.to(dtype=self.test_dtype) - (permuted_local_hidden_states, tokens_per_expert, permuted_probs) = token_permutation( + permuted_local_hidden_states, tokens_per_expert, permuted_probs = token_permutation( moe_layer.token_dispatcher, hidden_states, probs, indices ) @@ -295,7 +294,7 @@ def dispatcher_drop_and_pad_test(self): hidden_states.requires_grad = True probs_1, indices_1 = apply_module(moe_layer.router)(hidden_states) - (permuted_input_1, tokens_per_expert, permuted_probs_1) = token_permutation( + permuted_input_1, tokens_per_expert, permuted_probs_1 = token_permutation( moe_layer.token_dispatcher, hidden_states, probs_1, indices_1 ) permuted_input_1 = permuted_input_1 * permuted_probs_1.unsqueeze(-1) @@ -313,7 +312,7 @@ def dispatcher_drop_and_pad_test(self): moe_layer_2.load_state_dict(moe_layer.state_dict()) probs_2, indices_2 = apply_module(moe_layer_2.router)(hidden_states) - (permuted_input_2, tokens_per_expert, permuted_probs_2) = token_permutation( + permuted_input_2, tokens_per_expert, permuted_probs_2 = token_permutation( moe_layer_2.token_dispatcher, hidden_states, probs_2, indices_2 ) permuted_input_2 = permuted_input_2 * permuted_probs_2.unsqueeze(-1) @@ -366,7 +365,7 @@ def dispatcher_router_padding_for_fp8_test(self): hidden_states.requires_grad = True probs_1, indices_1 = apply_module(moe_layer.router)(hidden_states) - (permuted_input_1, tokens_per_expert_1, permuted_probs_1) = token_permutation( + permuted_input_1, tokens_per_expert_1, permuted_probs_1 = token_permutation( moe_layer.token_dispatcher, hidden_states, probs_1, indices_1 ) permuted_input_1 = permuted_input_1 * permuted_probs_1.unsqueeze(-1) @@ -383,7 +382,7 @@ def dispatcher_router_padding_for_fp8_test(self): moe_layer_2.load_state_dict(moe_layer.state_dict()) probs_2, indices_2 = apply_module(moe_layer_2.router)(hidden_states) - (permuted_input_2, tokens_per_expert_2, permuted_probs_2) = token_permutation( + permuted_input_2, tokens_per_expert_2, permuted_probs_2 = token_permutation( moe_layer_2.token_dispatcher, hidden_states, probs_2, indices_2 ) assert ( @@ -615,13 +614,6 @@ def test_forward_backward( hidden_size=1024, moe_flex_dispatcher_backend=moe_flex_dispatcher_backend, moe_permute_fusion_into_hybridep=moe_permute_fusion_into_hybridep, - # ncclep sizes a per-rank recv buffer from this and overflow HARD-TRAPS (device-side - # em_scan check -> CUDA launch failure), so size it generously: small token counts have - # high routing-imbalance variance and a tight factor traps. The staging buffer is tiny - # at this model size, so a large factor costs little. - moe_expert_rank_capacity_factor=( - 8.0 if moe_flex_dispatcher_backend == "ncclep" else None - ), test_dtype=torch.bfloat16, ) container.dispatcher_dropless_test() @@ -639,7 +631,8 @@ def test_forward_backward( @pytest.mark.timeout(120) @pytest.mark.parametrize("tp_size,ep_size", [(1, 8)]) def test_forward_backward_zero_copy(self, tp_size, ep_size): - # zero-copy requires static_shape, which requires BOTH op-fuser and grouped_gemm; bf16 so no + # zero-copy requires a capacity factor, which requires BOTH op-fuser and grouped_gemm; bf16 + # so no # fp8/Blackwell dependency. The op-fuser needs tp=1 and a SwiGLU activation. Parity: the # zero-copy IO path must match the staged (no-zc) path. container = MoEModelTestContainer( @@ -653,7 +646,6 @@ def test_forward_backward_zero_copy(self, tp_size, ep_size): moe_flex_dispatcher_backend="ncclep", moe_grouped_gemm=True, use_transformer_engine_op_fuser=True, - moe_ncclep_static_shape=True, gated_linear_unit=True, activation_func=F.silu, # ncclep sizes a per-rank recv buffer from this and overflow HARD-TRAPS; size generously. diff --git a/tests/unit_tests/transformer/test_cuda_graphs.py b/tests/unit_tests/transformer/test_cuda_graphs.py index f387e58953e..10297072664 100644 --- a/tests/unit_tests/transformer/test_cuda_graphs.py +++ b/tests/unit_tests/transformer/test_cuda_graphs.py @@ -1585,8 +1585,6 @@ def test_moe_partial_cudagraph(self, ep_size, moe_dropless_dispatcher, moe_dispa pytest.skip("NCCL EP requires expert_model_parallel_size >= 2 (ep_bootstrap)") extra_kwargs["moe_token_dispatcher_type"] = "flex" extra_kwargs["moe_flex_dispatcher_backend"] = "ncclep" - # ncclep sizes a per-rank recv buffer from this and overflow hard-traps; size generously. - extra_kwargs["moe_expert_rank_capacity_factor"] = 8.0 else: extra_kwargs["moe_token_dispatcher_type"] = moe_dispatcher_type if not moe_dropless_dispatcher: From db57c152e672e99bfdb0b57a93d3bad479c1b559 Mon Sep 17 00:00:00 2001 From: Skand Hurkat Date: Tue, 11 Aug 2026 07:06:34 -0700 Subject: [PATCH 248/290] Gate expensive work behind log levels (#5805) Signed-off-by: Skand Hurkat Co-authored-by: Claude Sonnet 4.6 (1M context) --- megatron/core/_rank_utils.py | 15 +- .../blended_megatron_dataset_builder.py | 5 +- .../core/dist_checkpointing/exchange_utils.py | 28 +-- megatron/core/dist_checkpointing/mapping.py | 2 +- megatron/core/dist_checkpointing/optimizer.py | 4 +- .../megatron_fsdp/param_and_grad_buffer.py | 58 +++-- .../core/distributed/param_and_grad_buffer.py | 39 ++- megatron/core/hyper_comm_grid.py | 10 +- .../pipeline_parallel/bridge_communicator.py | 236 +++++++++++------- .../multimodule_communicator.py | 42 ++-- .../resharding/nvshmem_copy_service/logger.py | 33 +-- .../planning/communication_scheduler.py | 20 +- .../planning/workload_packer.py | 43 ++-- .../pipeline_parallel_layer_layout.py | 14 +- megatron/core/utils.py | 21 +- megatron/training/checkpointing.py | 12 +- tests/unit_tests/test_rank_utils.py | 138 ++++++++++ 17 files changed, 495 insertions(+), 225 deletions(-) create mode 100644 tests/unit_tests/test_rank_utils.py diff --git a/megatron/core/_rank_utils.py b/megatron/core/_rank_utils.py index 68aaa7bcbbd..5d9200e526e 100644 --- a/megatron/core/_rank_utils.py +++ b/megatron/core/_rank_utils.py @@ -74,16 +74,23 @@ def safe_get_world_size() -> int: return 1 -def log_single_rank(logger: logging.Logger, *args: Any, rank: int = 0, **kwargs: Any) -> None: +def log_single_rank( + logger: logging.Logger, level: int, msg: object, *args: Any, rank: int = 0, **kwargs: Any +) -> None: """Log a message only on a single rank. If torch distributed is initialized, write log on only one rank. Args: logger: The logger to write the logs. - *args: All logging.Logger.log positional arguments. + level: Logging level for the message. + msg: Message format string. + *args: Message format arguments. rank: The rank to write on. Defaults to 0. - **kwargs: All logging.Logger.log keyword arguments. + **kwargs: Additional ``logging.Logger.log`` keyword arguments. """ + if not logger.isEnabledFor(level): + return + if safe_get_rank() == rank: - logger.log(*args, **kwargs) + logger.log(level, msg, *args, **kwargs) diff --git a/megatron/core/datasets/blended_megatron_dataset_builder.py b/megatron/core/datasets/blended_megatron_dataset_builder.py index f728fe10d03..f3a6942260f 100644 --- a/megatron/core/datasets/blended_megatron_dataset_builder.py +++ b/megatron/core/datasets/blended_megatron_dataset_builder.py @@ -57,7 +57,10 @@ def __init__( log_single_rank( logger, logging.INFO, - f"Building {cls.__name__} splits with sizes={self.sizes} and config={self.config}", + "Building %s splits with sizes=%s and config=%s", + cls.__name__, + self.sizes, + self.config, ) if not self.config.mock: diff --git a/megatron/core/dist_checkpointing/exchange_utils.py b/megatron/core/dist_checkpointing/exchange_utils.py index 7c7863532f6..45eb3d6f2fd 100644 --- a/megatron/core/dist_checkpointing/exchange_utils.py +++ b/megatron/core/dist_checkpointing/exchange_utils.py @@ -11,6 +11,8 @@ import numpy as np import torch +from megatron.core._rank_utils import safe_get_rank + from .core import CheckpointingException from .dict_utils import nested_values from .mapping import ShardedStateDict, ShardedTensor, is_main_replica @@ -401,8 +403,6 @@ def exchange_loaded_tensors_gather_object( previously loaded tensors (from `loaded_tensors` input) """ - from ..utils import log_single_rank - all_loaded_tensors_list = [None] * torch.distributed.get_world_size(group=parallelization_group) torch.distributed.all_gather_object( all_loaded_tensors_list, loaded_tensors, group=parallelization_group @@ -413,18 +413,18 @@ def exchange_loaded_tensors_gather_object( # Error checks if len(all_loaded_tensors) != sum(map(len, all_loaded_tensors_list)): err_msg = "Duplicate shard ids loaded by different ranks" - log_single_rank( - logger, - logging.ERROR, - f"{err_msg}. Shards ids by rank:" f" {[lt.keys() for lt in all_loaded_tensors_list]}", - ) + if logger.isEnabledFor(logging.ERROR) and safe_get_rank() == 0: + shard_ids_by_rank = [ + rank_loaded_tensors.keys() for rank_loaded_tensors in all_loaded_tensors_list + ] + logger.error("%s. Shards ids by rank: %s", err_msg, shard_ids_by_rank) raise CheckpointingException(err_msg) return all_loaded_tensors def exchange_loaded_objects_gather_object( - loaded_objects: Dict[_ShardId, Any] + loaded_objects: Dict[_ShardId, Any], ) -> Dict[_ShardId, Any]: """Exchange the objects loaded by different ranks with a simple all_gather_object call. @@ -436,8 +436,6 @@ def exchange_loaded_objects_gather_object( Dict[_ShardId, Any]: dictionary mapping shard ids to objects needed by this rank to load a given state dict. """ - from ..utils import log_single_rank - all_loaded_objects_list = [None] * torch.distributed.get_world_size() torch.distributed.all_gather_object(all_loaded_objects_list, loaded_objects, group=None) all_loaded_objects_list = cast(List[Dict[_ShardId, Any]], all_loaded_objects_list) @@ -446,11 +444,11 @@ def exchange_loaded_objects_gather_object( # Error checks if len(all_loaded_objects) != sum(map(len, all_loaded_objects_list)): err_msg = "Duplicate shard ids loaded by different ranks" - log_single_rank( - logger, - logging.ERROR, - f"{err_msg}. Shards ids by rank:" f" {[lt.keys() for lt in all_loaded_objects_list]}", - ) + if logger.isEnabledFor(logging.ERROR) and safe_get_rank() == 0: + shard_ids_by_rank = [ + rank_loaded_objects.keys() for rank_loaded_objects in all_loaded_objects_list + ] + logger.error("%s. Shards ids by rank: %s", err_msg, shard_ids_by_rank) raise CheckpointingException(err_msg) return all_loaded_objects diff --git a/megatron/core/dist_checkpointing/mapping.py b/megatron/core/dist_checkpointing/mapping.py index dfe7e7df55b..a43eed98ecf 100644 --- a/megatron/core/dist_checkpointing/mapping.py +++ b/megatron/core/dist_checkpointing/mapping.py @@ -535,7 +535,7 @@ def apply_factory_merges( f"Cannot merge two lists with different lengths " f"({len(x1)} and {len(x2)}, encountered at key {key})" ) - logger.error(err_msg + f"\nx1: {x1}\nx2: {x2}") + logger.error("%s\nx1: %s\nx2: %s", err_msg, x1, x2) raise ValueError(err_msg) for i, v2 in enumerate(x2): x1[i] = apply_factory_merges(x1[i], v2, key=key + (i,)) diff --git a/megatron/core/dist_checkpointing/optimizer.py b/megatron/core/dist_checkpointing/optimizer.py index 69227f1ab66..a435eb851e2 100644 --- a/megatron/core/dist_checkpointing/optimizer.py +++ b/megatron/core/dist_checkpointing/optimizer.py @@ -1,6 +1,6 @@ # Copyright (c) 2022-2023, NVIDIA CORPORATION. All rights reserved. -""" Helpers for defining sharding for optimizer states based on existing sharding +"""Helpers for defining sharding for optimizer states based on existing sharding for model parameters. """ @@ -67,7 +67,7 @@ def get_param_id_to_sharded_param_map( if id(ten.data) in param_to_id_map: id_to_sharded_param_map[param_to_id_map[id(ten.data)]] = ten else: - logger.debug(f'{ten} is not tracked by the optimizer') + logger.debug('%s is not tracked by the optimizer', ten) if not id_to_sharded_param_map: log_single_rank( diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/param_and_grad_buffer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/param_and_grad_buffer.py index 47b36a1d223..714652dd54a 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/param_and_grad_buffer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/param_and_grad_buffer.py @@ -54,6 +54,7 @@ get_mcore_tensor_parallel_partition_dim, is_mcore_tensor_parallel_duplicated, log_single_rank, + safe_get_rank, using_tensor_parallel, ) @@ -837,7 +838,7 @@ def allocate( # If the bucket is not eligible for fixed pool buffering, or no buffer is available, # fall back to dynamic allocation via the backup allocator. This means that we # will do dynamic memory allocation. - logging.debug(f"[FSDP] Using backup allocator for {bucket_id} {fsdp_unit_id}") + logging.debug("[FSDP] Using backup allocator for %s %s", bucket_id, fsdp_unit_id) return self.backup_allocator.allocate( bucket_id=bucket_id, size=size, dtype=dtype, device=device ) @@ -876,7 +877,9 @@ def free(self, bucket_id: int): return if self.fallback_to_persistent_buffer is False: # If not managed by fixed pool allocator, delegate to the backup allocator. - logging.debug(f"[FSDP] Free from the backup allocator for {bucket_id} {fsdp_unit_id}") + logging.debug( + "[FSDP] Free from the backup allocator for %s %s", bucket_id, fsdp_unit_id + ) self.backup_allocator.free(bucket_id) @@ -1129,8 +1132,9 @@ def allocate( # fall back to dynamic allocation via the backup allocator. This means that we # will do dynamic memory allocation. logging.debug( - "[MaxPoolAllocator] Using backup allocator for " - f"Bucket ID {bucket_id} in FSDP Unit {fsdp_unit_id}." + "[MaxPoolAllocator] Using backup allocator for Bucket ID %s in FSDP Unit %s.", + bucket_id, + fsdp_unit_id, ) return self.backup_allocator.allocate( bucket_id=bucket_id, size=size, dtype=dtype, device=device @@ -1172,8 +1176,9 @@ def free(self, bucket_id: int): if self.fallback_to_persistent_buffer is False: # If not persistent, free the storage allocated by the backup allocator. logging.debug( - "[MaxPoolAllocator] Free backup allocation for " - f"Bucket ID {bucket_id} in FSDP Unit {fsdp_unit_id}." + "[MaxPoolAllocator] Free backup allocation for Bucket ID %s in FSDP Unit %s.", + bucket_id, + fsdp_unit_id, ) self.backup_allocator.free(bucket_id) @@ -2256,6 +2261,8 @@ def manual_buffer_registration(self): def _log_parameter_groups(self): """Compact log of FSDP parameter groups and their parameters.""" + if not logger.isEnabledFor(logging.INFO) or safe_get_rank() != 0: + return def _bytes_to_mb(bytes_val: int) -> str: return f"{bytes_val / 1_000_000:.2f} MB" @@ -2301,7 +2308,7 @@ def _bytes_to_mb(bytes_val: int) -> str: f"Total pad: {_bytes_to_mb(total_padded_bytes)}" ) - log_single_rank(logger, logging.INFO, "\n".join(log_lines)) + logger.info("\n".join(log_lines)) def _resolve_group_grad_dtype( self, group: "ParameterGroup", meta_device_init_fp8_params: Dict[str, Tuple[bool, bool]] @@ -2802,24 +2809,25 @@ def _init_each_parameter_group_buffers(self, meta_device_init_fp8_params): for p in m.parameters(recurse=False): self.param_to_direct_module[p] = (name, m) - meta_params_numel = 0 - cuda_params_numel = 0 - cpu_params_numel = 0 - for group in self.parameter_groups: - for p in group.params: - p_numel = to_local_if_dtensor(p).shape.numel() - if p.is_meta: - meta_params_numel += p_numel - elif p.device.type == "cuda": - cuda_params_numel += p_numel - else: - cpu_params_numel += p_numel - log_str = ( - f"Meta params numel: {meta_params_numel / 1_000_000:.2f} M, " - f"CUDA params numel: {cuda_params_numel / 1_000_000:.2f} M, " - f"CPU params numel: {cpu_params_numel / 1_000_000:.2f} M" - ) - log_single_rank(logger, logging.INFO, log_str) + if logger.isEnabledFor(logging.INFO) and safe_get_rank() == 0: + meta_params_numel = 0 + cuda_params_numel = 0 + cpu_params_numel = 0 + for group in self.parameter_groups: + for p in group.params: + p_numel = to_local_if_dtensor(p).shape.numel() + if p.is_meta: + meta_params_numel += p_numel + elif p.device.type == "cuda": + cuda_params_numel += p_numel + else: + cpu_params_numel += p_numel + log_str = ( + f"Meta params numel: {meta_params_numel / 1_000_000:.2f} M, " + f"CUDA params numel: {cuda_params_numel / 1_000_000:.2f} M, " + f"CPU params numel: {cpu_params_numel / 1_000_000:.2f} M" + ) + logger.info(log_str) # Initialize the model weight buffer data of each parameter group. # Specifically, replace the Torch module's parameter data with tensors diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py index 1b13cdbe3cc..5761f0f3100 100644 --- a/megatron/core/distributed/param_and_grad_buffer.py +++ b/megatron/core/distributed/param_and_grad_buffer.py @@ -1412,28 +1412,27 @@ def _create_bucket(bucket_id, bucket_params, bucket_params_with_extra_main_grads _create_bucket(cur_bucket_id, bucket_params, bucket_params_with_extra_main_grads) ) # Log buckets for all PP stages. - log_strs = [] - log_strs.append( - f"Number of buckets for gradient all-reduce / reduce-scatter: {len(self.buckets)}" - ) - for index, bucket in enumerate(self.buckets): - numel = 0 - for param in bucket.params_list: - numel += param.data.nelement() + if ( + logger.isEnabledFor(logging.INFO) + and self.tp_group.rank() == 0 + and self.dp_cp_group.rank() == 0 + ): + log_strs = [] log_strs.append( - f"Params for bucket {index + 1} ({numel} elements, " - f"{bucket.grad_data.nelement()} padded size, " - f"{len(bucket.params_with_extra_main_grads)} param(s) with extra main_grads):" + f"Number of buckets for gradient all-reduce / reduce-scatter: {len(self.buckets)}" ) - for param in bucket.params_list: - log_strs.append(f"\t{param_to_name[param]} ({param.main_grad.dtype=})") - log_on_each_pipeline_stage( - logger, - logging.INFO, - "\n".join(log_strs), - tp_group=self.tp_group, - dp_cp_group=self.dp_cp_group, - ) + for index, bucket in enumerate(self.buckets): + numel = 0 + for param in bucket.params_list: + numel += param.data.nelement() + log_strs.append( + f"Params for bucket {index + 1} ({numel} elements, " + f"{bucket.grad_data.nelement()} padded size, " + f"{len(bucket.params_with_extra_main_grads)} param(s) with extra main_grads):" + ) + for param in bucket.params_list: + log_strs.append(f"\t{param_to_name[param]} ({param.main_grad.dtype=})") + logger.info("\n".join(log_strs)) def _compute_nvfp4_packed_layout(self, params_with_names): """Derive packed NVFP4 index map and bucket indices from the primary layout. diff --git a/megatron/core/hyper_comm_grid.py b/megatron/core/hyper_comm_grid.py index ad1546608b5..038232e688e 100644 --- a/megatron/core/hyper_comm_grid.py +++ b/megatron/core/hyper_comm_grid.py @@ -261,12 +261,16 @@ def create_pg( if dist.is_initialized() and dist.get_rank() == 0: if self._is_base_pg_key(unique_group_key): logging.info( - f"Generated process group for {unique_group_key} with enumeration {rank_enum}" + "Generated process group for %s with enumeration %s", + unique_group_key, + rank_enum, ) else: logging.info( - f"Generated process group for view {view_spec.name!r} {ordered_dims} with " - f"enumeration {rank_enum}" + "Generated process group for view %r %s with enumeration %s", + view_spec.name, + ordered_dims, + rank_enum, ) self._pgs[unique_group_key] = pg return pg diff --git a/megatron/core/pipeline_parallel/bridge_communicator.py b/megatron/core/pipeline_parallel/bridge_communicator.py index b7f85e36c56..94b14317842 100644 --- a/megatron/core/pipeline_parallel/bridge_communicator.py +++ b/megatron/core/pipeline_parallel/bridge_communicator.py @@ -10,6 +10,8 @@ from megatron.core.hyper_comm_grid import HyperCommGrid +logger = logging.getLogger() + class CommRole(Enum): """Communication role for ranks in bridge communication. @@ -167,14 +169,15 @@ def __init__( bridge_ranks = sorted(set(self.src_tp_leaders) | set(self.dest_tp_leaders)) self.bridge_pg = self._get_or_create_bridge_pg(bridge_ranks) - log_msg = ( - f"[Rank {self.current_rank}] " - f"srcLeader={self.src_local_leader_rank} " - f"destLeader={self.dest_local_leader_rank} " - f"srcBroadcastGrpRanks={self.src_grid_broadcast_ranks} " - f"destBroadcastGrpRanks={self.dest_grid_broadcast_ranks}" + logger.info( + "[Rank %s] srcLeader=%s destLeader=%s " + "srcBroadcastGrpRanks=%s destBroadcastGrpRanks=%s", + self.current_rank, + self.src_local_leader_rank, + self.dest_local_leader_rank, + self.src_grid_broadcast_ranks, + self.dest_grid_broadcast_ranks, ) - logging.info(log_msg) self.build_comm_map(self.src_tp_leaders, self.dest_tp_leaders) dist.barrier() @@ -376,9 +379,10 @@ def send_forward(self, tensor_to_send: torch.Tensor): tensor_splits = self._split_tensor_at_batch_dim(tensor_to_send, num_sends) self._communicate_shapes(tensor_to_send_next=tensor_splits) for dest_rank, tensor_split in zip(rank_info.send_to_ranks, tensor_splits): - logging.debug( - f"[Bridge Comunicator] [send_forward] Rank {self.current_rank} " - f"send to rank {dest_rank}" + logger.debug( + "[Bridge Comunicator] [send_forward] Rank %s send to rank %s", + self.current_rank, + dest_rank, ) dist.send(tensor_split, dst=dest_rank, group=self.bridge_pg) @@ -400,10 +404,13 @@ def recv_forward(self) -> torch.Tensor: rank_info = self.comm_map.get(self.current_rank) assert rank_info is not None, f"Rank {self.current_rank} is not in the comm map" - logging.debug( - f"[Bridge Communicator] [receive_forward] Rank {self.current_rank} " - f"[src - {self.src_module_name}] [dest - {self.dest_module_name}] " - f"rank_info: {rank_info}" + logger.debug( + "[Bridge Communicator] [receive_forward] Rank %s " + "[src - %s] [dest - %s] rank_info: %s", + self.current_rank, + self.src_module_name, + self.dest_module_name, + rank_info, ) if rank_info.role == CommRole.RECEIVER: assert ( @@ -411,9 +418,12 @@ def recv_forward(self) -> torch.Tensor: ), f"Rank {self.current_rank} is not the leader rank" # p2p call to receive the tensor recv_forward_shapes, recv_grad_shapes = self._communicate_shapes(recv_prev=True) - logging.debug( - f"[Bridge Communicator] [receive_forward] Rank {self.current_rank} " - f"received forward shapes {recv_forward_shapes} and grad shapes {recv_grad_shapes}" + logger.debug( + "[Bridge Communicator] [receive_forward] Rank %s " + "received forward shapes %s and grad shapes %s", + self.current_rank, + recv_forward_shapes, + recv_grad_shapes, ) received_tensors_list = [] for src_rank, shape in zip(rank_info.recv_from_ranks, recv_forward_shapes): @@ -424,17 +434,25 @@ def recv_forward(self) -> torch.Tensor: requires_grad=True, ) dist.recv(tensor_to_recv, src=src_rank, group=self.bridge_pg) - logging.debug( - f"[Bridge Communicator] [receive_forward] Rank {self.current_rank} " - f"received tensor from src rank {src_rank} " - f"shape {tensor_to_recv.shape} sum {tensor_to_recv.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [receive_forward] Rank %s " + "received tensor from src rank %s shape %s sum %s", + self.current_rank, + src_rank, + tensor_to_recv.shape, + tensor_to_recv.sum(), + ) received_tensors_list.append(tensor_to_recv) aggregated_tensor = torch.cat(received_tensors_list, dim=self._batch_dim) - logging.debug( - f"[Bridge Communicator] [receive_forward] Rank {self.current_rank} " - f"broadcasting tensor {aggregated_tensor.shape} sum {aggregated_tensor.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [receive_forward] Rank %s " + "broadcasting tensor %s sum %s", + self.current_rank, + aggregated_tensor.shape, + aggregated_tensor.sum(), + ) # Step 1: broadcast its shape so receivers can allocate shape_tensor = torch.tensor( @@ -474,9 +492,11 @@ def recv_forward(self) -> torch.Tensor: received_tensor, src=self.dest_local_leader_rank, group=self.dest_grid_broadcast_pg ) - logging.debug( - f"[Bridge Communicator] [receive_forward] Rank {self.current_rank} " - f"received tensor via broadcast, shape {received_tensor.shape}" + logger.debug( + "[Bridge Communicator] [receive_forward] Rank %s " + "received tensor via broadcast, shape %s", + self.current_rank, + received_tensor.shape, ) return received_tensor @@ -508,11 +528,15 @@ def send_backward(self, grad_tensor: torch.Tensor): if num_receives > 0: for src_rank, tensor_split in zip(rank_info.recv_from_ranks, tensor_splits): # Send the gradient split back to the source rank - logging.debug( - f"[Bridge Communicator] [send_backward] Rank {self.current_rank} " - f"sending gradient to src rank {src_rank} " - f"shape {tensor_split.shape} sum {tensor_split.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [send_backward] Rank %s " + "sending gradient to src rank %s shape %s sum %s", + self.current_rank, + src_rank, + tensor_split.shape, + tensor_split.sum(), + ) dist.send(tensor_split, dst=src_rank, group=self.bridge_pg) def recv_backward(self) -> torch.Tensor: @@ -541,9 +565,12 @@ def recv_backward(self) -> torch.Tensor: self.current_rank == self.src_local_leader_rank ), f"Rank {self.current_rank} is not the leader rank" recv_forward_shapes, recv_grad_shapes = self._communicate_shapes(recv_next=True) - logging.debug( - f"[Bridge Communicator] [receive_backward] Rank {self.current_rank} " - f"received forward shapes {recv_forward_shapes} and grad shapes {recv_grad_shapes}" + logger.debug( + "[Bridge Communicator] [receive_backward] Rank %s " + "received forward shapes %s and grad shapes %s", + self.current_rank, + recv_forward_shapes, + recv_grad_shapes, ) # Receive gradient tensors from destination ranks received_gradients_list = [] @@ -553,19 +580,26 @@ def recv_backward(self) -> torch.Tensor: grad_shape, device=torch.cuda.current_device(), dtype=self.comm_dtype ) dist.recv(grad_tensor, src=dest_rank, group=self.bridge_pg) - logging.debug( - f"[Bridge Communicator] [receive_backward] Rank {self.current_rank} " - f"received gradient from dest rank {dest_rank} " - f"shape {grad_tensor.shape} sum {grad_tensor.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [receive_backward] Rank %s " + "received gradient from dest rank %s shape %s sum %s", + self.current_rank, + dest_rank, + grad_tensor.shape, + grad_tensor.sum(), + ) received_gradients_list.append(grad_tensor) # Concatenate received gradients aggregated_gradient = torch.cat(received_gradients_list, dim=self._batch_dim) - logging.debug( - f"[Bridge Communicator] [receive_backward] Rank {self.current_rank} " - f"agg grad shape {aggregated_gradient.shape} sum {aggregated_gradient.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [receive_backward] Rank %s agg grad shape %s sum %s", + self.current_rank, + aggregated_gradient.shape, + aggregated_gradient.sum(), + ) shape_tensor = torch.tensor( aggregated_gradient.shape, device=torch.cuda.current_device(), dtype=torch.int64 @@ -590,11 +624,12 @@ def recv_backward(self) -> torch.Tensor: shape_tensor, src=self.src_local_leader_rank, group=self.src_grid_broadcast_pg ) - logging.debug( - f"[Bridge Communicator] [receive_backward] Rank {self.current_rank} " - f"received shape tensor {shape_tensor}" - ) received_shape = tuple(shape_tensor.tolist()) + logger.debug( + "[Bridge Communicator] [receive_backward] Rank %s received shape tensor %s", + self.current_rank, + shape_tensor, + ) received_gradient = torch.empty( received_shape, device=torch.cuda.current_device(), dtype=self.comm_dtype ) @@ -602,9 +637,11 @@ def recv_backward(self) -> torch.Tensor: dist.broadcast( received_gradient, src=self.src_local_leader_rank, group=self.src_grid_broadcast_pg ) - logging.debug( - f"[Bridge Communicator] [receive_backward] Rank {self.current_rank} " - f"received gradient from scatter operation, shape {received_gradient.shape}" + logger.debug( + "[Bridge Communicator] [receive_backward] Rank %s " + "received gradient from scatter operation, shape %s", + self.current_rank, + received_gradient.shape, ) return received_gradient @@ -628,10 +665,13 @@ def send_forward_recv_backward( rank_info = self.comm_map.get(self.current_rank) assert rank_info is not None, f"Rank {self.current_rank} is not in the comm map" - logging.debug( - f"[Bridge Communicator] [send_forward_recv_backward] Rank {self.current_rank} " - f"[src - {self.src_module_name}] [dest - {self.dest_module_name}] " - f"rank_info: {rank_info}" + logger.debug( + "[Bridge Communicator] [send_forward_recv_backward] Rank %s " + "[src - %s] [dest - %s] rank_info: %s", + self.current_rank, + self.src_module_name, + self.dest_module_name, + rank_info, ) if rank_info.role == CommRole.SENDER: assert ( @@ -644,9 +684,12 @@ def send_forward_recv_backward( recv_forward_shapes, recv_grad_shapes = self._communicate_shapes( tensor_to_send_next=activation_splits, recv_next=True ) - logging.debug( - f"[Bridge Communicator] [send_forward_recv_backward] Rank {self.current_rank} " - f"received forward shapes {recv_forward_shapes} and grad shapes {recv_grad_shapes}" + logger.debug( + "[Bridge Communicator] [send_forward_recv_backward] Rank %s " + "received forward shapes %s and grad shapes %s", + self.current_rank, + recv_forward_shapes, + recv_grad_shapes, ) # Prepare simultaneous send/receive operations @@ -677,9 +720,11 @@ def send_forward_recv_backward( ) ) - logging.debug( - f"[Bridge Communicator] [send_forward_recv_backward] Rank {self.current_rank} " - f"executing {len(ops)} simultaneous P2P operations" + logger.debug( + "[Bridge Communicator] [send_forward_recv_backward] Rank %s " + "executing %s simultaneous P2P operations", + self.current_rank, + len(ops), ) reqs = torch.distributed.batch_isend_irecv(ops) for req in reqs: @@ -687,10 +732,14 @@ def send_forward_recv_backward( # Concatenate received gradients aggregated_gradient = torch.cat(received_gradients_list, dim=self._batch_dim) - logging.debug( - f"[Bridge Communicator] [send_forward_recv_backward] Rank {self.current_rank} " - f"agg grad shape {aggregated_gradient.shape} sum {aggregated_gradient.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [send_forward_recv_backward] Rank %s " + "agg grad shape %s sum %s", + self.current_rank, + aggregated_gradient.shape, + aggregated_gradient.sum(), + ) # Broadcast tensor shape to all ranks in scatter_pg tensor_shape_to_broadcast = aggregated_gradient.shape shape_tensor = torch.tensor( @@ -727,9 +776,11 @@ def send_forward_recv_backward( dist.broadcast( received_gradient, src=self.src_local_leader_rank, group=self.src_grid_broadcast_pg ) - logging.debug( - f"[Bridge Communicator] [send_forward_recv_backward] Rank {self.current_rank} " - f"received gradient from broadcast, shape {received_gradient.shape}" + logger.debug( + "[Bridge Communicator] [send_forward_recv_backward] Rank %s " + "received gradient from broadcast, shape %s", + self.current_rank, + received_gradient.shape, ) return received_gradient @@ -765,9 +816,12 @@ def send_backward_recv_forward( recv_forward_shapes, recv_grad_shapes = self._communicate_shapes( tensor_to_send_prev=gradient_splits, recv_prev=True ) - logging.debug( - f"[Bridge Communicator] [send_backward_recv_backward] Rank {self.current_rank} " - f"received forward shapes {recv_forward_shapes} and grad shapes {recv_grad_shapes}" + logger.debug( + "[Bridge Communicator] [send_backward_recv_backward] Rank %s " + "received forward shapes %s and grad shapes %s", + self.current_rank, + recv_forward_shapes, + recv_grad_shapes, ) # Prepare simultaneous send/receive operations @@ -803,9 +857,11 @@ def send_backward_recv_forward( ) # Execute all operations simultaneously - logging.debug( - f"[Bridge Communicator] [send_backward_recv_backward] Rank {self.current_rank} " - f"executing {len(ops)} simultaneous P2P operations" + logger.debug( + "[Bridge Communicator] [send_backward_recv_backward] Rank %s " + "executing %s simultaneous P2P operations", + self.current_rank, + len(ops), ) reqs = torch.distributed.batch_isend_irecv(ops) for req in reqs: @@ -813,10 +869,14 @@ def send_backward_recv_forward( # Concatenate received activations aggregated_activation = torch.cat(received_activations_list, dim=self._batch_dim) - logging.debug( - f"[Bridge Communicator] [send_backward_recv_forward] Rank {self.current_rank} " - f"agg act shape {aggregated_activation.shape} sum {aggregated_activation.sum()}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "[Bridge Communicator] [send_backward_recv_forward] Rank %s " + "agg act shape %s sum %s", + self.current_rank, + aggregated_activation.shape, + aggregated_activation.sum(), + ) # Broadcast tensor shape to all ranks in scatter_pg tensor_shape_to_scatter = aggregated_activation.shape @@ -857,9 +917,11 @@ def send_backward_recv_forward( src=self.dest_local_leader_rank, group=self.dest_grid_broadcast_pg, ) - logging.debug( - f"[Bridge Communicator] [send_backward_recv_backward] Rank {self.current_rank} " - f"received activation from scatter operation, shape {received_activation.shape}" + logger.debug( + "[Bridge Communicator] [send_backward_recv_backward] Rank %s " + "received activation from scatter operation, shape %s", + self.current_rank, + received_activation.shape, ) return received_activation @@ -894,9 +956,11 @@ def _communicate_shapes( recv_forward_shapes = [] recv_grad_shapes = [] - logging.debug( - f"[Bridge Communicator] [communicate_shapes] Rank {self.current_rank} " - f"is a {rank_info.role} and is running the shape communication" + logger.debug( + "[Bridge Communicator] [communicate_shapes] Rank %s " + "is a %s and is running the shape communication", + self.current_rank, + rank_info.role, ) # Collect all P2P operations for batch execution ops = [] diff --git a/megatron/core/pipeline_parallel/multimodule_communicator.py b/megatron/core/pipeline_parallel/multimodule_communicator.py index b2e5682a29d..c27d3f753e2 100644 --- a/megatron/core/pipeline_parallel/multimodule_communicator.py +++ b/megatron/core/pipeline_parallel/multimodule_communicator.py @@ -46,7 +46,7 @@ class RankModuleInfo: def _prepare_tensor_for_comm( - tensor: Union[torch.Tensor, List[torch.Tensor], None] + tensor: Union[torch.Tensor, List[torch.Tensor], None], ) -> Union[torch.Tensor, List[torch.Tensor], None]: """Prepare tensor for P2P communication by expanding to 3D if needed. @@ -82,7 +82,7 @@ def _prepare_tensor_for_comm( def _restore_tensor_from_comm( - tensor: Union[torch.Tensor, List[torch.Tensor], None] + tensor: Union[torch.Tensor, List[torch.Tensor], None], ) -> Union[torch.Tensor, List[torch.Tensor], None]: """Restore tensor shape after P2P communication by squeezing singleton dim. @@ -269,11 +269,15 @@ def current_stage(self) -> int: stage = 0 assert stage < total, f"current_stage: {stage} must be less than total_stages: {total}" - logging.debug( - f"[Rank {dist.get_rank()} ][MultiModulePipelineCommunicator] " - f"current_stage: {stage} total_stages: {total} " - f"num_warmup_microbatches: {total - stage - 1}" - ) + if logging.getLogger().isEnabledFor(logging.DEBUG): + logging.debug( + "[Rank %s ][MultiModulePipelineCommunicator] " + "current_stage: %s total_stages: %s num_warmup_microbatches: %s", + dist.get_rank(), + stage, + total, + total - stage - 1, + ) return stage def _build_rank_module_info_map(self): @@ -331,10 +335,14 @@ def recv_forward( Returns: A dictionary mapping module names to tensors. """ - logging.debug( - f"[Rank {dist.get_rank()} ][MultiModulePipelineCommunicator] " - f"[receive_forward] tensors_shape: {tensor_shape}, is_first_stage: {is_first_stage}" - ) + if logging.getLogger().isEnabledFor(logging.DEBUG): + logging.debug( + "[Rank %s ][MultiModulePipelineCommunicator] " + "[receive_forward] tensors_shape: %s, is_first_stage: %s", + dist.get_rank(), + tensor_shape, + is_first_stage, + ) input_dict = {} for module_name, rank_module_info in self.rank_module_map.items(): @@ -449,10 +457,14 @@ def recv_backward( Returns: A dictionary mapping module names to tensors. """ - logging.debug( - f"[Rank {dist.get_rank()} ][MultiModulePipelineCommunicator] " - f"[recv_backward] tensor_shape: {tensor_shape}, is_last_stage: {is_last_stage}" - ) + if logging.getLogger().isEnabledFor(logging.DEBUG): + logging.debug( + "[Rank %s ][MultiModulePipelineCommunicator] " + "[recv_backward] tensor_shape: %s, is_last_stage: %s", + dist.get_rank(), + tensor_shape, + is_last_stage, + ) grad_dict = {} for module_name, rank_module_info in self.rank_module_map.items(): if rank_module_info.pp_rank == rank_module_info.pp_size - 1: diff --git a/megatron/core/resharding/nvshmem_copy_service/logger.py b/megatron/core/resharding/nvshmem_copy_service/logger.py index a3c7c1699ad..b58673b5f84 100644 --- a/megatron/core/resharding/nvshmem_copy_service/logger.py +++ b/megatron/core/resharding/nvshmem_copy_service/logger.py @@ -158,45 +158,50 @@ def trace(cls, msg: str): cls._logger.log(logging.DEBUG - 5, msg) @classmethod - def debug(cls, msg: str): + def is_enabled_for(cls, level: int) -> bool: + """Return whether a message at ``level`` would be emitted.""" + return cls._logger is not None and cls._logger.isEnabledFor(level) + + @classmethod + def debug(cls, msg: str, *args): """Log at DEBUG level.""" if cls._logger: - cls._logger.debug(msg) + cls._logger.debug(msg, *args) @classmethod - def info(cls, msg: str): + def info(cls, msg: str, *args): """Log at INFO level.""" if cls._logger: - cls._logger.info(msg) + cls._logger.info(msg, *args) @classmethod - def summary(cls, msg: str): + def summary(cls, msg: str, *args): """Log summary information (INFO level with [SUMMARY] prefix).""" if cls._logger: - cls._logger.info(f"[SUMMARY] {msg}") + cls._logger.info("[SUMMARY] " + msg, *args) @classmethod - def warn(cls, msg: str): + def warn(cls, msg: str, *args): """Log at WARNING level.""" if cls._logger: - cls._logger.warning(msg) + cls._logger.warning(msg, *args) @classmethod - def warning(cls, msg: str): + def warning(cls, msg: str, *args): """Log at WARNING level (alias for warn).""" - cls.warn(msg) + cls.warn(msg, *args) @classmethod - def error(cls, msg: str): + def error(cls, msg: str, *args): """Log at ERROR level.""" if cls._logger: - cls._logger.error(msg) + cls._logger.error(msg, *args) @classmethod - def critical(cls, msg: str): + def critical(cls, msg: str, *args): """Log at CRITICAL level.""" if cls._logger: - cls._logger.critical(msg) + cls._logger.critical(msg, *args) @classmethod def shutdown(cls): diff --git a/megatron/core/resharding/nvshmem_copy_service/planning/communication_scheduler.py b/megatron/core/resharding/nvshmem_copy_service/planning/communication_scheduler.py index 629c9958db9..2444075d2b9 100644 --- a/megatron/core/resharding/nvshmem_copy_service/planning/communication_scheduler.py +++ b/megatron/core/resharding/nvshmem_copy_service/planning/communication_scheduler.py @@ -1,5 +1,6 @@ # Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +import logging from typing import Dict, List, Tuple from ..logger import PELogger @@ -79,8 +80,8 @@ def _collect_all_batches( for i, _ in enumerate(groups): local_batches.append((my_pe, dest_pe, i)) # (src, dest, batch_idx) - PELogger.debug(f" Local batch count: {len(local_batches)}") - PELogger.debug(f" Local batches: {local_batches}") + PELogger.debug(" Local batch count: %s", len(local_batches)) + PELogger.debug(" Local batches: %s", local_batches) # Gather all batches from all PEs using torch.distributed all_batches_list: List[List[Tuple[int, int, int]] | None] = [None] * n_pes @@ -96,14 +97,17 @@ def _collect_all_batches( ScheduledBatch(src_pe=src, dest_pe=dest, batch_index=idx, iteration=-1) ) - PELogger.debug(f" Global batches collected: {len(global_batches)} total") + PELogger.debug(" Global batches collected: %s total", len(global_batches)) # Group by source for readability - batches_by_src: Dict[int, List[Tuple[int, int]]] = {} - for b in global_batches: - batches_by_src.setdefault(b.src_pe, []).append((b.dest_pe, b.batch_index)) - for src_pe in sorted(batches_by_src.keys()): - PELogger.debug(f" PE {src_pe} sends to: {batches_by_src[src_pe]}") + if PELogger.is_enabled_for(logging.DEBUG): + batches_by_src: Dict[int, List[Tuple[int, int]]] = {} + for batch in global_batches: + batches_by_src.setdefault(batch.src_pe, []).append( + (batch.dest_pe, batch.batch_index) + ) + for src_pe in sorted(batches_by_src): + PELogger.debug(" PE %s sends to: %s", src_pe, batches_by_src[src_pe]) return global_batches diff --git a/megatron/core/resharding/nvshmem_copy_service/planning/workload_packer.py b/megatron/core/resharding/nvshmem_copy_service/planning/workload_packer.py index 1f2374bc187..897d2b7cc2e 100644 --- a/megatron/core/resharding/nvshmem_copy_service/planning/workload_packer.py +++ b/megatron/core/resharding/nvshmem_copy_service/planning/workload_packer.py @@ -1,5 +1,6 @@ # Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +import logging from typing import Dict, List from ..logger import PELogger @@ -37,16 +38,22 @@ def pack_workloads( tasks = tasks_by_dest[dest_pe] workloads[dest_pe] = self._pack_single_destination(tasks, dest_pe) - if workloads[dest_pe]: - total_size = sum(b.total_size for b in workloads[dest_pe]) - PELogger.debug( - f" Dest PE {dest_pe}: {len(tasks)} tasks → " - f"{len(workloads[dest_pe])} batches, {total_size} bytes total" - ) - else: - PELogger.debug( - f" Dest PE {dest_pe}: {len(tasks)} tasks → 0 batches (empty after packing)" - ) + if PELogger.is_enabled_for(logging.DEBUG): + if workloads[dest_pe]: + total_size = sum(b.total_size for b in workloads[dest_pe]) + PELogger.debug( + " Dest PE %s: %s tasks → %s batches, %s bytes total", + dest_pe, + len(tasks), + len(workloads[dest_pe]), + total_size, + ) + else: + PELogger.debug( + " Dest PE %s: %s tasks → 0 batches (empty after packing)", + dest_pe, + len(tasks), + ) return workloads @@ -70,11 +77,17 @@ def _pack_single_destination( if (would_exceed_size or would_exceed_task_cap) and current_batch.tasks: # Finalize current batch batches.append(current_batch) - task_first_10_string = ", ".join([str(t.task_id) for t in current_batch.tasks[:10]]) - PELogger.debug( - f" Packed batch to PE {dest_pe} idx {len(batches) - 1}: " - f"{task_first_10_string}... (total {len(current_batch.tasks)} tasks)" - ) + if PELogger.is_enabled_for(logging.DEBUG): + task_first_10_string = ", ".join( + str(task.task_id) for task in current_batch.tasks[:10] + ) + PELogger.debug( + " Packed batch to PE %s idx %s: %s... (total %s tasks)", + dest_pe, + len(batches) - 1, + task_first_10_string, + len(current_batch.tasks), + ) # Start new batch current_batch = WorkloadGroup(dest_pe=dest_pe, tasks=[], total_size=0) diff --git a/megatron/core/transformer/pipeline_parallel_layer_layout.py b/megatron/core/transformer/pipeline_parallel_layer_layout.py index 7a8195e1bee..d1b2339654e 100644 --- a/megatron/core/transformer/pipeline_parallel_layer_layout.py +++ b/megatron/core/transformer/pipeline_parallel_layer_layout.py @@ -7,6 +7,7 @@ from typing import Optional from megatron.core import parallel_state +from megatron.core._rank_utils import safe_get_rank from megatron.core.transformer.enums import LayerType logger = logging.getLogger(__name__) @@ -264,13 +265,12 @@ def from_str(layout, pipeline_model_parallel_size): """Parse the pipeline model parallel layout from a string.""" parsed_layout = PipelineParallelLayerLayout(layout, pipeline_model_parallel_size) # Pretty print the layout distribution. - from megatron.core.utils import log_single_rank - - log_single_rank( - logger, - logging.INFO, - f"Parse pipeline model parallel layout {layout} to:\n" + parsed_layout.pretty_repr(), - ) + if logger.isEnabledFor(logging.INFO) and safe_get_rank() == 0: + logger.info( + "Parse pipeline model parallel layout %s to:\n%s", + layout, + parsed_layout.pretty_repr(), + ) return parsed_layout @staticmethod diff --git a/megatron/core/utils.py b/megatron/core/utils.py index c29700eb709..c6b83f00d30 100644 --- a/megatron/core/utils.py +++ b/megatron/core/utils.py @@ -893,6 +893,8 @@ def mup_scaled_init_method_normal(sigma, num_layers, width_mult, multiplier=2.0) def log_on_each_pipeline_stage( logger: logging.Logger, + level: int, + msg: object, *args: Any, tp_group: Optional[torch.distributed.ProcessGroup] = None, dp_cp_group: Optional[torch.distributed.ProcessGroup] = None, @@ -903,23 +905,32 @@ def log_on_each_pipeline_stage( Args: logger (logging.Logger): The logger to write the logs - args (Tuple[Any]): All logging.Logger.log positional arguments + level (int): Logging level for the message. + + msg (object): Message format string. + + args (Tuple[Any]): Message format arguments. kwargs (Dict[str, Any]): All logging.Logger.log keyword arguments """ assert torch.distributed.is_initialized() + if (tp_group is None) != (dp_cp_group is None): + raise ValueError("tp_group and dp_cp_group must be provided or not provided together") + + if not logger.isEnabledFor(level): + return + if tp_group is None and dp_cp_group is None: tp_rank = parallel_state.get_tensor_model_parallel_rank() dp_cp_rank = parallel_state.get_data_parallel_rank(with_context_parallel=True) - elif tp_group is not None and dp_cp_group is not None: + else: + assert tp_group is not None and dp_cp_group is not None tp_rank = tp_group.rank() dp_cp_rank = dp_cp_group.rank() - else: - raise ValueError("tp_group and dp_cp_group must be provided or not provided together") if tp_rank == 0 and dp_cp_rank == 0: - logger.log(*args, **kwargs) + logger.log(level, msg, *args, **kwargs) def check_param_hashes_across_dp_replicas( diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index 88559933f97..eac1be5c856 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -15,7 +15,7 @@ from argparse import Namespace from datetime import datetime from enum import Enum, auto -from logging import getLogger +from logging import DEBUG, getLogger from pathlib import Path from time import time from typing import Any, Dict, List, Optional, Union @@ -104,9 +104,13 @@ def finalize_deletion_processes(blocking=False): finished = [] for proc in _deletion_processes: if not proc.is_alive() or blocking: - logger.debug( - f'Joining deletion process {proc.pid} (blocking={blocking}, is_alive={proc.is_alive()})' - ) + if logger.isEnabledFor(DEBUG): + logger.debug( + "Joining deletion process %s (blocking=%s, is_alive=%s)", + proc.pid, + blocking, + proc.is_alive(), + ) proc.join() finished.append(proc) for proc in finished: diff --git a/tests/unit_tests/test_rank_utils.py b/tests/unit_tests/test_rank_utils.py new file mode 100644 index 00000000000..b75e6ba589c --- /dev/null +++ b/tests/unit_tests/test_rank_utils.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +import logging +from unittest.mock import Mock, patch + +import pytest + +from megatron.core._rank_utils import log_single_rank +from megatron.core.utils import log_on_each_pipeline_stage + + +def test_log_single_rank_skips_rank_query_when_level_is_disabled(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = False + + with patch("megatron.core._rank_utils.safe_get_rank") as safe_get_rank: + log_single_rank(logger, logging.DEBUG, "message") + + logger.isEnabledFor.assert_called_once_with(logging.DEBUG) + safe_get_rank.assert_not_called() + logger.log.assert_not_called() + + +def test_log_single_rank_preserves_keyword_call(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = True + + with patch("megatron.core._rank_utils.safe_get_rank", return_value=3) as safe_get_rank: + log_single_rank( + logger=logger, level=logging.INFO, msg="message", rank=3, extra={"key": "value"} + ) + + logger.isEnabledFor.assert_called_once_with(logging.INFO) + safe_get_rank.assert_called_once_with() + logger.log.assert_called_once_with(logging.INFO, "message", extra={"key": "value"}) + + +def test_log_on_each_pipeline_stage_skips_group_queries_when_level_is_disabled(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = False + tp_group = Mock() + dp_cp_group = Mock() + + with patch("megatron.core.utils.torch.distributed.is_initialized", return_value=True): + log_on_each_pipeline_stage( + logger, logging.DEBUG, "message", tp_group=tp_group, dp_cp_group=dp_cp_group + ) + + logger.isEnabledFor.assert_called_once_with(logging.DEBUG) + tp_group.rank.assert_not_called() + dp_cp_group.rank.assert_not_called() + logger.log.assert_not_called() + + +def test_log_on_each_pipeline_stage_validates_group_pair_when_level_is_disabled(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = False + + with patch("megatron.core.utils.torch.distributed.is_initialized", return_value=True): + with pytest.raises( + ValueError, match="tp_group and dp_cp_group must be provided or not provided together" + ): + log_on_each_pipeline_stage(logger, logging.DEBUG, "message", tp_group=Mock()) + + logger.isEnabledFor.assert_not_called() + logger.log.assert_not_called() + + +def test_log_on_each_pipeline_stage_requires_distributed_when_level_is_disabled(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = False + + with patch("megatron.core.utils.torch.distributed.is_initialized", return_value=False): + with pytest.raises(AssertionError): + log_on_each_pipeline_stage(logger, logging.DEBUG, "message") + + logger.isEnabledFor.assert_not_called() + logger.log.assert_not_called() + + +def test_log_single_rank_suppresses_log_when_rank_does_not_match(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = True + + with patch("megatron.core._rank_utils.safe_get_rank", return_value=2): + log_single_rank(logger, logging.INFO, "message", rank=3) + + logger.isEnabledFor.assert_called_once_with(logging.INFO) + logger.log.assert_not_called() + + +def test_log_single_rank_forwards_format_args(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = True + + with patch("megatron.core._rank_utils.safe_get_rank", return_value=0): + log_single_rank(logger, logging.INFO, "value=%s", 42) + + logger.log.assert_called_once_with(logging.INFO, "value=%s", 42) + + +def test_log_on_each_pipeline_stage_logs_and_forwards_arguments_on_emitter(): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = True + tp_group = Mock() + tp_group.rank.return_value = 0 + dp_cp_group = Mock() + dp_cp_group.rank.return_value = 0 + + with patch("megatron.core.utils.torch.distributed.is_initialized", return_value=True): + log_on_each_pipeline_stage( + logger, + logging.INFO, + "value=%s", + 42, + tp_group=tp_group, + dp_cp_group=dp_cp_group, + extra={"key": "value"}, + ) + + logger.log.assert_called_once_with(logging.INFO, "value=%s", 42, extra={"key": "value"}) + + +@pytest.mark.parametrize("tp_rank,dp_cp_rank", [(1, 0), (0, 1), (1, 1)]) +def test_log_on_each_pipeline_stage_suppresses_log_on_non_emitter(tp_rank, dp_cp_rank): + logger = Mock(spec=logging.Logger) + logger.isEnabledFor.return_value = True + tp_group = Mock() + tp_group.rank.return_value = tp_rank + dp_cp_group = Mock() + dp_cp_group.rank.return_value = dp_cp_rank + + with patch("megatron.core.utils.torch.distributed.is_initialized", return_value=True): + log_on_each_pipeline_stage( + logger, logging.INFO, "message", tp_group=tp_group, dp_cp_group=dp_cp_group + ) + + logger.log.assert_not_called() From 8018459d7371aa8f127ecb050222867fd53499b5 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Tue, 11 Aug 2026 07:09:13 -0700 Subject: [PATCH 249/290] Fixes for SP + MTP all-gathers (#5748) Signed-off-by: Keshav Santhanam Signed-off-by: shanmugamr1992 Co-authored-by: shanmugamr1992 Co-authored-by: Claude Opus 4.7 --- .../torch_symm_triton/collectives.py | 28 +- .../text_generation_controller.py | 13 +- .../core/tensor_parallel/inference_layers.py | 77 ++++- .../transformer/multi_token_prediction.py | 30 +- .../golden_values_dev_dgx_gb200.json | 134 +++++++++ .../golden_values_dev_dgx_h100.json | 134 +++++++++ .../model_config.yaml | 118 ++++++++ .../recipes/gb200/moe-dynamic-inference.yaml | 10 + .../recipes/h100/moe-dynamic-inference.yaml | 12 + .../test_moe_dispatching_and_routing.py | 135 +++++++++ .../test_mtp_cuda_graph_inference.py | 4 + .../test_text_generation_controller.py | 265 +++++++++++++++++- 12 files changed, 944 insertions(+), 16 deletions(-) create mode 100644 tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_gb200.json create mode 100644 tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_h100.json create mode 100644 tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/model_config.yaml diff --git a/megatron/core/inference/communication/torch_symm_triton/collectives.py b/megatron/core/inference/communication/torch_symm_triton/collectives.py index 3120f032812..1b597f56fee 100644 --- a/megatron/core/inference/communication/torch_symm_triton/collectives.py +++ b/megatron/core/inference/communication/torch_symm_triton/collectives.py @@ -58,7 +58,7 @@ def _ag_phase( + (RANK * numel_per_rank + offsets) * 2 ) local_ptrs = local_ptr.to(tl.pointer_type(tl.uint64)) + offsets * 2 - (x, y, z, w) = ld_128(local_ptrs, mask=mask, multicast_op=False) + x, y, z, w = ld_128(local_ptrs, mask=mask, multicast_op=False) st_128(multicast_ptrs, x, y, z, w, mask=mask, multicast_op=True) block_start += tl.num_programs(axis=0) * BLOCK_SIZE @@ -75,8 +75,24 @@ def _multimem_all_gather_kernel( NUMEL_PER_THREAD: tl.constexpr, RANK: tl.constexpr, WORLD_SIZE: tl.constexpr, + BARRIER_BEFORE: tl.constexpr, ): """Single-tensor multicast all-gather kernel.""" + if BARRIER_BEFORE: + # Ensure every rank has finished reading the previous contents before + # this all-gather reuses the symmetric buffer. The usual RS -> AG + # sequence gets this ordering from the reduce-scatter; consecutive AGs + # must request it explicitly. + symm_mem_sync( + signal_pad_ptrs, + None, + RANK, + WORLD_SIZE, + hasPreviousMemAccess=True, + hasSubsequentMemAccess=True, + ) + sync_threads() + _ag_phase( local_ptr, multicast_ptr, byte_offset, numel, BLOCK_SIZE, NUMEL_PER_THREAD, RANK, WORLD_SIZE ) @@ -198,7 +214,7 @@ def _multimem_reduce_scatter_kernel( multicast_ptr.to(tl.pointer_type(tl.uint64)) + (RANK * numel_per_rank + offsets) * 2 ) local_ptrs = local_ptr.to(tl.pointer_type(tl.uint64)) + offsets * 2 - (x, y, z, w) = ld_128(multicast_ptrs, mask=mask, multicast_op=True, reduce_f32=REDUCE_F32) + x, y, z, w = ld_128(multicast_ptrs, mask=mask, multicast_op=True, reduce_f32=REDUCE_F32) st_128(local_ptrs, x, y, z, w, mask=mask, multicast_op=False) block_start += tl.num_programs(axis=0) * BLOCK_SIZE @@ -232,12 +248,19 @@ def multimem_all_gather( input_tensor: torch.Tensor, symm_mem_hdl: _SymmetricMemory, byte_offset: int = 0, + barrier_before: bool = False, **kwargs, ) -> torch.Tensor: """ Multicast all-gather for a single tensor. Output tensor must be a symmetric memory buffer. Input tensor can be a regular torch tensor. + + ``barrier_before`` inserts a pre-all-gather barrier so every rank has finished + reading the buffer's previous contents before this all-gather overwrites them. + It must be set by the caller whenever this all-gather directly follows another + all-gather on the same symmetric buffer with no reduce-scatter in between (a + reduce-scatter already establishes that ordering inside its own kernel). """ assert HAVE_TRITON, "Triton is required for multimem all-gather." assert are_tensors_nvls_eligible( @@ -261,6 +284,7 @@ def multimem_all_gather( NUMEL_PER_THREAD=numel_per_thread, RANK=symm_mem_hdl.rank, WORLD_SIZE=symm_mem_hdl.world_size, + BARRIER_BEFORE=barrier_before, num_warps=config["num_warps"], ) diff --git a/megatron/core/inference/text_generation_controllers/text_generation_controller.py b/megatron/core/inference/text_generation_controllers/text_generation_controller.py index 8b96633a580..0675002bbae 100644 --- a/megatron/core/inference/text_generation_controllers/text_generation_controller.py +++ b/megatron/core/inference/text_generation_controllers/text_generation_controller.py @@ -979,6 +979,17 @@ def _compute_serial_mtp_and_sample(self, base_position: Optional[Tensor] = None) # Get decoder hidden states at last accepted positions. hidden_states = context.mtp_decoder_hidden_states + # Block-scope CUDA graphs write into a persistent max_tokens-sized + # buffer. Only the prefix for this step is valid. Slice each rank's + # local SP shard before gathering; gathering the oversized buffer + # would place rank 0's stale tail between the valid rank shards. + if context.inference_cuda_graph_scope == InferenceCudaGraphScope.block: + local_token_count = context.padded_active_token_count + if self._sp_enabled: + assert local_token_count % self._tp_size == 0 + local_token_count //= self._tp_size + hidden_states = hidden_states[:local_token_count] + # When SP is active the decoder output is in scattered format # [S/TP, B, H], but _last_accepted_seq_indices are indices into # the full (gathered) sequence. @@ -3639,7 +3650,7 @@ def generate_all_output_tokens_static_batch( if sampling_params.num_tokens_to_generate > 0: # Check end of generation status for each tensor # and update generated sequence lengths - (is_generation_done_tensor, generated_sequence_lengths) = ( + is_generation_done_tensor, generated_sequence_lengths = ( self.update_generation_status( updated_prompts_tokens=batch_prompt_tokens, generation_started=generation_started, diff --git a/megatron/core/tensor_parallel/inference_layers.py b/megatron/core/tensor_parallel/inference_layers.py index 1bf0ea8e74a..102cdbcb4b9 100644 --- a/megatron/core/tensor_parallel/inference_layers.py +++ b/megatron/core/tensor_parallel/inference_layers.py @@ -178,12 +178,27 @@ def __init__( self.triton_nvls_kernels_allowed = not config.inference_disable_triton_nvls_kernels + # Explicit toggle for the pre-all-gather buffer-reuse barrier. Left False by + # default; a caller sets it True when this layer's input all-gather directly + # follows another all-gather on the shared symmetric buffer with no + # reduce-scatter in between (e.g. the MTP eh_proj projection). + self.barrier_before_all_gather = False + # Boolean to be toggled externally for skipping norm and all-gather. # This is used when enabling fused reduce-scatter + add + rms-norm + all-gather # in tensor parallelism. In this case, the preceeding RowParallelLinear layer # has already applied the rms-norm and all-gather. self.skip_norm_and_all_gather = False + def set_barrier_before_all_gather(self, value: bool = True) -> None: + """Request a barrier before this layer's input all-gather reuses the buffer. + + Set by callers whose op sequence places another all-gather on the shared + symmetric buffer immediately before this layer's all-gather (e.g. the MTP + eh_proj projection), so the kernel synchronizes ranks before overwriting. + """ + self.barrier_before_all_gather = value + def _maybe_allocate_symmetric_buffer(self, x: torch.Tensor): """ Attempt to allocate symmetric memory buffer for all-gather. @@ -210,8 +225,14 @@ def _all_gather(self, x: torch.Tensor, symm_mem_buffer: dict) -> None: and symm_mem_buffer["handle"] is not None ) if can_use_nvls: - # do multimem all gather - multimem_all_gather(symm_mem_buffer["tensor"], x, symm_mem_buffer["handle"]) + # do multimem all gather; barrier before reusing the buffer only when this + # all-gather follows another all-gather on it (see barrier_before_all_gather). + multimem_all_gather( + symm_mem_buffer["tensor"], + x, + symm_mem_buffer["handle"], + barrier_before=self.barrier_before_all_gather, + ) return symm_mem_buffer["tensor"] else: # revert to torch dist (NCCL) all gather @@ -307,6 +328,21 @@ def __init__( self.triton_nvls_kernels_allowed = not config.inference_disable_triton_nvls_kernels + # Explicit toggle for the pre-all-gather buffer-reuse barrier. Left False by + # default; a caller sets it True when this layer's input all-gather directly + # follows another all-gather on the shared symmetric buffer with no + # reduce-scatter in between (e.g. the MTP eh_proj projection). + self.barrier_before_all_gather = False + + def set_barrier_before_all_gather(self, value: bool = True) -> None: + """Request a barrier before this layer's input all-gather reuses the buffer. + + Set by callers whose op sequence places another all-gather on the shared + symmetric buffer immediately before this layer's all-gather (e.g. the MTP + eh_proj projection), so the kernel synchronizes ranks before overwriting. + """ + self.barrier_before_all_gather = value + def _maybe_allocate_symmetric_buffer(self, x: torch.Tensor): """ Attempt to allocate symmetric memory buffer for all-gather. @@ -331,7 +367,14 @@ def _all_gather(self, x: torch.Tensor, symm_mem_buffer: dict) -> None: and symm_mem_buffer["handle"] is not None ) if can_use_nvls: - multimem_all_gather(symm_mem_buffer["tensor"], x, symm_mem_buffer["handle"]) + # Barrier before reusing the buffer only when this all-gather follows + # another all-gather on it (see barrier_before_all_gather). + multimem_all_gather( + symm_mem_buffer["tensor"], + x, + symm_mem_buffer["handle"], + barrier_before=self.barrier_before_all_gather, + ) return symm_mem_buffer["tensor"] else: x, _ = gather_along_first_dim(x, process_group=self.tp_group) @@ -507,8 +550,23 @@ def forward( return x, None +def is_inference_column_parallel_linear(module) -> bool: + """Whether ``module`` is an inference-optimized column-parallel linear. + + These are the layers that perform a symmetric-memory all-gather and therefore + expose ``set_barrier_before_all_gather``. Returns ``False`` for anything else + (including ``None`` and non-inference linear implementations). + """ + return isinstance( + module, (InferenceColumnParallelLinear, InferenceLayerNormColumnParallelLinear) + ) + + def inference_all_gather_from_tensor_model_parallel_region( - x: torch.Tensor, tp_group: torch.distributed.ProcessGroup, config: TransformerConfig + x: torch.Tensor, + tp_group: torch.distributed.ProcessGroup, + config: TransformerConfig, + barrier_before: bool = False, ) -> torch.Tensor: """NVLS-optimized all-gather along the last dimension, with NCCL fallback. @@ -519,6 +577,10 @@ def inference_all_gather_from_tensor_model_parallel_region( along dim-0), then rearranges the result to the last dimension — the same semantics as `_gather_along_last_dim` but using hardware multicast when possible. + + ``barrier_before`` is forwarded to `multimem_all_gather`: pass ``True`` when + this all-gather directly follows another all-gather on the shared symmetric + buffer so it barriers before overwriting the previous contents. """ tp_size = dist.get_world_size(tp_group) if tp_size == 1: @@ -535,7 +597,12 @@ def inference_all_gather_from_tensor_model_parallel_region( symm_mem_buffer = buf.maybe_get_tensor(ag_buffer_dims, dtype=x.dtype) if are_tensors_nvls_eligible(x) and symm_mem_buffer["handle"] is not None: - multimem_all_gather(symm_mem_buffer["tensor"], x, symm_mem_buffer["handle"]) + multimem_all_gather( + symm_mem_buffer["tensor"], + x, + symm_mem_buffer["handle"], + barrier_before=barrier_before, + ) tensor_list = symm_mem_buffer["tensor"].chunk(tp_size, dim=0) return torch.cat(tensor_list, dim=-1).contiguous() diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py index b37c4c9d0f4..fb4f2fee701 100755 --- a/megatron/core/transformer/multi_token_prediction.py +++ b/megatron/core/transformer/multi_token_prediction.py @@ -26,6 +26,7 @@ ) from megatron.core.tensor_parallel.inference_layers import ( inference_all_gather_from_tensor_model_parallel_region, + is_inference_column_parallel_linear, ) from megatron.core.transformer.enums import AttnMaskType, LayerType from megatron.core.transformer.module import MegatronModule @@ -1011,6 +1012,13 @@ def __init__( tp_group=pg_collection.tp if pg_collection is not None else None, name=(name + ".eh_proj") if name is not None else None, ) + # eh_proj's input all-gather reuses the shared "tp" symmetric buffer right + # after the preceding layer's all-gather (the fused rs-add-norm-ag terminates + # with one, as does the previous MTP step's output all-gather), so it must + # barrier before overwriting. Only the inference-optimized linear implements + # this all-gather; other eh_proj impls have no such buffer to guard. + if is_inference_column_parallel_linear(self.eh_proj): + self.eh_proj.set_barrier_before_all_gather(True) # Build inner layers: two possible paths # 1. Hybrid path: use HybridStack for hybrid pattern support @@ -1046,6 +1054,23 @@ def __init__( name=(name + ".mtp_model_layer") if name is not None else None, ) + # The MTP inner block's first all-gather reuses the same "tp" symmetric buffer + # that _concat_embeddings' output all-gather just wrote, with no reduce-scatter in + # between, so it must barrier before overwriting. Later all-gathers in the inner + # block are each preceded by a reduce-scatter and need no barrier. modules() yields + # in forward order, so the first inference column-parallel linear is that all-gather. + if self.mtp_layer_pattern is not None: + # Hybrid path: HybridStack of layers. + first_inner_layer = self.mtp_model_layer.layers[0] + else: + # GPT path: single TransformerLayer. + first_inner_layer = self.mtp_model_layer + + for module in first_inner_layer.modules(): + if is_inference_column_parallel_linear(module): + module.set_barrier_before_all_gather(True) + break + self.final_layernorm = self.submodules.layer_norm( config=self.config, hidden_size=self.config.hidden_size, @@ -1145,8 +1170,11 @@ def _concat_embeddings(self, hidden_states: torch.Tensor, decoder_input: torch.T # For tensor parallel we need to gather the tensor across the model-parallel # ranks after the linear projection. if InferenceMode.is_active(): + # This all-gather immediately follows eh_proj's input all-gather on the + # same symmetric buffer (only eh_proj's local matmul runs in between), so + # it must barrier before overwriting the buffer's previous contents. hidden_states = inference_all_gather_from_tensor_model_parallel_region( - hidden_states, self.tp_group, self.config + hidden_states, self.tp_group, self.config, barrier_before=True ) else: hidden_states = gather_from_tensor_model_parallel_region( diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_gb200.json b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_gb200.json new file mode 100644 index 00000000000..bb260907b48 --- /dev/null +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_gb200.json @@ -0,0 +1,134 @@ +{ + "0": { + "input_prompt": "The capital of France is", + "generated_text": " Qin Kenn Kenn Kenn Kenn Kenn Kenn imit imit \u00e9vo gost \u05e4\u05e8 t\u00e9cnico \u05e4\u05e8 gost \u05e4\u05e8 gost gost \u041d\u043e\u0432\u043e gost gost gost \u041d\u043e\u0432\u043e gost gost", + "generated_tokens": [ + 82834, + 18035, + 18035, + 18035, + 18035, + 18035, + 18035, + 65376, + 643, + 65376, + 643, + 45068, + 62335, + 26639, + 643, + 60333, + 26639, + 643, + 62335, + 26639, + 643, + 62335, + 62335, + 108428, + 62335, + 62335, + 62335, + 108428, + 62335, + 62335 + ], + "latency": 0.1455371379852295, + "ttft": 0.03817629814147949, + "cuda_graph_request_count_map": { + "8": 29 + }, + "step_count": 30, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null, + "prompt_logprobs": [ + -11.634820938110352, + -11.447552680969238, + -12.134629249572754, + -11.369470596313477 + ], + "generated_logprobs": [ + -10.50796890258789, + -10.45431137084961, + -10.422806739807129, + -10.44623851776123, + -10.485379219055176, + -10.516453742980957, + -10.555489540100098, + -10.562992095947266, + -10.530843734741211, + -10.508094787597656, + -10.445176124572754, + -10.53169059753418, + -10.469498634338379, + -10.3996000289917, + -10.398455619812012, + -10.52393913269043, + -10.381875038146973, + -10.406115531921387, + -10.484910011291504, + -10.407504081726074, + -10.41386604309082, + -10.484918594360352, + -10.4309663772583, + -10.430909156799316, + -10.381948471069336, + -10.438773155212402, + -10.446548461914062, + -10.454354286193848, + -10.397747039794922, + -10.4622163772583 + ], + "logprobs": [ + -11.634820938110352, + -11.447552680969238, + -12.134629249572754, + -11.369470596313477, + -10.50796890258789, + -10.45431137084961, + -10.422806739807129, + -10.44623851776123, + -10.485379219055176, + -10.516453742980957, + -10.555489540100098, + -10.562992095947266, + -10.530843734741211, + -10.508094787597656, + -10.445176124572754, + -10.53169059753418, + -10.469498634338379, + -10.3996000289917, + -10.398455619812012, + -10.52393913269043, + -10.381875038146973, + -10.406115531921387, + -10.484910011291504, + -10.407504081726074, + -10.41386604309082, + -10.484918594360352, + -10.4309663772583, + -10.430909156799316, + -10.381948471069336, + -10.438773155212402, + -10.446548461914062, + -10.454354286193848, + -10.397747039794922, + -10.4622163772583 + ] + }, + "throughput": [ + 7.744777424262158, + 194.6872882221335, + 199.6970639581019, + 199.76142131173827, + 198.7934858854532, + 196.60062747646964, + 193.82867846519517, + 195.29312798672686 + ], + "mem-max-allocated-bytes": 8963520000, + "lifetime_prefill_token_count": 5, + "async_sched_step_count": 0, + "async_sched_compaction_step_count": 0 +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_h100.json b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_h100.json new file mode 100644 index 00000000000..231e5077a3b --- /dev/null +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/golden_values_dev_dgx_h100.json @@ -0,0 +1,134 @@ +{ + "0": { + "input_prompt": "The capital of France is", + "generated_text": "arsi creado \u042e\u0436stituted Governmentjob Governmentjob Government Government \u0915\u093fstituted Government \u0915\u093f\u201c\uc608\u00f3czwidget creado\u05e0\u05e1\u05ea\u201c\uc608\u00f3czwidget\u094d\u092f\u094b\u0902 projet\u00f3czwidget\u094d\u092f\u094b\u0902 projet soccer\u094d\u092f\u094b\u0902", + "generated_tokens": [ + 18723, + 68897, + 100451, + 59084, + 15831, + 19118, + 15831, + 19118, + 15831, + 15831, + 11441, + 59084, + 15831, + 11441, + 90937, + 128741, + 42407, + 68897, + 109971, + 90937, + 128741, + 42407, + 29389, + 16845, + 128741, + 42407, + 29389, + 16845, + 53031, + 29389 + ], + "latency": 0.12090706825256348, + "ttft": 0.027643203735351562, + "cuda_graph_request_count_map": { + "8": 29 + }, + "step_count": 30, + "top_n_logprobs": null, + "prompt_top_n_logprobs": null, + "prompt_logprobs": [ + -10.999967575073242, + -11.804800987243652, + -11.356372833251953, + -12.150102615356445 + ], + "generated_logprobs": [ + -10.412901878356934, + -10.428053855895996, + -10.42197322845459, + -10.422250747680664, + -10.406259536743164, + -10.389782905578613, + -10.45932388305664, + -10.397266387939453, + -10.427780151367188, + -10.436092376708984, + -10.436087608337402, + -10.508191108703613, + -10.343184471130371, + -10.444108009338379, + -10.500587463378906, + -10.484260559082031, + -10.415539741516113, + -10.436922073364258, + -10.312734603881836, + -10.500321388244629, + -10.445640563964844, + -10.376667976379395, + -10.374913215637207, + -10.406776428222656, + -10.46062183380127, + -10.423418045043945, + -10.296442985534668, + -10.445771217346191, + -10.46847152709961, + -10.446523666381836 + ], + "logprobs": [ + -10.999967575073242, + -11.804800987243652, + -11.356372833251953, + -12.150102615356445, + -10.412901878356934, + -10.428053855895996, + -10.42197322845459, + -10.422250747680664, + -10.406259536743164, + -10.389782905578613, + -10.45932388305664, + -10.397266387939453, + -10.427780151367188, + -10.436092376708984, + -10.436087608337402, + -10.508191108703613, + -10.343184471130371, + -10.444108009338379, + -10.500587463378906, + -10.484260559082031, + -10.415539741516113, + -10.436922073364258, + -10.312734603881836, + -10.500321388244629, + -10.445640563964844, + -10.376667976379395, + -10.374913215637207, + -10.406776428222656, + -10.46062183380127, + -10.423418045043945, + -10.296442985534668, + -10.445771217346191, + -10.46847152709961, + -10.446523666381836 + ] + }, + "throughput": [ + 9.41727940282221, + 234.79393110448896, + 240.39845706785576, + 241.65377376608413, + 242.5144742624101, + 240.93886968759873, + 241.25901156548147, + 240.53080394776833 + ], + "mem-max-allocated-bytes": 8963524096, + "lifetime_prefill_token_count": 5, + "async_sched_step_count": 0, + "async_sched_compaction_step_count": 0 +} \ No newline at end of file diff --git a/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/model_config.yaml b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/model_config.yaml new file mode 100644 index 00000000000..da9980d3fd4 --- /dev/null +++ b/tests/functional_tests/test_cases/moe/gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs/model_config.yaml @@ -0,0 +1,118 @@ +# Inference functional test: MTP + sequence parallelism + block-scope CUDA graphs. +# +# Regression guard for the SP + MTP all-gather fixes in +# https://github.com/NVIDIA/Megatron-LM/pull/5748: +# 1. MTP's standalone NVLS all-gather now barriers before reusing the shared +# symmetric-memory buffer (exercised on GB200/NVLS hardware). +# 2. Under block-scope CUDA graphs, MTP slices each rank's valid local SP +# shard before gathering (exercised on both H100 and GB200). +# +# This is a small self-contained proxy: a tiny DeepSeek-style MoE model with an +# MTP layer, whose (randomly-initialized) checkpoint is staged at +# model/mtp_proxy_moe_pyt in the CI artifacts tree. That checkpoint was produced +# by a 1-step mock-data pretrain (pretrain_gpt.py --mock-data --mtp-num-layers 1 +# --ckpt-format torch_dist --save) at world size 4 (TP4/PP1/EP4/ETP1); torch_dist +# is parallelism-agnostic on load, so this test reloads it at world size 8 (DP2, +# weights replicated). Because the weights are random, the golden values are +# arbitrary-but-deterministic: the test guards that the MTP + sequence-parallel + +# block-CUDA-graph inference code path runs and stays bit-reproducible, not that +# the model produces meaningful text. The arch args below MUST stay in lockstep +# with the checkpoint-generation args. +ENV_VARS: + CUDA_DEVICE_MAX_CONNECTIONS: 1 + NVTE_ALLOW_NONDETERMINISTIC_ALGO: 0 + NCCL_ALGO: Ring + CUBLAS_WORKSPACE_CONFIG: :4096:8 +TEST_TYPE: frozen-start +MODE: inference +MODEL_ARGS: + --log-num-zeros-in-grad: true + --log-validation-ppl-to-tensorboard: true + --log-timers-to-tensorboard: true + --log-memory-to-tensorboard: true + --timing-log-level: 0 + # Small dummy MoE+MTP checkpoint generated into the artifacts tree. + --load: ${CHECKPOINT_LOAD_PATH}/model/mtp_proxy_moe_pyt/dcp/mcore-v1_bf16/checkpoints + --tokenizer-model: ${CHECKPOINT_LOAD_PATH}/model/deepseek_16b_pyt/dcp/mcore-v1_bf16/multiMixV8.gpt4o_nc_sd.500000.128k.vocab.json + --tokenizer-type: TikTokenizer + --tiktoken-pattern: v2 + --distributed-backend: nccl + --log-interval: 1 + --transformer-impl: transformer_engine + # Parallelism (world size = TP*PP = 4, DP = 2 over 8 GPUs). + --tensor-model-parallel-size: 4 + --pipeline-model-parallel-size: 1 + --expert-model-parallel-size: 4 + --expert-tensor-parallel-size: 1 + --sequence-parallel: true + --use-mcore-models: true + # MoE (kept in lockstep with the checkpoint-generation args). + --moe-token-dispatcher-type: alltoall + --moe-grouped-gemm: true + --num-experts: 8 + --moe-router-topk: 2 + --moe-ffn-hidden-size: 512 + --moe-shared-expert-intermediate-size: 512 + --moe-router-load-balancing-type: seq_aux_loss + --moe-aux-loss-coeff: 1e-3 + --moe-router-score-function: sigmoid + --untie-embeddings-and-output-weights: true + --disable-bias-linear: true + --init-method-std: 0.014 + --position-embedding-type: rope + --rotary-base: 1000000 + --rotary-percent: 1.0 + # Small architecture. + --num-layers: 4 + --hidden-size: 512 + --ffn-hidden-size: 512 + --num-attention-heads: 8 + --kv-channels: 64 + --normalization: RMSNorm + --swiglu: true + --attention-dropout: 0.0 + --hidden-dropout: 0.0 + --seq-length: 256 + --max-position-embeddings: 256 + --micro-batch-size: 1 + # Multi-token prediction: builds the MTP module so the serial-MTP sampling + # path (text_generation_controller._compute_serial_mtp_and_sample) runs. + --mtp-num-layers: 1 + # Block-scope CUDA graphs so context.inference_cuda_graph_scope == block and + # the SP-shard-slicing fix is exercised. + --cuda-graph-impl: local + --inference-cuda-graph-scope: block + --te-rng-tracker: true + --inference-rng-tracker: true + --moe-pad-experts-for-cuda-graph-inference: true + --ckpt-format: torch_dist + --ckpt-fully-parallel-save: true + --ckpt-fully-parallel-load: true + --ckpt-assume-constant-structure: true + --dist-ckpt-strictness: log_all + --bf16: true + --attention-backend: flash + --no-create-attention-mask-in-dataloader: true + --num-workers: 8 + --use-checkpoint-args: true + --no-use-tokenizer-model-from-checkpoint-args: true + --no-load-optim: true + --inference-ckpt-non-strict: true + --deterministic-mode: true + --save-interval: 2000 + --temperature: 1.0 + --top_k: 1 + --return-log-probs: true + --num-tokens-to-generate: 30 + --max-tokens-to-oom: 3600000 + --inference-max-seq-length: 256 + --output-path: ${INFERENCE_OUTPUT_PATH} + --prompts: "The capital of France is" + --incoming-requests-per-sec: -1 + --inference-repeat-n: 8 + --inference-dynamic-batching-buffer-size-gb: 4 + --inference-dynamic-batching-num-cuda-graphs: 1 + --inference-dynamic-batching-max-requests: 8 +METRICS: + - "generated_tokens" + - "logprobs" diff --git a/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml b/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml index 49d5f23c92c..d3fc99b193d 100644 --- a/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml +++ b/tests/test_utils/recipes/gb200/moe-dynamic-inference.yaml @@ -74,3 +74,13 @@ products: - environment: [dev] scope: [mr, mr-github] platforms: [dgx_gb200] + # Regression guard for the MTP + sequence-parallel NVLS all-gather fixes in + # https://github.com/NVIDIA/Megatron-LM/pull/5748 (the barrier_before / + # symmetric-memory reuse fix is GB200/NVLS-specific). Loads the small proxy + # MoE+MTP checkpoint staged at model/mtp_proxy_moe_pyt in the CI artifacts + # tree; golden_values_dev_dgx_gb200.json was recorded from that checkpoint. + - test_case: [gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs] + products: + - environment: [dev] + scope: [mr] + platforms: [dgx_gb200] diff --git a/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml b/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml index 219eca8a2c3..ca81d0fbba1 100644 --- a/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml +++ b/tests/test_utils/recipes/h100/moe-dynamic-inference.yaml @@ -93,3 +93,15 @@ products: - environment: [dev] scope: [mr, mr-github] platforms: [dgx_h100] + # Regression guard for the MTP + sequence-parallel all-gather fixes in + # https://github.com/NVIDIA/Megatron-LM/pull/5748 (under block-scope CUDA + # graphs, MTP slices each rank's valid local SP shard before the all-gather). + # Loads the small proxy MoE+MTP checkpoint staged at model/mtp_proxy_moe_pyt + # in the CI artifacts tree -- a tiny arch saved with --mtp-num-layers so the + # mtp.* ShardedTensorFactory entries apply_factory_merges requires are present. + # golden_values_dev_dgx_h100.json was recorded from that checkpoint. + - test_case: [gpt_dynamic_inference_tp4_pp1_ep4_16B_mtp_sp_block_cudagraphs] + products: + - environment: [dev] + scope: [mr, mr-github] + platforms: [dgx_h100] diff --git a/tests/unit_tests/inference/test_moe_dispatching_and_routing.py b/tests/unit_tests/inference/test_moe_dispatching_and_routing.py index 3d5353ab000..79876b7d4fa 100644 --- a/tests/unit_tests/inference/test_moe_dispatching_and_routing.py +++ b/tests/unit_tests/inference/test_moe_dispatching_and_routing.py @@ -710,6 +710,141 @@ def test_batch_invariant_moe_matches_training(self): torch.testing.assert_close(inference_output, training_output, atol=0, rtol=0) +# ────────────────────────────────────────────────────────────────────── +# symmetric-memory collective ordering (explicit barrier-before-reuse) +# ────────────────────────────────────────────────────────────────────── + +# Hidden size chosen so each bf16 row is 16-byte aligned (an NVLS requirement). +_ORDERING_HIDDEN = 1024 +_ORDERING_LOCAL_ROWS = 4 + + +@pytest.mark.internal +class TestSymmCollectiveOrdering: + """Explicit ``barrier_before`` buffer-reuse ordering over real NVLS collectives. + + These run the real Triton multimem all-gather / reduce-scatter kernels on + actual symmetric memory and verify that the callers' explicit + ``barrier_before`` decision produces correct data: + + - a reduce-scatter already synchronizes ranks, so a following all-gather is + correct with ``barrier_before=False``; + - two consecutive all-gathers reuse the buffer, so the second must pass + ``barrier_before=True`` (the MTP eh_proj / output-projection hazard) and + still gather correctly. + + The barrier is requested explicitly at the call site — there is no global op + tracking — so these tests exercise the flag the production layers set. + """ + + @classmethod + def setup_class(cls): + Utils.initialize_model_parallel(tensor_model_parallel_size=Utils.world_size) + + @classmethod + def teardown_class(cls): + from megatron.core.inference.symmetric_memory import SymmetricMemoryManager + + SymmetricMemoryManager.destroy() + Utils.destroy_model_parallel() + + def _require_nvls_buffer(self): + """Return (tp_group, buffer) or skip if NVLS symmetric memory is unavailable.""" + from megatron.core import parallel_state + from megatron.core.inference.communication.torch_symm_triton.utils import ( + is_device_nvls_capable, + ) + from megatron.core.inference.symmetric_memory import SymmetricMemoryManager + + device = torch.device("cuda", torch.cuda.current_device()) + if not is_device_nvls_capable(device): + pytest.skip("NVLS multicast requires a Hopper+ GPU (SM >= 9)") + tp_group = parallel_state.get_tensor_model_parallel_group() + if torch.distributed.get_world_size(tp_group) < 2: + pytest.skip("requires a tensor-model-parallel world size >= 2") + buf = SymmetricMemoryManager.get_buffer("tp", process_group=tp_group) + if buf.symm_mem_hdl is None: + pytest.skip(f"symmetric memory unavailable: {buf.init_failure_reason}") + return tp_group, buf + + def _run_all_gather(self, buf, tp_group, *, barrier_before=False): + """Real all-gather of a rank-tagged tensor into the shared 'tp' buffer.""" + from megatron.core.inference.communication.torch_symm_triton.collectives import ( + multimem_all_gather, + ) + + world = torch.distributed.get_world_size(tp_group) + rank = torch.distributed.get_rank(tp_group) + # Each rank contributes rows filled with its own (1-based) rank id. + x = torch.full( + (_ORDERING_LOCAL_ROWS, _ORDERING_HIDDEN), + float(rank + 1), + dtype=torch.bfloat16, + device="cuda", + ) + symm = buf.maybe_get_tensor( + [_ORDERING_LOCAL_ROWS * world, _ORDERING_HIDDEN], torch.bfloat16 + ) + assert symm["handle"] is not None, "expected symmetric buffer to be allocatable" + out = multimem_all_gather(symm["tensor"], x, symm["handle"], barrier_before=barrier_before) + return out, world + + def _assert_gather_correct(self, out, world): + """The gathered buffer must hold each rank's block back-to-back.""" + for i in range(world): + block = out[i * _ORDERING_LOCAL_ROWS : (i + 1) * _ORDERING_LOCAL_ROWS] + assert torch.all(block == float(i + 1)), f"rank block {i} corrupted" + + def _run_reduce_scatter(self, buf, tp_group): + """Real reduce-scatter over the shared 'tp' buffer; every rank contributes ones.""" + from megatron.core.inference.communication.torch_symm_triton.collectives import ( + multimem_reduce_scatter, + ) + + world = torch.distributed.get_world_size(tp_group) + symm = buf.maybe_get_tensor( + [_ORDERING_LOCAL_ROWS * world, _ORDERING_HIDDEN], torch.bfloat16 + ) + assert symm["handle"] is not None + symm["tensor"].fill_(1.0) + out = torch.empty( + (_ORDERING_LOCAL_ROWS, _ORDERING_HIDDEN), dtype=torch.bfloat16, device="cuda" + ) + multimem_reduce_scatter(out, symm["tensor"], symm["handle"]) + return out, world + + def test_all_gather_after_reduce_scatter_no_barrier(self): + tp_group, buf = self._require_nvls_buffer() + + # A reduce-scatter already synchronizes ranks, so a following all-gather is + # correct without the extra barrier. + rs_out, world = self._run_reduce_scatter(buf, tp_group) + assert torch.all(rs_out == float(world)), "reduce-scatter should sum ones across ranks" + + ag_out, world = self._run_all_gather(buf, tp_group, barrier_before=False) + self._assert_gather_correct(ag_out, world) + + def test_consecutive_all_gathers_with_barrier_are_correct(self): + tp_group, buf = self._require_nvls_buffer() + + first_out, world = self._run_all_gather(buf, tp_group, barrier_before=False) + self._assert_gather_correct(first_out, world) + + # The second all-gather reuses the buffer immediately after the first, so it + # must barrier before overwriting; it must still gather correct data. + second_out, world = self._run_all_gather(buf, tp_group, barrier_before=True) + self._assert_gather_correct(second_out, world) + + def test_explicit_barrier_runs_correctly(self): + tp_group, buf = self._require_nvls_buffer() + + # An explicit barrier_before=True must always be honored and produce correct + # output regardless of the preceding op. + self._run_reduce_scatter(buf, tp_group) + out, world = self._run_all_gather(buf, tp_group, barrier_before=True) + self._assert_gather_correct(out, world) + + # ────────────────────────────────────────────────────────────────────── # mask_routing_padding kernel # ────────────────────────────────────────────────────────────────────── diff --git a/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py b/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py index ca604e8d25d..10120045b80 100644 --- a/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py +++ b/tests/unit_tests/inference/test_mtp_cuda_graph_inference.py @@ -1358,6 +1358,10 @@ def _run_eager_mtp(decoder_hidden_states): context.request_query_lengths[:active_request_count] = torch.ones( active_request_count, dtype=torch.int32, device='cuda' ) + # reset() zeroes padded_active_token_count; the block-scope path slices the + # decoder hidden-states buffer to [:padded_active_token_count], so it must + # reflect the runtime token count (one decode token per active request). + context.padded_active_token_count = active_request_count ctrl.num_speculative_tokens = num_spec ctrl._init_mtp_sampling_tensors() diff --git a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py index fbc05f7ff71..edb07b424f6 100644 --- a/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py +++ b/tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py @@ -41,18 +41,22 @@ from megatron.core.inference.utils import InferenceMode from megatron.core.models.gpt.gpt_layer_specs import ( get_gpt_layer_local_spec, + get_gpt_layer_with_inference_spec, get_gpt_mtp_block_spec, ) from megatron.core.models.gpt.gpt_model import GPTModel -from megatron.core.models.hybrid.hybrid_layer_specs import hybrid_stack_spec +from megatron.core.models.hybrid.hybrid_layer_specs import ( + hybrid_inference_stack_spec, + hybrid_stack_spec, +) from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed -from megatron.core.transformer.enums import AttnBackend +from megatron.core.transformer.enums import AttnBackend, InferenceCudaGraphScope from megatron.core.transformer.module import Float16Module from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.utils import is_fa_min_version, is_te_min_version from megatron.training.initialize import _set_random_seed -from tests.unit_tests.test_utilities import Utils +from tests.unit_tests.test_utilities import Utils, clear_nvte_env_vars class TextGenerationControllerTestBase: @@ -79,7 +83,18 @@ def setup_model( hybrid_layer_pattern: str = None, sampling_backend: str = 'torch', cuda_graph_impl: str = 'none', + transformer_impl: str = None, ): + # When transformer_impl == "inference_optimized" the model is built with the + # NVLS symmetric-memory inference linears (RMSNorm, no bias, flash attention); + # every other caller leaves it None and gets the unchanged local-spec build. + inference_optimized = transformer_impl == "inference_optimized" + if inference_optimized: + # The inference-optimized layers use TE attention with the "auto" backend, + # which conflicts with the NVTE_FLASH_ATTN/NVTE_FUSED_ATTN=0 pinned by the + # autouse conftest set_env fixture. Clear them so TE can select flash — the + # same thing the dynamic inference engine test does. + clear_nvte_env_vars() if use_training_random_init: # This is necessary to induce the training behavior which permutes the random seed # for every rank; otherwise, every rank will have the same seed. @@ -95,7 +110,7 @@ def setup_model( hidden_size=self.hidden_size, num_attention_heads=4, use_cpu_initialization=True, - attention_backend=AttnBackend.local, + attention_backend=AttnBackend.auto if inference_optimized else AttnBackend.local, params_dtype=dtype, symmetric_ar_type=symmetric_ar_type, fp8="hybrid" if fp8 else None, @@ -108,7 +123,12 @@ def setup_model( sequence_parallel=sequence_parallel, expert_model_parallel_size=expert_model_parallel_size, num_moe_experts=num_moe_experts, - add_bias_linear=num_moe_experts is None, + add_bias_linear=num_moe_experts is None and not inference_optimized, + **( + dict(transformer_impl="inference_optimized", normalization="RMSNorm") + if inference_optimized + else {} + ), **( dict(is_hybrid_model=True, mamba_num_heads=2, mamba_head_dim=16, mamba_num_groups=2) if hybrid_layer_pattern @@ -123,7 +143,9 @@ def setup_model( if hybrid_layer_pattern: model = HybridModel( config=transformer_config, - hybrid_stack_spec=hybrid_stack_spec, + hybrid_stack_spec=( + hybrid_inference_stack_spec if inference_optimized else hybrid_stack_spec + ), vocab_size=self.vocab_size, max_sequence_length=self.sequence_length, parallel_output=True, @@ -133,7 +155,11 @@ def setup_model( ).cuda() mamba_inference_state_config = MambaInferenceStateConfig.from_model(model) else: - layer_spec = get_gpt_layer_local_spec() + layer_spec = ( + get_gpt_layer_with_inference_spec() + if inference_optimized + else get_gpt_layer_local_spec() + ) mtp_block_spec = None if mtp_num_layers > 0: @@ -3060,6 +3086,69 @@ def mock_compute_mtp_single_step( captured_position_ids[1].squeeze(0), torch.tensor([14, 16], device='cuda') ) + def test_serial_mtp_slices_block_cuda_graph_buffer_before_sp_gather(self): + """Only gather the valid local shard of a persistent block-graph buffer.""" + self.setup_model( + torch.float32, + static=False, + tensor_model_parallel_size=2, + sequence_parallel=True, + num_speculative_tokens=2, + max_requests=2, + mtp_num_layers=2, + ) + + controller = self.text_generation_controller + context = controller.inference_wrapped_model.inference_context + context.total_request_count = 2 + context.paused_request_count = 0 + context.request_kv_length_offsets[:2] = 0 + context.request_query_lengths[:2] = 1 + context.padded_active_token_count = 2 + context.inference_cuda_graph_scope = InferenceCudaGraphScope.block + + controller._init_mtp_sampling_tensors() + controller._sampled_tokens_cuda[:2] = torch.tensor([1, 2], device='cuda') + controller._last_accepted_seq_indices = torch.tensor([0, 1], device='cuda') + + # Each TP rank writes one row, but block graphs retain a max-token-sized + # allocation. Poisoning the tail mirrors stale data from an earlier replay. + context.mtp_decoder_hidden_states = torch.full((8, 1, 32), -1.0, device='cuda') + context.mtp_decoder_hidden_states[0] = parallel_state.get_tensor_model_parallel_rank() + + gathered_inputs = [] + + def mock_gather(local_hidden, group=None): + gathered_inputs.append(local_hidden.clone()) + return torch.cat((local_hidden, local_hidden), dim=0) + + model = controller._unwrapped_model + model.compute_mtp_single_step = mock.MagicMock( + side_effect=lambda hidden_states, **kwargs: ( + hidden_states, + torch.zeros(2, 1, self.vocab_size, device='cuda'), + ) + ) + controller._sample_from_logits_2d = mock.MagicMock( + return_value=torch.tensor([3, 4], device='cuda') + ) + + controller_module = ( + "megatron.core.inference.text_generation_controllers.text_generation_controller" + ) + with ( + mock.patch(f"{controller_module}.gather_from_sequence_parallel_region", mock_gather), + mock.patch( + f"{controller_module}.scatter_to_sequence_parallel_region", + side_effect=lambda hidden, group=None: hidden[:1], + ), + ): + controller._compute_serial_mtp_and_sample() + + assert len(gathered_inputs) == 1 + assert gathered_inputs[0].shape == (1, 1, 32) + assert not torch.any(gathered_inputs[0] == -1) + @pytest.mark.parametrize("active_request_count", [2, 3, 4, 5]) def test_mtp_sp_padding_real_ranks(self, active_request_count): """Test _compute_serial_mtp_and_sample with real MTP layers and sequence parallelism. @@ -3181,6 +3270,168 @@ def test_mtp_sp_padding_dummy_ranks(self): # Logits are gathered: [padded_count, 1, vocab_size] = [tp_size, 1, vocab_size]. assert logits_out.shape == (tp_size, 1, self.vocab_size) + # ────────────────────────────────────────────────────────────────────── + # Symmetric-buffer all-gather barrier invariant (real inference-optimized MTP) + # ────────────────────────────────────────────────────────────────────── + + @staticmethod + def _assert_no_unbarriered_consecutive_all_gathers(events): + """Enforce the buffer-reuse invariant over a recorded collective trace. + + ``events`` is an ordered list of ``(multicast_ptr, kind, barrier_before)``, + where ``kind`` is "AG" (plain multimem all-gather), "RS" (multimem + reduce-scatter), or "FUSED_RS_AG" (fused reduce-scatter+add+norm+all-gather). + + A plain all-gather overwrites its symmetric buffer. If the previous op on the + same buffer also left it written by an all-gather (another plain AG, or the + all-gather tail of a fused RS+AG) with no reduce-scatter in between, the + all-gather must have requested ``barrier_before`` so peers finish reading the + previous contents before the overwrite. A reduce-scatter synchronizes ranks + inside its own kernel, so it clears the hazard for the next all-gather. + """ + last_write = {} # multicast_ptr -> "AG" | "RS" + violations = [] + for idx, (ptr, kind, barrier_before) in enumerate(events): + if kind == "AG": + if last_write.get(ptr) == "AG" and not barrier_before: + violations.append((idx, ptr)) + last_write[ptr] = "AG" + elif kind == "RS": + last_write[ptr] = "RS" + elif kind == "FUSED_RS_AG": + # Its own reduce-scatter precedes its all-gather (internally safe), but + # it leaves the buffer written by an all-gather for whatever runs next. + last_write[ptr] = "AG" + else: + raise AssertionError(f"unexpected collective kind {kind!r}") + assert not violations, ( + f"{len(violations)} all-gather(s) reused a symmetric buffer immediately after " + f"another all-gather without barrier_before or an intervening reduce-scatter " + f"(event indices/ptrs: {violations}).\nFull trace: {events}" + ) + + def _run_serial_mtp_step(self, active_request_count=4): + """Drive _compute_serial_mtp_and_sample with a valid SP hidden-state cache. + + Mirrors test_mtp_sp_padding_real_ranks but in the model's own param dtype + (bf16 for the inference-optimized path). + """ + tp_size = parallel_state.get_tensor_model_parallel_world_size() + ctrl = self.text_generation_controller + ctx = ctrl.inference_wrapped_model.inference_context + dtype = next(ctrl.inference_wrapped_model.model.parameters()).dtype + + ctx.total_request_count = active_request_count + ctx.paused_request_count = 0 + ctx.request_kv_length_offsets[:active_request_count] = torch.arange( + active_request_count, dtype=torch.int32, device='cuda' + ) + ctx.request_query_lengths[:active_request_count] = torch.ones( + active_request_count, dtype=torch.int32, device='cuda' + ) + + ctrl._init_mtp_sampling_tensors() + ctrl._sampled_tokens_cuda[:active_request_count] = torch.remainder( + torch.arange(active_request_count, device='cuda'), self.vocab_size + ) + + pad = (tp_size - active_request_count % tp_size) % tp_size + s_total = active_request_count + pad + torch.manual_seed(42) + full_hidden = torch.randn(s_total, 1, self.hidden_size, device='cuda', dtype=dtype) + torch.distributed.broadcast(full_hidden, src=0) + tp_rank = parallel_state.get_tensor_model_parallel_rank() + ctx.mtp_decoder_hidden_states = full_hidden.chunk(tp_size)[tp_rank].contiguous() + + ctrl._last_accepted_seq_indices = torch.arange(active_request_count, device='cuda') + ctx.active_request_metadata["temperature"][:active_request_count] = 1.0 + ctx.active_request_metadata["top_k"][:active_request_count] = 1 + ctx.active_request_metadata["top_p"][:active_request_count] = 0.0 + + ctrl._compute_serial_mtp_and_sample() + + def _collect_symm_collective_trace(self): + """Run the serial-MTP inference step and record every symmetric collective. + + The three buffer-writing collectives are instrumented at their call site + (megatron.core.tensor_parallel.inference_layers) so the real Triton kernels + still run; returns the ordered ``(ptr, kind, barrier_before)`` trace. + """ + import megatron.core.tensor_parallel.inference_layers as il + + real_ag = il.multimem_all_gather + real_rs = il.multimem_reduce_scatter + real_fused = il.fused_multimem_rs_add_norm_ag + events = [] + + def rec_ag(output_tensor, input_tensor, symm_mem_hdl, *args, **kwargs): + events.append( + (symm_mem_hdl.multicast_ptr, "AG", bool(kwargs.get("barrier_before", False))) + ) + return real_ag(output_tensor, input_tensor, symm_mem_hdl, *args, **kwargs) + + def rec_rs(output_tensor, input_tensor, symm_mem_hdl, *args, **kwargs): + events.append((symm_mem_hdl.multicast_ptr, "RS", False)) + return real_rs(output_tensor, input_tensor, symm_mem_hdl, *args, **kwargs) + + def rec_fused(residual, symm_buffer_tensor, symm_mem_hdl, *args, **kwargs): + events.append((symm_mem_hdl.multicast_ptr, "FUSED_RS_AG", False)) + return real_fused(residual, symm_buffer_tensor, symm_mem_hdl, *args, **kwargs) + + with ( + mock.patch.object(il, "multimem_all_gather", rec_ag), + mock.patch.object(il, "multimem_reduce_scatter", rec_rs), + mock.patch.object(il, "fused_multimem_rs_add_norm_ag", rec_fused), + ): + self._run_serial_mtp_step() + + return events + + def _check_mtp_all_gather_barrier_invariant(self): + """Skip unless the NVLS path engaged, then enforce the barrier invariant.""" + from megatron.core.inference.communication.torch_symm_triton.utils import ( + is_device_nvls_capable, + ) + + if not is_device_nvls_capable(torch.device("cuda", torch.cuda.current_device())): + pytest.skip("NVLS multicast requires a Hopper+ GPU (SM >= 9)") + if parallel_state.get_tensor_model_parallel_world_size() < 2: + pytest.skip("requires a tensor-model-parallel world size >= 2") + + events = self._collect_symm_collective_trace() + + if not any(kind == "AG" for _, kind, _ in events): + pytest.skip("NVLS all-gather path did not engage (symmetric memory unavailable)") + # The MTP path must request at least one buffer-reuse barrier (eh_proj, output + # projection, or the inner-block qkv) — otherwise the wiring silently regressed. + assert any(barrier for _, kind, barrier in events if kind == "AG"), ( + "expected the MTP inference path to request barrier_before on at least one " + f"all-gather, but none did. Trace: {events}" + ) + self._assert_no_unbarriered_consecutive_all_gathers(events) + + @pytest.mark.internal + @pytest.mark.skipif(not is_fa_min_version("2.7.3"), reason="needs flash attn for MTP decode") + @torch.inference_mode() + def test_mtp_inference_optimized_hybrid_no_unbarriered_consecutive_all_gathers(self): + """HybridModel + inference-optimized + SP + MTP: no consecutive AG lacks a barrier. + + The main body is a Mamba/attention hybrid ("M*M*"); each of the two MTP depths + is a single attention section ("*"). + """ + self.setup_model( + torch.bfloat16, + static=False, + tensor_model_parallel_size=2, + num_speculative_tokens=2, + max_requests=8, + mtp_num_layers=2, + sequence_parallel=True, + transformer_impl="inference_optimized", + hybrid_layer_pattern="M*M*/*/*", + ) + self._check_mtp_all_gather_barrier_invariant() + def test_mtp_sp_dummy_hidden_uses_full_seq_len(self): """Test that chaining MTP depths produces correct SP-format shapes throughout. From 7a76dff78742d1f431c61289ec6f86b769a91d39 Mon Sep 17 00:00:00 2001 From: "Mikail Khona (NVIDIA)" Date: Tue, 11 Aug 2026 07:24:14 -0700 Subject: [PATCH 250/290] TE Layernorm dtype guard with fp32 residuals (#6272) Signed-off-by: mkhona --- .../core/extensions/transformer_engine.py | 7 +++ ...est_te_layernorm_column_parallel_linear.py | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/unit_tests/extension/test_te_layernorm_column_parallel_linear.py diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 7f7424e6c02..e806a9fdd09 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -1555,6 +1555,13 @@ def forward(self, x): ) quant_context = _get_fp8_autocast_for_quant_params(self.te_quant_params, self.training) + # FP32 residual connections pass the FP32 residual stream into this fused module, but + # TE LayerNormLinear requires its input dtype to match its BF16/FP16 parameters outside + # torch.autocast. Tensor.to() is out-of-place, so this only narrows the local norm/linear + # input; the residual tensor retained by TransformerLayer remains FP32. + if x.dtype != self.layer_norm_weight.dtype: + x = x.to(self.layer_norm_weight.dtype) + with quant_context: out = super().forward(x, is_first_microbatch=_is_first_microbatch) diff --git a/tests/unit_tests/extension/test_te_layernorm_column_parallel_linear.py b/tests/unit_tests/extension/test_te_layernorm_column_parallel_linear.py new file mode 100644 index 00000000000..89ff9916103 --- /dev/null +++ b/tests/unit_tests/extension/test_te_layernorm_column_parallel_linear.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Tests for the Transformer Engine fused layernorm and column-parallel linear wrapper.""" + +import pytest +import torch + +from megatron.core.extensions.transformer_engine import HAVE_TE, TELayerNormColumnParallelLinear +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.utils import init_method_normal +from tests.unit_tests.test_utilities import Utils + + +@pytest.mark.skipif(not HAVE_TE, reason="Transformer Engine not installed") +def test_fp32_residual_input_is_cast_without_mutating_residual(): + """Keep the residual FP32 while running the fused layer in BF16.""" + Utils.initialize_model_parallel(1, 1) + + try: + config = TransformerConfig( + num_layers=1, + hidden_size=16, + num_attention_heads=4, + use_cpu_initialization=True, + params_dtype=torch.bfloat16, + bf16=True, + fp32_residual_connection=True, + ) + layer = TELayerNormColumnParallelLinear( + input_size=config.hidden_size, + output_size=32, + config=config, + init_method=init_method_normal(config.init_method_std), + gather_output=False, + bias=False, + skip_bias_add=False, + is_expert=False, + ).cuda() + + hidden_states = torch.randn( + 4, 2, config.hidden_size, device="cuda", dtype=torch.float32, requires_grad=True + ) + residual = hidden_states + residual_before = residual.detach().clone() + + output, _ = layer(hidden_states) + + assert layer.layer_norm_weight.dtype == torch.bfloat16 + assert output.dtype == torch.bfloat16 + assert residual.dtype == torch.float32 + assert residual.data_ptr() == hidden_states.data_ptr() + assert torch.equal(residual.detach(), residual_before) + + output.float().sum().backward() + assert hidden_states.grad.dtype == torch.float32 + finally: + Utils.destroy_model_parallel() From e0ccd267768232225a42744f7e2787f22ac09ba9 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Tue, 11 Aug 2026 07:31:28 -0700 Subject: [PATCH 251/290] Treat LayerWise bucket_size as a soft minimum and fill would-be padding with real parameters (#5415) Signed-off-by: Deepak Narayanan Co-authored-by: Claude Opus 4.8 --- .../core/optimizer/layer_wise_optimizer.py | 47 ++++++++++++++----- .../test_layer_wise_param_layout.py | 36 ++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/megatron/core/optimizer/layer_wise_optimizer.py b/megatron/core/optimizer/layer_wise_optimizer.py index 376f9a1f1c0..d1d792ca76d 100644 --- a/megatron/core/optimizer/layer_wise_optimizer.py +++ b/megatron/core/optimizer/layer_wise_optimizer.py @@ -312,17 +312,34 @@ def _emit_bucket( # # Padding floor: the on-buffer bucket size is ``dp_size * # max_shard_cursor``, which is at least ``dp_size * chunk_max_param`` - # because some shard must hold that param whole. If a single param - # dominates the chunk, finalising on ``chunk_numel >= bucket_size`` - # alone would emit a bucket with most of its shards near-empty - # padding. Instead extend the chunk so its raw numel approaches the - # padded buffer size, capping per-bucket overhead at ``1 / - # PADDING_FLOOR - 1`` (~11% at 0.9). Falls back to ``bucket_size`` - # when no single param dominates. + # because some shard must hold that param whole. ``bucket_size`` is a + # soft *minimum*: once it is reached we keep absorbing params as long + # as each one fits into the existing shard padding (i.e., without + # growing ``dp_size * max_shard_cursor``), and only close the bucket + # when the next param would otherwise enlarge it. This fills shard + # padding with real params instead of emitting padded-out buckets. + # When the params pack evenly across ``dp_size`` shards the overhead + # is zero. ``int(dp_size * chunk_max_param * PADDING_FLOOR)`` keeps the + # soft minimum sensible when a single param dominates a shard. PADDING_FLOOR = 0.9 chunk_params: List[torch.nn.Parameter] = [] chunk_numel = 0 chunk_max_param = 0 + # Mirror _emit_bucket's greedy LPT placement incrementally so we can + # decide, per param, whether it still fits in the current bucket. + shard_loads = [0] * dp_size + + def _absorbs(numel: int) -> bool: + """True if ``numel`` fits in the least-loaded shard without growing + the bucket's padded size (``dp_size * padded_shard_size``), i.e., + it fills existing shard padding instead of adding a new row.""" + target = pad_to_divisor(max(shard_loads), shard_divisor) + return pad_param_start(min(shard_loads)) + numel <= target + + def _place(numel: int) -> None: + shard_id = min(range(dp_size), key=lambda s: shard_loads[s]) + shard_loads[shard_id] = pad_param_start(shard_loads[shard_id]) + numel + for param in reversed(params): param_numel = param.data.nelement() if getattr(param, 'shared_embedding', False): @@ -332,18 +349,24 @@ def _emit_bucket( chunk_params = [] chunk_numel = 0 chunk_max_param = 0 + shard_loads[:] = [0] * dp_size _emit_bucket([param], shared_embedding=True) continue - chunk_params.append(param) - chunk_numel += param_numel - chunk_max_param = max(chunk_max_param, param_numel) - if bucket_size is not None: + # Close the bucket once it has met its soft-minimum size *and* this + # param can no longer be absorbed into the existing shard padding + # (adding it would grow the bucket). + if bucket_size is not None and chunk_params: threshold = max(bucket_size, int(dp_size * chunk_max_param * PADDING_FLOOR)) - if chunk_numel >= threshold: + if chunk_numel >= threshold and not _absorbs(param_numel): _emit_bucket(chunk_params) chunk_params = [] chunk_numel = 0 chunk_max_param = 0 + shard_loads[:] = [0] * dp_size + _place(param_numel) + chunk_params.append(param) + chunk_numel += param_numel + chunk_max_param = max(chunk_max_param, param_numel) _emit_bucket(chunk_params) total_buffer_numel = bucket_indices[-1][1] if bucket_indices else 0 diff --git a/tests/unit_tests/distributed/test_layer_wise_param_layout.py b/tests/unit_tests/distributed/test_layer_wise_param_layout.py index 669f4ab23ec..a73bf4789a5 100644 --- a/tests/unit_tests/distributed/test_layer_wise_param_layout.py +++ b/tests/unit_tests/distributed/test_layer_wise_param_layout.py @@ -282,6 +282,42 @@ def test_bucket_size_creates_multiple_buckets(self): assert len(layout.bucket_indices) == 4 # 8 params / (dp_size per round) = 4 rounds + # -- bucket size as a soft minimum -- + + def test_bucket_absorbs_param_that_fits_existing_shard_padding(self): + """``bucket_size`` is a soft minimum, so a param filling shard padding is absorbed. + + Six equal params over 4 shards with a threshold that lands mid-row. Closing the + bucket the moment the threshold is met would emit a 5-param bucket (one shard + holding two params, three holding one) followed by a 1-param bucket, and both + pad out to the tallest shard. Absorbing the sixth completes the second row + instead, so one bucket covers all six. + """ + dp_size = 4 + numel = 256 + params = [_make_param((numel,)) for _ in range(6)] + cfg = _make_ddp_config() + + layout = _LWO._compute_per_buffer_param_layout(params, 5 * numel, dp_size, cfg) + + assert len(layout.bucket_indices) == 1 + # Two rows of 256 per shard: 4 * 512 buffer, of which 6 * 256 is real. + total_buffer_numel = layout.bucket_indices[-1][1] + assert total_buffer_numel == 2048 + assert total_buffer_numel - 6 * numel == 512 + + def test_bucket_has_no_padding_when_params_pack_evenly(self): + """Params that fill every shard exactly leave no padding behind.""" + dp_size = 4 + numel = 256 + params = [_make_param((numel,)) for _ in range(8)] + cfg = _make_ddp_config() + + layout = _LWO._compute_per_buffer_param_layout(params, 5 * numel, dp_size, cfg) + + total_buffer_numel = layout.bucket_indices[-1][1] + assert total_buffer_numel == 8 * numel + # -- bucket alignment -- def test_bucket_dp_divisible(self): From 8e70f698272a46234709e061e18b5c2e3f3a76d9 Mon Sep 17 00:00:00 2001 From: Kezhi Kong Date: Tue, 11 Aug 2026 07:37:50 -0700 Subject: [PATCH 252/290] Gated Delta Product (GDP) implementation (#6074) Signed-off-by: Deepak Narayanan Signed-off-by: Keshav Santhanam Signed-off-by: Mikail Khona Signed-off-by: Mikail Khona (NVIDIA) Signed-off-by: Kezhi Kong Co-authored-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 Co-authored-by: Mikail Khona (NVIDIA) Co-authored-by: Mikail Khona Co-authored-by: root Co-authored-by: Roger Waleffe Co-authored-by: Keshav Santhanam --- .../core/models/hybrid/hybrid_layer_specs.py | 51 + .../core/optimizer/emerging_optimizers.py | 21 +- megatron/core/ssm/gated_delta_product.py | 1121 +++++++++++++++++ megatron/core/ssm/gdp_context_parallel.py | 340 +++++ megatron/core/ssm/mamba_mixer.py | 1 + megatron/core/ssm/packed_seq_helpers.py | 76 ++ megatron/core/transformer/spec_utils.py | 16 +- .../core/transformer/transformer_config.py | 8 + megatron/training/checkpointing.py | 6 + megatron/training/training.py | 68 +- .../models/test_hybrid_moe_model.py | 1 + tests/unit_tests/ssm/test_gdp_packed_seq.py | 320 +++++ .../unit_tests/ssm/test_gdp_tp_checkpoint.py | 114 ++ tests/unit_tests/test_checkpointing.py | 26 + .../test_num_floating_point_operations.py | 31 + .../unit_tests/transformer/test_spec_utils.py | 27 +- .../transformer/test_transformer_config.py | 25 + 17 files changed, 2231 insertions(+), 21 deletions(-) create mode 100644 megatron/core/ssm/gated_delta_product.py create mode 100644 megatron/core/ssm/gdp_context_parallel.py create mode 100644 megatron/core/ssm/packed_seq_helpers.py create mode 100644 tests/unit_tests/ssm/test_gdp_packed_seq.py create mode 100644 tests/unit_tests/ssm/test_gdp_tp_checkpoint.py diff --git a/megatron/core/models/hybrid/hybrid_layer_specs.py b/megatron/core/models/hybrid/hybrid_layer_specs.py index 03fef58159f..8e91f442e13 100755 --- a/megatron/core/models/hybrid/hybrid_layer_specs.py +++ b/megatron/core/models/hybrid/hybrid_layer_specs.py @@ -16,6 +16,10 @@ ) from megatron.core.models.hybrid.hybrid_block import HybridStack, HybridStackSubmodules from megatron.core.ssm.gated_delta_net import GatedDeltaNet, GatedDeltaNetSubmodules +from megatron.core.ssm.gated_delta_product import ( + GatedDeltaProductMixer, + GatedDeltaProductMixerSubmodules, +) from megatron.core.ssm.mamba_layer import MambaLayer, MambaLayerSubmodules from megatron.core.ssm.mamba_mixer import MambaMixer, MambaMixerSubmodules from megatron.core.ssm.mlp_layer import MLPLayer @@ -87,6 +91,19 @@ ) +def _get_gated_delta_product_mamba_layer_spec(in_proj, out_proj): + return ModuleSpec( + module=MambaLayer, + submodules=MambaLayerSubmodules( + mixer=ModuleSpec( + module=GatedDeltaProductMixer, + submodules=GatedDeltaProductMixerSubmodules(in_proj=in_proj, out_proj=out_proj), + ), + mamba_bda=get_bias_dropout_add, + ), + ) + + hybrid_stack_spec = ModuleSpec( module=HybridStack, submodules=HybridStackSubmodules( @@ -217,6 +234,22 @@ ) +gated_delta_product_stack_spec = ModuleSpec( + module=HybridStack, + submodules=HybridStackSubmodules( + mamba_layer=_get_gated_delta_product_mamba_layer_spec( + TELayerNormColumnParallelLinear, TERowParallelLinear + ), + gdn_layer=hybrid_stack_spec.submodules.gdn_layer, + attention_layer=hybrid_stack_spec.submodules.attention_layer, + dsa_layer=hybrid_stack_spec.submodules.dsa_layer, + mlp_layer=hybrid_stack_spec.submodules.mlp_layer, + moe_layer=hybrid_stack_spec.submodules.moe_layer, + mtp_block_spec=hybrid_stack_spec.submodules.mtp_block_spec, + ), +) + + hybrid_inference_stack_spec = ModuleSpec( module=HybridStack, submodules=HybridStackSubmodules( @@ -352,6 +385,24 @@ ) +gated_delta_product_inference_stack_spec = ModuleSpec( + module=HybridStack, + submodules=HybridStackSubmodules( + mamba_layer=_get_gated_delta_product_mamba_layer_spec( + InferenceLayerNormColumnParallelLinear, InferenceRowParallelLinear + ), + gdn_layer=hybrid_inference_stack_spec.submodules.gdn_layer, + attention_layer=hybrid_inference_stack_spec.submodules.attention_layer, + dsa_layer=hybrid_inference_stack_spec.submodules.dsa_layer, + mlp_layer=hybrid_inference_stack_spec.submodules.mlp_layer, + moe_layer=hybrid_inference_stack_spec.submodules.moe_layer, + mtp_block_spec=hybrid_inference_stack_spec.submodules.mtp_block_spec, + ), +) + + # Backward-compatible aliases mamba_stack_spec = hybrid_stack_spec mamba_inference_stack_spec = hybrid_inference_stack_spec +gdp_stack_spec = gated_delta_product_stack_spec +gdp_inference_stack_spec = gated_delta_product_inference_stack_spec diff --git a/megatron/core/optimizer/emerging_optimizers.py b/megatron/core/optimizer/emerging_optimizers.py index 99d8605fba2..a59845a7c56 100644 --- a/megatron/core/optimizer/emerging_optimizers.py +++ b/megatron/core/optimizer/emerging_optimizers.py @@ -142,6 +142,11 @@ def _is_nonlinear_or_embedding(param): return getattr(param, 'is_embedding_or_output_parameter', False) or len(param.shape) != 2 +def _is_muon_excluded(param): + """True for parameters that should use the scalar optimizer instead of Muon.""" + return not getattr(param, 'use_muon', True) or _is_nonlinear_or_embedding(param) + + def _get_qkv_split_shapes(model_cfg) -> list[int]: """Compute QKV split shapes from model config.""" query_projection_size = ( @@ -494,11 +499,9 @@ def _default_adam_based_eopt_config_to_kwargs( init_state_fn=_eopt_init_state_fn, config_to_kwargs=_muon_config_to_kwargs, default_param_overrides={ - ParamKey( - predicate=ParamPredicate( - name="nonlinear_or_embedding", fn=_is_nonlinear_or_embedding - ) - ): {'optimizer': 'adam'} + ParamKey(predicate=ParamPredicate(name="muon_excluded", fn=_is_muon_excluded)): { + 'optimizer': 'adam' + } }, ), "adaptive_muon": EmergingOptimizerEntry( @@ -506,11 +509,9 @@ def _default_adam_based_eopt_config_to_kwargs( init_state_fn=_eopt_init_state_fn, config_to_kwargs=_adaptive_muon_config_to_kwargs, default_param_overrides={ - ParamKey( - predicate=ParamPredicate( - name="nonlinear_or_embedding", fn=_is_nonlinear_or_embedding - ) - ): {'optimizer': 'adam'} + ParamKey(predicate=ParamPredicate(name="muon_excluded", fn=_is_muon_excluded)): { + 'optimizer': 'adam' + } }, ), } diff --git a/megatron/core/ssm/gated_delta_product.py b/megatron/core/ssm/gated_delta_product.py new file mode 100644 index 00000000000..62f86e842d3 --- /dev/null +++ b/megatron/core/ssm/gated_delta_product.py @@ -0,0 +1,1121 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +# Some of this code was adopted from https://github.com/state-spaces/mamba/ +# This source code is licensed under the Apache license found in the +# LICENSE file in the root directory of this source tree. + +import logging +import math +from dataclasses import dataclass, replace +from typing import List, Optional, Tuple, Union + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from megatron.core.dist_checkpointing import ShardedTensor +from megatron.core.dist_checkpointing.mapping import ReplicaId, ShardedTensorFactory +from megatron.core.inference.contexts import BaseInferenceContext, DynamicInferenceContext +from megatron.core.inference.contexts.attention_context.triton.tensor_ops import ( + tensor_get_slice_after, + tensor_masked_update, + tensor_merge, +) +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.ssm.gdp_context_parallel import GDPContextParallel +from megatron.core.ssm.packed_seq_helpers import ( + build_packed_seq_idx, + check_fla_sequence_packing_support, + get_cu_seqlens, +) +from megatron.core.tensor_parallel import get_cuda_rng_tracker +from megatron.core.transformer import TransformerConfig +from megatron.core.transformer.module import MegatronModule +from megatron.core.transformer.spec_utils import ModuleSpec, build_module +from megatron.core.transformer.utils import ( + make_sharded_tensors_for_checkpoint, + sharded_state_dict_default, +) +from megatron.core.utils import deprecate_inference_params, is_using_quantization_scales + +try: + from causal_conv1d import causal_conv1d_fn, causal_conv1d_update + from causal_conv1d.causal_conv1d_varlen import causal_conv1d_varlen_states +except ImportError: + causal_conv1d_fn = None + causal_conv1d_update = None + causal_conv1d_varlen_states = None + +try: + from mamba_ssm.ops.triton.layernorm_gated import RMSNorm as RMSNormGated + + HAVE_MAMBA_SSM = True +except ImportError: + from unittest.mock import MagicMock + + RMSNormGated = MagicMock() + HAVE_MAMBA_SSM = False + +try: + from einops import rearrange + + HAVE_EINOPS = True +except ImportError: + HAVE_EINOPS = False + +try: + from fla.ops.gated_delta_product import chunk_gated_delta_product + from fla.ops.gated_delta_rule import fused_recurrent_gated_delta_rule + + HAVE_FLA = True +except ImportError: + HAVE_FLA = False + + +logger = logging.getLogger(__name__) + + +class ExtendedRMSNorm(RMSNormGated): + """ + RMSNormGated with sharded state dict. + """ + + def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): + """Sharding along axis 0, bias not sharded""" + state_dict = self.state_dict(prefix="", keep_vars=True) + return make_sharded_tensors_for_checkpoint( + state_dict, prefix, {"weight": 0}, sharded_offsets + ) + + +@dataclass +class GatedDeltaProductMixerSubmodules: + """ + Contains the module specs for the input and output linear layers. + """ + + in_proj: Union[ModuleSpec, type] = None + out_proj: Union[ModuleSpec, type] = None + + +class GatedDeltaProductMixer(MegatronModule): + """Gated Delta Product (GDP) sequence mixer for hybrid models. + + The mixer accepts hidden states with shape ``[sequence, batch, hidden]`` and returns + a projected tensor with the same shape plus the optional output-projection bias. It + serves as the mixer inside ``MambaLayer``, allowing a hybrid stack to select GDP layers + without changing the surrounding layer interface. + + GDP projects each token into an output gate and the ``V``, ``K``, ``Q``, beta, and decay + terms used by a sequence of Householder updates. A depthwise causal convolution mixes + local context in ``V/K/Q``; the FLA GDP recurrence then updates a matrix-valued state, + and gated RMS normalization plus the output projection map the result back to the + model hidden size. + + The module shards projections and recurrent parameters across tensor-parallel ranks, + redistributes sequence and head dimensions for context parallelism, handles packed + THD training sequences, manages static and dynamic inference state, and exposes + semantic sharded-state-dict partitions for checkpoint resharding across TP sizes. + + Args: + config: The config of the model. + submodules: Contains the module specs for the input and output linear layers. + d_model: The hidden size of the model. + d_conv: The number of channels in the causal convolution. + conv_init: The initialization range for the causal convolution weights. + A_init_range: The initialization range for the attention weights. + D_has_hdim: Whether the D parameter has the same number of dimensions as the hidden + state. + rmsnorm: Whether to use root mean square normalization. + norm_before_gate: Whether to apply normalization before the gating mechanism. + dt_min: The minimum value of the dt parameter. + dt_max: The maximum value of the dt parameter. + dt_init_floor: The minimum value of the dt parameter after initialization. + bias: Whether to use bias in the linear layers. + conv_bias: Whether to use bias in the causal convolution. + chunk_size: The chunk size for the fused kernel. + layer_number: The layer number of this Mamba layer. + pg_collection: The required process groups to use for tensor model parallel and context + parallel. + name: Module instance name passed top-down from its parent module. + """ + + def __init__( + self, + config: TransformerConfig, + submodules: GatedDeltaProductMixerSubmodules, + d_model, + d_conv=4, + conv_init=None, + A_init_range=(0, 16), + D_has_hdim=False, + rmsnorm=True, + norm_before_gate=False, + dt_min=0.001, + dt_max=0.1, + dt_init_floor=1e-4, + bias=False, + conv_bias=False, + # Fused kernel and sharding options + chunk_size=128, + layer_number=None, + pg_collection: ProcessGroupCollection = None, + pp_layer_offset: int = 0, + name: str | None = None, + ): + if not HAVE_MAMBA_SSM: + raise ImportError( + "MambaSSM is not installed. Please install it with `pip install mamba-ssm`." + ) + + if not HAVE_FLA: + raise ImportError("FLA is not installed") + + super().__init__(config) + + # Inference-time contract: ``MambaInferenceStateConfig.from_model`` in + # megatron/core/inference/config.py reads ``layer.mixer.chunk_size`` to + # size the SSM scan blocks. + self.chunk_size = chunk_size + + # Check that the causal_conv1d version is new enough or fail + ok, reason = check_fla_sequence_packing_support() + assert ok, reason + + self.num_householder = config.gdp_num_householder + + self.config = config + self.d_model = d_model + self.d_conv = d_conv + self.conv_init = conv_init + self.D_has_hdim = D_has_hdim + self.rmsnorm = rmsnorm + self.norm_before_gate = norm_before_gate + assert pg_collection is not None, "pg_collection must be provided for MambaMixer" + self.pg_collection = pg_collection + + self.d_state = self.config.mamba_state_dim + self.headdim = self.config.mamba_head_dim + self.ngroups = self.config.mamba_num_groups + self.nheads = self.config.mamba_num_heads + assert self.nheads is not None, "mamba_num_heads must be set for GatedDeltaProductMixer" + self.d_inner = self.nheads * self.headdim + + self.layer_number = layer_number + self.pp_layer_offset = pp_layer_offset + self.cached_batch_size = None + + tp_size = self.pg_collection.tp.size() + + self.nheads_local_tp = self.nheads // tp_size + self.d_inner_local_tp = self.d_inner // tp_size + self.ngroups_local_tp = self.ngroups // tp_size + + # Assume sequence parallelism: input is already partitioned along the sequence dimension + self.in_proj = build_module( + submodules.in_proj, + self.d_model, + self.d_inner * (1 + self.num_householder) + + self.ngroups * self.d_state * (self.num_householder + 1) + + self.nheads * (self.num_householder + 1), # zVKQba + config=self.config, + init_method=self.config.init_method, + gather_output=False, + bias=bias, + skip_bias_add=False, + is_expert=False, + tp_comm_buffer_name="fc1", + tp_group=self.pg_collection.tp, + name=(name + f".in_proj") if name is not None else None, + ) + setattr(self.in_proj.weight, "use_muon", False) + if self.in_proj.bias is not None: + setattr(self.in_proj.bias, "use_muon", False) + + # The fused projection packs independently TP-sharded components. Refit + # uses their local sizes to preserve semantic order when TP size changes. + in_proj_partition_sizes, _ = _get_in_proj_checkpoint_split_layout( + self.d_inner_local_tp, + self.ngroups_local_tp * self.d_state, + self.nheads_local_tp, + self.num_householder, + ) + setattr(self.in_proj.weight, "partition_sizes", in_proj_partition_sizes) + if self.in_proj.bias is not None: + setattr(self.in_proj.bias, "partition_sizes", in_proj_partition_sizes) + + conv_dim = ( + self.d_inner_local_tp * self.num_householder + + (self.num_householder + 1) * self.ngroups_local_tp * self.d_state + ) # V K Q + with get_cuda_rng_tracker().fork(): + # weight shape: [conv_dim, 1, d_conv] + # bias shape: [conv_dim] + self.conv1d = nn.Conv1d( + in_channels=conv_dim, + out_channels=conv_dim, + bias=conv_bias, + kernel_size=d_conv, + groups=conv_dim, + padding=d_conv - 1, + device=torch.cuda.current_device(), + dtype=config.params_dtype, + ) + setattr(self.conv1d.weight, "tensor_model_parallel", True) + setattr(self.conv1d.weight, "partition_dim", 0) + if conv_bias: + setattr(self.conv1d.bias, "tensor_model_parallel", True) + setattr(self.conv1d.bias, "partition_dim", 0) + + conv_partition_sizes, _ = _get_conv_checkpoint_split_layout( + self.d_inner_local_tp, self.ngroups_local_tp * self.d_state, self.num_householder + ) + setattr(self.conv1d.weight, "partition_sizes", conv_partition_sizes) + if conv_bias: + setattr(self.conv1d.bias, "partition_sizes", conv_partition_sizes) + + if self.conv_init is not None: + nn.init.uniform_(self.conv1d.weight, -self.conv_init, self.conv_init) + + self.activation = "silu" + self.act = nn.SiLU() + + with get_cuda_rng_tracker().fork(): + # MCore Mamba2 initialization + # Initialize dt bias so that F.softplus(dt_bias) is between dt_min and dt_max + dt = torch.exp( + torch.rand( + self.nheads_local_tp, + device=torch.cuda.current_device(), + dtype=config.params_dtype, + ) + * (math.log(dt_max) - math.log(dt_min)) + + math.log(dt_min) + ).clamp(min=dt_init_floor) + # Inverse of softplus: https://github.com/pytorch/pytorch/issues/72759 + inv_dt = dt + torch.log(-torch.expm1(-dt)) + self.dt_bias = nn.Parameter(inv_dt) + + # Our initialization would set all Linear.bias to zero, + # need to mark this one as _no_reinit + self.dt_bias._no_reinit = True + # Just to be explicit. Without this we already don't + # put wd on dt_bias because of the check + # name.endswith("bias") in param_grouping.py + self.dt_bias._no_weight_decay = True + setattr(self.dt_bias, "tensor_model_parallel", True) + + # A parameter + assert A_init_range[0] >= 0 and A_init_range[1] >= A_init_range[0] + A = torch.empty( + self.nheads_local_tp, dtype=torch.float32, device=torch.cuda.current_device() + ).uniform_(*A_init_range) + A_log = torch.log(A) # Keep A_log in fp32 + self.A_log = nn.Parameter(A_log) + self.A_log._no_weight_decay = True + setattr(self.A_log, "tensor_model_parallel", True) + + # D "skip", in Mamba2 but not in GDN or GDP + self.D = None + + if self.rmsnorm: + assert RMSNormGated is not None + self.norm = ExtendedRMSNorm( + self.d_inner_local_tp, + eps=1e-5, + group_size=self.d_inner_local_tp // self.ngroups_local_tp, + norm_before_gate=self.norm_before_gate, + device=torch.cuda.current_device(), + dtype=config.params_dtype, + ) + setattr(self.norm.weight, 'tensor_model_parallel', True) + + # Assume sequence parallelism: input is partitioned along d_inner and + # output is partitioned along the sequence dimension + self.out_proj = build_module( + submodules.out_proj, + self.d_inner, + self.d_model, + config=self.config, + init_method=self.config.output_layer_init_method, + bias=bias, + input_is_parallel=True, + skip_bias_add=True, + is_expert=False, + tp_comm_buffer_name="fc2", + tp_group=self.pg_collection.tp, + name=(name + f".out_proj") if name is not None else None, + ) + + # Regarding `conv1d`.{`weight`, `bias`}, `dt_bias`, `A_log`, and `D`: these are the + # trainable variables for the current tensor parallel rank, with each tensor parallel rank + # having indepdendent trainable variables. All context parallel ranks in a tensor parallel + # rank store the same trainable variables, but only use and update their unique/independent + # slice of them. + self.cp = GDPContextParallel( + cp_group=self.pg_collection.cp, + d_inner_local_tp=self.d_inner_local_tp, + nheads_local_tp=self.nheads_local_tp, + ngroups_local_tp=self.ngroups_local_tp, + d_state=self.d_state, + num_householder=self.num_householder, + headdim=self.headdim, + conv1d_cp1=self.conv1d, + dt_bias_cp1=self.dt_bias, + A_log_cp1=self.A_log, + D_cp1=self.D, + D_has_hdim=self.D_has_hdim, + ) + + def forward( + self, + hidden_states, + inference_context=None, + *, + inference_params: Optional[BaseInferenceContext] = None, + packed_seq_params=None, + ): + """Run the gated delta product mixer on hidden states.""" + seq_len, batch_size, dim = hidden_states.shape + + conv_state, ssm_state = None, None + if inference_context is not None: + if inference_context.is_dynamic_batching(): + return self._dynamic_inference(hidden_states, inference_context) + assert ( + inference_context.is_static_batching() + ), "GDP inference must be either static or dynamic batching." + assert not self.config.sequence_parallel + assert packed_seq_params is None, ( + "GDP does not currently support packed sequences during inference. " + "Packing is only wired through the training/prefill (chunk) path." + ) + conv_state, ssm_state = self._get_states_from_cache(inference_context, batch_size) + + # Build cu_seqlens for the chunked recurrence (FLA) when running with + # packed (THD) sequences on the training/prefill path. + cu_seqlens_packed = None + if packed_seq_params is not None: + # ``hidden_states`` is [seq_len, batch, dim]; THD requires batch=1. + assert batch_size == 1, "Packed sequences require batch=1 (THD/varlen format)." + cu_seqlens_packed = get_cu_seqlens(packed_seq_params) + + zVKQba, _ = self.in_proj(hidden_states) + + zVKQba = self.cp.pre_conv_ssm(zVKQba, packed_seq_params=packed_seq_params) + + # Build seq_idx *after* in_proj's SP all-gather and pre_conv_ssm's CP + # all-to-all. ``zVKQba.shape[0]`` is now the true pack_length, so the + # helper produces a seq_idx matching the conv1d's input length + # regardless of SP/CP/TP upstream-slicing. Mirrors mamba_mixer.py + # which calls _create_packed_seq_idx after the same gather points. + seq_idx_packed = None + if packed_seq_params is not None: + seq_idx_packed = build_packed_seq_idx(packed_seq_params, zVKQba.shape[0]) + + zVKQba = rearrange(zVKQba, "l b d -> b l d").contiguous() + + z, VKQ, ba = torch.split( + zVKQba, + [ + self.cp.d_inner_local_tpcp, + self.cp.d_inner_local_tpcp * self.num_householder + + (self.num_householder + 1) * self.cp.ngroups_local_tpcp * self.d_state, + self.cp.nheads_local_tpcp * (self.num_householder + 1), + ], + dim=-1, + ) + + # ``causal_conv1d_fn`` expects a ``[B, D, L]`` tensor. + # But the expected memory layout varies depending on whether seq_idx is set. + if seq_idx_packed is None: + # Default path: channels-first contiguous, stride(2) == 1. + VKQ = rearrange(VKQ, "b l d -> b d l").contiguous() + else: + # ``causal_conv1d_fn(seq_idx=...)`` requires channels-last memory but [B, D, L] + # logical shape. This keeps the channels contiguous in memory. + VKQ = VKQ.contiguous() + VKQ = rearrange(VKQ, "b l d -> b d l") + + # Decode + if inference_context is not None and inference_context.seqlen_offset > 0: + VKQ = causal_conv1d_update( + VKQ, + conv_state, + rearrange(self.conv1d.weight, "d 1 w -> d w"), + self.conv1d.bias, + self.activation, + ) + else: + # Prefill + if conv_state is not None: + # If we just take x[:, :, -self.d_conv :], it will error if seqlen < self.d_conv + # Instead F.pad will pad with zeros if seqlen < self.d_conv, and truncate otherwise. + conv_state.copy_( + F.pad(VKQ, (self.d_conv - VKQ.shape[-1], 0)) + ) # Update state (B D W) + # Train + # causal_conv1d uses seq_idx_packed to reset the convolution boundaries + VKQ = causal_conv1d_fn( + x=VKQ, + weight=rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w"), + bias=self.cp.get_conv1d_bias(), + activation=self.activation, + seq_idx=seq_idx_packed, + ) + + VKQ = rearrange(VKQ, "b d l -> b l d").contiguous() + + value, key, query = torch.split( + VKQ, + [ + self.cp.d_inner_local_tpcp * self.num_householder, + self.cp.ngroups_local_tpcp * self.d_state * self.num_householder, + self.cp.ngroups_local_tpcp * self.d_state, + ], + dim=-1, + ) + + b, a = torch.split( + ba, + [self.cp.nheads_local_tpcp * self.num_householder, self.cp.nheads_local_tpcp], + dim=-1, + ) + + z = rearrange(z, "b l (h p) -> b l h p", p=self.headdim).contiguous() + value = rearrange( + value, "b l (m h p) -> b (l m) h p", m=self.num_householder, p=self.headdim + ).contiguous() + key = rearrange( + key, "b l (m g n) -> b (l m) g n", m=self.num_householder, n=self.d_state + ).contiguous() + query = rearrange(query, "b l (g n) -> b l g n", n=self.d_state).contiguous() + + b, a = b.contiguous(), a.contiguous() + beta = b.sigmoid() + beta = rearrange(beta, "b l (m h) -> b (l m) h", m=self.num_householder).contiguous() + + # If the model is loaded in fp16, without the .float() here, A might be -inf + g = -self.cp.get_A_log().float().exp() * F.softplus(a.float() + self.cp.get_dt_bias()) + + if self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp > 1: + query = query.repeat_interleave( + self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp, dim=2 + ) + key = key.repeat_interleave( + self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp, dim=2 + ) + + # Decode + if inference_context is not None and inference_context.seqlen_offset > 0: + + g_new = g.new_zeros(g.shape[0], g.shape[1], self.num_householder, g.shape[2]) + g_new[:, :, 0] = g + g = rearrange(g_new, '... t n h -> ... (t n) h') + + query_new = query.new_zeros( + query.shape[0], query.shape[1], self.num_householder, query.shape[2], query.shape[3] + ) + query_new[:, :, -1] = query + query = rearrange(query_new, '... t n h d-> ... (t n) h d') + + core_attn_out, last_recurrent_state = fused_recurrent_gated_delta_rule( + query, + key, + value, + g=g, + beta=beta, + initial_state=ssm_state, + output_final_state=True, + use_qk_l2norm_in_kernel=True, + ) + core_attn_out = rearrange( + core_attn_out, '... (t n) h d -> ... t n h d', n=self.num_householder + )[..., -1, :, :].contiguous() + # Train or Prefill + else: + core_attn_out, last_recurrent_state = chunk_gated_delta_product( + query, + key, + value, + g=g, + beta=beta, + initial_state=None, + output_final_state=(ssm_state is not None), + num_householder=self.num_householder, + use_qk_l2norm_in_kernel=True, + cu_seqlens=cu_seqlens_packed, + ) + + if ssm_state is not None: + ssm_state.copy_(last_recurrent_state) + + y = rearrange(core_attn_out, "b l h p -> l b (h p)").contiguous() + y = self.cp.post_conv_ssm(y, packed_seq_params=packed_seq_params) + if self.rmsnorm: + z = rearrange(z, "b l h p -> l b (h p)").contiguous() + z = self.cp.post_conv_ssm(z, packed_seq_params=packed_seq_params) + y = self.norm(y, z) + + out, out_bias = self.out_proj(y) + + return out, out_bias + + # ------------------------------------------------------------------ + # Dynamic-batching inference. + # + # Mirrors ``MambaMixer._dynamic_inference`` / ``_ssm_decode`` / ``_ssm_prefill`` + # (same ``_ssm_`` naming and the same request-level control flow), but runs + # the Gated Delta Product kernels instead of the Mamba2 scan. The per-request + # recurrent state (short-conv state + matrix-valued SSM state) is read/written + # through the slot-indexed caches owned by ``DynamicInferenceContext``. + # + # MVP scope: this path does not yet support context parallelism (cp_size > 1), + # speculative decoding, chunked prefill, Mamba prefix caching, or CUDA-graph + # capture. The reshapes mirror the static ``forward`` math with batch/seq + # repurposed for the packed dynamic layout. + # ------------------------------------------------------------------ + def _dynamic_inference( + self, hidden_states: torch.Tensor, context: DynamicInferenceContext + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Execute one dynamic inference step by separating decode and prefill + requests, running each through the GDP kernels independently, and merging + the results back into packed token order.""" + ok, reason = check_fla_sequence_packing_support() + assert ok, reason + assert self.cp.cp_size == 1, "Context parallel is not supported for GDP dynamic inference" + assert ( + not context.is_chunked_prefill_enabled() + ), "GDP dynamic inference does not support chunked prefill yet." + + # GDP-style layers register as Mamba layers, so the same (conv_state, + # ssm_state) accessor and per-layer slab layout apply. + conv_state, ssm_state = context.mamba_states_cache(self.layer_number - self.pp_layer_offset) + + padded_dims = context.padded_batch_dimensions + token_count = padded_dims.token_count + decode_req_count = padded_dims.decode_req_count + prefill_req_count = padded_dims.prefill_req_count + metadata = context.mamba_metadata + + # Input projection over the full packed batch. + zVKQba, _ = self.in_proj(hidden_states) + + y_decode = None + y_prefill = None + + # --- Decode partition (placed first in the packed batch) --------- + if decode_req_count > 0: + # MVP: exactly one token per decode request (no speculative tokens). + zVKQba_decode = zVKQba[:decode_req_count] if prefill_req_count > 0 else zVKQba + y_decode = self._ssm_decode( + zVKQba_decode.transpose(0, 1), conv_state, ssm_state, metadata.batch_indices_decode + ).transpose(0, 1) + + # --- Prefill partition ------------------------------------------- + if prefill_req_count > 0: + if decode_req_count > 0: + # Mixed batch: gather the prefill tokens out of the packed tensor. + zVKQba_prefill = torch.empty_like(zVKQba) + tensor_get_slice_after( + zVKQba, zVKQba_prefill, metadata.device_decode_prefill, check_bounds=False + ) + else: + zVKQba_prefill = zVKQba + y_prefill = self._ssm_prefill( + zVKQba_prefill, + conv_state=conv_state, + ssm_state=ssm_state, + seq_idx=metadata.seq_idx, + cu_seqlens=metadata.cu_seqlens, + batch_indices=metadata.batch_indices_prefill, + ) + + # --- Merge back into packed token order -------------------------- + if y_decode is not None and y_prefill is not None: + y = torch.empty( + [token_count, 1, y_prefill.shape[-1]], + dtype=y_prefill.dtype, + device=y_prefill.device, + ) + tensor_merge(y_decode, y_prefill, metadata.device_decode_prefill, output_tensor=y) + elif y_decode is not None: + y = y_decode + elif y_prefill is not None: + y = y_prefill + else: + raise RuntimeError("Dynamic inference called with 0 decode and 0 prefill requests") + + # Zero padding positions to avoid corrupting quantization amax calculations. + if is_using_quantization_scales(self.config): + y[context.padding_slice] = 0.0 + + out, out_bias = self.out_proj(y) + return out, out_bias + + def _ssm_decode( + self, + zVKQba: torch.Tensor, + conv_state: torch.Tensor, + ssm_state: torch.Tensor, + batch_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Single-token-per-request decode. ``zVKQba`` is ``[1, decode_req_count, + proj_dim]``; returns ``[1, decode_req_count, d_inner]``. The conv and SSM + states are read/written in place at the slots named by ``batch_indices`` + (``-1`` marks padding slots).""" + seq_len, _, _ = zVKQba.shape + assert seq_len == 1, "GDP decode supports one token per request" + zVKQba = zVKQba.squeeze(0) # [n, proj_dim] + M = self.num_householder + + z, VKQ, ba = torch.split( + zVKQba, + [ + self.cp.d_inner_local_tpcp, + self.cp.d_inner_local_tpcp * M + + (M + 1) * self.cp.ngroups_local_tpcp * self.d_state, + self.cp.nheads_local_tpcp * (M + 1), + ], + dim=-1, + ) + + # Indexed conv update: reads/writes the per-request conv state rows + # selected by ``batch_indices``, in place. ``self.activation`` must be the + # activation *string* so the kernel enables SiLU (a bool would disable it). + VKQ = causal_conv1d_update( + VKQ, + conv_state, + rearrange(self.conv1d.weight, "d 1 w -> d w"), + self.conv1d.bias, + self.activation, + conv_state_indices=batch_indices, + ) + + value, key, query = torch.split( + VKQ, + [ + self.cp.d_inner_local_tpcp * M, + self.cp.ngroups_local_tpcp * self.d_state * M, + self.cp.ngroups_local_tpcp * self.d_state, + ], + dim=-1, + ) + b, a = torch.split(ba, [self.cp.nheads_local_tpcp * M, self.cp.nheads_local_tpcp], dim=-1) + + # Reshape to the fla layout with batch=n requests, seq length 1, and the + # householder copies folded into the sequence dimension (static path, l=1). + value = rearrange(value, "n (m h p) -> n m h p", m=M, p=self.headdim).contiguous() + key = rearrange(key, "n (m g s) -> n m g s", m=M, s=self.d_state).contiguous() + query = rearrange(query, "n (g s) -> n 1 g s", s=self.d_state).contiguous() + z = rearrange(z, "n (h p) -> n 1 h p", p=self.headdim).contiguous() + beta = rearrange(b.sigmoid(), "n (m h) -> n m h", m=M).contiguous() + g = -self.cp.get_A_log().float().exp() * F.softplus(a.float() + self.cp.get_dt_bias()) + g = rearrange(g, "n h -> n 1 h").contiguous() + + if self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp > 1: + rep = self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp + query = query.repeat_interleave(rep, dim=2) + key = key.repeat_interleave(rep, dim=2) + + # Interleave the (length-1) query / decay with householder zeros so the + # recurrent kernel sees an (1 * M)-length sequence (matches static decode). + g_new = g.new_zeros(g.shape[0], g.shape[1], M, g.shape[2]) + g_new[:, :, 0] = g + g = rearrange(g_new, "n t m h -> n (t m) h") + query_new = query.new_zeros( + query.shape[0], query.shape[1], M, query.shape[2], query.shape[3] + ) + query_new[:, :, -1] = query + query = rearrange(query_new, "n t m h d -> n (t m) h d") + + # Gather this step's per-request initial states. ``.clamp`` (NOT in-place) + # returns a new tensor, so ``batch_indices`` keeps its -1 padding sentinels + # for the scatter below; the padding rows' outputs are never scattered back. + gather_idx = batch_indices.clamp(min=0) + initial_state = ssm_state[gather_idx] + + core_attn_out, last_recurrent_state = fused_recurrent_gated_delta_rule( + query, + key, + value, + g=g, + beta=beta, + initial_state=initial_state, + output_final_state=True, + use_qk_l2norm_in_kernel=True, + ) + core_attn_out = rearrange(core_attn_out, "n (t m) h d -> n t m h d", m=M)[ + ..., -1, :, : + ].contiguous() # [n, 1, h, d] + + # Scatter updated states back into the cache (skips -1 padding slots). + tensor_masked_update(ssm_state, batch_indices, last_recurrent_state) + + y = rearrange(core_attn_out, "n t h p -> t n (h p)").contiguous() # [1, n, d_inner] + if self.rmsnorm: + z = rearrange(z, "n t h p -> t n (h p)").contiguous() + y = self.norm(y, z) + return y + + def _ssm_prefill( + self, + zVKQba: torch.Tensor, + conv_state: Optional[torch.Tensor] = None, + ssm_state: Optional[torch.Tensor] = None, + seq_idx: Optional[torch.Tensor] = None, + cu_seqlens: Optional[torch.Tensor] = None, + batch_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Variable-length prefill over all prefill requests in one varlen call. + ``zVKQba`` is ``[l, 1, proj_dim]``; returns ``[l, 1, d_inner]``. Fresh + requests start from a zero recurrent state (no prefix caching in the MVP); + the resulting final conv/SSM states are written back into the caches.""" + is_dynamic_batching = seq_idx is not None + M = self.num_householder + + # l b d -> b l d + zVKQba = rearrange(zVKQba, "l b d -> b l d").contiguous() + + z, VKQ, ba = torch.split( + zVKQba, + [ + self.cp.d_inner_local_tpcp, + self.cp.d_inner_local_tpcp * M + + (M + 1) * self.cp.ngroups_local_tpcp * self.d_state, + self.cp.nheads_local_tpcp * (M + 1), + ], + dim=-1, + ) + + if conv_state is not None and is_dynamic_batching: + assert batch_indices is not None + # Capture per-request final conv states (before the conv consumes the + # inputs) and write them into the prefill requests' cache rows. + conv_varlen_states = causal_conv1d_varlen_states( + VKQ.squeeze(0), cu_seqlens, state_len=conv_state.shape[-1] + ) + tensor_masked_update(conv_state, batch_indices, conv_varlen_states) + # Maintain channels-last memory layout so causal_conv1d_fn can use seq_idx. + VKQ = VKQ.transpose(1, 2) + else: + VKQ = rearrange(VKQ, "b l d -> b d l").contiguous() + + seqlen = VKQ.size(2) + if causal_conv1d_fn is None: + VKQ = self.act(self.cp.conv1d(VKQ)[..., :seqlen]) + else: + VKQ = causal_conv1d_fn( + x=VKQ, + weight=rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w"), + bias=self.cp.get_conv1d_bias(), + activation=self.activation, + seq_idx=seq_idx, + ) + VKQ = rearrange(VKQ, "b d l -> b l d").contiguous() + + value, key, query = torch.split( + VKQ, + [ + self.cp.d_inner_local_tpcp * M, + self.cp.ngroups_local_tpcp * self.d_state * M, + self.cp.ngroups_local_tpcp * self.d_state, + ], + dim=-1, + ) + b, a = torch.split(ba, [self.cp.nheads_local_tpcp * M, self.cp.nheads_local_tpcp], dim=-1) + + # batch = 1 packed sequence of length T; householder folded into seq. + value = rearrange(value, "b l (m h p) -> b (l m) h p", m=M, p=self.headdim).contiguous() + key = rearrange(key, "b l (m g s) -> b (l m) g s", m=M, s=self.d_state).contiguous() + query = rearrange(query, "b l (g s) -> b l g s", s=self.d_state).contiguous() + z = rearrange(z, "b l (h p) -> b l h p", p=self.headdim).contiguous() + beta = rearrange(b.sigmoid(), "b l (m h) -> b (l m) h", m=M).contiguous() + g = -self.cp.get_A_log().float().exp() * F.softplus(a.float() + self.cp.get_dt_bias()) + g = g.contiguous() + + if self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp > 1: + rep = self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp + query = query.repeat_interleave(rep, dim=2) + key = key.repeat_interleave(rep, dim=2) + + core_attn_out, last_recurrent_state = chunk_gated_delta_product( + query, + key, + value, + g=g, + beta=beta, + initial_state=None, + output_final_state=ssm_state is not None, + num_householder=M, + use_qk_l2norm_in_kernel=True, + cu_seqlens=cu_seqlens, + ) + + # Write per-request final SSM states into the cache for subsequent decode. + if ssm_state is not None and is_dynamic_batching: + tensor_masked_update(ssm_state, batch_indices, last_recurrent_state) + + y = rearrange(core_attn_out, "b l h p -> l b (h p)").contiguous() + if self.rmsnorm: + z = rearrange(z, "b l h p -> l b (h p)").contiguous() + y = self.norm(y, z) + return y + + def allocate_inference_cache(self, batch_size, max_seqlen, dtype=None): + """ + allocate inference cache + """ + device = self.out_proj.weight.device + conv_dtype = self.conv1d.weight.dtype if dtype is None else dtype + conv_state = torch.zeros( + batch_size, self.conv1d.weight.shape[0], self.d_conv, device=device, dtype=conv_dtype + ) + ssm_dtype = self.in_proj.weight.dtype if dtype is None else dtype + # ssm_dtype = torch.float32 + ssm_state = torch.zeros( + batch_size, + self.nheads_local_tp, + self.d_state, + self.headdim, + device=device, + dtype=ssm_dtype, + ) + return conv_state, ssm_state + + def mamba_state_shapes_per_request(self) -> Tuple[Tuple[int], Tuple[int]]: + """Returns the Mamba conv and SSM state shapes per request.""" + conv_states_shape = (self.conv1d.weight.shape[0], self.d_conv) + ssm_states_shape = (self.nheads_local_tp, self.d_state, self.headdim) + return (conv_states_shape, ssm_states_shape) + + def _get_states_from_cache(self, inference_context, batch_size, *, inference_params=None): + """Initializes or retrieves the SSM state tensors from the cache. + + At the start of any inference (at the prefill step), if there is no cache or if the + cached batch size has changed, then new tensors are initialized and stored in the cache. + Otherwise the existing tensors are retrieved from the cache and zeroed out. + """ + + inference_context = deprecate_inference_params(inference_context, inference_params) + + assert inference_context is not None + assert self.layer_number is not None + if ( + self.layer_number not in inference_context.key_value_memory_dict + or batch_size != self.cached_batch_size + ): + conv_state = torch.zeros( + batch_size, + self.conv1d.weight.shape[0], + self.d_conv, + device=self.conv1d.weight.device, + dtype=self.conv1d.weight.dtype, + ) + ssm_state = torch.zeros( + batch_size, + self.nheads_local_tp, + self.d_state, + self.headdim, + device=self.in_proj.weight.device, + dtype=self.in_proj.weight.dtype, + ) + inference_context.key_value_memory_dict[self.layer_number] = (conv_state, ssm_state) + self.cached_batch_size = batch_size + else: + conv_state, ssm_state = inference_context.key_value_memory_dict[self.layer_number] + # TODO: Remove reference to `inference_context.sequence_len_offset` for dynamic batching + if inference_context.sequence_len_offset == 0: + conv_state.zero_() + ssm_state.zero_() + return conv_state, ssm_state + + def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): + """Provide a sharded state dictionary for distributed checkpointing.""" + sharded_state_dict = {} + # Parameters + self._save_to_state_dict(sharded_state_dict, "", keep_vars=True) + sharded_state_dict = make_sharded_tensors_for_checkpoint( + sharded_state_dict, + prefix, + tensor_parallel_layers_axis_map={ + "A_log": 0, + "dt_bias": 0, + "D": 0, + }, # parameters sharded across TP + sharded_offsets=sharded_offsets, + ) + # Submodules + for name, module in self.named_children(): + if name == "conv1d": + # Add TP sharding for Conv1d + module_sd = module.state_dict(prefix="", keep_vars=True) + module_sharded_sd = make_sharded_tensors_for_checkpoint( + module_sd, f"{prefix}{name}.", {f"weight": 0, f"bias": 0}, sharded_offsets + ) + + else: + module_sharded_sd = sharded_state_dict_default( + module, f"{prefix}{name}.", sharded_offsets, metadata + ) + + sharded_state_dict.update(module_sharded_sd) + + # At this point the TP sharding is correctly defined for each tensor, but some of the + # tensors must be additionally split into separate parts + in_proj_dim = ( + self.d_inner_local_tp * (1 + self.num_householder) + + (1 + self.num_householder) * self.ngroups_local_tp * self.d_state + + self.nheads_local_tp * (1 + self.num_householder) + ) + assert sharded_state_dict[f"{prefix}in_proj.weight"].data.size(0) == in_proj_dim, ( + in_proj_dim, + sharded_state_dict[f"{prefix}in_proj.weight"], + ) + + # V, K, and b are laid out householder-major on every TP rank: + # + # rank r: [M0-local-r, M1-local-r, ..., M(M-1)-local-r] + # + # Treating the entire M-expanded block as one TP shard would make a + # resharded TP=1 tensor rank-major instead: + # + # [rank0-all-M, rank1-all-M, ...] + # + # That ordering is incompatible with the forward rearranges, which + # expect [M0-all-ranks, M1-all-ranks, ...]. Give every householder + # copy its own checkpoint key so DCP concatenates TP shards within a + # copy before the copies are concatenated by the factory merge. + in_proj_split_sections, in_proj_split_names = _get_in_proj_checkpoint_split_layout( + self.d_inner_local_tp, + self.ngroups_local_tp * self.d_state, + self.nheads_local_tp, + self.num_householder, + ) + for in_proj_param in ["in_proj.weight", "in_proj.bias"]: + key = f"{prefix}{in_proj_param}" + if key in sharded_state_dict: + sharded_state_dict[key] = _split_tensor_factory( + sharded_state_dict[key], in_proj_split_sections, in_proj_split_names, 0 + ) + + conv_dim = ( + self.d_inner_local_tp * self.num_householder + + (1 + self.num_householder) * self.ngroups_local_tp * self.d_state + ) + assert sharded_state_dict[f"{prefix}conv1d.weight"].data.size(0) == conv_dim, ( + conv_dim, + sharded_state_dict[f"{prefix}conv1d.weight"], + ) + + conv_split_sections, conv_split_names = _get_conv_checkpoint_split_layout( + self.d_inner_local_tp, self.ngroups_local_tp * self.d_state, self.num_householder + ) + for conv_param in ["conv1d.weight", "conv1d.bias"]: + key = f"{prefix}{conv_param}" + if key in sharded_state_dict: + sharded_state_dict[key] = _split_tensor_factory( + sharded_state_dict[key], conv_split_sections, conv_split_names, 0 + ) + + return sharded_state_dict + + +def _get_in_proj_checkpoint_split_layout( + d_inner_local_tp: int, group_state_local_tp: int, nheads_local_tp: int, num_householder: int +) -> Tuple[List[int], List[str]]: + """Return TP-reshardable splits for the packed ``[z,V,K,Q,b,a]`` projection.""" + sections = ( + [d_inner_local_tp] + + [d_inner_local_tp] * num_householder + + [group_state_local_tp] * num_householder + + [group_state_local_tp] + + [nheads_local_tp] * num_householder + + [nheads_local_tp] + ) + names = ( + ["z"] + + [f"V{i}" for i in range(num_householder)] + + [f"K{i}" for i in range(num_householder)] + + ["Q"] + + [f"b{i}" for i in range(num_householder)] + + ["a"] + ) + return sections, names + + +def _get_conv_checkpoint_split_layout( + d_inner_local_tp: int, group_state_local_tp: int, num_householder: int +) -> Tuple[List[int], List[str]]: + """Return TP-reshardable splits for the packed ``[V,K,Q]`` convolution.""" + sections = ( + [d_inner_local_tp] * num_householder + + [group_state_local_tp] * num_householder + + [group_state_local_tp] + ) + names = ( + [f"V{i}" for i in range(num_householder)] + + [f"K{i}" for i in range(num_householder)] + + ["Q"] + ) + return sections, names + + +def _split_tensor_factory( + orig_sh_ten: ShardedTensor, split_sections: List[int], split_names: List[str], split_dim: int +) -> ShardedTensorFactory: + """Builds a factory that splits a given ShardedTensor into several independent chunks.""" + assert isinstance(orig_sh_ten, ShardedTensor), type(orig_sh_ten) + orig_sh_ten_no_data = orig_sh_ten.without_data() # remove `data` reference + + if sum(split_sections) != orig_sh_ten_no_data.local_shape[split_dim]: + raise ValueError( + f"Split sections must cover the whole dimension size, " + f"got {split_sections=} vs dimensions size " + f"{orig_sh_ten_no_data.local_shape[split_dim]}" + ) + + assert not isinstance( + split_sections, int + ), "Splitting into predefined section sizes is supported (`split_sections` must be a list)" + assert len(split_sections) == len(split_names), (len(split_sections), len(split_names)) + + @torch.no_grad() + def sh_ten_build_fn( + key: str, t: torch.Tensor, replica_id: ReplicaId, flattened_range: Optional[slice] + ): + factory_sh_ten = replace( + orig_sh_ten_no_data, + key=key, + data=t, + dtype=t.dtype, + replica_id=replica_id, + flattened_range=flattened_range, + ) + + chunk_sh_tens = [] + split_start = 0 + for split_size, split_name in zip(split_sections, split_names): + split_chunks = factory_sh_ten.narrow(split_dim, split_start, split_size) + for sh_ten in split_chunks: + sh_ten.key = f"{sh_ten.key}.{split_name}" + chunk_sh_tens.extend(split_chunks) + split_start += split_size + + assert split_start == orig_sh_ten_no_data.local_shape[split_dim], ( + split_start, + orig_sh_ten_no_data.local_shape[split_dim], + ) + assert sum(sh_ten.data.numel() for sh_ten in chunk_sh_tens) == t.numel(), ( + chunk_sh_tens, + t.shape, + ) + return chunk_sh_tens + + @torch.no_grad() + def sh_ten_merge_fn(sub_state_dict): + return torch.cat(sub_state_dict) + + return ShardedTensorFactory( + orig_sh_ten.key, orig_sh_ten.data, sh_ten_build_fn, sh_ten_merge_fn, orig_sh_ten.replica_id + ) diff --git a/megatron/core/ssm/gdp_context_parallel.py b/megatron/core/ssm/gdp_context_parallel.py new file mode 100644 index 00000000000..8447ecdd87f --- /dev/null +++ b/megatron/core/ssm/gdp_context_parallel.py @@ -0,0 +1,340 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. + +""" +Context parallel support for Gated Delta Product (GDP) with num_householder > 1. + +The key difference from GDNContextParallel (which assumes a single copy of V/K/b) +is that GDP has `num_householder` copies of V, K, and b (beta). The in_proj output +layout is: + + [z(d_inner), V(d_inner*M), K(ngroups*d_state*M), Q(ngroups*d_state), b(nheads*M), a(nheads)] + +where M = num_householder. Similarly, the conv1d operates on: + + [V(d_inner*M), K(ngroups*d_state*M), Q(ngroups*d_state)] + +The all-to-all communication and parameter slicing must account for this. + +Strategy for householder-multiplied tensors (V, K, b): + We fold the M (householder) dimension into the batch dimension before calling + the standard all-to-all, then unfold afterward. This ensures each householder + copy is independently partitioned by heads across CP ranks. +""" + +from typing import Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from megatron.core.packed_seq_params import PackedSeqParams + +try: + from einops import repeat + + HAVE_EINOPS = True +except ImportError: + HAVE_EINOPS = False + +# Re-use the load balancing and all-to-all helpers from the existing module. +# The load-balancing helpers already handle packed (THD) input via their +# ``packed_seq_params`` argument, so GDP just threads it through below. +from megatron.core.ssm.mamba_context_parallel import ( + _all_to_all_cp2hp, + _all_to_all_hp2cp, + _redo_attention_load_balancing, + _undo_attention_load_balancing, +) + + +class GDPContextParallel: + """ + Context parallel support for Gated Delta Product (GDP) models with num_householder >= 1. + + Handles the "all-to-all" CP strategy where heads are partitioned across CP ranks + and each rank processes the full sequence for its head partition. Correctly handles + the num_householder multiplier on V, K, and beta projections. + + Args: + cp_group: The process group for context parallel. + d_inner_local_tp: d_inner on the current TP rank. + nheads_local_tp: nheads on the current TP rank. + ngroups_local_tp: ngroups on the current TP rank. + d_state: SSM state dimension. + num_householder: Number of householder reflections (M). + headdim: Dimension per head. + conv1d_cp1: The conv1d module for cp_size=1. + dt_bias_cp1: The dt_bias parameter for cp_size=1. + A_log_cp1: The A_log parameter for cp_size=1. + D_cp1: The D parameter for cp_size=1 (can be None). + D_has_hdim: Whether D is sized to the hidden dimension. + """ + + def __init__( + self, + cp_group: torch.distributed.ProcessGroup, + d_inner_local_tp: int, + nheads_local_tp: int, + ngroups_local_tp: int, + d_state: int, + num_householder: int, + headdim: int, + conv1d_cp1: nn.Conv1d, + dt_bias_cp1: torch.Tensor, + A_log_cp1: torch.Tensor, + D_cp1: torch.Tensor, + D_has_hdim: bool, + ) -> None: + if not HAVE_EINOPS: + raise ImportError("einops is required but cannot be imported") + + self.cp_group = cp_group + self.d_inner_local_tp = d_inner_local_tp + self.nheads_local_tp = nheads_local_tp + self.ngroups_local_tp = ngroups_local_tp + self.d_state = d_state + self.num_householder = num_householder + self.headdim = headdim + self.conv1d_cp1 = conv1d_cp1 + self.dt_bias_cp1 = dt_bias_cp1 + self.A_log_cp1 = A_log_cp1 + self.D_cp1 = D_cp1 + self.D_has_hdim = D_has_hdim + + self.cp_size = self.cp_group.size() + + M = self.num_householder + + if self.cp_size == 1: + self.d_inner_local_tpcp = self.d_inner_local_tp + self.nheads_local_tpcp = self.nheads_local_tp + self.ngroups_local_tpcp = self.ngroups_local_tp + return + + self.cp_rank = self.cp_group.rank() + + assert ( + self.nheads_local_tp % self.cp_size == 0 + ), "nheads must be evenly divisible by tp_size * cp_size" + self.nheads_local_tpcp = self.nheads_local_tp // self.cp_size + + self.d_inner_local_tpcp = self.d_inner_local_tp // self.cp_size + + # Group repeat logic (same as GDNContextParallel) + if self.ngroups_local_tp < self.cp_size: + assert ( + self.cp_size % self.ngroups_local_tp == 0 + ), "cp_size must be evenly divisible by ngroups/tp_size" + self.group_repeat_count = self.cp_size // self.ngroups_local_tp + self.ngroups_local_tpcp = 1 + else: + assert ( + self.ngroups_local_tp % self.cp_size == 0 + ), "ngroups must be evenly divisible by tp_size * cp_size" + self.group_repeat_count = 1 + self.ngroups_local_tpcp = self.ngroups_local_tp // self.cp_size + + def pre_conv_ssm( + self, input_: torch.Tensor, packed_seq_params: Optional[PackedSeqParams] = None + ) -> torch.Tensor: + """ + All-to-all from sequence-partitioned to head-partitioned layout, before conv + SSM. + + Input layout (last dim): + [z, V, K, Q, b, a] with sizes + [d_inner, d_inner*M, ngroups*d_state*M, ngroups*d_state, nheads*M, nheads] + + Output layout (last dim, after head partitioning): + [z, V, K, Q, b, a] with sizes + [d_inner/cp, d_inner/cp*M, ngroups_cp*d_state*M, + ngroups_cp*d_state, nheads/cp*M, nheads/cp] + + ``packed_seq_params`` must be passed for THD/SFT input — without it + the post-all-to-all undo uses the non-packed zigzag pattern, which + scrambles token order across pack boundaries. + """ + if self.cp_size == 1: + return input_ + + M = self.num_householder + l, b, _ = input_.shape + + z, V, K, Q, b_proj, a = torch.split( + input_, + [ + self.d_inner_local_tp, # z + self.d_inner_local_tp * M, # V (M copies) + self.ngroups_local_tp * self.d_state * M, # K (M copies) + self.ngroups_local_tp * self.d_state, # Q (single) + self.nheads_local_tp * M, # beta (M copies) + self.nheads_local_tp, # a (single) + ], + dim=-1, + ) + + # z: [l, b, d_inner] -> [l*cp, b, d_inner/cp] + z = _all_to_all_cp2hp(z, self.cp_group) + + # V: [l, b, M * d_inner] -> fold M into batch -> all-to-all -> unfold + # Layout within last dim is (m, h, p). Folding M into batch ensures each + # householder copy is independently split by heads. + V = V.view(l, b, M, self.d_inner_local_tp) + V = V.reshape(l, b * M, self.d_inner_local_tp) + V = _all_to_all_cp2hp(V, self.cp_group) # (l*cp, b*M, d_inner/cp) + V = V.reshape(l * self.cp_size, b, M * self.d_inner_local_tpcp) + + # K: [l, b, M * ngroups * d_state] -> group repeat each copy -> fold M -> all-to-all + K = K.view(l, b, M, self.ngroups_local_tp * self.d_state) + K_parts = [] + for i in range(M): + Ki = K[:, :, i, :] # (l, b, ngroups_tp * d_state) + Ki = repeat( + Ki, + "l b (g n) -> l b (g r n)", + g=self.ngroups_local_tp, + n=self.d_state, + r=self.group_repeat_count, + ) + K_parts.append(Ki) + K = torch.stack(K_parts, dim=2) # (l, b, M, ngroups_tp * r * d_state) + K = K.reshape(l, b * M, -1) + K = _all_to_all_cp2hp(K, self.cp_group) # (l*cp, b*M, ngroups_tpcp * d_state) + K = K.reshape(l * self.cp_size, b, M * self.ngroups_local_tpcp * self.d_state) + + # Q: [l, b, ngroups * d_state] -> group repeat -> all-to-all (single copy, no M) + Q = repeat( + Q, + "l b (g n) -> l b (g r n)", + g=self.ngroups_local_tp, + n=self.d_state, + r=self.group_repeat_count, + ) + Q = _all_to_all_cp2hp(Q, self.cp_group) # (l*cp, b, ngroups_tpcp * d_state) + + # b_proj (beta): [l, b, M * nheads] -> fold M -> all-to-all -> unfold + b_proj = b_proj.view(l, b, M, self.nheads_local_tp) + b_proj = b_proj.reshape(l, b * M, self.nheads_local_tp) + b_proj = _all_to_all_cp2hp(b_proj, self.cp_group) # (l*cp, b*M, nheads/cp) + b_proj = b_proj.reshape(l * self.cp_size, b, M * self.nheads_local_tpcp) + + # a: [l, b, nheads] -> [l*cp, b, nheads/cp] + a = _all_to_all_cp2hp(a, self.cp_group) + + output = torch.cat([z, V, K, Q, b_proj, a], dim=-1) + output = _undo_attention_load_balancing(output, self.cp_size, packed_seq_params) + + return output + + def post_conv_ssm( + self, input_: torch.Tensor, packed_seq_params: Optional[PackedSeqParams] = None + ) -> torch.Tensor: + """Method to be applied after the conv + SSM (on y and z, which have no M dim).""" + if self.cp_size == 1: + return input_ + else: + return _all_to_all_hp2cp( + _redo_attention_load_balancing(input_, self.cp_size, packed_seq_params), + self.cp_group, + ) + + def conv1d(self, input_: torch.Tensor) -> torch.Tensor: + """Performs conv1d using sliced weights for the current CP rank.""" + if self.cp_size == 1: + return self.conv1d_cp1(input_) + else: + return F.conv1d( + input=input_, + weight=self.get_conv1d_weight(), + bias=self.get_conv1d_bias(), + stride=self.conv1d_cp1.stride, + padding=self.conv1d_cp1.padding, + dilation=self.conv1d_cp1.dilation, + groups=self.conv1d_channels(), + ) + + def conv1d_channels(self): + """Number of conv channels on the current CP rank.""" + M = self.num_householder + return ( + self.d_inner_local_tpcp * M + + self.ngroups_local_tpcp * self.d_state * M + + self.ngroups_local_tpcp * self.d_state + ) + + def get_conv1d_weight(self) -> torch.Tensor: + """Returns sliced conv1d weight for the current CP rank.""" + return self._slice_conv_param(self.conv1d_cp1.weight) + + def get_conv1d_bias(self) -> torch.Tensor: + """Returns sliced conv1d bias for the current CP rank.""" + return self._slice_conv_param(self.conv1d_cp1.bias) + + def get_dt_bias(self) -> torch.Tensor: + """Returns sliced dt_bias for the current CP rank.""" + return self._slice_vector_param(self.dt_bias_cp1) + + def get_A_log(self) -> torch.Tensor: + """Returns sliced A_log for the current CP rank.""" + return self._slice_vector_param(self.A_log_cp1) + + def get_D(self) -> torch.Tensor: + """Returns sliced D for the current CP rank.""" + return self._slice_vector_param(self.D_cp1, has_hdim=self.D_has_hdim) + + def _slice_conv_param(self, param: torch.Tensor) -> torch.Tensor: + """ + Slices a cp_size=1 conv1d parameter along the channel dimension, + returning the channels needed on the current CP rank. + + Conv param layout (dim 0): + [V(d_inner * M), K(ngroups * d_state * M), Q(ngroups * d_state)] + + For V and K (which have M copies), we reshape to (M, per_copy_channels, ...), + slice the per-copy channels for this CP rank, then flatten back. + """ + if self.cp_size == 1 or param is None: + return param + + M = self.num_householder + extra_dims = param.shape[1:] # (1, d_conv) for weight, () for bias + + V, K, Q = torch.split( + param, + [ + self.d_inner_local_tp * M, + self.ngroups_local_tp * self.d_state * M, + self.ngroups_local_tp * self.d_state, + ], + dim=0, + ) + + # V: (M * d_inner_tp, ...) -> slice heads for this CP rank + V = V.view(M, self.d_inner_local_tp, *extra_dims) + v_size = self.d_inner_local_tpcp + v_start = self.cp_rank * v_size + V_sliced = V[:, v_start : v_start + v_size, ...].reshape(M * v_size, *extra_dims) + + # K: (M * ngroups_tp * d_state, ...) -> slice groups for this CP rank + K = K.view(M, self.ngroups_local_tp * self.d_state, *extra_dims) + k_size = self.ngroups_local_tpcp * self.d_state + k_start = (self.cp_rank // self.group_repeat_count) * k_size + K_sliced = K[:, k_start : k_start + k_size, ...].reshape(M * k_size, *extra_dims) + + # Q: (ngroups_tp * d_state, ...) -> slice groups (single copy, no M) + q_size = self.ngroups_local_tpcp * self.d_state + q_start = (self.cp_rank // self.group_repeat_count) * q_size + Q_sliced = Q[q_start : q_start + q_size, ...] + + return torch.cat([V_sliced, K_sliced, Q_sliced], dim=0).contiguous() + + def _slice_vector_param(self, param: torch.Tensor, has_hdim: bool = False) -> torch.Tensor: + """ + Slices a per-head vector parameter (dt_bias, A_log, D) for the current CP rank. + These are single-copy (no householder dimension). + """ + if self.cp_size == 1: + return param + + size = self.d_inner_local_tpcp if has_hdim else self.nheads_local_tpcp + start = self.cp_rank * size + return param[start : start + size] diff --git a/megatron/core/ssm/mamba_mixer.py b/megatron/core/ssm/mamba_mixer.py index e374592a125..80c8c894c55 100644 --- a/megatron/core/ssm/mamba_mixer.py +++ b/megatron/core/ssm/mamba_mixer.py @@ -305,6 +305,7 @@ def __init__( self.nheads_local_tp, # dt ] setattr(self.in_proj.weight, "partition_sizes", in_proj_partition_sizes) + setattr(self.in_proj.weight, "use_muon", False) if not self.use_mem_eff_path: log_single_rank( diff --git a/megatron/core/ssm/packed_seq_helpers.py b/megatron/core/ssm/packed_seq_helpers.py new file mode 100644 index 00000000000..c959f4b2793 --- /dev/null +++ b/megatron/core/ssm/packed_seq_helpers.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Shared helpers for SSM mixers handling packed (THD-format) sequences. + +Lifted from `MambaMixer._create_packed_seq_idx` so GDP, KDA, DPv2, GDN can +share a single reference implementation (avoids drift across mixers). +""" + +from __future__ import annotations + +from typing import Optional, Tuple + +import torch + +from megatron.core.packed_seq_params import PackedSeqParams +from megatron.core.utils import is_causal_conv1d_min_version + + +def get_cu_seqlens(packed_seq_params: PackedSeqParams) -> torch.Tensor: + """Pick the right cu_seqlens tensor (padded if available).""" + if packed_seq_params.cu_seqlens_q_padded is not None: + return packed_seq_params.cu_seqlens_q_padded + return packed_seq_params.cu_seqlens_q + + +def build_packed_seq_idx(packed_seq_params: PackedSeqParams, total_tokens: int) -> torch.Tensor: + """Build the per-token sequence index tensor used by varlen kernels. + + For ``packed_seq_params.cu_seqlens_q[_padded]`` of the form + ``[0, 5, 7, 11]`` and ``total_tokens=16`` returns + ``[0,0,0,0,0, 1,1, 2,2,2,2, 3,3,3,3,3]`` (shape ``[1, total_tokens]``, + int32). The trailing chunk after ``cu_seqlens[-1]`` is treated as one + extra sequence so the output covers every token in the pack. If + ``cu_seqlens[-1] == total_tokens`` no extra index is added. + + This is the per-token tensor consumed by ``causal_conv1d_fn(seq_idx=...)`` + and by Mamba's fused conv+SSM kernel as ``seq_idx``. + + ``total_tokens`` must equal the *post-parallelism-gather* sequence length + that the kernel will actually consume — not the caller's + ``hidden_states.shape[0]`` which may be sequence-parallel-sharded and/or + context-parallel-sliced. The robust pattern (mirrors ``mamba_mixer.py``) + is to call this *after* ``in_proj`` (SP all-gather) and ``pre_conv_ssm`` + (CP all-to-all), passing the post-gather tensor's seq dim — that way + the helper is agnostic to TP/SP/CP shapes upstream. + """ + cu_seqlens = get_cu_seqlens(packed_seq_params) + total_tokens_tensor = torch.tensor( + [total_tokens], dtype=cu_seqlens.dtype, device=cu_seqlens.device + ) + cu_seqlens_with_max = torch.cat([cu_seqlens, total_tokens_tensor]) + seq_lengths = cu_seqlens_with_max[1:] - cu_seqlens_with_max[:-1] + seq_idx = torch.repeat_interleave( + torch.arange(seq_lengths.numel(), device=cu_seqlens.device), + seq_lengths, + output_size=total_tokens, + ) + return seq_idx.to(torch.int32).unsqueeze(0) + + +def check_fla_sequence_packing_support() -> Tuple[bool, Optional[str]]: + """Lighter sibling of `_check_mamba_sequence_packing_support` for FLA-backed mixers. + + GDP/KDA/DPv2/GDN reach into FLA's chunk_kda / chunk_gated_delta_product / + chunk_gated_delta_rule, all of which manage their own variable-length + state internally. The only shared external dependency is the causal + conv1d kernel — `causal_conv1d_fn(seq_idx=...)` was added in 1.4.0 and + is required to reset the conv state at packed-document boundaries. + + Mamba2's stricter `mamba_ssm` minimums (used by `mamba_split_conv1d_scan_combined`) + do not apply. + """ + conv1d_min = "1.4.0" + if not is_causal_conv1d_min_version(conv1d_min): + return False, f"causal_conv1d >= {conv1d_min} is required for packed sequences" + return True, None diff --git a/megatron/core/transformer/spec_utils.py b/megatron/core/transformer/spec_utils.py index ba9c22f01b6..36c7001988b 100644 --- a/megatron/core/transformer/spec_utils.py +++ b/megatron/core/transformer/spec_utils.py @@ -1,13 +1,10 @@ # Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved. import functools -import logging import types from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, Tuple, Union -logger = logging.getLogger(__name__) - @dataclass class ModuleSpec: @@ -50,10 +47,15 @@ def import_module(module_path: Tuple[str]): base_path, name = module_path try: module = __import__(base_path, globals(), locals(), [name]) - except ImportError as e: - logger.error(f"couldn't import module due to {e}") - return None - return vars(module)[name] + except ImportError as exc: + raise ImportError( + f"Could not import module '{base_path}' for spec '{name}': {exc}" + ) from exc + + try: + return vars(module)[name] + except KeyError as exc: + raise ImportError(f"Could not find spec '{name}' in module '{base_path}'") from exc # pylint: disable=missing-function-docstring diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 1b6246613be..aa8af1b5c18 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -1196,6 +1196,9 @@ class TransformerConfig(ModelParallelConfig): """The number of heads used in Mamba layers. If None, the number of heads will be hidden_size * expand // mamba_head_dim.""" + gdp_num_householder: int = 3 + """The number of Householder reflections used in Gated Delta Product layers.""" + mamba_training_ssm_states_dtype: Optional[torch.dtype] = None """dtype of the materialized inter-chunk SSM states in Mamba training forwards and backwards. None causes the states to follow the activation dtype.""" @@ -1336,6 +1339,11 @@ def __post_init__(self): f"Only one of self.fp16: {self.fp16} and self.bf16 {self.bf16} should be True." ) + if self.gdp_num_householder < 1: + raise ValueError( + f"gdp_num_householder must be positive, got {self.gdp_num_householder}." + ) + # Apply BF16 matmul precision setting if needed if self.bf16 and self.disable_bf16_reduced_precision_matmul: torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index eac1be5c856..fa859664a46 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -173,6 +173,8 @@ def _compare(arg_name, old_arg_name=None, default=None): _compare('num_layers') _compare('hidden_size') _compare('num_attention_heads') + if hasattr(args, 'gdp_num_householder'): + _compare('gdp_num_householder', default=3) _compare('add_position_embedding', default=True) if args.vocab_file: _compare('max_position_embeddings') @@ -2105,6 +2107,10 @@ def _set_arg(arg_name, old_arg_name=None, force=False): _set_arg('mamba_head_dim', force=True) _set_arg('mamba_num_groups', force=True) _set_arg('mamba_num_heads', force=True) + # GDP checkpoints created before this argument existed always used three reflections. + if not hasattr(checkpoint_args, 'gdp_num_householder'): + setattr(checkpoint_args, 'gdp_num_householder', 3) + _set_arg('gdp_num_householder', force=True) # We need to be able to override hybrid_layer_pattern from the command-line so that different # pipelining can be specified when re-loading a model (e.g. for inference or post-training). _set_arg('hybrid_layer_pattern') diff --git a/megatron/training/training.py b/megatron/training/training.py index 51c177fc07d..ebfa93f6b87 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -493,6 +493,38 @@ def mamba_layer_flops(total_tokens, hidden_size, state_dim=16, + (2 * total_tokens * d_in * hidden_size) # out_proj ) + def gated_delta_product_layer_flops( + total_tokens, + hidden_size, + num_householder, + state_dim=128, + head_dim=64, + num_groups=8, + num_heads=None, + conv_kernel_dim=4, + ): + """Calculate FLOPs for a Gated Delta Product (GDP) layer.""" + if num_heads is None: + d_inner = 2 * hidden_size + num_heads = d_inner // head_dim + else: + d_inner = num_heads * head_dim + in_proj_dim = ( + d_inner * (1 + num_householder) + + num_groups * state_dim * (1 + num_householder) + + num_heads * (1 + num_householder) + ) + conv_dim = d_inner * num_householder + num_groups * state_dim * (1 + num_householder) + non_core_flops = 2 * total_tokens * ( + hidden_size * in_proj_dim + + conv_kernel_dim * conv_dim + + d_inner * hidden_size + ) + # Best-case recurrent GDP core estimate. The FLA chunk kernel may do additional + # score/solve/WY work, but this keeps the implementation-agnostic lower bound explicit. + core_flops = (4 * num_householder + 3) * total_tokens * d_inner * state_dim + return non_core_flops + core_flops + def gdn_layer_flops(total_tokens, hidden_size, qk_head_dim=128, v_head_dim=128, num_qk_heads=16, num_v_heads=32, @@ -521,6 +553,7 @@ def gdn_layer_flops(total_tokens, hidden_size, def hybrid_flops(total_tokens, seqlen_squared_sum, hidden_size, num_attn_layers, num_mamba_layers, num_mlp_layers, num_moe_layers, + gdp_num_householder, num_gdn_layers=0, mamba_state_dim=128, mamba_head_dim=64, mamba_num_groups=8, mamba_num_heads=128, @@ -529,20 +562,29 @@ def hybrid_flops(total_tokens, seqlen_squared_sum, hidden_size, mlp_expansion=4.0, swiglu=False, moe_latent_size=None, moe_ffn_hidden_size=2048, shared_expert_ffn_hidden_size=2048, num_experts_routed_to=1, + use_gated_delta_product=False, gdn_qk_head_dim=128, gdn_v_head_dim=128, gdn_num_qk_heads=16, gdn_num_v_heads=32, gdn_conv_kernel_dim=4, gdn_use_gdn2=False, vocab_size=256000, mtp_num_layers=0): """Calculate total FLOPs for the hybrid model.""" + mamba_flops = ( + gated_delta_product_layer_flops(total_tokens, hidden_size, + gdp_num_householder, + mamba_state_dim, mamba_head_dim, + mamba_num_groups, mamba_num_heads) + if use_gated_delta_product + else mamba_layer_flops(total_tokens, hidden_size, + mamba_state_dim, mamba_head_dim, + mamba_num_groups, mamba_num_heads) + ) flops_fwd = ( num_attn_layers * attn_layer_flops(total_tokens, seqlen_squared_sum, hidden_size, num_attn_heads, gqa, gqa_groups, kv_channels) + num_mlp_layers * mlp_layer_flops(total_tokens, hidden_size, mlp_expansion, swiglu) + - num_mamba_layers * mamba_layer_flops(total_tokens, hidden_size, - mamba_state_dim, mamba_head_dim, - mamba_num_groups, mamba_num_heads) + + num_mamba_layers * mamba_flops + num_moe_layers * moe_layer_flops(total_tokens, hidden_size, moe_ffn_hidden_size, shared_expert_ffn_hidden_size, num_experts_routed_to, moe_latent_size, swiglu) + @@ -858,6 +900,24 @@ def transformer_flops(): ) return total_floating_point_operations + def _uses_gated_delta_product_spec(args): + """Return True when the selected hybrid stack spec swaps Mamba layers to GDP.""" + def _split_spec_part(part): + return str(part).replace('[', ' ').replace(']', ' ').replace(',', ' ').split() + + spec = getattr(args, 'spec', None) + if spec is None: + return False + if isinstance(spec, str): + spec_parts = _split_spec_part(spec) + else: + spec_parts = [] + for part in spec: + spec_parts.extend(_split_spec_part(part)) + if not spec_parts: + return False + return spec_parts[-1] in {'gdp_stack_spec', 'gated_delta_product_stack_spec'} + # Main entrypoint for FLOPs calculation. if is_hybrid_model(args): # Calculate the number of each type of layer. @@ -890,12 +950,14 @@ def transformer_flops(): mamba_head_dim=args.mamba_head_dim, mamba_num_groups=args.mamba_num_groups, mamba_num_heads=args.mamba_num_heads, + gdp_num_householder=args.gdp_num_householder, num_attn_heads=args.num_attention_heads, gqa=args.group_query_attention, gqa_groups=args.num_query_groups, kv_channels=args.kv_channels, mlp_expansion=args.ffn_hidden_size / args.hidden_size, swiglu=args.swiglu, + use_gated_delta_product=_uses_gated_delta_product_spec(args), moe_latent_size=args.moe_latent_size, moe_ffn_hidden_size=(args.moe_ffn_hidden_size if args.moe_ffn_hidden_size is not None else args.ffn_hidden_size), diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index c2c00ae4d0b..6f3688b4385 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -134,6 +134,7 @@ "fused_residual_rmsnorm": False, "fused_single_qkv_rope": False, "gated_linear_unit": False, + "gdp_num_householder": 3, "gtp_remat_opt_in_modules": [], "gtp_weight_remat_size": 1, "glu_linear_offset": 0.0, diff --git a/tests/unit_tests/ssm/test_gdp_packed_seq.py b/tests/unit_tests/ssm/test_gdp_packed_seq.py new file mode 100644 index 00000000000..417f74b6183 --- /dev/null +++ b/tests/unit_tests/ssm/test_gdp_packed_seq.py @@ -0,0 +1,320 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""GDP v4 packed-sequence + context-parallel equivalence tests. + +Verifies that ``GatedDeltaProductMixer`` (v4) produces forward outputs and +parameter gradients under ``cp_size=2`` that match a ``cp_size=1`` reference +run on the same packed (THD/SFT-format) input. + +Style mirrors ``test_mamba_context_parallel.py``: ``Utils.initialize_model_parallel``, +``@pytest.mark.internal``, fixed-seed bf16 tensors, tolerance via +``torch.testing.assert_close``. + +Run with:: + + torchrun --nproc_per_node=2 -m pytest \\ + tests/unit_tests/ssm/test_gdp_packed_seq.py -m internal -v +""" + +from __future__ import annotations + +import os +from typing import List + +import pytest +import torch + +from megatron.core import parallel_state +from megatron.core.extensions.transformer_engine import ( + TELayerNormColumnParallelLinear, + TERowParallelLinear, +) +from megatron.core.packed_seq_params import PackedSeqParams +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.ssm.gated_delta_product import ( + GatedDeltaProductMixer, + GatedDeltaProductMixerSubmodules, +) +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer import TransformerConfig +from tests.unit_tests.test_utilities import Utils + +try: + import einops # noqa: F401 + import mamba_ssm # noqa: F401 + + HAVE_MAMBA_DEPS = True +except ImportError: + HAVE_MAMBA_DEPS = False + +try: + import fla # noqa: F401 + + HAVE_FLA = True +except ImportError: + HAVE_FLA = False + + +# Skip the whole file when bare ``pytest`` is invoked outside torchrun. The +# CP=2 reference setup needs a multi-rank world to be meaningful; without a +# distributed launcher the fixture would either fail loudly or worse, hang. +# Reported as SKIPPED with a clear ``reason`` so contributors running the +# unit suite locally see why and how to invoke it properly. The torchrun +# command sets WORLD_SIZE in the environment for every worker process. +_WORLD_SIZE = int(os.environ.get("WORLD_SIZE", "1")) +pytestmark = [ + pytest.mark.internal, + pytest.mark.skipif( + _WORLD_SIZE < 2, + reason=( + "CP=2 equivalence test requires a multi-rank world; run via " + "``torchrun --nproc_per_node=2 -m pytest ... -m internal``." + ), + ), +] + + +# Pack shapes used to parametrize both forward and backward equivalence tests. +# Each segment length must be a multiple of ``2 * cp_size = 4`` so that +# ``tex.thd_get_partitioned_indices`` can split the pack evenly across CP +# ranks (mirrors ``sft_dataset.py``'s pad_granularity). +PACK_SHAPES = [ + pytest.param([16, 8, 24], id="headline"), + pytest.param([48], id="single-long"), + pytest.param([8, 8, 8, 8, 8, 8], id="many-short"), + pytest.param([4, 20, 12, 12], id="mixed-short-long"), + pytest.param([40, 4, 4], id="head-heavy"), + pytest.param([4, 4, 40], id="tail-heavy"), +] + + +def _make_packed_seq_params(seq_lens: List[int]) -> PackedSeqParams: + """Build a PackedSeqParams for a single THD pack with these segment lengths.""" + cu = torch.tensor( + [0] + list(torch.cumsum(torch.tensor(seq_lens), 0).tolist()), + dtype=torch.int32, + device="cuda", + ) + total = int(cu[-1].item()) + return PackedSeqParams( + qkv_format="thd", + cu_seqlens_q=cu, + cu_seqlens_kv=cu, + cu_seqlens_q_padded=None, + cu_seqlens_kv_padded=None, + max_seqlen_q=total, + max_seqlen_kv=total, + ) + + +def _make_config(cp_size: int) -> TransformerConfig: + """Small-but-shape-valid TransformerConfig for the v4 GDP mixer.""" + return TransformerConfig( + num_layers=1, + hidden_size=64, + num_attention_heads=4, + num_query_groups=4, + ffn_hidden_size=128, + normalization="RMSNorm", + bf16=True, + mamba_num_heads=4, + mamba_head_dim=16, + mamba_num_groups=4, + mamba_state_dim=16, + tensor_model_parallel_size=1, + sequence_parallel=False, + context_parallel_size=cp_size, + ) + + +def _build_mixer(cp_group): + """Construct a v4 GDP mixer wired to the given CP group.""" + config = _make_config(cp_group.size()) + pg = ProcessGroupCollection(tp=parallel_state.get_tensor_model_parallel_group(), cp=cp_group) + submodules = GatedDeltaProductMixerSubmodules( + in_proj=TELayerNormColumnParallelLinear, out_proj=TERowParallelLinear + ) + mixer = GatedDeltaProductMixer( + config=config, + submodules=submodules, + d_model=config.hidden_size, + layer_number=1, + pg_collection=pg, + name="decoder.layers.0.mixer", + ) + return mixer.cuda().bfloat16(), config + + +def _sync_weights_from_rank0(mixer): + """Broadcast every parameter from global rank 0 so all ranks share weights.""" + for p in mixer.parameters(): + torch.distributed.broadcast(p.data, src=0) + + +def _build_cp_pair(): + """Build CP=2 + per-rank CP=1 reference mixers with identical weights. + + Returns ``(mixer_cp2, mixer_cp1, config, cp_group, cp_rank)``. The cp=1 + instance lives in a 1-rank subgroup (containing only this rank), so the + same mixer code path runs in cp=1 mode and provides a numerical reference. + """ + cp_group = parallel_state.get_context_parallel_group() + cp_rank = parallel_state.get_context_parallel_rank() + global_rank = torch.distributed.get_rank() + world_size = torch.distributed.get_world_size() + + cp1_groups = [torch.distributed.new_group(ranks=[r]) for r in range(world_size)] + cp1_group = cp1_groups[global_rank] + + mixer_cp2, _ = _build_mixer(cp_group) + mixer_cp1, config = _build_mixer(cp1_group) + _sync_weights_from_rank0(mixer_cp2) + mixer_cp1.load_state_dict(mixer_cp2.state_dict()) + return mixer_cp2, mixer_cp1, config, cp_group, cp_rank + + +def _make_hidden_packed(seq_lens, hidden_size): + """Build a packed [total_tokens, 1, hidden] input + matching PackedSeqParams. + + The tensor is broadcast from rank 0 so cp=1 reference and cp=2 sliced + paths see bit-identical input. + """ + psp = _make_packed_seq_params(seq_lens) + total_tokens = sum(seq_lens) + torch.manual_seed(0) + hidden_full = torch.randn(total_tokens, 1, hidden_size, device="cuda", dtype=torch.bfloat16) + torch.distributed.broadcast(hidden_full, src=0) + return hidden_full, psp + + +@pytest.mark.internal +@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA + NCCL") +@pytest.mark.skipif( + torch.cuda.device_count() < 2 if torch.cuda.is_available() else True, + reason="CP=2 test requires at least 2 GPUs", +) +@pytest.mark.skipif(not HAVE_MAMBA_DEPS, reason="GDP mixer requires mamba_ssm + einops") +@pytest.mark.skipif(not HAVE_FLA, reason="GDP mixer requires fla") +class TestGDPPackedSequence: + """v4 GDP forward + backward equivalence under CP=2 with packed (THD) input.""" + + @pytest.fixture(autouse=True) + def setup_method(self): + """Initialize TP=1 PP=1 CP=2 model parallel state for every test.""" + Utils.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, context_parallel_size=2 + ) + model_parallel_cuda_manual_seed(123) + yield + Utils.destroy_model_parallel() + + @pytest.mark.parametrize("seq_lens", PACK_SHAPES) + def test_forward_equivalence(self, seq_lens): + """CP=2 forward output (sliced+gathered) matches CP=1 reference on the + same packed input to bf16 tolerance. + """ + import transformer_engine_torch as tex + + mixer_cp2, mixer_cp1, config, cp_group, cp_rank = _build_cp_pair() + mixer_cp2.eval() + mixer_cp1.eval() + + hidden_full, psp = _make_hidden_packed(seq_lens, config.hidden_size) + total_tokens = hidden_full.shape[0] + + with torch.no_grad(): + ref_out = mixer_cp1(hidden_full, packed_seq_params=psp) + ref_out = ref_out[0] if isinstance(ref_out, tuple) else ref_out + assert ref_out.shape == hidden_full.shape + + idx = tex.thd_get_partitioned_indices( + psp.cu_seqlens_q, total_tokens, cp_group.size(), cp_rank + ) + hidden_local = hidden_full.index_select(0, idx) + + with torch.no_grad(): + cp2_out_local = mixer_cp2(hidden_local, packed_seq_params=psp) + cp2_out_local = cp2_out_local[0] if isinstance(cp2_out_local, tuple) else cp2_out_local + + # Scatter the per-rank slice back to its original positions, then + # all-reduce(SUM) across the CP group to reconstruct the full output. + cp2_out_full = torch.zeros_like(ref_out) + scatter_index = idx.long().view(-1, 1, 1).expand_as(cp2_out_local) + cp2_out_full.scatter_(0, scatter_index, cp2_out_local) + torch.distributed.all_reduce(cp2_out_full, group=cp_group) + + torch.testing.assert_close(cp2_out_full, ref_out, atol=5e-2, rtol=5e-2) + + @pytest.mark.parametrize("seq_lens", PACK_SHAPES) + def test_backward_equivalence(self, seq_lens): + """CP=2 parameter gradients (after all-reduce across CP) match CP=1 + reference gradients on the same packed input. + + Mechanics: weights are replicated across CP ranks, so for any scalar + loss ``L_full`` computed over the full output, + ``dL_full/dW = sum_{r in cp_ranks} dL_local/dW`` where ``L_local`` is + the same loss restricted to the rank's local output slice. + + Loss = ``out.float().pow(2).sum()`` so every output element + contributes — exercises every weight that feeds the output. + Tolerance is looser than the forward test: bf16 backward accumulates + error through the chain of matmuls and the all-to-all forward+backward + in CP. + """ + import transformer_engine_torch as tex + + mixer_cp2, mixer_cp1, config, cp_group, cp_rank = _build_cp_pair() + mixer_cp2.eval() + mixer_cp1.eval() + for p in mixer_cp1.parameters(): + p.grad = None + for p in mixer_cp2.parameters(): + p.grad = None + + hidden_full, psp = _make_hidden_packed(seq_lens, config.hidden_size) + total_tokens = hidden_full.shape[0] + + # CP=1 reference: full-sequence forward + backward. + ref_out = mixer_cp1(hidden_full, packed_seq_params=psp) + ref_out = ref_out[0] if isinstance(ref_out, tuple) else ref_out + ref_loss = ref_out.float().pow(2).sum() + ref_loss.backward() + + # CP=2: local slice forward + backward. ``L_full = sum_{r in cp_ranks} L_local`` + # by construction (sum of squares is additive across token partitions), + # so the all-reduced grad equals the reference grad up to bf16 noise. + idx = tex.thd_get_partitioned_indices( + psp.cu_seqlens_q, total_tokens, cp_group.size(), cp_rank + ) + hidden_local = hidden_full.index_select(0, idx) + out_local = mixer_cp2(hidden_local, packed_seq_params=psp) + out_local = out_local[0] if isinstance(out_local, tuple) else out_local + loss_local = out_local.float().pow(2).sum() + loss_local.backward() + + cp1_params = dict(mixer_cp1.named_parameters()) + cp2_params = dict(mixer_cp2.named_parameters()) + assert set(cp1_params) == set(cp2_params) + + mismatches = [] + n_compared = 0 + for name in sorted(cp1_params): + g1 = cp1_params[name].grad + g2 = cp2_params[name].grad + if g1 is None and g2 is None: + continue + assert g1 is not None, f"cp=1 has no grad for {name} but cp=2 does" + assert g2 is not None, f"cp=2 has no grad for {name} but cp=1 does" + g2_reduced = g2.clone().contiguous() + torch.distributed.all_reduce(g2_reduced, group=cp_group) + try: + torch.testing.assert_close(g2_reduced, g1, atol=8e-2, rtol=8e-2) + n_compared += 1 + except AssertionError as e: + mismatches.append((name, tuple(g1.shape), str(e).splitlines()[0])) + assert not mismatches, ( + f"{len(mismatches)} parameter(s) mismatched out of " + f"{n_compared + len(mismatches)} compared:\n" + + "\n".join(f" {n} {s}: {m}" for n, s, m in mismatches) + ) + assert n_compared > 0, "no parameters received a gradient — test setup is wrong" diff --git a/tests/unit_tests/ssm/test_gdp_tp_checkpoint.py b/tests/unit_tests/ssm/test_gdp_tp_checkpoint.py new file mode 100644 index 00000000000..2636e95ab8b --- /dev/null +++ b/tests/unit_tests/ssm/test_gdp_tp_checkpoint.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Regression tests for GDP tensor-parallel checkpoint resharding.""" + +import inspect +from collections import defaultdict + +import torch + +from megatron.core.dist_checkpointing import ShardedTensor +from megatron.core.ssm.gated_delta_product import ( + GatedDeltaProductMixer, + _get_in_proj_checkpoint_split_layout, + _split_tensor_factory, +) + + +def test_constructor_drops_unused_mamba_compatibility_arguments(): + """GDP dimensions come from TransformerConfig, not ignored constructor overrides.""" + parameters = inspect.signature(GatedDeltaProductMixer.__init__).parameters + removed_parameters = { + "expand", + "dt_init", + "dt_scale", + "use_mem_eff_path", + "d_state", + "headdim", + "ngroups", + } + assert parameters.keys().isdisjoint(removed_parameters) + + +def test_householder_components_reshard_tp2_to_tp1_in_semantic_order(): + """Each householder copy must gather across TP ranks before copies are concatenated.""" + num_householder = 3 + local_sections, names = _get_in_proj_checkpoint_split_layout( + d_inner_local_tp=2, + group_state_local_tp=1, + nheads_local_tp=1, + num_householder=num_householder, + ) + + # Local layout is [z, V0, V1, V2, K0, K1, K2, Q, b0, b1, b2, a]. + rank_data = [ + torch.tensor([0, 1, 10, 11, 20, 21, 30, 31, 40, 50, 60, 70, 80, 90, 100, 110]), + torch.tensor([2, 3, 12, 13, 22, 23, 32, 33, 41, 51, 61, 71, 81, 91, 101, 111]), + ] + + checkpoint_chunks = defaultdict(list) + for tp_rank, local_data in enumerate(rank_data): + sharded_tensor = ShardedTensor.from_rank_offsets( + "in_proj.weight", local_data, (0, tp_rank, 2) + ) + factory = _split_tensor_factory(sharded_tensor, local_sections, names, split_dim=0) + for chunk in factory.build(): + checkpoint_chunks[chunk.key].append(chunk.data) + + # Simulate DCP assembling every semantic checkpoint key for a TP=1 load. + assembled_checkpoint = { + key: torch.cat(chunks, dim=0) for key, chunks in checkpoint_chunks.items() + } + + global_sections, global_names = _get_in_proj_checkpoint_split_layout( + d_inner_local_tp=4, + group_state_local_tp=2, + nheads_local_tp=2, + num_householder=num_householder, + ) + target_tensor = ShardedTensor.from_rank_offsets( + "in_proj.weight", torch.empty(sum(global_sections), dtype=torch.int64), (0, 0, 1) + ) + target_factory = _split_tensor_factory( + target_tensor, global_sections, global_names, split_dim=0 + ) + loaded_chunks = [assembled_checkpoint[chunk.key] for chunk in target_factory.build()] + reloaded = target_factory.merge_fn(loaded_chunks) + + expected = torch.tensor( + [ + 0, + 1, + 2, + 3, + 10, + 11, + 12, + 13, + 20, + 21, + 22, + 23, + 30, + 31, + 32, + 33, + 40, + 41, + 50, + 51, + 60, + 61, + 70, + 71, + 80, + 81, + 90, + 91, + 100, + 101, + 110, + 111, + ] + ) + torch.testing.assert_close(reloaded, expected) diff --git a/tests/unit_tests/test_checkpointing.py b/tests/unit_tests/test_checkpointing.py index 2e717e4424f..15ab11458ae 100644 --- a/tests/unit_tests/test_checkpointing.py +++ b/tests/unit_tests/test_checkpointing.py @@ -24,6 +24,7 @@ _build_sharded_state_dict_metadata, _load_base_checkpoint, get_checkpoint_tracker_filename, + load_args_from_checkpoint, load_checkpoint, maybe_save_dataloader_state, read_metadata, @@ -149,6 +150,31 @@ def test_maybe_save_dataloader_state_skips_empty_state_after_barriers(tmp_path): save.assert_not_called() +@pytest.mark.parametrize( + ("checkpoint_args", "configured_num_householder", "expected_num_householder"), + [(SimpleNamespace(gdp_num_householder=5), 3, 5), (SimpleNamespace(), 5, 3)], +) +def test_load_args_restores_gdp_num_householder_from_checkpoint( + checkpoint_args, configured_num_householder, expected_num_householder +): + args = SimpleNamespace( + load="checkpoint", + iteration=0, + gdp_num_householder=configured_num_householder, + use_tokenizer_model_from_checkpoint_args=False, + use_mp_args_from_checkpoint_args=False, + ) + state_dict = {"args": checkpoint_args, "iteration": 12} + + with mock.patch( + "megatron.training.checkpointing._load_base_checkpoint", + return_value=(state_dict, "checkpoint", False, CheckpointType.LEGACY), + ): + restored_args, _ = load_args_from_checkpoint(args) + + assert restored_args.gdp_num_householder == expected_num_householder + + def create_checkpoint(load_path, ckpt_format): """Setup a dummy checkpoint directory.""" iteration = 123 diff --git a/tests/unit_tests/test_num_floating_point_operations.py b/tests/unit_tests/test_num_floating_point_operations.py index f358ada8fc1..df5e4191843 100644 --- a/tests/unit_tests/test_num_floating_point_operations.py +++ b/tests/unit_tests/test_num_floating_point_operations.py @@ -96,6 +96,7 @@ def _make_hybrid_args(*, num_layers=4, hidden_size=512, num_attention_heads=8, s args.mamba_head_dim = 64 args.mamba_num_groups = 8 args.mamba_num_heads = 128 + args.gdp_num_householder = 3 return args @@ -255,6 +256,36 @@ def test_hybrid_attention_layers_count(self): assert flops_doubled - flops_bshd == expected_delta +class TestGatedDeltaProductFlops: + """GDP FLOPs must use the Householder count from the model configuration.""" + + def test_householder_count_changes_flops(self): + args = _make_hybrid_args() + args.spec = ["megatron.core.models.hybrid.hybrid_layer_specs", "gdp_stack_spec"] + batch_size = 4 + + flops_m3 = num_floating_point_operations(args, batch_size) + args.gdp_num_householder = 4 + flops_m4 = num_floating_point_operations(args, batch_size) + + total_tokens = batch_size * args.seq_length + d_inner = args.mamba_num_heads * args.mamba_head_dim + group_state_dim = args.mamba_num_groups * args.mamba_state_dim + forward_delta_per_layer = ( + 2 + * total_tokens + * ( + args.hidden_size * (d_inner + group_state_dim + args.mamba_num_heads) + + 4 * (d_inner + group_state_dim) + ) + + 4 * total_tokens * d_inner * args.mamba_state_dim + ) + num_gdp_layers = 2 + expected_delta = 3 * num_gdp_layers * forward_delta_per_layer + + assert flops_m4 - flops_m3 == expected_delta + + class TestPaddingRemoval: """``total_real_tokens_in_batch`` removes padding from token-linear FLOPs. diff --git a/tests/unit_tests/transformer/test_spec_utils.py b/tests/unit_tests/transformer/test_spec_utils.py index e464b09380e..6e86e4d7d2f 100644 --- a/tests/unit_tests/transformer/test_spec_utils.py +++ b/tests/unit_tests/transformer/test_spec_utils.py @@ -5,7 +5,12 @@ import pytest -from megatron.core.transformer.spec_utils import ModuleSpec, build_module, get_submodules +from megatron.core.transformer.spec_utils import ( + ModuleSpec, + build_module, + get_submodules, + import_module, +) def dummy_method(x: int, y: str) -> dict: @@ -79,6 +84,26 @@ def test_build_module_by_call(self): assert mixed.y == 'ghi' +class TestImportModule: + """Unit tests for dynamic spec imports.""" + + def test_missing_module_raises(self): + with pytest.raises( + ImportError, match="Could not import module 'megatron.core.models.does_not_exist'" + ): + import_module(('megatron.core.models.does_not_exist', 'missing_spec')) + + def test_missing_spec_raises(self): + with pytest.raises( + ImportError, + match=( + "Could not find spec 'does_not_exist' in module " + "'megatron.core.transformer.identity_op'" + ), + ): + import_module(('megatron.core.transformer.identity_op', 'does_not_exist')) + + class OtherChild: def __init__(self, x: int): self.x = x diff --git a/tests/unit_tests/transformer/test_transformer_config.py b/tests/unit_tests/transformer/test_transformer_config.py index febb3842789..24339c12b5a 100644 --- a/tests/unit_tests/transformer/test_transformer_config.py +++ b/tests/unit_tests/transformer/test_transformer_config.py @@ -30,3 +30,28 @@ def test_ep_a2a_overlap_accepts_supported_mtp_layer_counts(mtp_num_layers: int | def test_ep_a2a_overlap_rejects_unsupported_mtp_layer_counts(mtp_num_layers: int): with pytest.raises(AssertionError, match="MTP supports at most one layer"): _make_overlap_config(mtp_num_layers) + + +def test_gdp_num_householder_defaults_to_three(): + config = TransformerConfig(num_layers=1, hidden_size=128, num_attention_heads=4) + + assert config.gdp_num_householder == 3 + + +def test_gdp_num_householder_accepts_positive_values(): + config = TransformerConfig( + num_layers=1, hidden_size=128, num_attention_heads=4, gdp_num_householder=5 + ) + + assert config.gdp_num_householder == 5 + + +@pytest.mark.parametrize("num_householder", [0, -1]) +def test_gdp_num_householder_rejects_non_positive_values(num_householder: int): + with pytest.raises(ValueError, match="gdp_num_householder must be positive"): + TransformerConfig( + num_layers=1, + hidden_size=128, + num_attention_heads=4, + gdp_num_householder=num_householder, + ) From 1d402ad6e2609daed44390bc184e4e01522cf334 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 11 Aug 2026 08:59:43 -0700 Subject: [PATCH 253/290] fix(mfsdp): refresh V2 compute weights after optimizer step (#6336) Signed-off-by: Jingyue Wu Co-authored-by: Claude Opus 5 (1M context) --- .../megatron_fsdp/experimental/checkpoint.py | 21 ++----------------- .../megatron_fsdp/experimental/optimizer.py | 16 +++++--------- .../experimental/parameter_group.py | 17 +++++++++++++++ .../core/optimizer/fully_sharded_optimizer.py | 6 +++++- .../mfsdp_v2/test_mcore_adapter.py | 4 ++-- 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py index c9dc44b04db..7502cedb93a 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/checkpoint.py @@ -41,28 +41,11 @@ ) from ..uneven_dtensor import preprocess_state_dict_for_uneven_dtensor -from .module import FsdpModule +from .parameter_group import sync_model_weights_from_main_weights __all__ = ["save_checkpoint", "load_checkpoint"] -def _sync_model_weight_from_main_weight(model: torch.nn.Module) -> None: - """Refresh every FSDP group's compute weights from its (loaded) main weights. - - A load writes into the ``main_weight``-backed sharded DTensors. When mixed precision keeps a - separate lower-precision compute buffer, that buffer is stale until the next forward pre-hook - would resync it; doing it here makes the post-load state deterministic. It is a no-op when the - compute buffer aliases the main buffer. - - Args: - model: Root module (or any module tree) containing ``FsdpModule`` instances. - """ - for module in model.modules(): - if isinstance(module, FsdpModule): - for parameter_group in module.parameter_groups: - parameter_group.sync_model_weight_from_main_weight() - - def _init_optimizer_state(optimizer: torch.optim.Optimizer) -> None: """Allocate optimizer state so a DCP load has DTensors to fill. @@ -139,4 +122,4 @@ def load_checkpoint( set_model_state_dict(model, model_state_dict) set_optimizer_state_dict(model, optimizer, optimizer_state_dict) if sync_model_weights: - _sync_model_weight_from_main_weight(model) + sync_model_weights_from_main_weights(model.parameters()) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py index f1617141569..c5e4b28f211 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/optimizer.py @@ -14,12 +14,13 @@ """Optimizer adapter for the minimal Megatron-FSDP path.""" +from itertools import chain from typing import Any, NamedTuple import torch from torch import nn -from .parameter_group import FsdpParameterGroup, get_containing_parameter_group +from .parameter_group import get_containing_parameter_group, sync_model_weights_from_main_weights def fully_shard_optimizer( @@ -109,16 +110,9 @@ def step_post_hook( set_grad(parameter, original_grad) casted_grads.clear() - fsdp_parameter_groups: set[FsdpParameterGroup] = set() - for optimizer_group in hooked_optimizer.param_groups: - for parameter in optimizer_group["params"]: - parameter_group = get_containing_parameter_group(parameter) - if parameter_group is None: - continue - fsdp_parameter_groups.add(parameter_group) - - for parameter_group in fsdp_parameter_groups: - parameter_group.sync_model_weight_from_main_weight() + sync_model_weights_from_main_weights( + chain.from_iterable(group["params"] for group in hooked_optimizer.param_groups) + ) optimizer.register_step_pre_hook(step_pre_hook) optimizer.register_step_post_hook(step_post_hook) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py index 32241d8f4ea..59bd7d7b569 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/parameter_group.py @@ -14,6 +14,7 @@ """Parameter-group runtime state for the minimal Megatron-FSDP path.""" +from collections.abc import Iterable from contextlib import nullcontext from dataclasses import dataclass from weakref import ReferenceType, ref @@ -41,6 +42,22 @@ def get_containing_parameter_group(parameter: nn.Parameter) -> "FsdpParameterGro return parameter_group_ref() +def sync_model_weights_from_main_weights(parameters: Iterable[nn.Parameter]) -> None: + """Refresh MFSDP compute weights for parameter groups represented by ``parameters``. + + Parameters outside the experimental MFSDP path are ignored. A parameter group + may own multiple parameters, but its compute-weight buffer is refreshed once. + """ + seen_parameter_groups = set() + for parameter in parameters: + if (parameter_group := get_containing_parameter_group(parameter)) is None: + continue + if parameter_group in seen_parameter_groups: + continue + seen_parameter_groups.add(parameter_group) + parameter_group.sync_model_weight_from_main_weight() + + @dataclass(frozen=True, eq=False) class FsdpParameter: """One physical parameter and its FSDP runtime representations.""" diff --git a/megatron/core/optimizer/fully_sharded_optimizer.py b/megatron/core/optimizer/fully_sharded_optimizer.py index 18c2354dcb2..9c70c5b6f86 100644 --- a/megatron/core/optimizer/fully_sharded_optimizer.py +++ b/megatron/core/optimizer/fully_sharded_optimizer.py @@ -8,6 +8,9 @@ from ..config_logger import has_config_logger_enabled, log_config_to_disk from ..dist_checkpointing.mapping import ShardedStateDict +from ..distributed.fsdp.src.megatron_fsdp.experimental.parameter_group import ( + sync_model_weights_from_main_weights, +) from ..transformer.module import MegatronModule from .grad_scaler import MegatronGradScaler from .optimizer import MixedPrecisionOptimizer @@ -120,7 +123,8 @@ def _copy_model_grads_to_main_grads(self) -> None: """No-op: MFSDP v2 reduces directly into optimizer-visible sharded grads.""" def _copy_main_params_to_model_params(self) -> None: - """No-op: MFSDP v2 currently syncs compute weights in its forward pre-hook.""" + """Refresh MFSDP V2 compute weights after updating optimizer weights.""" + sync_model_weights_from_main_weights(self.get_parameters()) def _copy_model_params_to_main_params(self, state_dict=None) -> None: """No-op: model loads already write into MFSDP v2's main weights.""" diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py index a132ff88139..18f236d6bb6 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py @@ -164,7 +164,7 @@ def test_build_train_and_step(self): torch.randn(8, 2, config.hidden_size, device="cuda", dtype=torch.bfloat16) for _ in range(2) ] - for _ in range(3) + for _ in range(10) ] reference_losses = [] @@ -198,4 +198,4 @@ def test_build_train_and_step(self): reference_losses = torch.stack(reference_losses) assert torch.isfinite(losses).all() assert torch.isfinite(reference_losses).all() - torch.testing.assert_close(losses, reference_losses, rtol=1e-2, atol=0) + torch.testing.assert_close(losses, reference_losses, rtol=1e-3, atol=0) From c3d44dcf141ae400ca18f2d67f8259ac449240ae Mon Sep 17 00:00:00 2001 From: chengcuiping <96756894+chengcuiping@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:22:43 +0800 Subject: [PATCH 254/290] [core] Clarify GPT output_processor callback contract (#5739) Signed-off-by: chengcuiping <96756894+chengcuiping@users.noreply.github.com> Signed-off-by: svcnvidia-nemo-ci Signed-off-by: janEbert Co-authored-by: svcnvidia-nemo-ci Co-authored-by: janEbert --- .../models/common/model_chunk_schedule_plan.py | 2 +- megatron/core/models/gpt/gpt_model.py | 6 +++--- tests/unit_tests/models/test_gpt_model.py | 16 ++++++++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/megatron/core/models/common/model_chunk_schedule_plan.py b/megatron/core/models/common/model_chunk_schedule_plan.py index 35fa97b3d38..bda35c3993a 100644 --- a/megatron/core/models/common/model_chunk_schedule_plan.py +++ b/megatron/core/models/common/model_chunk_schedule_plan.py @@ -351,7 +351,7 @@ def __init__( loss_mask: Optional[Tensor] = None, padding_mask=None, *, - output_processor: Optional[Callable[..., Tensor]] = None, + output_processor: Optional[Callable[..., Any]] = None, output_processor_context: Optional[Any] = None, ): """Initialize the schedule plan of all Transformer layers' sub-modules. diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index ff3514b7433..3e292ed5d76 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -531,9 +531,9 @@ def forward( inference_params: Optional[BaseInferenceContext] = None, loss_mask: Optional[Tensor] = None, padding_mask: Optional[Tensor] = None, - output_processor: Optional[Callable[..., Tensor]] = None, + output_processor: Optional[Callable[..., Any]] = None, output_processor_context: Optional[Any] = None, - ) -> Tensor: + ) -> Any: """Forward function of the GPT Model This function passes the input tensors through the embedding layer, and then the decoder and finally into the post processing layer (optional). @@ -810,7 +810,7 @@ def build_schedule_plan( loss_mask: Optional[Tensor] = None, padding_mask: Optional[Tensor] = None, *, - output_processor: Optional[Callable[..., Tensor]] = None, + output_processor: Optional[Callable[..., Any]] = None, output_processor_context: Optional[Any] = None, ): """Builds a computation schedule plan for the model. diff --git a/tests/unit_tests/models/test_gpt_model.py b/tests/unit_tests/models/test_gpt_model.py index d2cb12841c4..64b8deeb9c6 100644 --- a/tests/unit_tests/models/test_gpt_model.py +++ b/tests/unit_tests/models/test_gpt_model.py @@ -144,6 +144,7 @@ def test_output_processor_forward(self): ).cuda() context = {"selected_token_positions": torch.tensor([0, 2], device="cuda")} + created = {} seen = {} def output_processor(**kwargs): @@ -158,7 +159,14 @@ def output_processor(**kwargs): dim=-1, index=kwargs["labels"].unsqueeze(-1) ) token_logprobs = token_logprobs.squeeze(-1) - return token_logprobs.index_select(1, kwargs["context"]["selected_token_positions"]) + result = { + "payload": token_logprobs.index_select( + 1, kwargs["context"]["selected_token_positions"] + ), + "tag": "structured", + } + created["result"] = result + return result with torch.no_grad(): logits = self.gpt_model.forward( @@ -176,10 +184,14 @@ def output_processor(**kwargs): output_processor_context=context, ) - assert torch.allclose(output, expected) + assert output is created["result"] + assert isinstance(output, dict) + assert torch.allclose(output["payload"], expected) + assert output["tag"] == "structured" assert seen["context"] is context assert seen["output_layer"] is self.gpt_model.output_layer assert seen["output_weight"] is None + assert seen["output_layer"].weight is not None assert seen["labels"] is labels assert seen["runtime_gather_output"] is None assert seen["config"] is config From 03318a83cb519ff78d3c6c52be4a61a62a1a3a02 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 11 Aug 2026 11:59:19 -0700 Subject: [PATCH 255/290] Validate MFSDP module phase transitions (#6359) Signed-off-by: Jingyue Wu --- .../src/megatron_fsdp/experimental/module.py | 48 +++++++++++++------ .../distributed/mfsdp_v2/test_fully_shard.py | 12 ++--- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py index 101d247ce09..4c410899285 100644 --- a/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py +++ b/megatron/core/distributed/fsdp/src/megatron_fsdp/experimental/module.py @@ -148,8 +148,10 @@ class Phase(enum.Enum): # ``None`` lets pre_forward enqueue an all-gather unless an earlier FsdpModule # already prefetched this module. _unshard_event: torch.cuda.Event | None - # Backward-pre hook sets this to BACKWARD before activation recomputation - # can run. Forward and backward hooks own all other transitions. + # ``phase`` is FORWARD between pre_forward() and post_forward(), BACKWARD + # between pre_backward() and post_backward(), and RESTING otherwise. The only + # exception is non-reentrant activation recomputation: it runs between pre_backward() + # and post_backward(), preserving BACKWARD through its nested forward hooks. _phase: Phase def __init__( @@ -199,6 +201,24 @@ def context(self) -> FsdpContext: """Return the FSDP context.""" return self._context + @property + def phase(self) -> Phase: + """Return this module's lifecycle phase.""" + return self._phase + + @phase.setter + def phase(self, phase: Phase) -> None: + """Transition this module between its valid lifecycle phases.""" + allowed_transitions = { + (FsdpModule.Phase.RESTING, FsdpModule.Phase.FORWARD), + (FsdpModule.Phase.FORWARD, FsdpModule.Phase.RESTING), + (FsdpModule.Phase.RESTING, FsdpModule.Phase.BACKWARD), + (FsdpModule.Phase.BACKWARD, FsdpModule.Phase.RESTING), + } + if (self._phase, phase) not in allowed_transitions: + raise RuntimeError(f"Invalid FSDP module phase transition: {self._phase} -> {phase}.") + self._phase = phase + @property def name(self) -> str: """Return this FsdpModule's name.""" @@ -262,17 +282,14 @@ def pre_forward(self) -> None: on the comm stream, so ``AG_{i+1}`` is launched before ``F_i`` finishes. """ context = self.context + # This is the first MFSDP hook to run, so finalize the context here once + # before any module begins communication. context.ensure_finalized() - # post_forward() resets the phase after a non-recomputed forward, so a - # FORWARD phase here means this forward-pre hook ran while the previous - # forward was still in progress. - assert self._phase is not FsdpModule.Phase.FORWARD # A reentrant checkpoint recomputes before the child module's backward-pre - # hook can set its phase. Its forward still runs inside the active autograd - # GraphTask, which is the signal PyTorch FSDP2 uses as well. - is_recomputing = self._phase is FsdpModule.Phase.BACKWARD or _is_in_backward() - if not is_recomputing: - self._phase = FsdpModule.Phase.FORWARD + # hook runs. The active autograd GraphTask identifies that recomputation. + is_recomputing = self.phase is FsdpModule.Phase.BACKWARD or _is_in_backward() + if self.phase is not FsdpModule.Phase.BACKWARD: + self.phase = FsdpModule.Phase.FORWARD torch.cuda.nvtx.range_push(self._nvtx_label("forward")) self._num_ready_grad_parameters = 0 allgather_stream = context.allgather_stream @@ -317,10 +334,11 @@ def post_forward(self) -> None: # Recomputed parameters are consumed immediately by this module's # backward. Keep them materialized to avoid an unnecessary all-gather; # post_backward() will reshard them after gradient reduction. - is_recomputing = self._phase is FsdpModule.Phase.BACKWARD or _is_in_backward() + is_recomputing = self.phase is FsdpModule.Phase.BACKWARD or _is_in_backward() if not is_recomputing: self._reshard_parameter_groups() - self._phase = FsdpModule.Phase.RESTING + if self.phase is FsdpModule.Phase.FORWARD: + self.phase = FsdpModule.Phase.RESTING torch.cuda.nvtx.range_pop() def _reshard_parameter_groups(self) -> None: @@ -343,7 +361,7 @@ def _reshard_parameter_groups(self) -> None: def pre_backward(self) -> None: """Prepare full parameters and prefetch the next FsdpModule in backward order.""" - self._phase = FsdpModule.Phase.BACKWARD + self.phase = FsdpModule.Phase.BACKWARD torch.cuda.nvtx.range_push(self._nvtx_label("backward")) context = self.context current_stream = context.current_stream() @@ -370,7 +388,7 @@ def post_backward(self) -> None: """Reduce gradients and return parameters to their sharded resting state.""" self._reduce_gradient_groups() self._reshard_parameter_groups() - self._phase = FsdpModule.Phase.RESTING + self.phase = FsdpModule.Phase.RESTING torch.cuda.nvtx.range_pop() def _reduce_gradient_groups(self) -> None: diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 413f4ea9a36..8b8cbcfabbc 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -262,16 +262,16 @@ def test_fully_shard_activation_recompute_reshards_parameters(distributed_setup, # Backward completes each module before recomputing the previous one, so # every module-local phase must be cleared after its matching backward. - assert model._phase is FsdpModule.Phase.RESTING - assert model.fc1._phase is FsdpModule.Phase.RESTING - assert model.fc2._phase is FsdpModule.Phase.RESTING + assert model.phase is FsdpModule.Phase.RESTING + assert model.fc1.phase is FsdpModule.Phase.RESTING + assert model.fc2.phase is FsdpModule.Phase.RESTING # A second forward after backward runs in the forward phase again, so # forward-order prefetch resumes and the module phases return to resting. model(x).sum().backward() - assert model._phase is FsdpModule.Phase.RESTING - assert model.fc1._phase is FsdpModule.Phase.RESTING - assert model.fc2._phase is FsdpModule.Phase.RESTING + assert model.phase is FsdpModule.Phase.RESTING + assert model.fc1.phase is FsdpModule.Phase.RESTING + assert model.fc2.phase is FsdpModule.Phase.RESTING @pytest.mark.parametrize("set_to_none", [True, False]) From 80cda5db7f38f00c932f6f3f5cea2774aba03f6b Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 11 Aug 2026 12:33:17 -0700 Subject: [PATCH 256/290] Refine MFSDP v2 configuration validation (#6333) Signed-off-by: Jingyue Wu --- .../distributed/fsdp/mcore_fsdp_adapter.py | 27 ++++++-------- .../mfsdp_v2/test_mcore_adapter.py | 37 ++++++++++++++++++- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py index a8393fcf147..284beef606e 100644 --- a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py +++ b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py @@ -564,7 +564,11 @@ def __init__( placements = Placements( dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()] ) - with fully_shard_context(device=device): + # NCCL symmetric memory requires UB. MFSDP v2 intentionally does not support UB + # without symmetric memory: it uses ncclCommRegister rather than the more performant + # ncclCommWindowRegister: + # https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/bufferreg.html#window-registration + with fully_shard_context(device=device, use_symmetric_memory=ddp_config.nccl_ub): for submodule in reversed(list(module.modules())): if submodule is module: # The root is always sharded after selected child units so it is not @@ -642,12 +646,8 @@ def _validate_config( raise ValueError( "MFSDP v2 requires data_parallel_sharding_strategy='optim_grads_params'." ) - if ddp_config.num_distributed_optimizer_instances != 1: - raise ValueError("MFSDP v2 does not currently support HSDP.") if ddp_config.outer_dp_sharding_strategy != "no_shard": raise ValueError("MFSDP v2 does not currently support outer DP sharding.") - if ddp_config.overlap_grad_reduce or ddp_config.overlap_param_gather: - raise ValueError("MFSDP v2 does not currently support communication overlap modes.") if config.gradient_accumulation_fusion: raise ValueError("MFSDP v2 does not currently support gradient accumulation fusion.") if config.calculate_per_token_loss: @@ -657,16 +657,13 @@ def _validate_config( if config.cuda_graph_impl != "none" or ddp_config.megatron_fsdp_cuda_graph_mode: raise ValueError("MFSDP v2 does not currently support CUDA graphs.") - if ddp_config.fsdp_double_buffer: - raise ValueError("MFSDP v2 does not support fsdp_double_buffer.") if ddp_config.fsdp_db_use_persist_buf_on_alloc_fail: - raise ValueError("MFSDP v2 does not support fsdp_db_use_persist_buf_on_alloc_fail.") - if ddp_config.fsdp_all_gather_in_start_param_sync: - raise ValueError("MFSDP v2 does not support fsdp_all_gather_in_start_param_sync.") - if ddp_config.nccl_ub: - raise ValueError("MFSDP v2 does not support nccl_ub.") - if ddp_config.disable_symmetric_registration: - raise ValueError("MFSDP v2 does not support disable_symmetric_registration.") + raise ValueError( + "MFSDP v2 does not support fsdp_db_use_persist_buf_on_alloc_fail: " + "it allocates communication buffers from PyTorch memory pools." + ) + if ddp_config.nccl_ub and ddp_config.disable_symmetric_registration: + raise ValueError("MFSDP v2 requires symmetric registration when nccl_ub is enabled.") if ddp_config.fsdp_manual_registration: raise ValueError("MFSDP v2 does not support fsdp_manual_registration.") if ddp_config.delay_wgrad_compute: @@ -685,7 +682,7 @@ def _validate_config( raise ValueError("MFSDP v2 does not support megatron_fsdp_max_pool_double_buffer.") def start_param_sync(self, *unused, **unused_kwargs) -> None: - """MFSDP v2 gathers parameters from its forward pre-hook.""" + """No-op: MFSDP v2 gathers parameters from its forward pre-hooks.""" def start_grad_sync(self, *unused, **unused_kwargs) -> None: """MFSDP v2 reduces gradients during backward.""" diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py index 18f236d6bb6..3892a354540 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py @@ -7,6 +7,7 @@ import pytest import torch +import megatron.core.distributed.fsdp.mcore_fsdp_adapter as mcore_fsdp_adapter from megatron.core.distributed import DistributedDataParallelConfig from megatron.core.distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallel from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental.module import FsdpModule @@ -73,7 +74,6 @@ def test_wraps_fsdp_unit_modules_before_root(self): data_parallel_sharding_strategy="optim_grads_params", megatron_fsdp_main_params_dtype=torch.float32, megatron_fsdp_main_grads_dtype=torch.float32, - fsdp_all_gather_in_start_param_sync=False, ), module=model, fsdp_unit_modules=[TransformerLayer], @@ -100,6 +100,40 @@ def test_wraps_fsdp_unit_modules_before_root(self): assert child_parameter_names assert root_parameter_names == {"1.weight", "1.bias"} + def test_nccl_ub_enables_symmetric_memory(self, monkeypatch): + config = TransformerConfig( + num_layers=1, + hidden_size=16, + num_attention_heads=4, + ffn_hidden_size=32, + bf16=True, + params_dtype=torch.bfloat16, + ) + model = torch.nn.Linear(config.hidden_size, config.hidden_size).to( + device="cuda", dtype=config.params_dtype + ) + fully_shard_context_calls = [] + original_fully_shard_context = mcore_fsdp_adapter.fully_shard_context + + def record_fully_shard_context(*args, **kwargs): + fully_shard_context_calls.append(kwargs["use_symmetric_memory"]) + return original_fully_shard_context(*args, **kwargs) + + monkeypatch.setattr(mcore_fsdp_adapter, "fully_shard_context", record_fully_shard_context) + FullyShardedDataParallel( + config=config, + ddp_config=DistributedDataParallelConfig( + use_megatron_fsdp=True, + megatron_fsdp_version=2, + data_parallel_sharding_strategy="optim_grads_params", + nccl_ub=True, + ), + module=model, + pg_collection=self.pg_collection, + ) + + assert fully_shard_context_calls == [True] + def test_build_train_and_step(self): config = TransformerConfig( num_layers=2, @@ -128,7 +162,6 @@ def test_build_train_and_step(self): data_parallel_sharding_strategy="optim_grads_params", megatron_fsdp_main_params_dtype=torch.float32, megatron_fsdp_main_grads_dtype=torch.bfloat16, - fsdp_all_gather_in_start_param_sync=False, ), module=model, pg_collection=self.pg_collection, From f3eb0dfa1283b1a700c2874d957e354563238959 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Tue, 11 Aug 2026 16:31:59 -0400 Subject: [PATCH 257/290] Add hybrid layer config definitions (#6410) Signed-off-by: Philip Petrakian --- megatron/core/ssm/gdn_layer_config.py | 10 ++++++++++ megatron/core/ssm/mamba_layer_config.py | 10 ++++++++++ megatron/core/ssm/mlp_layer_config.py | 10 ++++++++++ megatron/core/transformer/attention_layer_config.py | 10 ++++++++++ .../experimental_attention_variant/dsa_layer_config.py | 10 ++++++++++ megatron/core/transformer/mla_layer_config.py | 10 ++++++++++ megatron/core/transformer/moe/moe_layer_config.py | 10 ++++++++++ 7 files changed, 70 insertions(+) create mode 100644 megatron/core/ssm/gdn_layer_config.py create mode 100644 megatron/core/ssm/mamba_layer_config.py create mode 100644 megatron/core/ssm/mlp_layer_config.py create mode 100644 megatron/core/transformer/attention_layer_config.py create mode 100644 megatron/core/transformer/experimental_attention_variant/dsa_layer_config.py create mode 100644 megatron/core/transformer/mla_layer_config.py create mode 100644 megatron/core/transformer/moe/moe_layer_config.py diff --git a/megatron/core/ssm/gdn_layer_config.py b/megatron/core/ssm/gdn_layer_config.py new file mode 100644 index 00000000000..15f509250f3 --- /dev/null +++ b/megatron/core/ssm/gdn_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import TransformerConfig + + +class GDNLayerConfig(TransformerConfig): + """Configuration for a Gated DeltaNet layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in TransformerConfig. + """ diff --git a/megatron/core/ssm/mamba_layer_config.py b/megatron/core/ssm/mamba_layer_config.py new file mode 100644 index 00000000000..a9b58c3843d --- /dev/null +++ b/megatron/core/ssm/mamba_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import TransformerConfig + + +class MambaLayerConfig(TransformerConfig): + """Configuration for a Mamba layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in TransformerConfig. + """ diff --git a/megatron/core/ssm/mlp_layer_config.py b/megatron/core/ssm/mlp_layer_config.py new file mode 100644 index 00000000000..6da7de589d2 --- /dev/null +++ b/megatron/core/ssm/mlp_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import TransformerConfig + + +class MLPLayerConfig(TransformerConfig): + """Configuration for a dense MLP layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in TransformerConfig. + """ diff --git a/megatron/core/transformer/attention_layer_config.py b/megatron/core/transformer/attention_layer_config.py new file mode 100644 index 00000000000..09b46aaa3f2 --- /dev/null +++ b/megatron/core/transformer/attention_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import TransformerConfig + + +class AttentionLayerConfig(TransformerConfig): + """Configuration for an attention layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in TransformerConfig. + """ diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_layer_config.py b/megatron/core/transformer/experimental_attention_variant/dsa_layer_config.py new file mode 100644 index 00000000000..eeab6e9ba3b --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/dsa_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import MLATransformerConfig + + +class DSALayerConfig(MLATransformerConfig): + """Configuration for a DeepSeek Sparse Attention layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in MLATransformerConfig. + """ diff --git a/megatron/core/transformer/mla_layer_config.py b/megatron/core/transformer/mla_layer_config.py new file mode 100644 index 00000000000..6a688e01e57 --- /dev/null +++ b/megatron/core/transformer/mla_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import MLATransformerConfig + + +class MLALayerConfig(MLATransformerConfig): + """Configuration for a Multi-Latent Attention layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in MLATransformerConfig. + """ diff --git a/megatron/core/transformer/moe/moe_layer_config.py b/megatron/core/transformer/moe/moe_layer_config.py new file mode 100644 index 00000000000..6d3aa8911c4 --- /dev/null +++ b/megatron/core/transformer/moe/moe_layer_config.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +from megatron.core.transformer.transformer_config import TransformerConfig + + +class MoELayerConfig(TransformerConfig): + """Configuration for a Mixture-of-Experts layer in a hybrid stack. + + Due to backwards-compatibility, this config's arguments are defined in TransformerConfig. + """ From da18ed661d9df19f9bfee0dc9bb2e1aa003b0282 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Tue, 11 Aug 2026 16:32:21 -0400 Subject: [PATCH 258/290] Propagate RL runtime config updates to module configs (#6423) Signed-off-by: Philip Petrakian --- megatron/core/transformer/utils.py | 38 +++++++- megatron/rl/rl_utils.py | 44 ++++++--- tests/unit_tests/rl/test_rl_utils.py | 104 ++++++++++++++++++--- tests/unit_tests/transformer/test_utils.py | 44 +++++++++ 4 files changed, 201 insertions(+), 29 deletions(-) diff --git a/megatron/core/transformer/utils.py b/megatron/core/transformer/utils.py index aee4e961b9e..9983f2f6dc0 100644 --- a/megatron/core/transformer/utils.py +++ b/megatron/core/transformer/utils.py @@ -1,6 +1,7 @@ # Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. """Utilities for transformer layers.""" + import gc import logging from operator import itemgetter @@ -218,7 +219,7 @@ def make_sharded_object_for_checkpoint( def _get_extra_state_offsets( - sharded_offsets: Iterable[Tuple[int, int, int]] + sharded_offsets: Iterable[Tuple[int, int, int]], ) -> Tuple[Tuple[int, ...], Tuple[int, ...]]: """Turns ShardedTensor offsets into offsets suitable for ShardedObject.""" if sharded_offsets: @@ -300,6 +301,41 @@ def sharded_state_dict_default( _sequence_parallel_attr_cache = None +def set_model_config_attribute(model: Any, attribute: str, value: Any) -> None: + """Set a config attribute on a model and all distinct child-module configs. + + Some models give individual layers separate config objects. Runtime model-wide + toggles must update those configs just as they did when every layer shared the + model's root config. + + Args: + model: Model whose configs should be updated. + attribute: Config attribute to set. + value: Value to assign. The same value object is assigned to every config. + """ + root_config = model.config + setattr(root_config, attribute, value) + updated_config_ids = {id(root_config)} + + module_root = model + visited_wrapper_ids = set() + while not isinstance(module_root, torch.nn.Module) or not hasattr(module_root, "_modules"): + visited_wrapper_ids.add(id(module_root)) + module_root = getattr(module_root, "module", None) + if module_root is None or id(module_root) in visited_wrapper_ids: + return + + for module in module_root.modules(): + config = getattr(module, "config", None) + if ( + config is not None + and id(config) not in updated_config_ids + and hasattr(config, attribute) + ): + setattr(config, attribute, value) + updated_config_ids.add(id(config)) + + def _init_sequence_parallel_cache(model, exclude_modules): """ Initialize the cache of modules with sequence parallel attributes. diff --git a/megatron/rl/rl_utils.py b/megatron/rl/rl_utils.py index 0b834def67b..262c366110f 100644 --- a/megatron/rl/rl_utils.py +++ b/megatron/rl/rl_utils.py @@ -48,7 +48,11 @@ is_batch_invariant_mode_enabled, ) from megatron.core.transformer.enums import CudaGraphModule -from megatron.core.transformer.utils import toggle_cuda_graphs, transition_moe_cudagraphs +from megatron.core.transformer.utils import ( + set_model_config_attribute, + toggle_cuda_graphs, + transition_moe_cudagraphs, +) from megatron.core.utils import ( get_asyncio_loop, get_attr_wrapped_model, @@ -805,7 +809,7 @@ def get_logprobs(model, tokens, position_ids, no_grad=False, sequence_packing=Fa # This is a hack to fix megatron's behaviour when flash-decode affects the training code flow. flash_decode = model.config.flash_decode - model.config.flash_decode = False + set_model_config_attribute(model, "flash_decode", False) fp32_output = not (args.fp16 or args.bf16) with torch.no_grad() if no_grad else nullcontext(): logits_or_hidden_states = model( @@ -816,7 +820,7 @@ def get_logprobs(model, tokens, position_ids, no_grad=False, sequence_packing=Fa runtime_gather_output=True, fp32_output=fp32_output, ) - model.config.flash_decode = flash_decode + set_model_config_attribute(model, "flash_decode", flash_decode) pg_collection = get_attr_wrapped_model(model, "pg_collection") pp_group = pg_collection.pp @@ -2200,9 +2204,11 @@ def megatron_rl_inference_mode( # Use local CUDA graphs during rollout inference. An empty module list preserves # full-layer capture when the configured inference scope is layer. - model[0].config.cuda_graph_modules = [] - model[0].config.cuda_graph_impl = "local" - model[0].config.inference_cuda_graph_scope = args.inference_cuda_graph_scope + set_model_config_attribute(model[0], "cuda_graph_modules", []) + set_model_config_attribute(model[0], "cuda_graph_impl", "local") + set_model_config_attribute( + model[0], "inference_cuda_graph_scope", args.inference_cuda_graph_scope + ) # If we get a lower precision wrapper, we go one object deeper. lang_module = model[0].module.module if hasattr(model[0].module, "module") else model[0].module @@ -2260,17 +2266,25 @@ def megatron_rl_inference_mode( # Restore cudagraph scope for training. # MoE partial capture requires specific scopes that aren't user-facing. - model[0].config.cuda_graph_impl = args.cuda_graph_impl - model[0].config.inference_cuda_graph_scope = args.inference_cuda_graph_scope + set_model_config_attribute(model[0], "cuda_graph_impl", args.cuda_graph_impl) + set_model_config_attribute( + model[0], "inference_cuda_graph_scope", args.inference_cuda_graph_scope + ) if args.num_experts is not None: - model[0].config.cuda_graph_modules = [ - CudaGraphModule.mamba, - CudaGraphModule.attn, - CudaGraphModule.moe_router, - CudaGraphModule.moe_preprocess, - ] + set_model_config_attribute( + model[0], + "cuda_graph_modules", + [ + CudaGraphModule.mamba, + CudaGraphModule.attn, + CudaGraphModule.moe_router, + CudaGraphModule.moe_preprocess, + ], + ) else: - model[0].config.cuda_graph_modules = copy.copy(args.cuda_graph_modules) + set_model_config_attribute( + model[0], "cuda_graph_modules", copy.copy(args.cuda_graph_modules) + ) # Switch MoE layers to partial CUDA graph capture for training if args.rl_training_cuda_graphs and args.num_experts is not None: diff --git a/tests/unit_tests/rl/test_rl_utils.py b/tests/unit_tests/rl/test_rl_utils.py index c37d5ec00e1..72b65689ff5 100644 --- a/tests/unit_tests/rl/test_rl_utils.py +++ b/tests/unit_tests/rl/test_rl_utils.py @@ -108,16 +108,37 @@ def make_token_rollout(trajectory, logprobs, generation_mask=None, reward=1.0, p ) -class DummyLangModule: +class DummyConfigModule(torch.nn.Module): def __init__(self, config): + super().__init__() + self.config = config + + +class DummyLogprobsModel(torch.nn.Module): + def __init__(self, config, layer_config): + super().__init__() + self.config = config + self.layer = DummyConfigModule(layer_config) + self.pg_collection = SimpleNamespace(pp=object()) + self.config_values_during_forward = None + + def forward(self, tokens, position_ids, attention_mask, **kwargs): + del position_ids, attention_mask, kwargs + self.config_values_during_forward = ( + self.config.flash_decode, + self.layer.config.flash_decode, + ) + return torch.ones((tokens.shape[0], tokens.shape[1], VOCAB)) + + +class DummyLangModule(torch.nn.Module): + def __init__(self, config): + super().__init__() self.config = config self.rotary_pos_emb = None self.eval = MagicMock() self.train = MagicMock() - def modules(self): - return iter(()) - class DummyMoELayer: def __init__(self, use_partial_cudagraphs): @@ -346,17 +367,33 @@ def _toggle(lang_module, set_to): return MagicMock(side_effect=_toggle) - def test_megatron_rl_inference_mode_restores_training_cuda_graph_state(self, monkeypatch): + @pytest.mark.parametrize( + "share_config", + [pytest.param(True, id="shared-config"), pytest.param(False, id="distinct-config")], + ) + @pytest.mark.parametrize("num_experts", [None, 8], ids=["dense", "moe"]) + def test_megatron_rl_inference_mode_restores_training_cuda_graph_state( + self, monkeypatch, share_config, num_experts + ): config = SimpleNamespace( cuda_graph_impl="none", cuda_graph_modules=[CudaGraphModule.attn], inference_cuda_graph_scope=InferenceCudaGraphScope.none, ) - lang_module = DummyLangModule(config) + layer_config = ( + config + if share_config + else SimpleNamespace( + cuda_graph_impl="none", + cuda_graph_modules=[CudaGraphModule.attn], + inference_cuda_graph_scope=InferenceCudaGraphScope.none, + ) + ) + lang_module = DummyLangModule(layer_config) model = [SimpleNamespace(config=config, module=lang_module)] args = SimpleNamespace( rl_training_cuda_graphs=False, - num_experts=None, + num_experts=num_experts, curr_iteration=11, cuda_graph_impl="local", cuda_graph_modules=[CudaGraphModule.attn], @@ -368,20 +405,61 @@ def test_megatron_rl_inference_mode_restores_training_cuda_graph_state(self, mon with rl_utils.megatron_rl_inference_mode(model, MagicMock(), "local", False) as result: assert result is interface - assert config.cuda_graph_impl == "local" - assert config.cuda_graph_modules == [] - assert config.inference_cuda_graph_scope == InferenceCudaGraphScope.block + for current_config in (config, layer_config): + assert current_config.cuda_graph_impl == "local" + assert current_config.cuda_graph_modules == [] + assert current_config.inference_cuda_graph_scope == InferenceCudaGraphScope.block assert toggle_cuda_graphs.call_args_list == [ call(lang_module, "local"), call(lang_module, "none"), ] - assert config.cuda_graph_impl == "local" - assert config.cuda_graph_modules == [CudaGraphModule.attn] - assert config.inference_cuda_graph_scope == InferenceCudaGraphScope.block + expected_modules = ( + [ + CudaGraphModule.mamba, + CudaGraphModule.attn, + CudaGraphModule.moe_router, + CudaGraphModule.moe_preprocess, + ] + if num_experts is not None + else [CudaGraphModule.attn] + ) + for current_config in (config, layer_config): + assert current_config.cuda_graph_impl == "local" + assert current_config.cuda_graph_modules == expected_modules + assert current_config.inference_cuda_graph_scope == InferenceCudaGraphScope.block lang_module.eval.assert_called_once() lang_module.train.assert_called_once() + @pytest.mark.parametrize( + "share_config", + [pytest.param(True, id="shared-config"), pytest.param(False, id="distinct-config")], + ) + def test_get_logprobs_updates_all_model_configs(self, monkeypatch, share_config): + config = SimpleNamespace(flash_decode=True) + layer_config = config if share_config else SimpleNamespace(flash_decode=True) + model = DummyLogprobsModel(config, layer_config) + monkeypatch.setattr(rl_utils, "get_args", lambda: SimpleNamespace(fp16=False, bf16=False)) + monkeypatch.setattr( + rl_utils, "get_nvtx_range", lambda: (lambda *args, **kwargs: nullcontext()) + ) + monkeypatch.setattr( + rl_utils, "get_attr_wrapped_model", lambda model, name: getattr(model, name) + ) + monkeypatch.setattr(rl_utils, "is_pp_last_stage", lambda _group: False) + + output = rl_utils.get_logprobs( + model, + torch.ones((1, 2), dtype=torch.long), + position_ids=None, + packed_seq_params=object(), + ) + + assert output.shape == (1, 2, VOCAB) + assert model.config_values_during_forward == (False, False) + assert config.flash_decode is True + assert layer_config.flash_decode is True + @pytest.mark.parametrize( "initialize_model_parallel", [ diff --git a/tests/unit_tests/transformer/test_utils.py b/tests/unit_tests/transformer/test_utils.py index 481a6bf5d61..9bbc97eb045 100644 --- a/tests/unit_tests/transformer/test_utils.py +++ b/tests/unit_tests/transformer/test_utils.py @@ -2,6 +2,7 @@ import inspect import os +from types import SimpleNamespace import pytest import torch @@ -13,11 +14,54 @@ from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.transformer.utils import ( is_layer_window_attention, + set_model_config_attribute, set_model_to_sequence_parallel, ) from tests.unit_tests.test_utilities import Utils +class _TrackingConfig: + def __init__(self, value): + self._runtime_value = value + self.update_count = 0 + + @property + def runtime_value(self): + return self._runtime_value + + @runtime_value.setter + def runtime_value(self, value): + self._runtime_value = value + self.update_count += 1 + + +class _ConfigModule(torch.nn.Module): + def __init__(self, config): + super().__init__() + self.config = config + + +def test_set_model_config_attribute_updates_distinct_configs_once(): + root_config = _TrackingConfig("original") + child_config = _TrackingConfig("original") + unsupported_config = SimpleNamespace() + + module = _ConfigModule(root_config) + module.first_child = _ConfigModule(child_config) + module.second_child = _ConfigModule(child_config) + module.unsupported_child = _ConfigModule(unsupported_config) + model = SimpleNamespace(config=root_config, module=SimpleNamespace(module=module)) + new_value = object() + + set_model_config_attribute(model, "runtime_value", new_value) + + assert root_config.runtime_value is new_value + assert child_config.runtime_value is new_value + assert root_config.update_count == 1 + assert child_config.update_count == 1 + assert not hasattr(unsupported_config, "runtime_value") + + class TestGPTModel: def setup_method(self, method): From e22bcb09b91375639588217c12e96242e8e48c36 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Wed, 12 Aug 2026 06:28:09 +0800 Subject: [PATCH 259/290] [fix] GTP+recompute: keep adjacent GTP recompute weights off the same gather buffer (#6407) Signed-off-by: Shiqing Fan --- .../core/generalized_tensor_parallel.md | 9 +- .../generalized_tensor_parallelism.py | 236 ++++++++++---- .../gtp_test_utils.py | 19 +- .../test_gtp_recompute_chain.py | 295 ++++++++++++++++++ 4 files changed, 492 insertions(+), 67 deletions(-) create mode 100644 tests/unit_tests/generalized_tensor_parallel/test_gtp_recompute_chain.py diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index 831db034d90..11c01b8a504 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -417,7 +417,7 @@ The figure visualizes the per-class split from the list above: green = resolves Two distinct pools with explicit lifecycle rules: -- **`GTPWeightCache`** (AG/RS output buffers) — ticket-based, keyed on `(shape, dtype, fwd, expert_idx, reduce_scatter)`. Same-shape buffers across layers are shared, **except between chain neighbours** — one-step-ahead keeps `prev_w` and the current weight live at once, so `_ensure_distinct_buffer_from_prev` folds a parity bit into the key when the two would collide, at the cost of one extra buffer for the second of the pair. Normally inert (neighbours are different weight roles, hence different shapes); it fires when CG capture leaves two same-shaped weights adjacent — embedding + output_layer alone in the `UNGRAPHED` chain. Tickets persistent; buffer allocated lazily on first `get()`; addresses stable across iterations for CG replay. +- **`GTPWeightCache`** (AG/RS output buffers) — ticket-based, keyed on `(shape, dtype, fwd, expert_idx, reduce_scatter)`, plus a `("recompute", parity)` suffix for recompute-chain gathers. Same-shape buffers across layers are shared, **except between chain neighbours** — one-step-ahead keeps the predecessor and the current weight live at once, so `_ensure_no_shared_buffer_with` folds a parity bit into the key when the two would collide, at the cost of one extra buffer for the second of the pair. The caller names which chain to guard, because the chains disagree on who a weight's neighbour is: on the fwd chain the check is normally inert (neighbours are different roles, hence different shapes) and fires only when CG capture leaves two same-shaped weights adjacent — embedding + output_layer alone in the `UNGRAPHED` chain — whereas on a recompute chain same-shape adjacency is the norm. Tickets persistent; buffer allocated lazily on first `get()`; addresses stable across iterations for CG replay. - **`_wgrad_buf_pool`** (wgrad-GEMM output recycling) — holds the **full, unsharded** wgrad-GEMM output buffer (shape `_unsharded_shape`, dtype `main_grad.dtype` — fp32 when `grad_reduce_in_fp32`, else bf16). The TE backward writes the wgrad into it via `main_grad_func = weight.grad_buffer` (a `DistributedWeight` protocol method backed by `get_wgrad_tensor`; it is a *scratch*, distinct from the sharded `param.main_grad`); the protocol's `finalize_group_grads` (backed by `wgrad_reduce_scatter`) then reduce-scatters it down to the shard and the buffer is returned here. This is a full-weight-shaped fp32/bf16 transient — one of the larger per-weight buffers — and is **precision-independent** (wgrad is always computed in high precision), so it is identical in BF16 vs MXFP8 runs. Buffers are tagged `_from_gtp_wgrad_pool=True` at `_wgrad_pool_get`; `_wgrad_pool_put` no-ops on foreign buffers (fresh allocs from Megatron `layers.py` or aten F.embedding bwd) → caching allocator handles those, so the pool never accumulates untagged buffers. #### Overlap design summary @@ -433,7 +433,7 @@ GTP_remat runs up to **three** independent prefetch chains, all following one ru |---|------|---------|--------------------|--------------|------| | 1 | fwd | weight `i` | `next_w` = i+1 ‖ `GEMM_i` | rowwise (`fwd=True`) | `_prefetch_handle` | | 2 | bwd dgrad | weight `i` | `prev_w` = i−1 ‖ `Dgrad_i` | columnwise (`fwd=False`) | `_prefetch_handle` | -| 3 | bwd recompute | weight `i` | `_recompute_next` = i+1 ‖ `recompute_GEMM_i` | rowwise (`fwd=True`) | `_recompute_prefetch_handle` (separate) | +| 3 | bwd recompute | weight `i` | `_recompute_next` = i+1 ‖ `recompute_GEMM_i` | rowwise (`fwd=True`) | `_recompute_prefetch_handle` + `_ag_ticket_recompute` (separate) | | 1b | fwd (MoE, eager) | expert weight `i` | same role in MoE block i+1 ‖ *whole block i* | rowwise (`fwd=True`) | `_prefetch_handle` | Row 1b is chain 1 applied to a *homogeneous* chain: routed-expert `fc1`/`fc2` link across consecutive MoE blocks, so the runway is a full block rather than one GEMM (§3.4 *Grouped-expert chains*). @@ -452,7 +452,7 @@ A future MR will add an opt-in wgrad-before-dgrad schedule on `_Linear` / `_Laye ##### Recompute-forward prefetch chain *(GTP_remat + activation recompute)* -When a GTP_remat-sharded module is in `--recompute-modules` (e.g. `shared_experts`), its forward is **re-run during backward** to regenerate activations. That recompute-forward must all-gather each weight **rowwise** again — a *third* gather lifecycle, concurrent with the in-flight **columnwise** dgrad gather of the *same* weight. Since both share one `GTPShardedParam`, the recompute path gets its **own** prefetch slot (`_recompute_prefetch_handle` / `_recompute_ag_event`, reusing the `_ag_ticket_fwd` rowwise buffer) so it never clobbers the dgrad lifecycle's `state` / `_prefetch_handle` / `ag_event`. +When a GTP_remat-sharded module is in `--recompute-modules` (e.g. `shared_experts`), its forward is **re-run during backward** to regenerate activations. That recompute-forward must all-gather each weight **rowwise** again — a *third* gather lifecycle, concurrent with the in-flight **columnwise** dgrad gather of the *same* weight. Since both share one `GTPShardedParam`, the recompute path gets its **own** prefetch slot (`_recompute_prefetch_handle` / `_recompute_ag_event`) so it never clobbers the dgrad lifecycle's `state` / `_prefetch_handle` / `ag_event`, and its **own** buffer ticket (`_ag_ticket_recompute`) with a parity of its own. Reusing `_ag_ticket_fwd` is unsafe twice over: a fwd prefetch may still be in flight in that buffer, and the fwd parity is decided against `prev_w` — a different neighbour. Without its own parity, consecutive recompute nodes share one buffer and the one-ahead prefetch overwrites the weight still being read: silent wrong activations, then NaN. The recompute weights form a **separate** linked list (`_recompute_next`), **self-populated** on the first backward from the weights actually re-gathered while `in_fp8_activation_recompute_phase()` is true — membership is *observed*, not configured (no tagging, so it tracks exactly what each checkpointed module re-gathers). Each recompute-forward consume prefetches the next recompute weight, so every gather **except the global-first** overlaps preceding recompute / dgrad / wgrad compute: @@ -606,7 +606,7 @@ Three consequences: - one-block-ahead makes block *N* and block *N+1* weights **live at the same time** — same key, two tensors in flight; - fix: a chain-position **parity (0,1,0,1…)** is folded into the cache key, so consecutive blocks alternate between **exactly two** buffers (counter cleared by `reset_gtp_state()`); - without it the prefetch would **overwrite the weight the running GEMM is still reading** — a silent-correctness bug, not a crash; - - the hazard is not exclusive to grouped chains — *any* chain whose neighbours share a key has it. Grouped chains are same-key throughout, so they take the blanket counter; others take the narrower `_ensure_distinct_buffer_from_prev` check, which allocates only where the collision is real (see *Buffer / memory management*). + - the hazard is not exclusive to grouped chains — *any* chain whose neighbours share a key has it. Grouped chains are same-key throughout, so they take the blanket counter; others take the narrower `_ensure_no_shared_buffer_with` check, which allocates only where the collision is real (see *Buffer / memory management*). - **Eager only** — the optimization disables itself under CUDA-graph capture: - `_classify_param_chain` evaluates `graphed = _FULL_ITERATION or ("moe" in cuda_graph_modules)` **before** the split, and returns the plain `GRAPHED` chain when it is true; - so with `--cuda-graph-impl full_iteration` **every** param is `GRAPHED` — expert weights included — and they keep the ordinary one-step-ahead prefetch; @@ -741,6 +741,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_gtp_partial_cg.py` | Four-layer partial-CG loss and eager-vs-replay grad-norm parity with two-slot ring reuse across independently replayed graphs (§3.5). | | `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | | `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | +| `test_gtp_recompute_chain.py` | Recompute-chain buffers (§3.1): adjacent nodes never share a gather buffer, dense and grouped, plus dgrad/wgrad parity vs no-recompute. | | `test_gtp_mtp.py` | GTP_remat + MTP shared weights (§3.5), 14 cases over `mtp_use_repeated_layer` × dense/MoE. Both MTP hazards are silent, so each needs its own guard: the async reduce-scatter path is compared numerically against the sync path on an identical model/sharding/batch, and all-gathers issued are tallied against consumes to catch a consume reading a buffer nothing gathered into. | | `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. | | `test_gtp_custom_pgs.py` | `pg_collection` plumbing: a custom `gtp_remat` group (permuted ranks, same size) must give the same fwd/bwd results as the MPU groups — catches modules reading `parallel_state` instead of the collection passed to them. | diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index 5eac911d1b3..de97cdab121 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -878,6 +878,11 @@ def _init_gtp_runtime_attrs(obj): obj._recompute_prefetch_handle = None obj._recompute_ag_event = torch.cuda.Event(external=True) obj._recompute_already_drained = False + # Own AG buffer for the recompute chain, with its own parity so a one-ahead prefetch cannot + # land in the buffer the previous recompute node is still reading. See + # _ensure_no_shared_buffer_with. + obj._ag_ticket_recompute = None + obj._recompute_buf_parity = None # Chain identity (GRAPHED/UNGRAPHED). Defaults to UNGRAPHED; classify_gtp_chains(model) # walks the model at init (after set_cuda_graph_modules) and reclassifies on param name + # active cuda_graph_modules. @@ -926,6 +931,7 @@ class GTPShardedParam(torch.nn.Parameter): _recompute_chain_state: Dict[str, dict] = {} _link_tables_flushed: bool = False + _recompute_link_tables_flushed: bool = False @classmethod def _get_chain_state(cls, chain_id: str) -> dict: @@ -940,7 +946,11 @@ def _get_chain_state(cls, chain_id: str) -> dict: @classmethod def _get_recompute_chain_state(cls, chain_id: str) -> dict: if chain_id not in cls._recompute_chain_state: - cls._recompute_chain_state[chain_id] = {"last_weight": None} + cls._recompute_chain_state[chain_id] = { + "last_weight": None, + "link_node_count": 0, + "link_table_buffer": [], + } return cls._recompute_chain_state[chain_id] @classmethod @@ -950,18 +960,50 @@ def flush_link_tables(cls) -> None: Call only where the chains are complete -- NOT on "this weight is already linked", which MTP hits mid-forward while later links are still being created. """ + # Clear each buffer on emit: the latch alone is not enough, since the dynamic GTP_* + # subclasses each carry their own copy of it over this one shared buffer set. + emitted = False for chain in cls._chain_state.values(): if chain["link_table_buffer"]: log_single_rank(logger, logging.INFO, "\n".join(chain["link_table_buffer"]) + "\n") - cls._link_tables_flushed = True + chain["link_table_buffer"] = [] + emitted = True + cls._link_tables_flushed = emitted + + @classmethod + def flush_recompute_link_tables(cls) -> None: + """Log every recompute chain's link table once, atomically. + + Recompute links are built during backward, so call this only where one has finished -- + never on "this weight is already linked", which a replayed MTP block reaches too early. + """ + # Latch on having emitted, not on having been called: the first forward runs before any + # backward has built a chain, and must not suppress the real flush. + emitted = False + for rchain in cls._recompute_chain_state.values(): + if rchain["link_table_buffer"]: + log_single_rank(logger, logging.INFO, "\n".join(rchain["link_table_buffer"]) + "\n") + rchain["link_table_buffer"] = [] + emitted = True + cls._recompute_link_tables_flushed = emitted + + @classmethod + def _recompute_link_table_row( + cls, prev: "GTPShardedParam", curr: "GTPShardedParam", rchain: dict + ) -> None: + """Buffer one recompute-chain link row, under its own table heading.""" + cls._buffer_link_table_row(prev, curr, rchain, label="RECOMPUTE chain") @classmethod def _buffer_link_table_row( - cls, prev: "GTPShardedParam", curr: "GTPShardedParam", chain: dict + cls, prev: "GTPShardedParam", curr: "GTPShardedParam", chain: dict, label: str = "chain" ) -> None: - """Buffer one prefetch-link row (flushed atomically on the second forward pass).""" + """Buffer one prefetch-link row (flushed atomically once the chain is complete). + + ``label`` lets the recompute chain reuse this with its own table heading. + """ _W = 70 - _D = 20 + _D = 8 # widest realistic value is "bfloat16"; MXFP8/NVFP4 are 5 _S = 20 def _layer_id(name: str) -> str: @@ -977,40 +1019,37 @@ def _shape(param: "GTPShardedParam") -> str: return str(tuple(param.shape)) def _dtype(param: "GTPShardedParam") -> str: - # Report the dtype of the tensor that is ACTUALLY all-gathered, not the - # GTPShardedParam wrapper (whose logical dtype is the high-precision model-weight - # shard, i.e. params_dtype — bf16 in mixed precision). When the param has an FP8 - # representation (``param.quantized`` populated — by --fp8-param-gather's optimizer - # FP32->FP8 write, or by the per-forward cast otherwise), that quantized tensor is - # what gets gathered, yet a TE QuantizedTensor still reports a "fake" params_dtype - # ``.dtype``. So surface its raw storage dtype (e.g. uint8) tagged with the quantized - # class to make the FP8 all-gather unambiguous. + # ``.dtype`` lies here: the wrapper and the TE quantized tensor both report + # params_dtype (bf16), so read the actually-gathered format off the quantized class. q = getattr(param, "quantized", None) if getattr(param, "_gtp_native_fp8", False) and q is not None: - raw = getattr(q, "_rowwise_data", None) - if raw is None: - raw = getattr(q, "_data", None) - raw_dt = str(raw.dtype).replace("torch.", "") if raw is not None else "?" - return f"{type(q).__name__}/{raw_dt}" - return str(getattr(param, "dtype", "-")) + # GTP_MXFP8Tensor -> MXFP8. Derived, not hardcoded, so NVFP4/FP8 recipes work too. + name = type(q).__name__ + if name.startswith("GTP_"): + name = name[len("GTP_") :] + for suffix in ("QTensor", "Tensor"): + if name.endswith(suffix): + name = name[: -len(suffix)] + break + return name + return str(getattr(param, "dtype", "-")).replace("torch.", "") chain["link_node_count"] += 1 if chain["link_node_count"] == 1: chain_id = getattr(curr, "chain_id", GTPChain.UNGRAPHED.value) chain["link_table_buffer"].append( - f"\n[{chain_id} chain]\n{'node_id':>7} | {'layer_id':>8} |" - f" {'dtype':<{_D}} | {'shape':<{_S}} | {'curr_weight_name':<{_W}} |" - f" prev_weight_name\n{'-'*7}-+-{'-'*8}-+-{'-'*_D}-+-{'-'*_S}-+-{'-'*_W}-+-{'-'*_W}" + f"\n[{chain_id} {label}]\n{'node_id':>7} | {'layer_id':>8} |" + f" {'dtype':<{_D}} | {'shape':<{_S}} | weight_name\n" + f"{'-'*7}-+-{'-'*8}-+-{'-'*_D}-+-{'-'*_S}-+-{'-'*_W}" ) - # Seed weight (first GTP param) as row 0 + # Seed weight (chain head) as row 0 chain["link_table_buffer"].append( f"{'0':>7} | {_layer_id(prev._debug_name):>8} | " - f"{_dtype(prev):<{_D}} | {_shape(prev):<{_S}} | {prev._debug_name:<{_W}} | -" + f"{_dtype(prev):<{_D}} | {_shape(prev):<{_S}} | {prev._debug_name}" ) chain["link_table_buffer"].append( f"{chain['link_node_count']:>7} | {_layer_id(curr._debug_name):>8} | " - f"{_dtype(curr):<{_D}} | {_shape(curr):<{_S}} | " - f"{curr._debug_name:<{_W}} | {prev._debug_name}" + f"{_dtype(curr):<{_D}} | {_shape(curr):<{_S}} | {curr._debug_name}" ) @staticmethod @@ -1089,30 +1128,35 @@ def _gather_buffer_identity(self, dtype) -> tuple: """The part of the cache key that decides which weights share a gather buffer.""" return (self._unsharded_shape_padded, dtype, self.expert_idx) - def _ensure_distinct_buffer_from_prev(self, dtype): - """Move self to a second buffer if its chain predecessor would share one. + def _ensure_no_shared_buffer_with(self, predecessor, predecessor_dtype, dtype, parity_attr): + """Guarantee that two adjacent weights on a prefetch chain never share a gather buffer. - One-step-ahead prefetch keeps prev_w and self live at once, so sharing a buffer lets - self's gather clobber the weight prev_w's GEMM is still reading. Neighbours normally - differ in shape; a CUDA-graph-partitioned chain can leave two same-shaped weights - adjacent (embedding + output_layer alone in the UNGRAPHED chain). + Sharing one is a data race: one-step-ahead prefetch keeps both neighbours live at once, + so self's gather writes the buffer while the predecessor's GEMM is still reading it. They + share a buffer exactly when they resolve to the same cache key (same gathered shape and + dtype); flipping ``parity_attr`` moves self to a second buffer and breaks the tie. + Differently-shaped neighbours never shared a key, so this is a no-op for them. - Grouped chains use their own counter (``_GTP_GROUPED_BUF_PARITY_COUNTER``). + Which chain to guard is the caller's to say: pass (``prev_w``, ``_buf_parity``) or + (``_recompute_prev``, ``_recompute_buf_parity``). No default, because the chains disagree + on who a weight's neighbour is. ``predecessor_dtype`` is the dtype that weight actually + gathers in, ``None`` if it never has -- passed in rather than read off the predecessor, + because grouped weights cache their dtypes on the batch anchor, not per expert. + + Callers on the fwd chain skip grouped weights, which get their parity from + ``_GTP_GROUPED_BUF_PARITY_COUNTER`` instead. """ - prev = self.prev_w - if prev is None or _chain_is_grouped(self.chain_id): - return - if self.is_routed_expert or prev.is_routed_expert: - return - if prev._cached_dtypes is None: # never gathered — no buffer to collide with + if predecessor is None or predecessor_dtype is None: # nothing gathered to collide with return - if prev._gather_buffer_identity(prev._cached_dtypes[0]) != self._gather_buffer_identity( + if predecessor._gather_buffer_identity(predecessor_dtype) != self._gather_buffer_identity( dtype ): return - self._buf_parity = 1 - (getattr(prev, "_buf_parity", None) or 0) + setattr(self, parity_attr, 1 - (getattr(predecessor, parity_attr, None) or 0)) - def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: + def _get_cache_key( + self, dtype, fwd: bool, reduce_scatter: bool, recompute: bool = False + ) -> tuple: """Build a cache key that includes the communication scheduling domain. ``GTPWeightCache.release`` retains a ticket's buffer pointer while returning the storage to @@ -1155,9 +1199,16 @@ def _get_cache_key(self, dtype, fwd: bool, reduce_scatter: bool) -> tuple: # parity alternates consecutive blocks between two buffers. key = key + (self.chain_id, self._double_buffer_parity()) elif getattr(self, "_buf_parity", None): - # Set by _ensure_distinct_buffer_from_prev. Parity 0 keeps the shared buffer, so + # Set by _ensure_no_shared_buffer_with. Parity 0 keeps the shared buffer, so # only the second weight of an adjacent same-key pair costs an extra allocation. key = key + (self._buf_parity,) + if recompute: + # Two components, guarding two different collisions: + # "recompute" keeps these buffers away from the fwd ones, which may still hold a + # prefetch in flight when a recompute gather lands; + # the parity keeps recompute NEIGHBOURS apart. _buf_parity above cannot do that -- + # it is decided against prev_w, and the recompute chain links different weights. + key = key + ("recompute", getattr(self, "_recompute_buf_parity", None) or 0) return key def _strip_padding(self, tensor): @@ -1213,8 +1264,15 @@ def _strip_padding(self, tensor): return tensor[: -self.pad_length] - def _all_gather_weight(self, async_op, fwd, nvtx_label=None): - """Quantize (if needed) and all-gather weight. Returns (weight_total, handle).""" + def _all_gather_weight( + self, async_op, fwd, nvtx_label=None, recompute=False, recompute_prev=None + ): + """Quantize (if needed) and all-gather weight. Returns (weight_total, handle). + + ``recompute=True`` targets the recompute chain's own buffer, not the fwd/bwd one; + ``recompute_prev`` is this node's recompute-chain predecessor, used once to pick a + non-colliding buffer. + """ if nvtx_label is None: nvtx_label = ( self._debug_name + (".fwd" if fwd else ".bwd") + (".async" if async_op else ".sync") @@ -1260,8 +1318,27 @@ def _all_gather_weight(self, async_op, fwd, nvtx_label=None): self._cached_dtypes = dtypes out_buffers = [] cache = get_global_GTP_cache() - for p, dt in zip(weights, dtypes): - if fwd: + # Match experts index-for-index: the cache key carries expert_idx, so expert k collides + # with expert k of the neighbouring block, never with that block's anchor. + prev_weights = recompute_prev._weights if recompute_prev is not None else [] + prev_dtypes = recompute_prev._cached_dtypes if recompute_prev is not None else None + for idx, (p, dt) in enumerate(zip(weights, dtypes)): + if recompute: + if p._ag_ticket_recompute is None: + # Must run before reserve — it decides which buffer the ticket gets. + p._ensure_no_shared_buffer_with( + predecessor=prev_weights[idx] if idx < len(prev_weights) else None, + predecessor_dtype=( + prev_dtypes[idx] if prev_dtypes and idx < len(prev_dtypes) else None + ), + dtype=dt, + parity_attr="_recompute_buf_parity", + ) + p._ag_ticket_recompute = cache.reserve(p, dt, fwd=True, recompute=True) + cache.get(p._ag_ticket_recompute) + cache.release(p._ag_ticket_recompute) + out_buffers.append(cache.get(p._ag_ticket_recompute)) + elif fwd: if p._ag_ticket_fwd is None: p._ag_ticket_fwd = cache.reserve(p, dt, fwd=True) cache.get(p._ag_ticket_fwd) @@ -1348,8 +1425,15 @@ def _wait_param_gather(self): self._prefetch_handle = None self.ag_event.record() - def _all_gather_weight_on_demand(self, fwd): - result, _ = self._all_gather_weight(async_op=False, fwd=fwd) + def _all_gather_weight_on_demand(self, fwd, recompute=False, recompute_prev=None): + # Only pass the recompute kwargs when they apply, so the fwd/bwd path keeps calling + # _all_gather_weight with its original signature. + if recompute: + result, _ = self._all_gather_weight( + async_op=False, fwd=fwd, recompute=True, recompute_prev=recompute_prev + ) + else: + result, _ = self._all_gather_weight(async_op=False, fwd=fwd) result = result if self.is_routed_expert else [result] result = [self._strip_padding(r) for r in result] result = [r.detach().requires_grad_(w.requires_grad) for r, w in zip(result, self._weights)] @@ -1420,14 +1504,16 @@ def _wait_recompute_param_gather(self): def _recompute_prefetch_next(self, target, nvtx_label=None): # Issue target's rowwise (fwd) AG into its recompute slot. _all_gather_weight skips the - # AG-state transition under recompute, so target's dgrad state is untouched; result lands - # in target._ag_ticket_fwd. - _, handle = target._all_gather_weight(async_op=True, fwd=True, nvtx_label=nvtx_label) + # AG-state transition under recompute, so target's dgrad state is untouched; the write + # lands in target._ag_ticket_recompute, which self is guaranteed not to be reading. + _, handle = target._all_gather_weight( + async_op=True, fwd=True, nvtx_label=nvtx_label, recompute=True, recompute_prev=self + ) target._recompute_prefetch_handle = handle def _get_recompute_prefetched_weight(self): # Recompute-chain analogue of _get_prefetched_weight (state-neutral; reads the - # rowwise _ag_ticket_fwd via the _recompute_* slot). + # rowwise gather via the _recompute_* slot). if self._recompute_already_drained: # Producer already drained via wait_async_comms (CG capture); skip the # captured cross-graph wait (CUDA no-op anyway). @@ -1439,7 +1525,7 @@ def _get_recompute_prefetched_weight(self): result = [] cache = get_global_GTP_cache() for w in self._weights: - result.append(cache.get(w._ag_ticket_fwd)) + result.append(cache.get(w._ag_ticket_recompute)) result = [self._strip_padding(r) for r in result] result = [r.detach().requires_grad_(w.requires_grad) for r, w in zip(result, self._weights)] return result if self.is_routed_expert else result[0] @@ -1508,6 +1594,11 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): in_recompute = in_fp8_activation_recompute_phase() use_recompute_chain = in_recompute and GTP_CONFIG.weight_prefetch + # Reaching a forward gather proves the previous backward finished, so the chains it + # built are complete. Mirrors flush_link_tables, which fires on the first backward AG. + if not in_recompute and not type(self)._recompute_link_tables_flushed: + type(self).flush_recompute_link_tables() + # Consume current weight. if use_recompute_chain and self._recompute_prev is not None: result = self._get_recompute_prefetched_weight() @@ -1518,8 +1609,20 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): and self._prefetch_available() ): result = self._get_prefetched_weight(True) + elif use_recompute_chain: + # Recompute chain head. It still needs the recompute buffer, and on the first + # backward the chain links do not exist yet, so take the predecessor from the cursor. + result = self._all_gather_weight_on_demand( + True, + recompute=True, + recompute_prev=( + self._recompute_prev + or type(self)._get_recompute_chain_state(self.chain_id)["last_weight"] + ), + ) else: - # On-demand: chain head (fwd or recompute global-first) or first-iter build. + # On-demand: fwd chain head or first-iter build. Deliberately called with the + # original signature so the recompute plumbing never perturbs the fwd path. result = self._all_gather_weight_on_demand(True) # Prefetch next weight on the matching chain. @@ -1559,6 +1662,8 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): if last_r is not None and last_r._recompute_next is None: last_r._recompute_next = self self._recompute_prev = last_r + # Only once a link exists, so the head lands in row 0 -- same as the fwd table. + cls._recompute_link_table_row(last_r, self, rchain) self._recompute_initialized = True rchain["last_weight"] = self @@ -1581,7 +1686,19 @@ def all_gather_and_prefetch(self, fwd: bool = True, nvtx_label: str = None): q.dtype if q is not None else w.dtype for q, w in zip(quantizers, self._weights) ] # Must run before the reserve below — it decides which buffer the ticket gets. - self._ensure_distinct_buffer_from_prev(dtypes[0]) + # Grouped/routed weights take their fwd parity from _double_buffer_parity instead. + prev_w = self.prev_w + if not _chain_is_grouped(self.chain_id) and not self.is_routed_expert: + self._ensure_no_shared_buffer_with( + predecessor=prev_w, + predecessor_dtype=( + prev_w._cached_dtypes[0] + if prev_w is not None and prev_w._cached_dtypes + else None + ), + dtype=dtypes[0], + parity_attr="_buf_parity", + ) for w, dt in zip(self._weights, dtypes): w._ag_ticket_fwd = cache.reserve(w, dt, fwd=True) @@ -2121,9 +2238,11 @@ def _allocate_buffer( ) return buf - def reserve(self, param: "GTPShardedParam", dtype, fwd: bool, reduce_scatter=False) -> int: + def reserve( + self, param: "GTPShardedParam", dtype, fwd: bool, reduce_scatter=False, recompute=False + ) -> int: """Assign a persistent ticket. No buffer is allocated until ``get()``.""" - key = param._get_cache_key(dtype, fwd, reduce_scatter) + key = param._get_cache_key(dtype, fwd, reduce_scatter, recompute=recompute) ticket = self._next_ticket self._next_ticket += 1 @@ -2373,6 +2492,7 @@ def reset_gtp_state(): GTPShardedParam._chain_state.clear() GTPShardedParam._recompute_chain_state.clear() GTPShardedParam._link_tables_flushed = False + GTPShardedParam._recompute_link_tables_flushed = False _GTP_GROUPED_BUF_PARITY_COUNTER.clear() diff --git a/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py b/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py index 259cc6ed0d5..dab9fa0790c 100644 --- a/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py +++ b/tests/unit_tests/generalized_tensor_parallel/gtp_test_utils.py @@ -1,7 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -"""Shared fixtures and helpers for all GTP unit tests. -""" +"""Shared fixtures and helpers for all GTP unit tests.""" import pytest import torch @@ -9,7 +8,10 @@ from transformer_engine.pytorch import is_mxfp8_available, is_nvfp4_available from transformer_engine.pytorch.quantization import FP8GlobalStateManager -from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTPShardedParam +from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + GTPShardedParam, + reset_gtp_state, +) from tests.unit_tests.test_utilities import Utils # --------------------------------------------------------------------------- @@ -33,9 +35,16 @@ def reset_fp8_state(): @pytest.fixture(autouse=True) def reset_gtp_globals(): - """Reset GTP mutable class-level state between tests.""" + """Reset GTP mutable class-level state between tests. + + Defers to the production reset so this cannot drift as new class-level state is added. + Note it only clears the process-global cursors: the chain links themselves live on the + params (``prev_w`` / ``_recompute_prev`` / ``_ag_ticket_*``, set once in + ``_init_gtp_runtime_attrs``), so a test that reuses modules across cases would inherit + stale links. Every GTP test builds fresh modules for this reason. + """ yield - GTPShardedParam._chain_state = {} + reset_gtp_state() # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_recompute_chain.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_recompute_chain.py new file mode 100644 index 00000000000..66367c9a86a --- /dev/null +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_recompute_chain.py @@ -0,0 +1,295 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Regression tests for the GTP recompute-forward prefetch chain. + +Weights re-gathered during an activation-recompute forward form their own chain and prefetch one +node ahead, so two adjacent nodes sharing a gather buffer is a data race: the prefetch of node +i+1 overwrites the weight node i is still reading. In training that shows up as silently wrong +recomputed activations, exploding grad norm, then NaN. + +The buffer tests are the regression guard. The clobber is a stream race, so a numerical test +only trips when the timing lines up -- removing the parity fails the buffer tests but not the +numerical one at this size. TestGroupedDoubleBuffer asserts cache keys for the same reason. + +Test groups +----------- +TestGTPRecomputeChainBuffers - adjacent recompute nodes never share a gather buffer +TestGTPRecomputeCorrectness - recompute reproduces the non-recompute dgrad and wgrads +TestGroupedGTPRecomputeChainBuffers - same invariant per expert on a grouped chain +""" + +import pytest +import torch +import torch.distributed as dist + +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP + +if not HAVE_GTP: + pytest.skip("GTP requires TransformerEngine >= 2.19", allow_module_level=True) + +import megatron.core.tensor_parallel.generalized_tensor_parallelism as gtp_module +from megatron.core.tensor_parallel.random import CheckpointWithoutOutput +from tests.unit_tests.generalized_tensor_parallel.gtp_test_utils import ( + _make_gtp_linear, + _make_gtp_remat_grouped_linear, + _requires_multi_gpu, + _run_distributed, + _torchrun_dist_init, + reset_fp8_state, + reset_gtp_globals, +) + +# Topology matters. Each block is one recomputed square GEMM followed by two NON-recomputed +# GEMMs of a different shape: +# +# fwd chain: A0 (H,H) -> B0 (O,H) -> C0 (H,O) -> A1 (H,H) -> ... heterogeneous +# recompute chain: A0 -------------------------------> A1 -> ... homogeneous +# +# The differently-shaped B/C are what make this a real test: without them the A weights are +# adjacent in the FORWARD chain too, _ensure_no_shared_buffer_with separates them there, +# and the recompute-chain guard becomes redundant -- so the test would pass even when broken. +HIDDEN = 512 +OTHER = 256 +NUM_LAYERS = 4 +DTYPE = torch.bfloat16 + + +def _recompute_buffer_addr(param): + """Address of the buffer this weight gathers into on the recompute chain, or None. + + Test-only: the production code has no reason to know a buffer's address. + """ + ticket = getattr(param, "_ag_ticket_recompute", None) + if ticket is None: # ticket ids start at 0, so compare against None + return None + slot = gtp_module.get_global_GTP_cache()._slots.get(ticket) + buf = slot.buf if slot is not None else None + if buf is None: + return None + raw = getattr(buf, "_rowwise_data", None) + if raw is None: + raw = getattr(buf, "_data", buf) + return raw.data_ptr() + + +def _build_layers(world_size): + """Return (recomputed square layers, non-recomputed differently-shaped spacer pairs).""" + gtp_remat_group = dist.new_group(list(range(world_size))) + + def linear(in_f, out_f): + return _make_gtp_linear(in_f, out_f, gtp_remat_group, DTYPE, fuse_wgrad_accumulation=True) + + recomputed = [linear(HIDDEN, HIDDEN) for _ in range(NUM_LAYERS)] + spacers = [(linear(HIDDEN, OTHER), linear(OTHER, HIDDEN)) for _ in range(NUM_LAYERS)] + for layer in recomputed + [m for pair in spacers for m in pair]: + # GTP reduce-scatters wgrad into main_grad, on the local shard shape. + layer.weight.main_grad = torch.zeros(layer.weight.shape, dtype=DTYPE, device="cuda") + return recomputed, spacers + + +def _zero_grads(layers): + recomputed, spacers = layers + for layer in recomputed + [m for pair in spacers for m in pair]: + layer.weight.main_grad.zero_() + + +def _forward_backward(layers, x, recompute): + """Run the stack, optionally checkpointing every layer so it is recomputed in backward. + + Mirrors the production pattern: checkpoint the GTP GEMM, let a downstream op consume (and + save) its output, then hook the recompute on that downstream tensor so it fires during this + layer's backward. + """ + recomputed, spacers = layers + h = x + for layer, (spacer_down, spacer_up) in zip(recomputed, spacers): + # te.Linear returns a bare tensor (bias=False), not Megatron's (out, bias) tuple. + if recompute: + checkpoint = CheckpointWithoutOutput() + y = checkpoint.checkpoint(lambda inp, l=layer: l(inp), h) + # gelu saves y for backward, so discarding y is what makes the recompute necessary. + h = torch.nn.functional.gelu(y) + checkpoint.discard_output_and_register_recompute(h) + else: + h = torch.nn.functional.gelu(layer(h)) + # Not checkpointed: keeps the fwd chain heterogeneous around each recomputed weight. + h = torch.nn.functional.gelu(spacer_down(h)) + h = torch.nn.functional.gelu(spacer_up(h)) + loss = h.float().sum() + loss.backward() + # Return the INPUT gradient, not the loss: the loss is produced by the forward pass, so it + # is identical either way and cannot witness anything the recompute got wrong. + return x.grad.detach().clone() + + +def _worker_adjacent_nodes_use_distinct_buffers(rank, world_size, port): + """Every adjacent pair of recompute-chain nodes must gather into different buffers.""" + torch.manual_seed(0) + gtp_module.reset_gtp_state() + layers = _build_layers(world_size) + recomputed = layers[0] + + x = torch.randn(8, HIDDEN, dtype=DTYPE, device="cuda", requires_grad=True) + dist.broadcast(x, src=0) + + # First pass builds the chain (all gathers on demand); the second uses it. + for _ in range(2): + _forward_backward(layers, x, recompute=True) + + chain = [] + node = recomputed[-1].weight + while node is not None and node._recompute_prev is not None: + node = node._recompute_prev + while node is not None: + chain.append(node) + node = node._recompute_next + + assert len(chain) == NUM_LAYERS, f"recompute chain has {len(chain)} nodes, want {NUM_LAYERS}" + + addrs = [_recompute_buffer_addr(w) for w in chain] + assert all(a is not None for a in addrs), f"unallocated recompute buffer: {addrs}" + shared = [i for i in range(len(addrs) - 1) if addrs[i] == addrs[i + 1]] + assert not shared, ( + f"recompute-chain nodes {shared} share a gather buffer with their successor " + f"(addrs={[hex(a) for a in addrs]}); the one-ahead prefetch would clobber the weight " + "still being read" + ) + # One-ahead needs exactly two buffers; more would mean the pool stopped being reused. + assert len(set(addrs)) == 2, f"want 2 alternating buffers, got {len(set(addrs))}: {addrs}" + + +def _worker_recompute_matches_no_recompute(rank, world_size, port): + """Recompute is pure rematerialization: same input grad and same weight grads. + + End-to-end sanity check over the real CheckpointWithoutOutput path. It does not reliably + catch the buffer-sharing bug on its own (see the module docstring); the buffer invariant is + what guards that. + """ + torch.manual_seed(0) + gtp_module.reset_gtp_state() + layers = _build_layers(world_size) + recomputed = layers[0] + + x = torch.randn(8, HIDDEN, dtype=DTYPE, device="cuda", requires_grad=True) + dist.broadcast(x, src=0) + + # Warm up so the chains exist, then measure a steady-state step of each variant. + _forward_backward(layers, x, recompute=False) + _forward_backward(layers, x, recompute=True) + + _zero_grads(layers) + x.grad = None + dgrad_ref = _forward_backward(layers, x, recompute=False) + grads_ref = [l.weight.main_grad.clone() for l in recomputed] + + _zero_grads(layers) + x.grad = None + dgrad_rc = _forward_backward(layers, x, recompute=True) + grads_rc = [l.weight.main_grad.clone() for l in recomputed] + + # Both dgrad and wgrad flow through the recomputed weights, so both witness a clobber. + torch.testing.assert_close(dgrad_rc.float(), dgrad_ref.float(), rtol=1e-3, atol=1e-3) + for i, (g_rc, g_ref) in enumerate(zip(grads_rc, grads_ref)): + # A clobbered recompute uses a neighbour's weight, which moves the grad by O(grad), + # far outside this tolerance. + torch.testing.assert_close( + g_rc.float(), g_ref.float(), rtol=1e-3, atol=1e-3, msg=f"layer {i} wgrad mismatch" + ) + + +class TestGTPRecomputeChainBuffers: + def test_adjacent_nodes_use_distinct_buffers(self): + _requires_multi_gpu(4) + _run_distributed(_worker_adjacent_nodes_use_distinct_buffers, 4) + + +class TestGTPRecomputeCorrectness: + def test_recompute_matches_no_recompute(self): + _requires_multi_gpu(4) + _run_distributed(_worker_recompute_matches_no_recompute, 4) + + +# --------------------------------------------------------------------------- +# Grouped (routed-expert) recompute chains +# --------------------------------------------------------------------------- + +NUM_GEMMS = 2 +GROUPED_CHAIN = "GTP_remat_grouped_fc1_ungraphed" +# Strict subset: block 1 and 3 are gathered but never recomputed (see the worker docstring). +RECOMPUTED_BLOCKS = {0, 2} + + +def _worker_grouped_adjacent_nodes_use_distinct_buffers(rank, world_size, port): + """Same invariant on a grouped one-block-ahead chain, per expert. + + Two details make this a real test rather than a tautology: + * only a SUBSET of blocks is recomputed. The grouped chain's own _double_buffer_parity is + drawn in FORWARD order over every block, so recomputing all of them leaves the recompute + chain alternating by accident and the test passes even when unguarded. Skipping a block + makes two same-parity weights adjacent on the recompute chain -- the real collision. + * buffers are compared per EXPERT: grouped weights gather as a batch and the cache key + carries expert_idx, so expert k of block N collides with expert k of block N+1, not with + the anchor. + """ + torch.manual_seed(0) + gtp_module.reset_gtp_state() + + gtp_remat_group = dist.new_group(list(range(world_size))) + blocks = [ + _make_gtp_remat_grouped_linear( + NUM_GEMMS, HIDDEN, HIDDEN, gtp_remat_group, DTYPE, fuse_wgrad_accumulation=True + ) + for _ in range(NUM_LAYERS) + ] + # Production assigns these from the param name in _classify_param_chain; do it by hand so + # the weights land on the grouped one-block-ahead chain rather than the generic one. + for block in blocks: + for w in block.weight0.weight_list: + w.chain_id = GROUPED_CHAIN + w.main_grad = torch.zeros(w.shape, dtype=DTYPE, device="cuda") + + tokens = 8 * NUM_GEMMS + m_splits = [tokens // NUM_GEMMS] * NUM_GEMMS + x = torch.randn(tokens, HIDDEN, dtype=DTYPE, device="cuda", requires_grad=True) + dist.broadcast(x, src=0) + + def fwd_bwd(): + h = x + for i, block in enumerate(blocks): + call = lambda inp, b=block: b(inp, m_splits=m_splits, is_first_microbatch=True) + if i in RECOMPUTED_BLOCKS: + checkpoint = CheckpointWithoutOutput() + y = checkpoint.checkpoint(call, h) + h = torch.nn.functional.gelu(y) + checkpoint.discard_output_and_register_recompute(h) + else: + h = torch.nn.functional.gelu(call(h)) + h.float().sum().backward() + + for _ in range(2): # first pass builds the chain, second uses it + fwd_bwd() + + chain = [blocks[i].weight0 for i in sorted(RECOMPUTED_BLOCKS)] + assert all( + a._recompute_initialized for a in chain + ), "grouped weights never gathered under recompute -- the chain was not built" + + failures = [] + for expert in range(NUM_GEMMS): + addrs = [_recompute_buffer_addr(a.weight_list[expert]) for a in chain] + if any(a is None for a in addrs): + failures.append(f"expert {expert}: unallocated buffer {addrs}") + continue + shared = [i for i in range(len(addrs) - 1) if addrs[i] == addrs[i + 1]] + if shared: + failures.append( + f"expert {expert}: chain nodes {shared} share a buffer with their successor " + f"({[hex(a) for a in addrs]})" + ) + assert not failures, "grouped recompute chain collides:\n " + "\n ".join(failures) + + +class TestGroupedGTPRecomputeChainBuffers: + def test_grouped_adjacent_nodes_use_distinct_buffers(self): + _requires_multi_gpu(4) + _run_distributed(_worker_grouped_adjacent_nodes_use_distinct_buffers, 4) From a0394fef6d74359e4ba57114dbd778241409803f Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 12 Aug 2026 00:16:33 +0000 Subject: [PATCH 260/290] Update copy-pr-bot.yaml [skip ci] --- .github/copy-pr-bot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copy-pr-bot.yaml b/.github/copy-pr-bot.yaml index eface2e458a..9b33b89739d 100644 --- a/.github/copy-pr-bot.yaml +++ b/.github/copy-pr-bot.yaml @@ -1,4 +1,4 @@ enabled: true auto_sync_draft: false auto_sync_ready: true -trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "DanialTaheri", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] +trustees_override: ["AAnoosheh", "ArEsKay3", "Autumn1998", "BestJuly", "BoxiangW", "CarlosGomes98", "ChenhanYu", "Connor-XY", "DanialTaheri", "FDecaYed", "HaochenYuan", "ISEEKYAN", "JF-D", "JRD971000", "Leili", "Mellonta", "Phlip79", "QiZhangNV", "RPrenger", "ShriyaRishab", "WanZzzzzz", "Wohox", "YangFei1990", "ZhiyuLi-Nvidia", "adistomar", "ahmadki", "aklife97", "alokpathy", "ananthsub", "anlthms", "aroshanghias-nvd", "ashehper", "asolergi-nv", "athitten", "balasaajay", "buptzyb", "chtruong814", "cjld", "cspades", "cuichenx", "deepakn94", "desh2608", "dimapihtar", "dingqingy-nv", "duncanriach", "ehosseiniasl", "erhoo82", "ericharper", "fanshiqing", "faradawn", "fitsumreda", "freewym", "frsun-nvda", "gautham-kollu", "gdengk", "goelarushi", "guihong-nv", "guyueh1", "hexinw-nvidia", "huvunvidia", "hxbai", "ilml", "jalbericiola", "janEbert", "jaredcasper", "jenchen13", "jiaji-huang", "jiemingz", "jingqiny-99", "jkamalu", "jon-barker", "jstjohn", "kajalj22", "kamran-nvidia", "kevalmorabia97", "kevjshih", "kingformatty", "ko3n1g", "ksivaman", "kunlunl", "kvareddy", "kwyss-nvidia", "lauradang", "layalir", "lhb8125", "liding-nv", "lmcafee-nvidia", "maanug-nv", "macandro96", "mathemakitten", "matthieule", "mchrzanowski", "mehraakash", "minitu", "mkhona-nvidia", "nanz-nv", "niyunsheng", "ntajbakhsh", "nvcsathe", "parthmannan", "philipcmonk", "prajwal1210", "pthombre", "rapatel", "rhewett-nv", "rogerwaleffe", "sajadn", "sancha", "santhnm2", "sbak5", "shanmugamr1992", "sharathts", "sheliang-nv", "shengf-nv", "shifangx", "shjwudp", "sidsingh-nvidia", "skyw", "sraman-rgb", "sudhakarsingh27", "svcnvidia-nemo-ci", "tdene", "theothermike", "thomasdhc", "tomlifu", "trintamaki", "tylerpoon", "vasunvidia", "wdykas", "wujingyue", "xiaoyao0115", "xuantengh", "xuwchen", "yaox12", "yaoyu-33", "yashaswikarnati", "yeyu-nvidia", "yobibyte", "youngeunkwon0405", "yqwangustc", "yueshen2016", "yuzhongw-nvidia", "zhehuaichen", "zhongbozhu"] From 49a49a9b0dadab21c747fad519f72f9e6f969bd0 Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Tue, 11 Aug 2026 15:35:41 -0700 Subject: [PATCH 261/290] Add MFSDP v2 design document (#6431) Signed-off-by: Jingyue Wu --- .../distributed/fsdp/src/docs/mfsdp_design.md | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 megatron/core/distributed/fsdp/src/docs/mfsdp_design.md diff --git a/megatron/core/distributed/fsdp/src/docs/mfsdp_design.md b/megatron/core/distributed/fsdp/src/docs/mfsdp_design.md new file mode 100644 index 00000000000..6bb9a93b287 --- /dev/null +++ b/megatron/core/distributed/fsdp/src/docs/mfsdp_design.md @@ -0,0 +1,404 @@ +# Megatron FSDP Design + +Contributors: @wujingyue, @cspades, @shjwudp, @Autumn1998 + +GitHub tracker: https://github.com/orgs/NVIDIA/projects/276 + +# Executive Summary + +This design doc proposes MFSDP v2 to better satisfy +[Megatron FSDP Requirements](http://nv/mfsdp-requirements). In particular, the new +version enables: + +- **Fine-grained control.** For FSDP, automatically determining the optimal bucketing + and prefetching strategy has been challenging. + - The proposed high-level `fully_shard` API, similar to + [PyTorch FSDP2’s `fully_shard` API](https://docs.pytorch.org/docs/main/distributed.fsdp.fully_shard.html), + provides per-module control that is finer-grained than the production version. + - In addition, we also plan to expose lower-level APIs (e.g., ParameterGroup and + DBuffer) to give users even more fine-grained control. Spatially, this allows users + to control which parameters belong to each “bucket”. Temporally, this allows users + to control when unsharding and resharding occur during forward, backward, and + optimization. +- **Emerging optimizers**. For example, it supports tensor-atomic sharding needed for + the Muon optimizer. +- **Simplified lower-precision support.** Through a block-atomic sharding format, as + motivated by [veScale-FSDP](https://arxiv.org/abs/2602.22437). + +These capabilities are difficult to implement cleanly within the current codebase +architecture. Therefore, the code will be +[developed in branch `main`](#separate-code-paths-in-main) as a separate code path from +the existing `megatron_fsdp` implementation. The development will follow the +prototype-design-execute process that we’ll detail in +[this section](#development-process). Once the new code is on parity, we’ll gradually +migrate users over. + +The remainder of the doc focuses on the core MFSDP abstractions and building blocks that +serve as the foundation for extensions. Capabilities such as MXFP8, CUDA Graphs, double +buffering, prefetching, NCCL user buffers, HFSDP, offloading, and checkpointing build on +these primitives and introduce additional design considerations. We will cover these +areas in dedicated follow-on design documents, leveraging the interfaces and mechanisms +established here. + +# Subdesigns + +- [Optimizer](optimizer.md) +- [Runtime schedule](runtime_schedule.md) + +# API + +```py +class Placement +class Replicate(Placement) +class Partial(Placement) +class Flat(Placement) +class TensorAtomic(Placement) + + +type MeshAxis = int | str + + +@dataclass +class Placements: + dp_axes: list[MeshAxis] # outer to inner + parameter: list[Placement] # same length as dp_axes + gradient: list[Placement] + optimizer: list[Placement] + + +def fully_shard( + module: nn.Module, + mesh: DeviceMesh, + placements: Placements, + mixed_precision_policy: MixedPrecisionPolicy | None, + offload_policy: OffloadPolicy | None) -> None +``` + +Unlike MFSDP v1, `fully_shard` is expected to be called on each `nn.Module` that forms +an FSDP unit. Under the hood, `fully_shard` attaches the `FsdpModule` mixin to the +target module. FSDP units can also be nested: an outer unit owns the parameters within +its scope, excluding those managed by any inner FSDP units. This is compatible with +FSDP2’s behavior. For example, + +``` +FSDP Unit: RootModule +owns: + RootModule.root_weight + RootModule.root_bias + +contains: + FSDP Unit: SubmoduleA + owns: + SubmoduleA.weight + SubmoduleA.bias + + FSDP Unit: SubmoduleB + owns: + SubmoduleB.weight + SubmoduleB.bias + +Ownership view: + +RootModule FSDP unit +├── owns RootModule.* params +├── does NOT own SubmoduleA.* params +└── does NOT own SubmoduleB.* params + +SubmoduleA FSDP unit +└── owns SubmoduleA.* params + +SubmoduleB FSDP unit +└── owns SubmoduleB.* params +``` + +`fully_shard` sets each parameter in the given module to a shard. As a contract, no +parameters can escape its lowest FsdpModule ancestor to avoid issues like +https://github.com/NVIDIA/Megatron-LM/pull/4899. Without this contract, it can be unsafe +to unshard or reshard a parameter at the module boundary. + +The placement is similar to DTensor’s placement but for the whole **unit** and per mesh +axis. + +- `Replicate`. Not sharded. +- `Partial`. Used internally for pre-reduce-scatter gradients, which are unsharded and + only partially accumulated; not user-facing. +- `Flat`. The current per-unit, dim-0 flat sharding. Good for elementwise optimizers. +- `TensorAtomic`. Don’t cut a parameter. For emerging optimizers that need full + parameters. +- `BlockAtomic(block_size)`. Don’t cut a block of `block_size` rows. Simplifies + blockwise quantization support. Currently, a 32x1 mxfp8 block may be sharded across + ranks. This introduces complex host-side logic and custom quantization kernels to + handle two levels of absmax reduction. Using block-atomic sharding with block_size=32 + ensures that every 32-row block is owned by a single rank. +- `PerTensor(dist.tensor.Placement)`. If needed. Per-tensor dim-0 sharding used in + FSDP2. It leads to extra data copy so won’t be used by default. + +``` +Block-atomic sharding example +Input tensor: 8 rows × 4 columns +Block size: 2 rows +Earlier tensors in the parameter group occupy ranks 0, 1, and part of rank 2. + + c0 c1 c2 c3 + ┌────┬────┬────┬────┐ +r0 │ x │ x │ x │ x │ +r1 │ x │ x │ x │ x │ + ├────┼────┼────┼────┤ block 0: rows [0, 1] → rank 2 +r2 │ x │ x │ x │ x │ +r3 │ x │ x │ x │ x │ + ├────┼────┼────┼────┤ block 1: rows [2, 3] → rank 3 +r4 │ x │ x │ x │ x │ +r5 │ x │ x │ x │ x │ + ├────┼────┼────┼────┤ block 2: rows [4, 5] → rank 3 +r6 │ x │ x │ x │ x │ +r7 │ x │ x │ x │ x │ + └────┴────┴────┴────┘ block 3: rows [6, 7] → rank 4 +``` + +`Placements` encodes the various grand [sharding strategies](#sharding-strategies) we +care about and gives users the flexibility to choose which sharding format/granularity +to use for each unit. We may want to pre-define a set of common placements for +convenience, e.g., + +```py +# Assuming `Flat` placement +def hfsdp(dp_outer: MeshAxis, dp_inner: MeshAxis) -> Placements: + return Placements(dp_axes=[dp_outer, dp_inner], + parameter=[Replicate(), Flat()], + gradient=[Partial(), Flat()], + optimizer=[Flat(), Flat()]) +``` + +If needed, grouping can be customized via the `fully_shard` API, similar to +[the `buckets` argument](https://github.com/pytorch/torchtitan/pull/2378/changes#diff-35ddb8c23734307a1b5fe23e06ffe8e0f2f2c84c58943380d137371e6e21e203R3289) +in the FlexShard proposal. + +## Sharding Strategies + +Unlike MFSDP v1, MFSDP v2 does not special-case named strategies such as HSDP or HFSDP. +Instead, +`fully_shard` receives a `Placements` configuration that independently specifies the +parameter, gradient, and optimizer placement for each data-parallel axis. The table +below illustrates familiar configurations; it is not an exhaustive list of supported +strategies. + +`N` \= size of the inner DP shard dim (`dp_shard_dim`). `M` \= size of the outer DP dim +(`dp_outer_dim`), only present for HSDP/HFSDP. "Sharded" \= persistent state is +partitioned across that dim; "replicated" \= each rank holds a full copy. + +| Strategy | Parameters | Gradients | Optimizer states | +| :--------------------------------------- | :-------------------------- | :----------------------- | :-------------------------------------------------- | +| **DDP / `no_shard`** | replicated (N) | partial (N) | replicated (N) | +| **ZeRO-1 / `optim`** | replicated (N) | partial (N) | sharded (N) | +| **ZeRO-2 / `optim_grads`** | replicated (N) | sharded (N) | sharded (N) | +| **ZeRO-3 / `optim_grads_params` / FSDP** | sharded (N) | sharded (N) | sharded (N) | +| **HSDP** (FSDP inner, replicate outer) | sharded (N), replicated (M) | sharded (N), partial (M) | sharded (N), replicated (M) | +| **HFSDP** (FSDP inner, `optim` outer) | sharded (N), replicated (M) | sharded (N), partial (M) | sharded (N × M) — fully sharded across flattened DP | + +The per-axis representation also expresses combinations beyond those in the table. For +example, a configuration with ZeRO-1 on the outer DP axis and ZeRO-2 on the inner axis +uses the following placement lists, ordered to match `dp_axes` from outer to inner: + +```py +placements = Placements( + dp_axes=[dp_outer, dp_inner], + parameter=[Replicate(), Replicate()], + gradient=[Partial(), Flat()], + optimizer=[Flat(), Flat()], +) +``` + +## Compatibility + +### FSDP2 + +Introduce a separate adapter API, `fully_shard_compat`, that mirrors the signature of +PyTorch’s `fully_shard` but omits certain MFSDP-specific features. This would give +existing FSDP2 users a low-friction migration path: they can first switch to +`fully_shard_compat`, and then optionally move to MFSDP’s `fully_shard` to take +advantage of the full feature set. + +```py +def fully_shard_compat(...fsdp2 args...): + convert the args + fully_shard(...converted args...) +``` + +### MCore Adapter + +This rewrite should be mostly transparent to users of +megatron/core/distributed/fsdp/mcore_fsdp_adapter.py. We’ll implement the adapter using +the new API. + +However, certain features may behave differently. For example, +`enable_fine_grained_param_gather_hook` currently makes all-gather fine-grained (one per +submodule), but not reduce-scatter. With per-module control, users would instead apply +FSDP directly to individual submodules, causing both all-gather and reduce-scatter +operations to occur at the submodule level. + +# Key Building Blocks + +### Ownership and lifetime + +The FSDP module tree owns its persistent runtime state: + +``` +nn.Module / FsdpModule +├── active nn.Parameter +├── FsdpParameterGroup +│ ├── paired sharded and unsharded nn.Parameters +│ └── DBuffers +└── shared FsdpContext + ├── communication streams + └── prefetch-order metadata +``` + +The module’s active parameter is one of the pair owned by its parameter group. + +After construction is finalized, every backedge to the module tree **must use a weak +reference**: context prefetch metadata, parameter-group ownership markers, and hook +callbacks. Otherwise, deleting a model retains its persistent CUDA storage until cyclic +garbage collection; with weak backedges, storage is released immediately without +teardown. See https://github.com/NVIDIA/Megatron-LM/pull/6230. + +### FsdpContext + +- Created by `fully_shard_context` and shared by every `FsdpModule` constructed in that + scope. On exit, it identifies FSDP roots and finalizes the static forward and backward + prefetch orders. +- Per-device all-gather and reduce-scatter streams. Module compute runs on PyTorch’s + current stream. +- Last-microbatch state for HSDP/HFSDP gradient accumulation. +- An optional PyTorch NCCL symmetric-memory pool for communication staging buffers. + +### FsdpModule + +A mixin attached in place to the original module, so its parent retains the same child +module reference. + +- Registered forward and backward hooks drive parameter materialization, resharding, + gradient reduction, and all-gather prefetching. +- `phase` tracks the module lifecycle: `RESTING` outside module computation, `FORWARD` + between its forward hooks, and `BACKWARD` between its backward hooks. Activation + recomputation preserves `BACKWARD` through its nested forward hooks. +- Parameter groups partition the module’s owned parameters by dtype and `requires_grad`. + +### ParameterGroup + +- dtype +- requires_grad: bool +- A sharded `nn.Parameter` for every logical parameter. Its `.data` is a DTensor backed + by `main_weight`, and it is the parameter visible to the optimizer. +- The original `nn.Parameter` objects remain attached to the module. During compute, + their `.data` views a temporary replicated buffer materialized from `model_weight`; + their `.grad` is temporary full-gradient storage. +- `model_weight`: the persistent compute-dtype buffer, sharded according to + `Placements.parameter`. It may alias `main_weight` when their dtype and placements + match. +- `main_weight`: the persistent optimizer-dtype buffer, sharded according to + `Placements.optimizer`. +- `main_grad`: the persistent gradient buffer for trainable groups, sharded according to + `Placements.gradient` and allocated in the configured gradient dtype. + +### DBuffer + +Conceptually, a group of logical tensors, potentially with different shapes, stored in +one contiguous local buffer. + +- `local_buffer`: a flat `torch.Tensor` holding this rank’s contiguous shard. +- `mesh` and a per-mesh-axis `placements` tuple. The current implementation requires the + mesh to contain only data-parallel axes; callers extend returned DTensors with TP or + EP axes when needed. +- `GlobalLayout`: global tensor shapes and stable offsets used to compute every rank’s + local range. +- `redistribute(new_placements)`, with `allgather`, `allreduce`, `reduce_scatter`, and + `scatter` convenience operations. Redistributing between sharded placements preserves + the global layout; [the optimizer subdesign](optimizer.md) converts between `Flat` and + `TensorAtomic` this way. +- `get_local_tensor(index)`: the local view for one logical tensor. +- `get_dtensor(index)`: the corresponding DTensor, used by the optimizer and distributed + checkpointing. + +# Flow + +Below is what module parameters look like after each FSDP stage. + +Key contract: an FsdpModule’s owned parameters are only unsharded during its forward and +backward. + +| Stage | Action | param.data after action | param.grad after action | +| :------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | :---------------------------- | :------------------------------------------- | +| After fully_shard / Start of a training loop | optimizer.zero_grad(set_to_none=True) | DTensor backed by main_weight | None or a zeroed DTensor backed by main_grad | +| Pre-forward / during forward | Switch to the unsharded parameter; allgather model_weight into param.data | A full-size plain Tensor | None | +| During forward | None | Unchanged | Unchanged | +| Post-forward | Release param.data; switch to the sharded parameter | DTensor backed by main_weight | None or a DTensor backed by main_grad | +| Pre-backward | Switch to the unsharded parameter; allgather model_weight into param.data | A full-size plain Tensor | None | +| During backward | Autograd sets param.grad | Unchanged | A full-size plain Tensor | +| Post-backward | Reduce-scatter param.grad; The result is written/accumulated to the sharded parameter’s grad; Release param.grad; switch to the sharded parameter | DTensor backed by main_weight | DTensor backed by main_grad | +| If more microbatches | Go back to pre-forward | Unchanged | Unchanged | +| Optimizer step | optimizer.step() | DTensor backed by main_weight | DTensor backed by main_grad | +| Post optimizer step | Quantize main_weight to model_weight | DTensor backed by main_weight | DTensor backed by main_grad | + +For HSDP and HFSDP, during post-backward for the last microbatch, we should further +reduce-scatter the sharded parameters according to `Placements.optimizer`. This way, the +reduce-scatters can be overlapped with backward compute instead of being exposed before +the optimizer step. Accordingly, prior to the forward pass of the first micro_batch, we +also need to all-gather the sharded parameters across the entire DP domain (outer \+ +inner). + +# Implementation Plan + +## Separate code paths in main + +### Production + +- [`megatron_fsdp`](../megatron_fsdp/): the existing, non-experimental implementation +- Still maintained and occasionally optimized + +### Experimental (this doc) + +- [`megatron_fsdp/experimental`](../megatron_fsdp/experimental/): the long-term version + of MFSDP that we want to maintain and use to support next generation of architectures + and training techniques +- Development will be design driven and incremental with a peer review process +- Experimental will live alongside Production **in the `main` branch** +- Once battle-tested and demonstrating performance parity (e.g. by MLPerf models) on a + per-model basis, onboard models and customers gradually. +- After enough adoption, production will become legacy and experimental will become + production + +### Prototype + +A prototype implementation by @shjwudp and @Autumn1998 remains in +[@shjwudp's fork](https://github.com/shjwudp/Megatron-LM/tree/mfsdp_refactor). The +objective is to battle-test selected features—such as per-module control and +`TracePoolAllocator`—with early users and derisk this design. Once `main` reaches the +prototype feature set (see below), further prototype development and validation will +shift to `main` so we can focus on the same code path. + +Current prototype features: + +- MXFP8 +- Overlapping +- Prefetching +- Checkpointing to DCP +- Composibility with EP +- Double buffering (through TracePoolAllocator) + +## Development process + +We’ll follow a standard prototype-design-execute process. + +1. **Prototype**: Strictly optional. Make the feature work in a draft PR **only** to + derisk the design. +2. **Design**: Update this design or create a subdesign to support a new feature. Draw + and write documentation explaining the feature and how it works. Converge and align + on the design change. +3. **Execute**: Update code and merge. Some general guidelines: + - Code, review, and test incrementally. Keep + [PRs small and focused](https://google.github.io/eng-practices/review/developer/small-cls.html). + - Favor simplicity and maintainability by default. Any performance optimization that + increases complexity should be justified with clear evidence and measurable impact. + - Critical horizontal features (for example, CUDA Graphs and `torch.compile`) should + be validated from the beginning. These integrations are easy to break and difficult + to retrofit, so we should rely on CI coverage to catch regressions early. From 8af9b04665171cabe11a4e166bea4de7304b7b8e Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Tue, 11 Aug 2026 21:08:39 -0400 Subject: [PATCH 262/290] Make core-nemo fallback owner for top-level files (#6358) Signed-off-by: Philip Petrakian --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3ca7754175b..d939d68729c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,6 @@ +# Files directly in the repository root without a more specific owner. +/* @NVIDIA/core-nemo + megatron/core/ @NVIDIA/core-adlr @NVIDIA/core-nemo megatron/core/tensor_parallel/generalized_tensor_parallelism.py @NVIDIA/core-adlr @NVIDIA/core-nemo @NVIDIA/gtp From 725949abbfa0878b2d84997a07cc7d959ee55624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Wed, 12 Aug 2026 03:17:40 +0200 Subject: [PATCH 263/290] ci(auth): treat svcnemo-autobot as internal (#6436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../actions/check-nvidia-sso-membership/action.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/actions/check-nvidia-sso-membership/action.yml b/.github/actions/check-nvidia-sso-membership/action.yml index 71926c4547d..e96863e2264 100644 --- a/.github/actions/check-nvidia-sso-membership/action.yml +++ b/.github/actions/check-nvidia-sso-membership/action.yml @@ -1,5 +1,5 @@ name: 'Check NVIDIA SSO Membership' -description: 'Check if a GitHub username exists in the NVIDIA SSO users list from github-audits' +description: 'Check if a GitHub username is in the NVIDIA SSO users list or internal allowlist' author: 'NVIDIA' inputs: @@ -24,7 +24,7 @@ inputs: outputs: is_member: - description: 'Boolean - true if user is in NVIDIA SSO list, false otherwise' + description: 'Boolean - true if user is in NVIDIA SSO list or internal allowlist, false otherwise' value: ${{ steps.check-membership.outputs.is_member }} is_org_member: description: 'Boolean - true if user has NVIDIA or NVIDIA-NeMo in org_roles' @@ -91,6 +91,15 @@ runs: echo "Checking if $USERNAME is in NVIDIA SSO users list..." + # Service accounts cannot enroll in SSO but can be explicitly trusted. + if [ "$USERNAME" = "svcnemo-autobot" ]; then + echo "$USERNAME found in NVIDIA internal user allowlist" + echo "is_member=true" >> $GITHUB_OUTPUT + echo "is_org_member=false" >> $GITHUB_OUTPUT + echo "user_orgs=" >> $GITHUB_OUTPUT + exit 0 + fi + # Check if SSO file is available if [ "${{ steps.download-sso.outputs.sso_file_available }}" != "true" ] || [ ! -f "$SSO_FILE" ]; then echo "ERROR: $SSO_FILE not available - cannot check membership" From 897649d0bae3ab28a95dbeee05ae18e1b6fae2da Mon Sep 17 00:00:00 2001 From: "Mikail Khona (NVIDIA)" Date: Wed, 12 Aug 2026 00:31:27 -0400 Subject: [PATCH 264/290] Add mixed-precision (FP32) LM output logits (#6252) Signed-off-by: mkhona Signed-off-by: Deepak Narayanan Signed-off-by: Keshav Santhanam Co-authored-by: Deepak Narayanan Co-authored-by: Keshav Santhanam --- .../detxoify_lm/generate_samples_gpt.py | 1 + gpt_builders.py | 1 + hybrid_builders.py | 1 + .../core/extensions/transformer_engine.py | 5 +- megatron/core/models/gpt/gpt_model.py | 6 + megatron/core/models/hybrid/hybrid_model.py | 6 + megatron/core/tensor_parallel/layers.py | 71 ++++++++-- .../elastification/pretrain_hybrid_flex.py | 1 + megatron/post_training/model_builder.py | 2 + megatron/training/argument_utils.py | 2 + megatron/training/arguments.py | 6 + megatron/training/models/gpt.py | 2 + megatron/training/models/hybrid.py | 4 + .../test_te_lmhead_column_parallel_linear.py | 18 +++ .../unit_tests/tensor_parallel/test_layers.py | 132 +++++++++++++++++- tests/unit_tests/test_argument_utils.py | 23 +++ .../training/models/test_gpt_builder.py | 5 + .../training/models/test_hybrid_builder.py | 6 + 18 files changed, 278 insertions(+), 14 deletions(-) diff --git a/examples/academic_paper_scripts/detxoify_lm/generate_samples_gpt.py b/examples/academic_paper_scripts/detxoify_lm/generate_samples_gpt.py index 2a2b1d63a21..2db8e62ba40 100644 --- a/examples/academic_paper_scripts/detxoify_lm/generate_samples_gpt.py +++ b/examples/academic_paper_scripts/detxoify_lm/generate_samples_gpt.py @@ -70,6 +70,7 @@ def model_provider(pre_process=True, post_process=True) -> GPTModel: pre_process=pre_process, post_process=post_process, fp16_lm_cross_entropy=args.fp16_lm_cross_entropy, + logit_dtype=getattr(args, 'logit_dtype', None), parallel_output=False, share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights, position_embedding_type=args.position_embedding_type, diff --git a/gpt_builders.py b/gpt_builders.py index 3512918efe6..00a9b3bd2b5 100644 --- a/gpt_builders.py +++ b/gpt_builders.py @@ -91,6 +91,7 @@ def gpt_builder(args, pre_process, post_process, vp_stage=None, config=None, pg_ pre_process=pre_process, post_process=post_process, fp16_lm_cross_entropy=args.fp16_lm_cross_entropy, + logit_dtype=getattr(args, 'logit_dtype', None), parallel_output=True, share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights, position_embedding_type=args.position_embedding_type, diff --git a/hybrid_builders.py b/hybrid_builders.py index 7e1c58682ac..4ab668eb8bd 100644 --- a/hybrid_builders.py +++ b/hybrid_builders.py @@ -33,6 +33,7 @@ def hybrid_builder(args, pre_process, post_process, vp_stage=None, config=None, pre_process=pre_process, post_process=post_process, fp16_lm_cross_entropy=args.fp16_lm_cross_entropy, + logit_dtype=getattr(args, 'logit_dtype', None), parallel_output=True, share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights, position_embedding_type=args.position_embedding_type, diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index e806a9fdd09..1f67b1a5b5b 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -1749,7 +1749,7 @@ class TELMHeadColumnParallelLinear(TEColumnParallelLinear): ``delay_wgrad_compute`` is forced off to mirror its no-op ``backward_dw``, and ``get/set_extra_state`` match the bf16 LM head's state-dict shim. The LM-head kwargs ``keep_master_weight_for_test``, ``skip_weight_param_allocation``, - ``defer_embedding_wgrad_compute`` buffers, and ``disable_grad_reduce`` are + ``defer_embedding_wgrad_compute`` buffers, ``disable_grad_reduce``, and ``output_dtype`` are accepted to preserve the ``ColumnParallelLinear`` signature but currently raise when set non-default — TE will not support them natively, so they would have to be implemented in this subclass, which has not been done yet. @@ -1776,6 +1776,7 @@ def __init__( tp_comm_buffer_name: Optional[str] = None, disable_grad_reduce: bool = False, tp_group: Optional[torch.distributed.ProcessGroup] = None, + output_dtype: Optional[torch.dtype] = None, ): from megatron.core.fp8_utils import is_mxfp8_output_proj_active @@ -1794,6 +1795,8 @@ def __init__( ) if disable_grad_reduce: raise ValueError("TE output projection does not support disable_grad_reduce.") + if output_dtype is not None: + raise ValueError("TE MXFP8 output projection does not support output_dtype.") te_config = copy.copy(config) # Match ColumnParallelLinear.backward_dw's no-op so the LM head keeps diff --git a/megatron/core/models/gpt/gpt_model.py b/megatron/core/models/gpt/gpt_model.py index 3e292ed5d76..ad5f46c1f97 100644 --- a/megatron/core/models/gpt/gpt_model.py +++ b/megatron/core/models/gpt/gpt_model.py @@ -66,6 +66,9 @@ class GPTModel(LanguageModule): Include an output layer (used with pipeline parallelism). Defaults to True. fp16_lm_cross_entropy (bool, optional): Defaults to False. + logit_dtype (torch.dtype, optional): + Dtype for the output-layer GEMM result. Defaults to None, which uses + the hidden-state dtype. parallel_output (bool, optional): Do not gather the outputs, keep them split across tensor parallel ranks. Defaults to True. @@ -100,6 +103,7 @@ def __init__( pre_process: bool = True, post_process: bool = True, fp16_lm_cross_entropy: bool = False, + logit_dtype: Optional[torch.dtype] = None, parallel_output: bool = True, share_embeddings_and_output_weights: bool = False, position_embedding_type: Literal[ @@ -133,6 +137,7 @@ def __init__( self.pre_process = pre_process self.post_process = post_process self.fp16_lm_cross_entropy = fp16_lm_cross_entropy + self.logit_dtype = logit_dtype self.parallel_output = parallel_output self.share_embeddings_and_output_weights = share_embeddings_and_output_weights self.vp_stage = vp_stage @@ -283,6 +288,7 @@ def __init__( embedding_activation_buffer=self.embedding_activation_buffer, grad_output_buffer=self.grad_output_buffer, tp_group=self.pg_collection.tp, + output_dtype=self.logit_dtype, ) if self.pre_process or self.post_process or self.mtp_process: diff --git a/megatron/core/models/hybrid/hybrid_model.py b/megatron/core/models/hybrid/hybrid_model.py index f0358de57b9..65ab3ba1178 100644 --- a/megatron/core/models/hybrid/hybrid_model.py +++ b/megatron/core/models/hybrid/hybrid_model.py @@ -3,6 +3,7 @@ import logging from typing import Literal, Optional +import torch from torch import Tensor from megatron.core import tensor_parallel @@ -83,6 +84,8 @@ class HybridModel(LanguageModule, GraphableMegatronModule): post_process (bool, optional): Include an output layer (used with pipeline parallelism). Defaults to True. fp16_lm_cross_entropy (bool, optional): Defaults to False. + logit_dtype (torch.dtype, optional): Dtype for the output-layer GEMM result. + Defaults to None, which uses the hidden-state dtype. parallel_output (bool, optional): Do not gather the outputs, keep them split across tensor parallel ranks. Defaults to True. share_embeddings_and_output_weights (bool, optional): When True, input embeddings and @@ -113,6 +116,7 @@ def __init__( pre_process: bool = True, post_process: bool = True, fp16_lm_cross_entropy: bool = False, + logit_dtype: Optional[torch.dtype] = None, parallel_output: bool = True, share_embeddings_and_output_weights: bool = False, # Mamba with no attention has no need for position embeddings, so none is default @@ -144,6 +148,7 @@ def __init__( self.pre_process = pre_process self.post_process = post_process self.fp16_lm_cross_entropy = fp16_lm_cross_entropy + self.logit_dtype = logit_dtype self.parallel_output = parallel_output self.share_embeddings_and_output_weights = share_embeddings_and_output_weights self.position_embedding_type = position_embedding_type @@ -323,6 +328,7 @@ def __init__( skip_weight_param_allocation=self.pre_process and self.share_embeddings_and_output_weights, tp_group=self.pg_collection.tp, + output_dtype=self.logit_dtype, pg_collection=self.pg_collection, ) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index 0f248bcf399..e842e5d9f39 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -411,21 +411,20 @@ class LinearWithFrozenWeight(torch.autograd.Function): @staticmethod @custom_fwd - def forward(ctx, input, weight, bias, allreduce_dgrad, tp_group): + def forward(ctx, input, weight, bias, allreduce_dgrad, tp_group, output_dtype): """Forward with frozen weight.""" ctx.save_for_backward(weight) ctx.allreduce_dgrad = allreduce_dgrad ctx.tp_group = tp_group - output = torch.matmul(input, weight.t()) - if bias is not None: - output = output + bias - return output + ctx.input_dtype = input.dtype + return _linear_forward(input, weight, bias, output_dtype) @staticmethod @custom_bwd def backward(ctx, grad_output): """Backward with frozen weight.""" (weight,) = ctx.saved_tensors + grad_output = grad_output.to(ctx.input_dtype) if grad_output.dim() > 2: # Work around PyTorch matmul not folding some size-1 leading dims to mm. # Remove this once https://github.com/pytorch/pytorch/issues/186148 is fixed. @@ -439,7 +438,34 @@ def backward(ctx, grad_output): # All-reduce. Note: here async and sync are effectively the same. torch.distributed.all_reduce(grad_input, group=ctx.tp_group) - return grad_input, None, None, None, None + return grad_input, None, None, None, None, None + + +def _linear_forward( + input: torch.Tensor, + weight: torch.Tensor, + bias: Optional[torch.Tensor], + output_dtype: Optional[torch.dtype], +) -> torch.Tensor: + """Run a linear GEMM with an optional output dtype distinct from its input dtype.""" + if output_dtype is None or output_dtype == input.dtype: + output = torch.matmul(input, weight.t()) + if bias is not None: + output = output + bias + return output + + # Deferred to avoid a circular import: transformer_engine imports tensor-parallel layers. + from megatron.core.extensions.transformer_engine import te_general_gemm + + if te_general_gemm is None: + raise RuntimeError( + "A mixed-precision linear output requires Transformer Engine general_gemm." + ) + + input_shape = input.shape + input_2d = input.reshape(-1, input_shape[-1]) + output = te_general_gemm(weight, input_2d, out_dtype=output_dtype, layout="TN", bias=bias)[0] + return output.reshape(*input_shape[:-1], weight.size(0)) def linear_with_frozen_weight( @@ -453,6 +479,7 @@ def linear_with_frozen_weight( grad_output_buffer: Optional[List[torch.Tensor]] = None, wgrad_deferral_limit: None = None, gtp_remat_size: int = 1, + output_dtype: Optional[torch.dtype] = None, ) -> torch.Tensor: """Linear layer execution with weight.requires_grad == False. @@ -493,6 +520,9 @@ def linear_with_frozen_weight( gtp_remat_size (int): GTP shard count. When > 1 the weight is GTP-sharded and must be all-gathered to its full shape before the matmul, mirroring the trainable path. Defaults to 1 (no-op) for the common non-GTP / non-sharded case. + + output_dtype (torch.dtype optional): Optional GEMM output dtype. A dtype different from + the input dtype requires Transformer Engine ``general_gemm``. """ assert grad_output_buffer is None, ( @@ -516,7 +546,7 @@ def linear_with_frozen_weight( if gtp_remat_size > 1: weight = weight.all_gather_and_prefetch(fwd=True) - args = [input, weight, bias, allreduce_dgrad, tp_group] + args = [input, weight, bias, allreduce_dgrad, tp_group, output_dtype] return LinearWithFrozenWeight.apply(*args) @@ -538,6 +568,7 @@ def forward( wgrad_deferral_limit, tp_group, gtp_remat_size, + output_dtype, ): """Forward.""" if gradient_accumulation_fusion and hasattr(weight, "main_grad"): @@ -561,6 +592,7 @@ def forward( ctx.grad_output_buffer = grad_output_buffer ctx.tp_group = tp_group ctx.gtp_remat_size = gtp_remat_size + ctx.input_dtype = input.dtype if sequence_parallel: dim_size = list(input.size()) @@ -572,10 +604,7 @@ def forward( else: total_input = input - output = torch.matmul(total_input, weight.t()) - if bias is not None: - output = output + bias - return output + return _linear_forward(total_input, weight, bias, output_dtype) @staticmethod @custom_bwd @@ -584,6 +613,9 @@ def backward(ctx, grad_output): input, weight = ctx.saved_tensors main_grad = ctx.main_grad use_bias = ctx.use_bias + # TE owns only the forward GEMM here; Megatron retains the backward contract. + # Cast dY to the input dtype to match the legacy FP32-logit-cast backward path. + grad_output = grad_output.to(ctx.input_dtype) # GTP: re-gather weight for dgrad if ctx.gtp_remat_size > 1: @@ -743,12 +775,13 @@ def backward(ctx, grad_output): None, None, None, + None, ) if ctx.allreduce_dgrad: handle.wait() - return grad_input, grad_weight, grad_bias, None, None, None, None, None, None, None + return grad_input, grad_weight, grad_bias, None, None, None, None, None, None, None, None def linear_with_grad_accumulation_and_async_allreduce( @@ -762,6 +795,7 @@ def linear_with_grad_accumulation_and_async_allreduce( wgrad_deferral_limit: Optional[int] = 0, tp_group: Optional[torch.distributed.ProcessGroup] = None, gtp_remat_size: int = 1, + output_dtype: Optional[torch.dtype] = None, ) -> torch.Tensor: """Linear layer execution with asynchronous communication and gradient accumulation fusion in backprop. @@ -824,6 +858,9 @@ def linear_with_grad_accumulation_and_async_allreduce( wgrad_deferral_limit (int optional): Limit on the number of micro-batches for which embedding weight gradient GEMM should be deferred. Disable by setting this to 0. Defaults to 0. + + output_dtype (torch.dtype optional): Optional GEMM output dtype. A dtype different from + the input dtype requires Transformer Engine ``general_gemm``. """ tp_group = get_tensor_model_parallel_group_if_none(tp_group) @@ -839,6 +876,7 @@ def linear_with_grad_accumulation_and_async_allreduce( wgrad_deferral_limit, tp_group, gtp_remat_size, + output_dtype, ] if not linear_with_grad_accumulation_and_async_allreduce.warned: @@ -912,6 +950,12 @@ class ColumnParallelLinear(torch.nn.Module): If True, reduction of output gradients across tensor-parallel ranks will be disabled. Defaults to False. This feature is used by Lora Adapter in Nemo to delay and fuse reduction along with other gradients for performance optimization. + output_dtype: + Optional dtype for the GEMM output. When it differs from the input dtype, + Transformer Engine ``general_gemm`` is used. + pg_collection: + Optional process group collection. Used to resolve the generalized tensor + parallel remat group; falls back to the global parallel state when omitted. """ def __init__( @@ -934,6 +978,7 @@ def __init__( disable_grad_reduce: bool = False, tp_group: Optional[torch.distributed.ProcessGroup] = None, name: str | None = None, + output_dtype: Optional[torch.dtype] = None, pg_collection: Optional[ProcessGroupCollection] = None, ): super(ColumnParallelLinear, self).__init__() @@ -951,6 +996,7 @@ def __init__( self.config = config self.disable_grad_reduce = disable_grad_reduce self.tp_group = tp_group + self.output_dtype = output_dtype self.tp_group = get_tensor_model_parallel_group_if_none( self.tp_group, is_expert=self.is_expert @@ -1180,6 +1226,7 @@ def forward( ), tp_group=self.tp_group, gtp_remat_size=self.gtp_remat_size, + output_dtype=self.output_dtype, ) gather_output = self.gather_output diff --git a/megatron/elastification/pretrain_hybrid_flex.py b/megatron/elastification/pretrain_hybrid_flex.py index 13eeca1f7c8..adddab019f7 100644 --- a/megatron/elastification/pretrain_hybrid_flex.py +++ b/megatron/elastification/pretrain_hybrid_flex.py @@ -134,6 +134,7 @@ def model_provider(pre_process=True, post_process=True, vp_stage: Optional[int] hybrid_layer_pattern=args.hybrid_layer_pattern, post_process=post_process, fp16_lm_cross_entropy=args.fp16_lm_cross_entropy, + logit_dtype=getattr(args, 'logit_dtype', None), parallel_output=True, share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights, position_embedding_type=args.position_embedding_type, diff --git a/megatron/post_training/model_builder.py b/megatron/post_training/model_builder.py index cd497907406..39c2be07e02 100644 --- a/megatron/post_training/model_builder.py +++ b/megatron/post_training/model_builder.py @@ -383,6 +383,7 @@ def modelopt_gpt_hybrid_builder( "pre_process": pre_process, "post_process": post_process, "fp16_lm_cross_entropy": args.fp16_lm_cross_entropy, + "logit_dtype": getattr(args, "logit_dtype", None), "parallel_output": True, "share_embeddings_and_output_weights": not args.untie_embeddings_and_output_weights, "position_embedding_type": args.position_embedding_type, @@ -428,6 +429,7 @@ def modelopt_gpt_hybrid_builder( "pre_process": pre_process, "post_process": post_process, "fp16_lm_cross_entropy": args.fp16_lm_cross_entropy, + "logit_dtype": getattr(args, "logit_dtype", None), "parallel_output": True, "share_embeddings_and_output_weights": not args.untie_embeddings_and_output_weights, "position_embedding_type": args.position_embedding_type, diff --git a/megatron/training/argument_utils.py b/megatron/training/argument_utils.py index 70f26c64d56..124083ead83 100644 --- a/megatron/training/argument_utils.py +++ b/megatron/training/argument_utils.py @@ -457,6 +457,7 @@ def gpt_config_from_args( kwargs["transformer_layer_spec"] = import_module(args.spec) kwargs["fp16_lm_cross_entropy"] = args.fp16_lm_cross_entropy + kwargs["logit_dtype"] = getattr(args, "logit_dtype", None) kwargs["position_embedding_type"] = args.position_embedding_type kwargs["rotary_percent"] = args.rotary_percent kwargs["rotary_base"] = args.rotary_base @@ -507,6 +508,7 @@ def hybrid_config_from_args( kwargs["hybrid_stack_spec"] = import_module(args.spec) kwargs["fp16_lm_cross_entropy"] = args.fp16_lm_cross_entropy + kwargs["logit_dtype"] = getattr(args, "logit_dtype", None) kwargs["hybrid_layer_pattern"] = args.hybrid_layer_pattern kwargs["position_embedding_type"] = args.position_embedding_type kwargs["rotary_percent"] = args.rotary_percent diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index e54f8a22252..3497834ef93 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1052,6 +1052,7 @@ def validate_args(args, defaults={}): args.mamba_inference_conv_states_dtype = map_dtype(args.mamba_inference_conv_states_dtype) args.mamba_inference_ssm_states_dtype = map_dtype(args.mamba_inference_ssm_states_dtype) args.mamba_training_ssm_states_dtype = map_dtype(args.mamba_training_ssm_states_dtype) + args.logit_dtype = map_dtype(getattr(args, 'logit_dtype', None)) args.megatron_fsdp_main_params_dtype = map_dtype(args.megatron_fsdp_main_params_dtype) args.megatron_fsdp_main_grads_dtype = map_dtype(args.megatron_fsdp_main_grads_dtype) @@ -2935,6 +2936,11 @@ def _add_mixed_precision_args(parser): group.add_argument('--fp16-lm-cross-entropy', action='store_true', help='Move the cross entropy unreduced loss calculation' 'for lm head to fp16.') + group.add_argument('--output-logit-dtype', type=str, choices=['bf16', 'fp32'], default=None, + dest='logit_dtype', + help='Output dtype for the language-model output-layer GEMM. When the ' + 'requested dtype differs from the input dtype, Transformer Engine ' + 'general_gemm is used. By default, logits use the output-layer input dtype.') group.add_argument('--reuse-grad-buf-for-mxfp8-param-ag', action='store_true', help='If True, reuse the grad buffer for MXFP8 parameter all-gather.') group.add_argument('--mamba-training-ssm-states-dtype', type=str, diff --git a/megatron/training/models/gpt.py b/megatron/training/models/gpt.py index 63448b196c0..46dcc9b28f4 100644 --- a/megatron/training/models/gpt.py +++ b/megatron/training/models/gpt.py @@ -169,6 +169,7 @@ class GPTModelConfig(ModelConfig): ### GPT Model initialization ### seq_length: int = 1024 fp16_lm_cross_entropy: bool = False + logit_dtype: torch.dtype | None = None parallel_output: bool = True share_embeddings_and_output_weights: bool = False position_embedding_type: Literal["learned_absolute", "rope", "mrope", "yarn", "none"] = "learned_absolute" @@ -316,6 +317,7 @@ def build_model( vocab_size=padded_vocab_size, max_sequence_length=self._model_config.seq_length, fp16_lm_cross_entropy=self._model_config.fp16_lm_cross_entropy, + logit_dtype=self._model_config.logit_dtype, parallel_output=self._model_config.parallel_output, share_embeddings_and_output_weights=self._model_config.share_embeddings_and_output_weights, position_embedding_type=self._model_config.position_embedding_type, diff --git a/megatron/training/models/hybrid.py b/megatron/training/models/hybrid.py index 99f98920eff..c7d1c91b118 100644 --- a/megatron/training/models/hybrid.py +++ b/megatron/training/models/hybrid.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import Any, Callable, ClassVar, Literal, override +import torch + from megatron.core.distributed.distributed_data_parallel_config import DistributedDataParallelConfig from megatron.core.enums import ModelType from megatron.core.models.hybrid.hybrid_layer_specs import ( @@ -50,6 +52,7 @@ class HybridModelConfig(ModelConfig): builder: ClassVar[str] = "megatron.training.models.hybrid.HybridModelBuilder" transformer: TransformerConfig fp16_lm_cross_entropy: bool = False + logit_dtype: torch.dtype | None = None parallel_output: bool = True share_embeddings_and_output_weights: bool = False hybrid_attention_ratio: float = 0.0 @@ -179,6 +182,7 @@ def build_model( max_sequence_length=self._model_config.seq_length, hybrid_layer_pattern=self._model_config.hybrid_layer_pattern, fp16_lm_cross_entropy=self._model_config.fp16_lm_cross_entropy, + logit_dtype=self._model_config.logit_dtype, parallel_output=self._model_config.parallel_output, share_embeddings_and_output_weights=self._model_config.share_embeddings_and_output_weights, position_embedding_type=self._model_config.position_embedding_type, diff --git a/tests/unit_tests/extension/test_te_lmhead_column_parallel_linear.py b/tests/unit_tests/extension/test_te_lmhead_column_parallel_linear.py index dbde4c67df4..7d5d58777af 100644 --- a/tests/unit_tests/extension/test_te_lmhead_column_parallel_linear.py +++ b/tests/unit_tests/extension/test_te_lmhead_column_parallel_linear.py @@ -92,6 +92,10 @@ def test_rejects_disable_grad_reduce(self): with pytest.raises(ValueError, match="disable_grad_reduce"): TELMHeadColumnParallelLinear(**self._kwargs(disable_grad_reduce=True)) + def test_rejects_output_dtype(self): + with pytest.raises(ValueError, match="output_dtype"): + TELMHeadColumnParallelLinear(**self._kwargs(output_dtype=torch.float32)) + class TestGPTModelOutputLayerSelection: """Verify GPTModel picks the right output-layer class based on config.""" @@ -118,6 +122,20 @@ def test_default_uses_column_parallel_linear(self): assert isinstance(model.output_layer, tensor_parallel.ColumnParallelLinear) assert not isinstance(model.output_layer, TELMHeadColumnParallelLinear) + @pytest.mark.internal + def test_logit_dtype_is_forwarded_to_output_layer(self): + config = TransformerConfig( + num_layers=2, hidden_size=12, num_attention_heads=4, use_cpu_initialization=True + ) + model = GPTModel( + config=config, + transformer_layer_spec=get_gpt_layer_with_transformer_engine_spec(), + vocab_size=100, + max_sequence_length=4, + logit_dtype=torch.float32, + ) + assert model.output_layer.output_dtype == torch.float32 + @pytest.mark.internal @pytest.mark.skipif( not _IS_BLACKWELL, reason="MXFP8 output projection requires Blackwell (SM >= 10)" diff --git a/tests/unit_tests/tensor_parallel/test_layers.py b/tests/unit_tests/tensor_parallel/test_layers.py index dbc27f502c6..cf1e8185017 100644 --- a/tests/unit_tests/tensor_parallel/test_layers.py +++ b/tests/unit_tests/tensor_parallel/test_layers.py @@ -2,11 +2,33 @@ import pytest import torch -from megatron.core.tensor_parallel.layers import linear_with_frozen_weight +from megatron.core.extensions.transformer_engine import te_general_gemm +from megatron.core.tensor_parallel.layers import ( + linear_with_frozen_weight, + linear_with_grad_accumulation_and_async_allreduce, +) from megatron.core.tensor_parallel.mappings import gather_from_tensor_model_parallel_region from tests.unit_tests.test_utilities import Utils +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32]) +def test_linear_default_output_dtype_preserves_input_dtype(dtype): + Utils.initialize_model_parallel(1, 1) + + try: + input_data = torch.randn(4, 3, 16, device="cuda", dtype=dtype) + weight = torch.randn(32, 16, device="cuda", dtype=dtype) + output = linear_with_grad_accumulation_and_async_allreduce( + input_data, weight, None, False, False, False, tp_group=None, output_dtype=None + ) + reference = torch.nn.functional.linear(input_data, weight) + + assert output.dtype == input_data.dtype + torch.testing.assert_close(output, reference) + finally: + Utils.destroy_model_parallel() + + @pytest.mark.parametrize("tensor_parallel,allreduce_dgrad", [(1, False), (8, True)]) def test_LinearWithFrozenWeight(tensor_parallel, allreduce_dgrad): Utils.initialize_model_parallel(tensor_parallel, 1) @@ -70,3 +92,111 @@ def test_LinearWithFrozenWeight_3d_input_matches_torch_linear(): assert torch.allclose(input_data.grad, expected_input.grad) Utils.destroy_model_parallel() + + +@pytest.mark.skipif( + te_general_gemm is None, reason="Transformer Engine general_gemm is not available" +) +def test_linear_with_grad_accumulation_supports_fp32_output_and_bf16_backward(): + Utils.initialize_model_parallel(1, 1) + + input_data = torch.randn(4, 3, 16, device="cuda", dtype=torch.bfloat16, requires_grad=True) + weight = torch.randn(32, 16, device="cuda", dtype=torch.bfloat16, requires_grad=True) + reference_input = input_data.detach().clone().requires_grad_(True) + reference_weight = weight.detach().clone().requires_grad_(True) + + output = linear_with_grad_accumulation_and_async_allreduce( + input_data, weight, None, False, False, False, tp_group=None, output_dtype=torch.float32 + ) + output.sum().backward() + + reference_output = torch.nn.functional.linear(reference_input, reference_weight) + reference_output.sum().backward() + fp32_reference_output = torch.nn.functional.linear( + input_data.detach().float(), weight.detach().float() + ) + + assert output.dtype == torch.float32 + assert input_data.grad.dtype == torch.bfloat16 + assert weight.grad.dtype == torch.bfloat16 + assert torch.allclose(output, fp32_reference_output, atol=1e-4, rtol=1e-4) + assert torch.allclose(input_data.grad, reference_input.grad) + assert torch.allclose(weight.grad, reference_weight.grad) + + Utils.destroy_model_parallel() + + +@pytest.mark.skipif( + te_general_gemm is None, reason="Transformer Engine general_gemm is not available" +) +def test_linear_fp32_output_is_bitwise_exact_for_integer_bf16_operands(): + Utils.initialize_model_parallel(1, 1) + + generator = torch.Generator(device="cuda").manual_seed(1234) + input_data = torch.randint( + -8, 9, (4, 3, 512), device="cuda", dtype=torch.int32, generator=generator + ).to(torch.bfloat16) + weight = torch.randint( + -8, 9, (128, 512), device="cuda", dtype=torch.int32, generator=generator + ).to(torch.bfloat16) + + output = linear_with_grad_accumulation_and_async_allreduce( + input_data, weight, None, False, False, False, tp_group=None, output_dtype=torch.float32 + ) + reference = torch.nn.functional.linear(input_data.float(), weight.float()) + + # K * 8^2 = 32,768, so every possible integer product and partial sum is + # exactly representable in FP32. Compare raw words instead of using a tolerance. + assert output.dtype == torch.float32 + assert torch.equal(output.contiguous().view(torch.int32), reference.view(torch.int32)) + + Utils.destroy_model_parallel() + + +@pytest.mark.skipif( + te_general_gemm is None, reason="Transformer Engine general_gemm is not available" +) +def test_linear_fp32_output_matches_plain_te_general_gemm(): + from transformer_engine.pytorch.cpp_extensions import general_gemm + + try: + from transformer_engine.pytorch.module.base import get_workspace + except ImportError: + get_workspace = None + + Utils.initialize_model_parallel(1, 1) + + input_data = torch.randn(4, 3, 64, device="cuda", dtype=torch.bfloat16) + weight = torch.randn(96, 64, device="cuda", dtype=torch.bfloat16) + wrapped_output = linear_with_grad_accumulation_and_async_allreduce( + input_data, weight, None, False, False, False, tp_group=None, output_dtype=torch.float32 + ) + + kwargs = { + "out_dtype": torch.float32, + "quantization_params": None, + "gelu": None, + "gelu_in": None, + "accumulate": False, + "layout": "TN", + "out": None, + "bias": None, + "use_split_accumulator": False, + "grad": False, + "ub": None, + "ub_type": None, + "extra_output": None, + "bulk_overlap": False, + } + if get_workspace is not None: + kwargs["workspace"] = get_workspace() + plain_te_output = general_gemm(weight, input_data.reshape(-1, 64), **kwargs)[0] + plain_te_output = plain_te_output.reshape_as(wrapped_output) + + assert wrapped_output.dtype == torch.float32 + assert torch.equal( + wrapped_output.contiguous().view(torch.int32), + plain_te_output.contiguous().view(torch.int32), + ) + + Utils.destroy_model_parallel() diff --git a/tests/unit_tests/test_argument_utils.py b/tests/unit_tests/test_argument_utils.py index 7c0b30d3d56..57a327cddfd 100644 --- a/tests/unit_tests/test_argument_utils.py +++ b/tests/unit_tests/test_argument_utils.py @@ -678,6 +678,29 @@ def test_transformer_callback_fields_are_not_registered_as_cli_args(self): assert not hasattr(args, field_name) +class TestMegatronMixedPrecisionArguments: + """Test language-model logit dtype CLI choices.""" + + @staticmethod + def _parser() -> ArgumentParser: + from megatron.training.arguments import _add_mixed_precision_args + + return _add_mixed_precision_args(ArgumentParser(exit_on_error=False)) + + def test_logit_dtype_defaults_to_input_dtype(self): + args = self._parser().parse_args([]) + assert args.logit_dtype is None + + @pytest.mark.parametrize("dtype", ["bf16", "fp32"]) + def test_logit_dtype_accepts_supported_choices(self, dtype): + args = self._parser().parse_args(["--output-logit-dtype", dtype]) + assert args.logit_dtype == dtype + + def test_logit_dtype_rejects_fp16(self): + with pytest.raises(ArgumentError, match="invalid choice"): + self._parser().parse_args(["--output-logit-dtype", "fp16"]) + + # --------------------------------------------------------------------------- # Tests for pretrain_cfg_container_from_args # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/training/models/test_gpt_builder.py b/tests/unit_tests/training/models/test_gpt_builder.py index 2263525e030..5ae7ab4f418 100644 --- a/tests/unit_tests/training/models/test_gpt_builder.py +++ b/tests/unit_tests/training/models/test_gpt_builder.py @@ -261,6 +261,7 @@ def test_default_values(self): assert config.should_pad_vocab is False assert config.seq_length == 1024 assert config.fp16_lm_cross_entropy is False + assert config.logit_dtype is None assert config.parallel_output is True assert config.share_embeddings_and_output_weights is False assert config.position_embedding_type == "learned_absolute" @@ -279,6 +280,7 @@ def test_custom_initialization(self): transformer=_make_transformer(), seq_length=4096, fp16_lm_cross_entropy=True, + logit_dtype=torch.float32, parallel_output=False, share_embeddings_and_output_weights=True, position_embedding_type="rope", @@ -287,6 +289,7 @@ def test_custom_initialization(self): ) assert config.seq_length == 4096 assert config.fp16_lm_cross_entropy is True + assert config.logit_dtype == torch.float32 assert config.parallel_output is False assert config.share_embeddings_and_output_weights is True assert config.position_embedding_type == "rope" @@ -678,6 +681,7 @@ def test_config_params_passed_to_mcore(self, mock_model, *_): vocab_size=32000, seq_length=4096, fp16_lm_cross_entropy=True, + logit_dtype=torch.float32, parallel_output=False, share_embeddings_and_output_weights=True, position_embedding_type="rope", @@ -699,6 +703,7 @@ def test_config_params_passed_to_mcore(self, mock_model, *_): assert kw["vocab_size"] == 32000 assert kw["max_sequence_length"] == 4096 assert kw["fp16_lm_cross_entropy"] is True + assert kw["logit_dtype"] == torch.float32 assert kw["parallel_output"] is False assert kw["share_embeddings_and_output_weights"] is True assert kw["position_embedding_type"] == "rope" diff --git a/tests/unit_tests/training/models/test_hybrid_builder.py b/tests/unit_tests/training/models/test_hybrid_builder.py index 9984e224ce3..fa304f7ca34 100644 --- a/tests/unit_tests/training/models/test_hybrid_builder.py +++ b/tests/unit_tests/training/models/test_hybrid_builder.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, call, patch import pytest +import torch from megatron.core.transformer import ModuleSpec from megatron.core.transformer.transformer_config import TransformerConfig @@ -39,6 +40,7 @@ def test_builder_classvar(self): def test_default_values(self): config = HybridModelConfig(transformer=_make_transformer()) assert config.fp16_lm_cross_entropy is False + assert config.logit_dtype is None assert config.parallel_output is True assert config.share_embeddings_and_output_weights is False assert config.hybrid_layer_pattern is None @@ -55,6 +57,7 @@ def test_custom_initialization(self): config = HybridModelConfig( transformer=_make_transformer(), fp16_lm_cross_entropy=True, + logit_dtype=torch.float32, parallel_output=False, hybrid_attention_ratio=0.25, hybrid_mlp_ratio=0.1, @@ -63,6 +66,7 @@ def test_custom_initialization(self): vocab_size=50000, ) assert config.fp16_lm_cross_entropy is True + assert config.logit_dtype == torch.float32 assert config.parallel_output is False assert config.hybrid_attention_ratio == 0.25 assert config.hybrid_mlp_ratio == 0.1 @@ -329,6 +333,7 @@ def test_config_params_passed_to_mcore(self, mock_model, *_): seq_length=4096, hybrid_layer_pattern="M-A-", fp16_lm_cross_entropy=True, + logit_dtype=torch.float32, parallel_output=False, share_embeddings_and_output_weights=True, position_embedding_type="rope", @@ -345,6 +350,7 @@ def test_config_params_passed_to_mcore(self, mock_model, *_): assert kw["max_sequence_length"] == 4096 assert kw["hybrid_layer_pattern"] == "M-A-" assert kw["fp16_lm_cross_entropy"] is True + assert kw["logit_dtype"] == torch.float32 assert kw["parallel_output"] is False assert kw["share_embeddings_and_output_weights"] is True assert kw["position_embedding_type"] == "rope" From 14346b65a2d0790e451919858f7771078105c5f0 Mon Sep 17 00:00:00 2001 From: Antoni-Joan Solergibert Date: Wed, 12 Aug 2026 07:06:55 +0200 Subject: [PATCH 265/290] Eliminate the ShardedObject all_gather_object on the FullyParallel load path (#5551) Signed-off-by: asolergi-nv --- .../strategies/fully_parallel.py | 73 +++++++++++++++---- megatron/training/checkpointing.py | 7 +- megatron/training/config/training_config.py | 8 ++ .../model_config.yaml | 11 +++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/megatron/core/dist_checkpointing/strategies/fully_parallel.py b/megatron/core/dist_checkpointing/strategies/fully_parallel.py index ca7f28f3cc6..657942cadb0 100644 --- a/megatron/core/dist_checkpointing/strategies/fully_parallel.py +++ b/megatron/core/dist_checkpointing/strategies/fully_parallel.py @@ -178,6 +178,7 @@ def __init__( parallelization_group: Optional[torch.distributed.ProcessGroup] = None, do_cache_distribution: bool = False, exchange_algo: str = 'broadcast', + per_rank_object_load: bool = False, ): self.base_strategy = strategy if parallelization_group is None: @@ -187,6 +188,11 @@ def __init__( self.parallelization_group = parallelization_group self.do_cache_distribution = do_cache_distribution self.exchange_algo = exchange_algo + # When True, every rank loads *all* of its ShardedObjects directly from + # storage instead of loading only its main replicas and exchanging them + # with a WORLD-wide `all_gather_object`. Opt-in; defaults to the legacy + # gather-based exchange. See `load` for correctness rationale. + self.per_rank_object_load = per_rank_object_load self.cached_distribution: Optional[ShardDistribution] = None self.cached_global_metadata: Optional[Metadata] = None @@ -256,15 +262,47 @@ def load( assert ( len(sharded_state_dict) == 0 ), "sharded_state_dict is not empty after deferring tensors and objects" - with debug_time("base_load_ShardedObjects", logger): - # Load sharded objects first - loaded_objects = self.base_strategy.load( - to_load_objects, checkpoint_dir, async_strategy - ) - with debug_time("base_load_ShardedTensors", logger): - # Load sharded tensors separately - loaded_tensors = self.base_strategy.load(to_load_shards, checkpoint_dir, async_strategy) + if self.per_rank_object_load: + # Opt-in: every rank loads *all* of its own objects directly from + # storage, so no inter-rank object exchange is needed. This is correct + # because an object is addressed in the checkpoint by its `unique_key` + # (key + global_offset + global_shape), which excludes `replica_id`; + # each distinct object position is therefore written to disk exactly + # once (by its main replica) and any rank that needs it can read it + # locally. We merge both the main (`to_load_objects`) and non-main + # (`unloaded_objects`) replica maps so this rank loads every object in + # its state dict. This replaces the WORLD-wide `all_gather_object` + # collective with extra (but cheap) local reads of small artifacts + # (RNG states, `_extra_state`, ...). + # + # Objects are loaded together with this rank's tensor shards in a + # single base-strategy `.load()` call: `mcore_to_pyt_state_dict` + # supports a mixed tensor/object state dict, and one call means one + # metadata read and one load plan instead of two. + all_objects_to_load = {**to_load_objects, **unloaded_objects} + with debug_time("base_load_ShardedTensorsAndObjects", logger): + loaded = self.base_strategy.load( + {**to_load_shards, **all_objects_to_load}, checkpoint_dir, async_strategy + ) + # The base strategy returns the loaded values keyed by the same shard + # ids we passed in; split them back into tensors and objects. Tensor + # and object shard ids never collide (a given key is either a tensor + # or an object), so membership in the original maps is an unambiguous + # split. + loaded_tensors = {shard_id: loaded[shard_id] for shard_id in to_load_shards} + loaded_objects = {shard_id: loaded[shard_id] for shard_id in all_objects_to_load} + else: + # Default (legacy): load only this rank's main-replica objects and + # exchange them across ranks below. + with debug_time("base_load_ShardedObjects", logger): + loaded_objects = self.base_strategy.load( + to_load_objects, checkpoint_dir, async_strategy + ) + with debug_time("base_load_ShardedTensors", logger): + loaded_tensors = self.base_strategy.load( + to_load_shards, checkpoint_dir, async_strategy + ) with debug_time("self.exchange_loaded_tensors", logger): @@ -286,14 +324,17 @@ def load( with debug_time("torch.cuda.synchronize", logger): torch.cuda.synchronize() - all_loaded_objects = exchange_loaded_objects_gather_object(loaded_objects) - - if not set(unloaded_objects.keys()).issubset(all_loaded_objects.keys()): - missing_object_shards = set(unloaded_objects.keys()) - all_loaded_objects.keys() - raise CheckpointingException( - f'Missing object shards after fully parallel loading: {missing_object_shards}' - ) - torch.cuda.synchronize() + if self.per_rank_object_load: + # No object exchange: each rank already loaded every object it needs. + all_loaded_objects = loaded_objects + else: + all_loaded_objects = exchange_loaded_objects_gather_object(loaded_objects) + if not set(unloaded_objects.keys()).issubset(all_loaded_objects.keys()): + missing_object_shards = set(unloaded_objects.keys()) - all_loaded_objects.keys() + raise CheckpointingException( + f'Missing object shards after fully parallel loading: {missing_object_shards}' + ) + torch.cuda.synchronize() self.fill_in_deferred_sharded_tensors(sharded_tensors, all_loaded_tensors) self.fill_in_deferred_sharded_objects(sharded_objects, all_loaded_objects) diff --git a/megatron/training/checkpointing.py b/megatron/training/checkpointing.py index fa859664a46..a81a6c2dfe1 100644 --- a/megatron/training/checkpointing.py +++ b/megatron/training/checkpointing.py @@ -1748,7 +1748,12 @@ def _load_global_dist_base_checkpoint( ) load_strategy = FullyParallelLoadStrategyWrapper( - load_strategy, process_group, exchange_algo=args.ckpt_fully_parallel_load_exchange_algo + load_strategy, + process_group, + exchange_algo=args.ckpt_fully_parallel_load_exchange_algo, + per_rank_object_load=getattr( + args, 'ckpt_fully_parallel_load_per_rank_objects', False + ), ) if checkpointing_context is not None: checkpointing_context['load_strategy'] = load_strategy diff --git a/megatron/training/config/training_config.py b/megatron/training/config/training_config.py index 1ba743d5962..a379039ff20 100644 --- a/megatron/training/config/training_config.py +++ b/megatron/training/config/training_config.py @@ -548,6 +548,14 @@ class CheckpointConfig: "gather_object": Gather the checkpoint from all ranks in a single operation. """ + ckpt_fully_parallel_load_per_rank_objects: bool = False + """Load ShardedObjects per-rank during fully parallel load of distributed checkpoints. + When True, every rank reads all of its own ShardedObjects (RNG states, + TE `_extra_state`, ...) directly from storage, which removes the WORLD-wide + `all_gather_object` that otherwise exchanges them. Objects are + content-addressable by `unique_key`, so the loaded values are identical. + When False (default), the legacy gather-based object exchange is used.""" + ckpt_fully_parallel_save_process_group: Literal["dp", "ep_dp"] = "dp" """Process group for fully parallel save of distributed checkpoints. "dp"(default): Data parallel process group. diff --git a/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml index 10d94d27f03..f913c1a62ca 100644 --- a/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml +++ b/tests/functional_tests/test_cases/nemotron/nemotron3_5_lightning_no_load_optim_tp1_pp1_cp1_ep4_dgx_gb200_1N4G/model_config.yaml @@ -141,6 +141,17 @@ MODEL_ARGS: --ckpt-fully-parallel-load: true --dist-ckpt-strictness: log_all + # Checkpointing optimizations exercised by this load. All three are meant to be + # numerically neutral, so the golden values must stay identical with them on. + # Every rank reads its own ShardedObjects instead of exchanging them with a + # WORLD all_gather_object. + --ckpt-fully-parallel-load-per-rank-objects: true + # Skips the determine_global_metadata all_gather_object that validates sharding. + --no-ckpt-load-validate-sharding-integrity: true + # Redundant TE _extra_state is neither written nor requested on load; only + # delayed-scaling FP8 extra state is ever needed, and this model does not use it. + --ckpt-drop-redundant-extra-state: true + # Validation and functional metrics. eval-interval < train-iters so the # evaluation loop actually runs during the test. --eval-interval: 10 From 15c83d2fcd00e283bb59ff26dce40266a445c615 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Aug 2026 09:26:08 +0000 Subject: [PATCH 266/290] chore: rotate oncall schedule --- .github/oncall_schedule.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/oncall_schedule.json b/.github/oncall_schedule.json index 0fe3ecb86ab..622a830a73e 100644 --- a/.github/oncall_schedule.json +++ b/.github/oncall_schedule.json @@ -1,8 +1,4 @@ [ - { - "user": "janEbert", - "date": "2026-08-05" - }, { "user": "maanug-nv", "date": "2026-08-12" @@ -46,5 +42,9 @@ { "user": "janEbert", "date": "2026-10-21" + }, + { + "user": "maanug-nv", + "date": "2026-10-28" } ] From 81fe7c746f6556e5d8dc24196a1358ef0898dbf7 Mon Sep 17 00:00:00 2001 From: Jiangfei Duan <30710061+JF-D@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:08:25 -0700 Subject: [PATCH 267/290] GTP + PartialCG: Classify latent projections for partial CUDA graphs (#6446) Signed-off-by: Jiangfei Duan --- .../generalized_tensor_parallelism.py | 4 + .../test_gtp_basics.py | 36 +++++ .../test_gtp_partial_cg.py | 144 +++++++++++++----- 3 files changed, 150 insertions(+), 34 deletions(-) diff --git a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py index de97cdab121..b8d2a8faff2 100644 --- a/megatron/core/tensor_parallel/generalized_tensor_parallelism.py +++ b/megatron/core/tensor_parallel/generalized_tensor_parallelism.py @@ -217,6 +217,10 @@ def _classify_param_chain(param_name: str) -> str: if not scope: # CG disabled return U + # MoE latent projections. + if ".mlp.fc1_latent_proj." in n or ".mlp.fc2_latent_proj." in n: + return G if "moe_router" in scope else U + if ".mlp.shared_experts." in n: if _MOE_SHARED_EXPERT_OVERLAP: return U diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py index 297c89578f1..6c594d0be62 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_basics.py @@ -502,6 +502,42 @@ def test_graphness_helpers(self): assert gtp_module._chain_is_graphed("GTP_graphed") +@pytest.mark.skipif(not torch.cuda.is_available(), reason="GTPShardedParam requires CUDA") +class TestLatentProjectionChainClassification: + """Classify opted-in latent projections through the public GTP chain API.""" + + FC1 = "decoder.layers.3.mlp.fc1_latent_proj.weight" + FC2 = "decoder.layers.3.mlp.fc2_latent_proj.weight" + + def teardown_method(self, method): + gtp_module.set_cuda_graph_modules(None, cuda_graph_impl="none") + gtp_module.reset_gtp_state() + + def _chains(self, *, cuda_graph_modules=None, cuda_graph_impl="none"): + params = tuple(GTPShardedParam(torch.zeros(1, device="cuda")) for _ in range(2)) + assert all(isinstance(param, GTPShardedParam) for param in params) + + class _Model: + def named_parameters(_self): + return iter(zip((self.FC1, self.FC2), params)) + + gtp_module.classify_gtp_remat_chains( + _Model(), cuda_graph_modules=cuda_graph_modules, cuda_graph_impl=cuda_graph_impl + ) + return tuple(param.chain_id for param in params) + + def test_eager_latent_projections_are_ungraphed(self): + assert self._chains() == (GTPChain.UNGRAPHED.value, GTPChain.UNGRAPHED.value) + + def test_local_router_captures_both_latent_projections(self): + chains = self._chains(cuda_graph_modules={"moe_router"}, cuda_graph_impl="local") + assert chains == (GTPChain.GRAPHED.value, GTPChain.GRAPHED.value) + + def test_unrelated_local_scope_leaves_latent_projections_ungraphed(self): + chains = self._chains(cuda_graph_modules={"mamba", "attn"}, cuda_graph_impl="local") + assert chains == (GTPChain.UNGRAPHED.value, GTPChain.UNGRAPHED.value) + + class TestGroupedDoubleBuffer: """One-block-ahead grouped chains must double-buffer: consecutive MoE layers get distinct gather buffers (else prefetching layer N+1 clobbers layer N's in-use weight). Pure cache-key diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py index addcb9cb657..14641c3a3b4 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_partial_cg.py @@ -36,9 +36,10 @@ ) -def _worker_gtp_partial_cg_correctness(rank, world_size, port): - """Compare eager and local attention CUDA graphs with GTP2 x DP2.""" +def _worker_gtp_partial_cg_correctness(rank, world_size, port, partial_cg_modules, opt_in_modules): + """Compare eager and local CUDA graphs with GTP2 x DP2.""" del port + gtp_module._GTP_PARAMS.clear() from megatron.core import parallel_state as ps from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec @@ -56,14 +57,15 @@ def _worker_gtp_partial_cg_correctness(rank, world_size, port): ) from megatron.core.transformer.identity_op import IdentityFuncOp, IdentityOp from megatron.core.transformer.transformer_config import TransformerConfig - - hidden = 4096 - num_heads = 32 - ffn_hidden = 16384 - # Four layers force parameters with matching scheduling domains/shapes to reuse the two-slot - # wgrad ring across independently replayed graphs. - num_layers = 4 - sequence_length = 32 + from megatron.core.transformer.transformer_layer import MoETransformerLayer + + latent_projection_case = "moe_latent_proj" in opt_in_modules + hidden = 256 if latent_projection_case else 4096 + num_heads = 8 if latent_projection_case else 32 + ffn_hidden = 512 if latent_projection_case else 16384 + # Use multiple layers to exercise repeated local CUDA-graph execution with GTP parameters. + num_layers = 1 if latent_projection_case else 4 + sequence_length = 16 if latent_projection_case else 32 batch_size = 1 learning_rate = 0.01 steps = 10 @@ -73,6 +75,18 @@ def _worker_gtp_partial_cg_correctness(rank, world_size, port): assert world_size == gtp_degree * dp_degree def make_config(*, partial_cg=False): + moe_options = {} + if latent_projection_case: + moe_options = { + "num_moe_experts": 2, + "moe_router_topk": 1, + "moe_router_pre_softmax": True, + "moe_ffn_hidden_size": ffn_hidden, + "moe_grouped_gemm": True, + "moe_token_dispatcher_type": "allgather", + "moe_aux_loss_coeff": 0.0, + "moe_latent_size": 128, + } return TransformerConfig( num_attention_heads=num_heads, num_layers=num_layers, @@ -83,16 +97,40 @@ def make_config(*, partial_cg=False): hidden_dropout=0.0, attention_dropout=0.0, bias_dropout_fusion=False, + gradient_accumulation_fusion=latent_projection_case, tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_weight_remat_size=gtp_degree, + gtp_remat_opt_in_modules=opt_in_modules, cuda_graph_impl="local" if partial_cg else "none", - cuda_graph_modules=["attn"] if partial_cg else [], + cuda_graph_modules=partial_cg_modules if partial_cg else [], cuda_graph_warmup_steps=2, + **moe_options, ) - def make_attention_stack(config, pg_collection): - spec = copy.deepcopy(get_gpt_layer_with_transformer_engine_spec()) + def make_layer_stack(config, pg_collection): + spec = copy.deepcopy( + get_gpt_layer_with_transformer_engine_spec( + num_experts=2 if latent_projection_case else None, + moe_grouped_gemm=latent_projection_case, + ) + ) + if latent_projection_case: + spec.submodules.input_layernorm = IdentityOp + spec.submodules.self_attention = IdentityOp + spec.submodules.self_attn_bda = IdentityFuncOp + return torch.nn.ModuleList( + [ + MoETransformerLayer( + config, + spec.submodules, + layer_number=1, + pg_collection=pg_collection, + name="decoder.layers.0", + ) + ] + ) + spec.submodules.pre_mlp_layernorm = IdentityOp spec.submodules.mlp = IdentityOp spec.submodules.mlp_bda = IdentityFuncOp @@ -105,6 +143,27 @@ def make_attention_stack(config, pg_collection): ] ) + def get_cudagraph_managers(layers): + if latent_projection_case: + return [ + manager + for layer in layers + for manager in (layer.cudagraph_manager_router, layer.cudagraph_manager_postprocess) + ] + return [layer.cudagraph_manager for layer in layers] + + def get_latent_params(layers): + return [ + param + for name, param in layers.named_parameters() + if "fc1_latent_proj.weight" in name or "fc2_latent_proj.weight" in name + ] + + def make_pg_collection(): + if latent_projection_case: + return ProcessGroupCollection.use_mpu_process_groups() + return ProcessGroupCollection.use_mpu_process_groups(required_pgs=["tp", "cp", "gtp_remat"]) + def run_step(layers, x): with fp8_autocast(enabled=False): for layer in layers: @@ -162,17 +221,19 @@ def apply_sgd_step(layers, gtp_size): tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=gtp_degree ) model_parallel_cuda_manual_seed(42) - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["tp", "cp", "gtp_remat"] - ) + pg_collection = make_pg_collection() eager_config = make_config() - eager = make_attention_stack(eager_config, pg_collection).cuda() + eager = make_layer_stack(eager_config, pg_collection).cuda() eager_gtp_group = ps.get_gtp_weight_remat_group() eager_dp_group = ps.get_data_parallel_group(with_gtp_remat=False) eager_dp_rank = eager_dp_group.rank() assert eager_gtp_group.size() == gtp_degree assert eager_dp_group.size() == dp_degree assert any(isinstance(param, GTPShardedParam) for param in eager.parameters()) + if latent_projection_case: + eager_latent_params = get_latent_params(eager) + assert len(eager_latent_params) == 2 + assert all(isinstance(param, GTPShardedParam) for param in eager_latent_params) initialize_main_grads(eager) saved_local_weights = {name: param.data.clone() for name, param in eager.named_parameters()} @@ -190,20 +251,20 @@ def apply_sgd_step(layers, gtp_size): apply_sgd_step(eager, eager_gtp_group.size()) del eager, loss, x + torch.cuda.synchronize() ps.destroy_model_parallel() gtp_module.reset_gtp_state() + gtp_module._GTP_PARAMS.clear() - # Optimized path: the same GTP2 x DP2 topology with attention-only local CUDA graphs. + # Optimized path: the same GTP2 x DP2 topology with local CUDA graphs. ps.initialize_model_parallel( tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=gtp_degree ) initialize_rng_tracker(use_te_rng_tracker=True, force_reset=True) model_parallel_cuda_manual_seed(42) - pg_collection = ProcessGroupCollection.use_mpu_process_groups( - required_pgs=["tp", "cp", "gtp_remat"] - ) + pg_collection = make_pg_collection() partial_cg_config = make_config(partial_cg=True) - partial_cg = make_attention_stack(partial_cg_config, pg_collection).cuda() + partial_cg = make_layer_stack(partial_cg_config, pg_collection).cuda() classify_gtp_remat_chains( partial_cg, cuda_graph_modules=partial_cg_config.cuda_graph_modules, @@ -220,7 +281,8 @@ def apply_sgd_step(layers, gtp_size): assert dp_rank == eager_dp_rank gtp_params = [param for param in partial_cg.parameters() if isinstance(param, GTPShardedParam)] assert gtp_params, "GTP not active: no GTPShardedParam found" - assert all(param.chain_id == GTPChain.GRAPHED.value for param in gtp_params) + params_in_scope = get_latent_params(partial_cg) if latent_projection_case else gtp_params + assert all(param.chain_id == GTPChain.GRAPHED.value for param in params_in_scope) for name, param in partial_cg.named_parameters(): param.data.copy_(saved_local_weights[name]) # Production captures after DDP maps every parameter into a main-grad buffer and initializes @@ -241,18 +303,21 @@ def apply_sgd_step(layers, gtp_size): wait_for_gtp_grad_reduction_on_current_stream() eager_grad_norm = global_grad_norm(partial_cg, gtp_group) eager_probe_loss_value = eager_probe_loss.item() + del eager_probe_loss, eager_probe_x + reset_grad_state(partial_cg) create_cudagraphs() assert _CudagraphGlobalRecord.cudagraph_created - runners = [layer.cudagraph_manager.cudagraph_runners[0] for layer in partial_cg] - assert all(runner.gtp_remat for runner in runners) - assert any(runner._gtp_wgrad_ring_slots for runner in runners) + managers = get_cudagraph_managers(partial_cg) + assert all(len(manager.cudagraph_runners) == 1 for manager in managers) + runners = [manager.cudagraph_runners[0] for manager in managers] + assert any(runner.gtp_remat for runner in runners) replay_grad_norms = [] replay_losses = [] for _ in range(3): reset_grad_state(partial_cg) - replay_x = eager_probe_x.detach().clone().requires_grad_() + replay_x = make_replica_input(1234, dp_rank).requires_grad_() replay_loss = run_step(partial_cg, replay_x) replay_loss.backward() wait_for_gtp_grad_reduction_on_current_stream() @@ -280,7 +345,7 @@ def apply_sgd_step(layers, gtp_size): flush=True, ) - del eager_probe_loss, eager_probe_x, replay_loss, replay_x + del replay_loss, replay_x for step in range(steps): reset_grad_state(partial_cg) @@ -295,19 +360,21 @@ def apply_sgd_step(layers, gtp_size): del loss, x finally: torch.cuda.synchronize() - for layer in partial_cg: - for runner in layer.cudagraph_manager.cudagraph_runners: + managers = get_cudagraph_managers(partial_cg) + for manager in managers: + for runner in manager.cudagraph_runners: if runner.fwd_graph is not None: runner.fwd_graph.reset() if runner.bwd_graph is not None: runner.bwd_graph.reset() delete_cuda_graphs() - for layer in partial_cg: - layer.cudagraph_manager.cudagraph_runners.clear() + for manager in managers: + manager.cudagraph_runners.clear() gc.collect() ps.destroy_model_parallel() ps.initialize_model_parallel() gtp_module.reset_gtp_state() + gtp_module._GTP_PARAMS.clear() if rank == 0: for step, (eager_loss, partial_cg_loss) in enumerate(zip(eager_losses, partial_cg_losses)): @@ -324,8 +391,17 @@ def apply_sgd_step(layers, gtp_size): class TestGTPPartialCGCorrectness: - def test_gtp_partial_cg_loss_and_grad_norm_match_eager(self): + @pytest.mark.parametrize( + "partial_cg_modules,opt_in_modules", + [ + pytest.param(["attn"], [], id="attention"), + pytest.param(["moe_router"], ["moe_latent_proj"], id="moe-router-latent-projections"), + ], + ) + def test_gtp_partial_cg_loss_and_grad_norm_match_eager( + self, partial_cg_modules, opt_in_modules + ): """Local-CG loss trajectory and global grad norm must match eager execution.""" if torch.cuda.device_count() < 4: pytest.skip("Requires at least 4 CUDA devices") - _run_distributed(_worker_gtp_partial_cg_correctness, 4) + _run_distributed(_worker_gtp_partial_cg_correctness, 4, partial_cg_modules, opt_in_modules) From 85344907be6a9985410bf528acbedaca77b2a020 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Wed, 12 Aug 2026 07:13:41 -0700 Subject: [PATCH 268/290] Balance LayerWise optimizer shards by Newton-Schulz cost, not parameter size (#6379) Signed-off-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 --- .../core/optimizer/layer_wise_optimizer.py | 69 +++++++-- .../test_layer_wise_param_layout.py | 134 ++++++++++++++++++ 2 files changed, 194 insertions(+), 9 deletions(-) diff --git a/megatron/core/optimizer/layer_wise_optimizer.py b/megatron/core/optimizer/layer_wise_optimizer.py index d1d792ca76d..a791d262954 100644 --- a/megatron/core/optimizer/layer_wise_optimizer.py +++ b/megatron/core/optimizer/layer_wise_optimizer.py @@ -213,15 +213,19 @@ def _compute_per_buffer_param_layout( followed by an isolated bucket for that embedding alone. 2. When the chunk's total numel reaches ``bucket_size`` (or all params have been consumed), bin-pack the chunk into ``dp_size`` - shards via greedy LPT — sort by numel descending and assign each - param to the shard with the smallest current load. + shards: sort by estimated Newton-Schulz compute cost descending and + assign each param to the shard with the smallest accumulated compute + load, subject to a per-bucket numel cap that bounds shard-imbalance + padding. Compute loads persist across buckets so expensive + (GTP-sharded) matrices spread over the whole buffer instead of + clustering inside each bucket. 3. Pad each shard to ``max(shard_cursors)`` aligned to :meth:`_shard_divisor`, then emit the bucket. Each bucket therefore spans a contiguous backprop range so that ``overlap_grad_reduce`` can dispatch the bucket's reduce-scatter as soon as the bucket's backward segment finishes — preserving the - original DDP overlap semantics. LPT bin-packing keeps shards close + original DDP overlap semantics. Greedy bin-packing keeps shards close to balanced; for uniform transformer blocks where ``params_per_layer * num_layers`` is a multiple of ``dp_size`` the packing is perfect. @@ -247,6 +251,27 @@ def _compute_per_buffer_param_layout( bucket_id = 0 shard_imbalance_padding_numel = 0 + # Persistent compute loads across buckets so LPT spreads expensive + # (GTP-sharded) params evenly instead of clustering them per bucket. + shard_compute_loads = [0] * dp_size + + def _ns_compute_cost(param): + """Estimate Newton-Schulz compute cost for a parameter. + + Newton-Schulz only runs on matrices, so anything that is not 2D falls + back to its element count. For a 2D param the cost is + ~ max(M,N) * min(M,N)^2, the dominant term in the orthogonalization; + GTP-sharded params reconstruct the full post-AllGather shape first + (GTP always shards along dim 0). + """ + if param.dim() != 2: + return param.data.nelement() + m, n = param.data.shape + if getattr(param, 'is_gtp_weight_remat', False): + m = m * getattr(param, 'gtp_remat_size', 1) + big, small = max(m, n), min(m, n) + return big * small * small + def _emit_bucket( chunk_params: List[torch.nn.Parameter], shared_embedding: bool = False ) -> None: @@ -276,17 +301,33 @@ def _emit_bucket( shard_assignments[shard_id].append((None, numel)) shard_cursors[shard_id] = numel else: - # Greedy LPT: largest first, assign to the least-loaded shard. - # The within-shard order is sorted-by-numel, not backprop — + # Compute-balanced LPT: sort by Newton-Schulz compute cost + # (accounts for full post-AllGather shape under GTP), assign to + # the shard with least accumulated compute load. Compute loads + # persist across buckets; numel cursors reset per bucket. + # A per-bucket numel cap prevents excessive padding. + # The within-shard order is sorted-by-compute-cost, not backprop; # that is fine because all params in the chunk share the same # bucket_id, so DDP's backprop-order iteration still sees # monotonic bucket_ids across the chunk boundary. - for param in sorted(chunk_params, key=lambda p: -p.data.nelement()): + _NUMEL_EPSILON = 0.3 + total_chunk_numel = sum(p.data.nelement() for p in chunk_params) + max_shard_numel = total_chunk_numel / dp_size * (1 + _NUMEL_EPSILON) + for param in sorted(chunk_params, key=lambda p: -_ns_compute_cost(p)): numel = param.data.nelement() - min_shard = min(range(dp_size), key=lambda s: shard_cursors[s]) + candidates = [ + s + for s in range(dp_size) + if pad_param_start(shard_cursors[s]) + numel <= max_shard_numel + ] + if candidates: + min_shard = min(candidates, key=lambda s: shard_compute_loads[s]) + else: + min_shard = min(range(dp_size), key=lambda s: shard_cursors[s]) placement = pad_param_start(shard_cursors[min_shard]) shard_assignments[min_shard].append((param, numel)) shard_cursors[min_shard] = placement + numel + shard_compute_loads[min_shard] += _ns_compute_cost(param) padded_shard_size = pad_to_divisor(max(shard_cursors), shard_divisor) bucket_start_index = buffer_cursor @@ -325,8 +366,18 @@ def _emit_bucket( chunk_params: List[torch.nn.Parameter] = [] chunk_numel = 0 chunk_max_param = 0 - # Mirror _emit_bucket's greedy LPT placement incrementally so we can - # decide, per param, whether it still fits in the current bucket. + # Approximate _emit_bucket's placement so we can decide, per param, + # whether it still fits in the current bucket. This estimates rather + # than mirrors, for two reasons: _emit_bucket sorts the chunk before + # packing it, while this places params in backprop order, and + # _emit_bucket assigns by Newton-Schulz compute cost, while this tracks + # numel. Equal-sized params make both differences vanish, because + # sorting is then a no-op and cost is proportional to numel. Mixed + # sizes send params to different shards under the two orders, so the + # real maximum shard load can exceed the estimated one. _absorbs then + # admits a param that does grow the bucket, leaving a buffer larger + # than closing the bucket early would have produced. The layout stays + # valid either way; see test_mixed_sizes_can_absorb_into_larger_bucket. shard_loads = [0] * dp_size def _absorbs(numel: int) -> bool: diff --git a/tests/unit_tests/distributed/test_layer_wise_param_layout.py b/tests/unit_tests/distributed/test_layer_wise_param_layout.py index a73bf4789a5..2d68a96d7ad 100644 --- a/tests/unit_tests/distributed/test_layer_wise_param_layout.py +++ b/tests/unit_tests/distributed/test_layer_wise_param_layout.py @@ -106,6 +106,15 @@ def _assert_param_within_shard(layout, param, dp_size): class TestSizeMatchingLayout: + """Packing and bucketing rules, exercised with 1-D params. + + Every param here is 1-D, so ``_ns_compute_cost`` falls back to ``nelement()`` + and Newton-Schulz cost equals numel. That keeps these cases focused on the + packing and bucketing rules, but it also means they cannot tell + compute-balanced placement apart from numel-balanced placement: both order + and assign identically when the two metrics agree. ``TestComputeBalancedLayout`` + covers that distinction with 2-D GTP-sharded params, where the metrics diverge. + """ # -- uniform params: all same size, dp_size divides count -- @@ -318,6 +327,32 @@ def test_bucket_has_no_padding_when_params_pack_evenly(self): total_buffer_numel = layout.bucket_indices[-1][1] assert total_buffer_numel == 8 * numel + def test_mixed_sizes_can_absorb_into_larger_bucket(self): + """Absorbing is not always a win: with mixed sizes it can cost space. + + ``_place`` walks params in backprop order while ``_emit_bucket`` sorts the + chunk first, so for mixed sizes the two reach different shard loads. + ``_absorbs`` compares against its own lower estimate and admits the two + 128-element params, after which the sorted packing stacks both onto one + shard and the bucket grows. Closing at the threshold instead would have + emitted 768 + 512 = 1280 elements; absorbing emits 1024 + 384 = 1408. + + Documented rather than fixed: the target case is equal-sized expert + matrices, where sorting is a no-op and the estimate is exact. + """ + dp_size = 2 + # Backprop order is reversed(params), so this list is written back to front. + backprop_order_numels = [192, 192, 256, 128, 128, 192] + params = [_make_param((n,)) for n in reversed(backprop_order_numels)] + cfg = _make_ddp_config() + + layout = _LWO._compute_per_buffer_param_layout(params, 448, dp_size, cfg) + + for param in params: + _assert_param_within_shard(layout, param, dp_size) + assert len(layout.bucket_indices) == 2 + assert layout.bucket_indices[-1][1] == 1408 + # -- bucket alignment -- def test_bucket_dp_divisible(self): @@ -427,3 +462,102 @@ def test_expert_parallel_separate_buffer(self): cfg = _make_ddp_config() layout = _LWO.compute_full_param_layout([dense, expert], None, dp_size, cfg) assert len(layout.layouts) == 2 + + +# --------------------------------------------------------------------------- +# Tests for compute-balanced LPT (Newton-Schulz cost, not numel) +# --------------------------------------------------------------------------- + + +class TestComputeBalancedLayout: + """Placement keys on Newton-Schulz cost, so GTP-sharded params spread out. + + A GTP-sharded param has the numel of its local shard but Newton-Schulz runs on + the full all-gathered matrix, so its cost is far higher than numel suggests. + """ + + def _ns_cost(self, param): + rows, cols = param.data.shape + rows *= getattr(param, 'gtp_remat_size', 1) + big, small = max(rows, cols), min(rows, cols) + return big * small * small + + def _shard_compute_loads(self, layout, params, dp_size): + loads = [0] * dp_size + for param in params: + loads[_get_shard_for_param(layout, param, dp_size)] += self._ns_cost(param) + return loads + + def test_gtp_params_balanced_by_compute_not_numel(self): + """GTP-sharded params dominate cost while having the smallest numel. + + Sorting by numel puts the three cheap-but-large params first and leaves the + expensive GTP ones to fill in, which piles them onto shards that are already + loaded. Sorting by compute cost spreads them instead. + """ + dp_size = 4 + gtp = [ + _make_param((64, 256), is_gtp_weight_remat=True, gtp_remat_size=64) for _ in range(3) + ] + dense = [_make_param((128, 1024)), _make_param((256, 256)), _make_param((256, 256))] + tail = [_make_param((64, 256))] + params = dense + gtp + tail + cfg = _make_ddp_config() + + layout = _LWO._compute_per_buffer_param_layout(params, None, dp_size, cfg) + + # Each GTP param costs 268M against 16.8M for the largest dense param, so no + # two of the three may share a shard. + gtp_shards = [_get_shard_for_param(layout, param, dp_size) for param in gtp] + assert len(set(gtp_shards)) == len(gtp), f"GTP params clustered onto {gtp_shards}" + + loads = self._shard_compute_loads(layout, params, dp_size) + imbalance = max(loads) / (sum(loads) / dp_size) + # Numel-ordered placement gives 3.77x on this input. + assert imbalance < 1.5, f"compute imbalance {imbalance:.2f}x too high: {loads}" + + def test_gtp_params_land_on_different_shards_in_each_bucket(self): + """Expensive params spread across buckets because compute loads do not reset. + + Four buckets, each holding one GTP-sharded matrix plus three dense params of + identical numel. ``shard_cursors`` resets per bucket, so numel gives every + bucket the same starting state and would send all four GTP matrices to shard + 0. ``shard_compute_loads`` carries over, so each bucket sees the previous + ones' cost and picks a different shard. + """ + dp_size = 4 + buckets = 4 + params = [] + for _ in range(buckets): + params.append(_make_param((64, 256), is_gtp_weight_remat=True, gtp_remat_size=64)) + params.extend(_make_param((128, 128)) for _ in range(3)) + gtp_params = [param for param in params if hasattr(param, 'gtp_remat_size')] + cfg = _make_ddp_config() + + # Each group of four params is 4 * 16384 elements, so this cuts one bucket per group. + layout = _LWO._compute_per_buffer_param_layout(params, 4 * 16384, dp_size, cfg) + + assert len(layout.bucket_indices) == buckets + gtp_shards = [_get_shard_for_param(layout, param, dp_size) for param in gtp_params] + assert len(set(gtp_shards)) == buckets, f"GTP params clustered onto {gtp_shards}" + + def test_param_larger_than_cap_is_still_placed(self): + """A param larger than the per-bucket cap still gets placed. + + The cap is ``total_chunk_numel / dp_size * 1.3``. A param above it disqualifies + every shard, so assignment falls back to the previous least-numel rule instead + of wedging. + """ + dp_size = 2 + oversized = _make_param((1024,)) + small_params = [_make_param((64,)) for _ in range(2)] + cfg = _make_ddp_config() + + # cap = (1024 + 64 + 64) / 2 * 1.3 = 748.8, below the oversized param's numel. + layout = _LWO._compute_per_buffer_param_layout( + small_params + [oversized], None, dp_size, cfg + ) + + for param in small_params + [oversized]: + _assert_param_within_shard(layout, param, dp_size) + assert oversized in layout.param_index_map From fad6111eea2d0ecce70597eb8d7795ffb4facfe2 Mon Sep 17 00:00:00 2001 From: Deepak Narayanan Date: Wed, 12 Aug 2026 10:46:05 -0700 Subject: [PATCH 269/290] Suppress noisy external-library log lines on non-rank-0 processes (#5590) Signed-off-by: Deepak Narayanan --- megatron/training/initialize.py | 10 ++++++++++ pretrain_gpt.py | 13 +++++++++++++ pretrain_hybrid.py | 13 +++++++++++++ 3 files changed, 36 insertions(+) diff --git a/megatron/training/initialize.py b/megatron/training/initialize.py index 87d6aa65b03..c3eb2cccb2e 100644 --- a/megatron/training/initialize.py +++ b/megatron/training/initialize.py @@ -580,3 +580,13 @@ def setup_logging() -> None: if is_rank0(): logger.info(f'Setting logging level to {logging_level}') logging.getLogger().setLevel(logging_level) + + if not is_rank0(): + for noisy_logger_name in [ + 'GroupedGemmQuantSm100', + 'GroupedGemmDsreluSm100', + 'GroupedGemmSreluSm100', + 'GroupedGemmWgradSm100', + 'absl', + ]: + logging.getLogger(noisy_logger_name).setLevel(logging.ERROR) diff --git a/pretrain_gpt.py b/pretrain_gpt.py index 47c1935eb90..8b979d16d4a 100644 --- a/pretrain_gpt.py +++ b/pretrain_gpt.py @@ -17,6 +17,19 @@ if rank != 0: warnings.filterwarnings("ignore", category=UserWarning) warnings.filterwarnings("ignore", category=FutureWarning) + warnings.filterwarnings("ignore", category=DeprecationWarning) + + # Some libraries (e.g., CUTLASS DSL) use warnings.catch_warnings() with + # simplefilter("always"), which overrides the filters above. Override + # showwarning as a fallback to suppress warnings that slip through. + _original_showwarning = warnings.showwarning + + def _rank0_only_showwarning(message, category, filename, lineno, file=None, line=None): + if issubclass(category, (UserWarning, FutureWarning, DeprecationWarning)): + return + _original_showwarning(message, category, filename, lineno, file, line) + + warnings.showwarning = _rank0_only_showwarning from functools import lru_cache, partial from typing import Any, List, Optional, Tuple diff --git a/pretrain_hybrid.py b/pretrain_hybrid.py index 39bc7f30b57..cf665b36887 100644 --- a/pretrain_hybrid.py +++ b/pretrain_hybrid.py @@ -16,6 +16,19 @@ if rank != 0: warnings.filterwarnings("ignore", category=UserWarning) warnings.filterwarnings("ignore", category=FutureWarning) + warnings.filterwarnings("ignore", category=DeprecationWarning) + + # Some libraries (e.g., CUTLASS DSL) use warnings.catch_warnings() with + # simplefilter("always"), which overrides the filters above. Override + # showwarning as a fallback to suppress warnings that slip through. + _original_showwarning = warnings.showwarning + + def _rank0_only_showwarning(message, category, filename, lineno, file=None, line=None): + if issubclass(category, (UserWarning, FutureWarning, DeprecationWarning)): + return + _original_showwarning(message, category, filename, lineno, file, line) + + warnings.showwarning = _rank0_only_showwarning from functools import lru_cache, partial from typing import Any, List, Optional, Tuple From b2f2888e00273d1ae725cf3ec6c92276e2a8e231 Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 12 Aug 2026 20:04:10 +0000 Subject: [PATCH 270/290] feat(ci): enhance triage script to assign on-call assignee dynamically (#6469) Signed-off-by: Ajay Balasa --- .gitlab/stages/06.triage.yml | 16 ++- docker/Dockerfile.linting | 2 +- .../python_scripts/resolve_oncall_assignee.py | 117 ++++++++++++++++++ tests/test_utils/test_ci_triage.py | 10 +- 4 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/test_utils/python_scripts/resolve_oncall_assignee.py diff --git a/.gitlab/stages/06.triage.yml b/.gitlab/stages/06.triage.yml index 91f8a4e703c..bac492bc873 100644 --- a/.gitlab/stages/06.triage.yml +++ b/.gitlab/stages/06.triage.yml @@ -49,11 +49,17 @@ triage:linear_write: artifacts: true allow_failure: true script: - - >- - cerno-linear write - --config "${CERNO_CONFIG}" - --plan linear_action_plan.json - --output linear_action_plan_post.json + # The shared GitHub email lookup reads the existing GH_TOKEN CI variable. + - | + ONCALL_ASSIGNEE="$( + python tests/test_utils/python_scripts/resolve_oncall_assignee.py \ + --schedule-file .github/oncall_schedule.json + )" + cerno-linear write \ + --config "${CERNO_CONFIG}" \ + --plan linear_action_plan.json \ + --output linear_action_plan_post.json \ + --assignee "${ONCALL_ASSIGNEE}" artifacts: when: always paths: diff --git a/docker/Dockerfile.linting b/docker/Dockerfile.linting index e9a4de80455..ef3a5813e6a 100644 --- a/docker/Dockerfile.linting +++ b/docker/Dockerfile.linting @@ -24,7 +24,7 @@ RUN --mount=type=secret,id=JET_INDEX_URLS \ # Keep this in the internal-only stage so public CI has no internal service dependency. ARG CI_SERVER_URL -ARG CERNO_COMMIT=5a5fb5360e67f8f09d189871bbc0d768c09c43fa +ARG CERNO_COMMIT=3219afcbfcdbddd1d55e013d94e54a15986e2b1f RUN --mount=type=secret,id=CERNO_TOKEN \ GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.extraHeader \ diff --git a/tests/test_utils/python_scripts/resolve_oncall_assignee.py b/tests/test_utils/python_scripts/resolve_oncall_assignee.py new file mode 100644 index 00000000000..8cebeb09b7a --- /dev/null +++ b/tests/test_utils/python_scripts/resolve_oncall_assignee.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +"""Resolve the scheduled Megatron-LM on-call user to an NVIDIA email address.""" + +import argparse +import json +import sys +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path + +_GITHUB_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / ".github" / "scripts" +sys.path.insert(0, str(_GITHUB_SCRIPTS_DIR)) + +from github_slack_utils import get_user_email # noqa: E402 + + +class AssigneeResolutionError(RuntimeError): + """Raised when the current on-call Linear assignee cannot be resolved safely.""" + + +def load_schedule(path: Path) -> list[dict[str, object]]: + """Load and validate the top-level on-call schedule structure.""" + + try: + schedule = json.loads(path.read_text()) + except OSError as exc: + raise AssigneeResolutionError(f"could not read on-call schedule {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise AssigneeResolutionError(f"on-call schedule {path} is not valid JSON") from exc + + if not isinstance(schedule, list) or not schedule: + raise AssigneeResolutionError(f"on-call schedule {path} must be a non-empty list") + return schedule + + +def current_oncall(schedule: list[dict[str, object]]) -> str: + """Return the current on-call user, matching the existing rotation manager.""" + + if not schedule: + raise AssigneeResolutionError("on-call schedule must be a non-empty list") + entry = schedule[0] + if not isinstance(entry, dict): + raise AssigneeResolutionError("the current on-call schedule entry must be an object") + + username = entry.get("user") + if not isinstance(username, str) or not username.strip(): + raise AssigneeResolutionError("the current on-call schedule entry must have a user") + return username.strip() + + +def resolve_nvidia_email(github_login: str) -> str: + """Resolve a GitHub login with the repository's shared email lookup helper.""" + + # The shared helper reports lookup details on stdout. Suppress those messages + # so command substitution receives only the email printed by ``main`` and CI + # logs do not repeat email addresses from fallback diagnostics. + try: + with redirect_stdout(StringIO()): + email = get_user_email(github_login) + except SystemExit as exc: + raise AssigneeResolutionError( + f"could not look up an email for GitHub user {github_login!r}" + ) from exc + + if not isinstance(email, str): + raise AssigneeResolutionError( + f"GitHub user {github_login!r} did not resolve to an NVIDIA email" + ) + email = email.strip() + local_part, separator, domain = email.rpartition("@") + if ( + separator != "@" + or not local_part + or "@" in local_part + or domain.casefold() != "nvidia.com" + or any(character.isspace() for character in email) + ): + raise AssigneeResolutionError( + f"GitHub user {github_login!r} did not resolve to a valid NVIDIA email" + ) + return email + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + + parser = argparse.ArgumentParser( + description="Resolve the scheduled Megatron-LM on-call user's NVIDIA email" + ) + parser.add_argument( + "--schedule-file", + type=Path, + default=Path(".github/oncall_schedule.json"), + help="path to the dated GitHub on-call schedule", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Print only the current on-call user's NVIDIA email to stdout.""" + + args = parse_args(argv) + try: + username = current_oncall(load_schedule(args.schedule_file)) + email = resolve_nvidia_email(username) + except AssigneeResolutionError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Resolved scheduled on-call GitHub user {username!r}.", file=sys.stderr) + print(email) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_utils/test_ci_triage.py b/tests/test_utils/test_ci_triage.py index 626013c73ef..3c3b3b7ed08 100644 --- a/tests/test_utils/test_ci_triage.py +++ b/tests/test_utils/test_ci_triage.py @@ -129,13 +129,21 @@ def test_cerno_hard_cutover_contract(): assert triage.count("cerno-linear") == 3 assert triage.count("cerno-notify") == 2 assert triage.count('--config "${CERNO_CONFIG}"') == 3 - assert "ARG CERNO_COMMIT=5a5fb5360e67f8f09d189871bbc0d768c09c43fa" in dockerfile assert '"cerno @ git+${CI_SERVER_URL}/dl/nemo/cerno.git@${CERNO_COMMIT}"' in dockerfile assert "id=CERNO_TOKEN" in dockerfile assert "/run/secrets/CERNO_TOKEN" in dockerfile assert "--secret id=CERNO_TOKEN,env=PAT" in build_script +def test_linear_write_assigns_new_issues_to_scheduled_oncall(): + triage = yaml.safe_load(Path(".gitlab/stages/06.triage.yml").read_text()) + script = "\n".join(triage["triage:linear_write"]["script"]) + + assert "resolve_oncall_assignee.py" in script + assert "--schedule-file .github/oncall_schedule.json" in script + assert '--assignee "${ONCALL_ASSIGNEE}"' in script + + def test_notification_rules_use_expected_pipeline_sources(): unit = yaml.safe_load(Path(".gitlab/stages/02.test.yml").read_text()) functional = yaml.safe_load(Path(".gitlab/stages/04.functional-tests.yml").read_text()) From 543628902932815fc3ea29e74e7a526c03ce99d6 Mon Sep 17 00:00:00 2001 From: Teodor-Dumitru Ene Date: Thu, 13 Aug 2026 01:20:34 +0000 Subject: [PATCH 271/290] Skip attention mask materialization in RL training (#5885) Signed-off-by: Teodor-Dumitru Ene --- megatron/rl/rl_utils.py | 7 ++-- megatron/training/utils/common_utils.py | 43 +++++++++++++++++++------ train_rl.py | 4 +++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/megatron/rl/rl_utils.py b/megatron/rl/rl_utils.py index 262c366110f..e43e2f72ced 100644 --- a/megatron/rl/rl_utils.py +++ b/megatron/rl/rl_utils.py @@ -1708,16 +1708,19 @@ def prepare_data_for_update( data_loader = DataLoader(dataset, batch_size=1) logprobs_batch_size = 1 else: - # Always compute standard masks for the original data (we'll need them later) + # Compute the loss mask and position ids for the original data (we'll need them later). + # No dense attention mask: the forward pass masks via PackedSeqParams (see + # get_logprobs), even when sequence packing is disabled. with nvtx_range("rl/get-ltor-masks", time=True): _, original_loss_mask, original_position_ids = get_ltor_masks_and_position_ids( trajs, tokenizer.eod, tokenizer.pad, args.reset_position_ids, - args.reset_attention_mask, + reset_attention_mask=False, eod_mask_loss=False, pad_mask_loss=True, + create_attention_mask=False, ) original_loss_mask[~generation_masks] = 0.0 compute_trajs = trajs diff --git a/megatron/training/utils/common_utils.py b/megatron/training/utils/common_utils.py index 30617ef9b4c..1b20eb5a985 100644 --- a/megatron/training/utils/common_utils.py +++ b/megatron/training/utils/common_utils.py @@ -377,20 +377,44 @@ def get_ltor_masks_and_position_ids(data, reset_position_ids, reset_attention_mask, eod_mask_loss, - pad_mask_loss): - """Build masks and position id for left to right model.""" + pad_mask_loss, + create_attention_mask=True): + """Build masks and position id for left to right model. + + Args: + data: Token ids, shape [micro_batch_size, seq_length]. + eod_token: End-of-document token id. + pad_token: Padding token id. + reset_position_ids: Restart position ids from 0 after each EOD token. + reset_attention_mask: Additionally mask attention across document boundaries, + turning the shared causal mask into a per-sample block-causal mask. + Requires create_attention_mask, since it modifies the materialized mask. + eod_mask_loss: Zero the loss mask at EOD tokens. + pad_mask_loss: Zero the loss mask at pad tokens. + create_attention_mask: Materialize the dense causal attention mask. + Can be disabled if the attention kernel generates the mask by itself + (e.g. from PackedSeqParams), in which case attention_mask is returned as None. + + Returns: + Tuple of (attention_mask or None, loss_mask, position_ids). + """ + assert create_attention_mask or not reset_attention_mask, \ + "reset_attention_mask requires the attention mask to be created." # Extract batch size and sequence length. micro_batch_size, seq_length = data.size() # Attention mask (lower triangular). - if reset_attention_mask: - att_mask_batch = micro_batch_size + if create_attention_mask: + if reset_attention_mask: + att_mask_batch = micro_batch_size + else: + att_mask_batch = 1 + attention_mask = torch.tril( + torch.ones((att_mask_batch, seq_length, seq_length), device=data.device) + ).view(att_mask_batch, 1, seq_length, seq_length) else: - att_mask_batch = 1 - attention_mask = torch.tril( - torch.ones((att_mask_batch, seq_length, seq_length), device=data.device) - ).view(att_mask_batch, 1, seq_length, seq_length) + attention_mask = None # Loss mask. loss_mask = torch.ones(data.size(), dtype=torch.float, device=data.device) @@ -429,7 +453,8 @@ def get_ltor_masks_and_position_ids(data, prev_index = i + 1 # Convert attention mask to binary: - attention_mask = attention_mask < 0.5 + if create_attention_mask: + attention_mask = attention_mask < 0.5 return attention_mask, loss_mask, position_ids diff --git a/train_rl.py b/train_rl.py index acf54680f4a..17eec357b15 100644 --- a/train_rl.py +++ b/train_rl.py @@ -415,6 +415,10 @@ def _model_builder( extra_args_provider=add_inference_args, args_defaults={}, ) + assert not args.reset_attention_mask, ( + "--reset-attention-mask is not supported in RL training: " + "the forward pass masks via PackedSeqParams and never consumes a dense attention mask." + ) if is_hybrid_model(args): model_cfg = hybrid_config_from_args(args) else: From a34880f0adbb8730709675c49898944014771dc7 Mon Sep 17 00:00:00 2001 From: Jiangfei Duan <30710061+JF-D@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:07:12 +0000 Subject: [PATCH 272/290] Avoid unnecessary MoE router host synchronization (#6432) Signed-off-by: Jiangfei Duan --- .../core/transformer/transformer_layer.py | 21 +++-- .../transformer/test_transformer_layer.py | 94 ++++++++++++++++++- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index 5c55f2abe6c..0f7be974418 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -1560,8 +1560,6 @@ def transition_cudagraph_scope(self, mode): and "moe" in self.config.recompute_modules and self.config.cuda_graph_impl == "local" ) - if not hasattr(self, '_router_dtoh_event'): - self._router_dtoh_event = torch.cuda.Event() if not hasattr(self, 'cudagraph_manager_router'): self.cudagraph_manager_router = CudaGraphManager( self.config, self, function_name="_forward_mlp_router" @@ -1630,6 +1628,16 @@ def _get_token_dispatcher_attrs(self): return tuple(attr_names), token_dispatcher_attr_outputs + def _synchronize_router_host_outputs(self, attr_outputs): + """Wait for partial-router graph outputs only when they reside on the host.""" + if not any(attr.device.type == "cpu" for attr in attr_outputs): + return + + if not hasattr(self, '_router_dtoh_event'): + self._router_dtoh_event = torch.cuda.Event() + self._router_dtoh_event.record() + self._router_dtoh_event.synchronize() + def _forward_mlp_router(self, hidden_states, padding_mask=None): """ Executes the router phase of the MoE block. @@ -1735,12 +1743,9 @@ def _forward_mlp_partial_cudagraphs( *token_dispatcher_attr_outputs, ) = router_outputs - # After the router graph replays, the captured .copy_() operations that update - # the returned dispatcher tensors via `_maybe_dtoh_and_synchronize` are queued on - # the current stream but may not have completed. Record an event after the router - # graph and wait on it, so we block only until the router's D2H copies complete. - self._router_dtoh_event.record() - self._router_dtoh_event.synchronize() + # CUDA outputs remain ordered by the graph-completion event. Only host outputs need + # a CPU-blocking wait before the eager dispatcher can consume them. + self._synchronize_router_host_outputs(token_dispatcher_attr_outputs) expert_output, mlp_bias = self._forward_mlp_expert_compute( hidden_states, probs, token_dispatcher_attr_outputs diff --git a/tests/unit_tests/transformer/test_transformer_layer.py b/tests/unit_tests/transformer/test_transformer_layer.py index 93650cf13b0..9f6a20ae453 100644 --- a/tests/unit_tests/transformer/test_transformer_layer.py +++ b/tests/unit_tests/transformer/test_transformer_layer.py @@ -2,28 +2,37 @@ import gc +from unittest.mock import Mock, patch import pytest import torch from megatron.core import parallel_state from megatron.core.dist_checkpointing.mapping import ShardedObject, ShardedTensor +from megatron.core.fusions.fused_bias_dropout import get_bias_dropout_add from megatron.core.inference.contexts import StaticInferenceContext from megatron.core.inference.utils import InferenceMode from megatron.core.models.gpt.gpt_layer_specs import ( get_gpt_layer_with_transformer_engine_spec, get_gpt_layer_with_transformer_engine_submodules, ) +from megatron.core.models.gpt.moe_module_specs import get_moe_module_spec from megatron.core.tensor_parallel.random import ( HAVE_TE, initialize_rng_tracker, model_parallel_cuda_manual_seed, ) -from megatron.core.transformer.cuda_graphs import CudaGraphManager, _CudagraphGlobalRecord -from megatron.core.transformer.enums import InferenceCudaGraphScope +from megatron.core.transformer.cuda_graphs import ( + CudaGraphManager, + _CudagraphGlobalRecord, + create_cudagraphs, +) +from megatron.core.transformer.enums import CudaGraphModule, InferenceCudaGraphScope from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.transformer.transformer_layer import ( + MoETransformerLayer, TransformerLayer, + TransformerLayerSubmodules, get_transformer_layer_offset, ) from megatron.core.utils import is_te_min_version @@ -378,6 +387,52 @@ def _no_layers_have_manager(block) -> bool: return all(not hasattr(layer, 'cudagraph_manager') for layer in block.layers) +def _make_moe_transformer_layer(*, partial_cudagraph: bool): + config = TransformerConfig( + num_layers=1, + hidden_size=32, + num_attention_heads=4, + ffn_hidden_size=64, + moe_ffn_hidden_size=64, + num_moe_experts=4, + moe_router_topk=2, + moe_router_load_balancing_type="none", + moe_token_dispatcher_type="allgather", + hidden_dropout=0.0, + attention_dropout=0.0, + bias_dropout_fusion=False, + add_bias_linear=False, + use_cpu_initialization=True, + cuda_graph_impl="local" if partial_cudagraph else "none", + cuda_graph_modules=[CudaGraphModule.moe_router] if partial_cudagraph else [], + ) + submodules = TransformerLayerSubmodules( + mlp=get_moe_module_spec(use_te=False, num_experts=4, moe_grouped_gemm=False), + mlp_bda=get_bias_dropout_add, + ) + return MoETransformerLayer(config, submodules) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required") +def test_moe_router_synchronizes_host_outputs_and_reuses_event(): + layer = object.__new__(MoETransformerLayer) + host_output = Mock() + host_output.device.type = "cpu" + cuda_output = torch.empty(1, device="cuda") + event = Mock() + + with patch.object(torch.cuda, "Event", return_value=event) as event_factory: + layer._synchronize_router_host_outputs((host_output,)) + layer._synchronize_router_host_outputs((cuda_output,)) + assert event.record.call_count == 1 + assert event.synchronize.call_count == 1 + layer._synchronize_router_host_outputs((host_output,)) + + event_factory.assert_called_once_with() + assert event.record.call_count == 2 + assert event.synchronize.call_count == 2 + + @pytest.mark.skipif( not (HAVE_TE and is_te_min_version("1.5.0")), reason="CUDA graph tests require TransformerEngine >= 1.5", @@ -393,6 +448,41 @@ def teardown_method(self, method): _reset_cudagraph_state() gc.collect() + def test_moe_router_partial_cudagraph_forward_matches_eager(self): + eager_layer = _make_moe_transformer_layer(partial_cudagraph=False) + partial_cg_layer = _make_moe_transformer_layer(partial_cudagraph=True) + partial_cg_layer.load_state_dict(eager_layer.state_dict()) + eager_layer.cuda() + partial_cg_layer.cuda() + for param in partial_cg_layer.parameters(): + param.main_grad = torch.zeros_like(param) + + hidden_states = torch.randn(8, 2, 32, device="cuda", requires_grad=True) + eager_output, _ = eager_layer(hidden_states.clone(), attention_mask=None) + eager_output = eager_output.detach().clone() + + # The first forward/backward records the real router and postprocess graph boundaries. + recorded_output, _ = partial_cg_layer(hidden_states.clone(), attention_mask=None) + recorded_output.sum().backward() + create_cudagraphs() + + assert _CudagraphGlobalRecord.cudagraph_created + assert partial_cg_layer.use_partial_cudagraphs + for manager in ( + partial_cg_layer.cudagraph_manager_router, + partial_cg_layer.cudagraph_manager_postprocess, + ): + assert len(manager.cudagraph_runners) == 1 + assert manager.cudagraph_runners[0].fwd_graph is not None + + partial_cg_layer.zero_grad(set_to_none=True) + partial_cg_output, _ = partial_cg_layer(hidden_states.clone(), attention_mask=None) + partial_cg_output = partial_cg_output.detach().clone() + + # All-gather routing metadata stays on CUDA, so replay must not create a host-wait event. + assert not hasattr(partial_cg_layer, '_router_dtoh_event') + torch.testing.assert_close(partial_cg_output, eager_output, rtol=0, atol=0) + def test_empty_scope_transformer_layer_has_per_layer_manager(self): block = _make_cuda_graph_gpt_block( cuda_graph_impl='local', cuda_graph_modules=[], inference_cuda_graph_scope='layer' From 3cd8cf30a20b1eea4ae29ae201d0c021738680d9 Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 13 Aug 2026 03:22:27 +0000 Subject: [PATCH 273/290] Refactor GDP dynamic inference onto the shared SSM interface (#6443) Signed-off-by: Deepak Narayanan Signed-off-by: Keshav Santhanam Signed-off-by: Mikail Khona Signed-off-by: Mikail Khona (NVIDIA) Signed-off-by: Kezhi Kong Co-authored-by: Kezhi Kong Co-authored-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 Co-authored-by: Mikail Khona (NVIDIA) Co-authored-by: Mikail Khona Co-authored-by: root Co-authored-by: Roger Waleffe Co-authored-by: Kezhi Kong --- megatron/core/ssm/gated_delta_product.py | 314 +++++++++-------------- 1 file changed, 122 insertions(+), 192 deletions(-) diff --git a/megatron/core/ssm/gated_delta_product.py b/megatron/core/ssm/gated_delta_product.py index 62f86e842d3..1baaea0ea3a 100644 --- a/megatron/core/ssm/gated_delta_product.py +++ b/megatron/core/ssm/gated_delta_product.py @@ -17,9 +17,7 @@ from megatron.core.dist_checkpointing.mapping import ReplicaId, ShardedTensorFactory from megatron.core.inference.contexts import BaseInferenceContext, DynamicInferenceContext from megatron.core.inference.contexts.attention_context.triton.tensor_ops import ( - tensor_get_slice_after, tensor_masked_update, - tensor_merge, ) from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.ssm.gdp_context_parallel import GDPContextParallel @@ -28,6 +26,7 @@ check_fla_sequence_packing_support, get_cu_seqlens, ) +from megatron.core.ssm.ssm_inference import SSMDynamicInferenceMixin from megatron.core.tensor_parallel import get_cuda_rng_tracker from megatron.core.transformer import TransformerConfig from megatron.core.transformer.module import MegatronModule @@ -36,7 +35,7 @@ make_sharded_tensors_for_checkpoint, sharded_state_dict_default, ) -from megatron.core.utils import deprecate_inference_params, is_using_quantization_scales +from megatron.core.utils import deprecate_inference_params try: from causal_conv1d import causal_conv1d_fn, causal_conv1d_update @@ -98,7 +97,7 @@ class GatedDeltaProductMixerSubmodules: out_proj: Union[ModuleSpec, type] = None -class GatedDeltaProductMixer(MegatronModule): +class GatedDeltaProductMixer(SSMDynamicInferenceMixin, MegatronModule): """Gated Delta Product (GDP) sequence mixer for hybrid models. The mixer accepts hidden states with shape ``[sequence, batch, hidden]`` and returns @@ -381,7 +380,12 @@ def forward( conv_state, ssm_state = None, None if inference_context is not None: if inference_context.is_dynamic_batching(): - return self._dynamic_inference(hidden_states, inference_context) + ok, reason = check_fla_sequence_packing_support() + assert ok, reason + assert ( + self.cp.cp_size == 1 + ), "Context parallel is not supported for GDP dynamic inference" + return self.ssm_dynamic_inference(hidden_states, inference_context) assert ( inference_context.is_static_batching() ), "GDP inference must be either static or dynamic batching." @@ -391,6 +395,9 @@ def forward( "Packing is only wired through the training/prefill (chunk) path." ) conv_state, ssm_state = self._get_states_from_cache(inference_context, batch_size) + if inference_context.seqlen_offset > 0: + # The states are updated in place. + return self._static_decode(hidden_states, conv_state, ssm_state) # Build cu_seqlens for the chunked recurrence (FLA) when running with # packed (THD) sequences on the training/prefill path. @@ -437,32 +444,19 @@ def forward( VKQ = VKQ.contiguous() VKQ = rearrange(VKQ, "b l d -> b d l") - # Decode - if inference_context is not None and inference_context.seqlen_offset > 0: - VKQ = causal_conv1d_update( - VKQ, - conv_state, - rearrange(self.conv1d.weight, "d 1 w -> d w"), - self.conv1d.bias, - self.activation, - ) - else: - # Prefill - if conv_state is not None: - # If we just take x[:, :, -self.d_conv :], it will error if seqlen < self.d_conv - # Instead F.pad will pad with zeros if seqlen < self.d_conv, and truncate otherwise. - conv_state.copy_( - F.pad(VKQ, (self.d_conv - VKQ.shape[-1], 0)) - ) # Update state (B D W) - # Train - # causal_conv1d uses seq_idx_packed to reset the convolution boundaries - VKQ = causal_conv1d_fn( - x=VKQ, - weight=rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w"), - bias=self.cp.get_conv1d_bias(), - activation=self.activation, - seq_idx=seq_idx_packed, - ) + if conv_state is not None: + # Static-batching prefill: seed the conv state from the prompt's tail. + # If we just take x[:, :, -self.d_conv :], it will error if seqlen < self.d_conv + # Instead F.pad will pad with zeros if seqlen < self.d_conv, and truncate otherwise. + conv_state.copy_(F.pad(VKQ, (self.d_conv - VKQ.shape[-1], 0))) # Update state (B D W) + # causal_conv1d uses seq_idx_packed to reset the convolution boundaries + VKQ = causal_conv1d_fn( + x=VKQ, + weight=rearrange(self.cp.get_conv1d_weight(), "d 1 w -> d w"), + bias=self.cp.get_conv1d_bias(), + activation=self.activation, + seq_idx=seq_idx_packed, + ) VKQ = rearrange(VKQ, "b d l -> b l d").contiguous() @@ -506,46 +500,18 @@ def forward( self.cp.nheads_local_tpcp // self.cp.ngroups_local_tpcp, dim=2 ) - # Decode - if inference_context is not None and inference_context.seqlen_offset > 0: - - g_new = g.new_zeros(g.shape[0], g.shape[1], self.num_householder, g.shape[2]) - g_new[:, :, 0] = g - g = rearrange(g_new, '... t n h -> ... (t n) h') - - query_new = query.new_zeros( - query.shape[0], query.shape[1], self.num_householder, query.shape[2], query.shape[3] - ) - query_new[:, :, -1] = query - query = rearrange(query_new, '... t n h d-> ... (t n) h d') - - core_attn_out, last_recurrent_state = fused_recurrent_gated_delta_rule( - query, - key, - value, - g=g, - beta=beta, - initial_state=ssm_state, - output_final_state=True, - use_qk_l2norm_in_kernel=True, - ) - core_attn_out = rearrange( - core_attn_out, '... (t n) h d -> ... t n h d', n=self.num_householder - )[..., -1, :, :].contiguous() - # Train or Prefill - else: - core_attn_out, last_recurrent_state = chunk_gated_delta_product( - query, - key, - value, - g=g, - beta=beta, - initial_state=None, - output_final_state=(ssm_state is not None), - num_householder=self.num_householder, - use_qk_l2norm_in_kernel=True, - cu_seqlens=cu_seqlens_packed, - ) + core_attn_out, last_recurrent_state = chunk_gated_delta_product( + query, + key, + value, + g=g, + beta=beta, + initial_state=None, + output_final_state=(ssm_state is not None), + num_householder=self.num_householder, + use_qk_l2norm_in_kernel=True, + cu_seqlens=cu_seqlens_packed, + ) if ssm_state is not None: ssm_state.copy_(last_recurrent_state) @@ -561,112 +527,70 @@ def forward( return out, out_bias + # ================================================================== + # Static / eager inference + # + # ``_static_decode`` implements legacy static-batching decode. It is + # deliberately kept separate from the dynamic inference hooks below so that + # static-batching bookkeeping does not pollute the interface defined by + # ``SSMDynamicInferenceMixin``. Static-batching prefill shares the training + # body in ``forward``, which seeds the conv/SSM state when the caches are + # present. Mirrors ``MambaMixer._static_decode``. + # ================================================================== + def _static_decode( + self, hidden_states: torch.Tensor, conv_state: torch.Tensor, ssm_state: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Single-token static-batching decode step (updates state in place).""" + assert hidden_states.shape[0] == 1, "Only support decoding with 1 token at a time for now" + assert self.cp.cp_size == 1, "Context parallel not supported for GDP inference decode" + + # (1, b, d_model) -> (1, b, proj_dim) + zVKQba, _ = self.in_proj(hidden_states) + + # The decode kernels are batch-first: (1, b, proj_dim) -> (b, 1, proj_dim). + # Static batching has no slot remapping, so batch_indices is None. + y = self.ssm_decode( + zVKQba.transpose(0, 1), conv_state=conv_state, ssm_state=ssm_state, batch_indices=None + ) + + # (b, 1, d_inner) -> (1, b, d_inner), which is what out_proj expects. + return self.out_proj(y.transpose(0, 1)) + # ------------------------------------------------------------------ # Dynamic-batching inference. # - # Mirrors ``MambaMixer._dynamic_inference`` / ``_ssm_decode`` / ``_ssm_prefill`` - # (same ``_ssm_`` naming and the same request-level control flow), but runs - # the Gated Delta Product kernels instead of the Mamba2 scan. The per-request - # recurrent state (short-conv state + matrix-valued SSM state) is read/written - # through the slot-indexed caches owned by ``DynamicInferenceContext``. + # These are the two hooks required by ``SSMDynamicInferenceMixin``; the + # mixin owns the surrounding decode/prefill partitioning and merge. They run + # the Gated Delta Product kernels instead of the Mamba2 scan. The + # per-request recurrent state (short-conv state + matrix-valued SSM state) + # is read/written through the slot-indexed caches owned by + # ``DynamicInferenceContext``. # # MVP scope: this path does not yet support context parallelism (cp_size > 1), # speculative decoding, chunked prefill, Mamba prefix caching, or CUDA-graph # capture. The reshapes mirror the static ``forward`` math with batch/seq # repurposed for the packed dynamic layout. # ------------------------------------------------------------------ - def _dynamic_inference( - self, hidden_states: torch.Tensor, context: DynamicInferenceContext - ) -> Tuple[torch.Tensor, torch.Tensor]: - """Execute one dynamic inference step by separating decode and prefill - requests, running each through the GDP kernels independently, and merging - the results back into packed token order.""" - ok, reason = check_fla_sequence_packing_support() - assert ok, reason - assert self.cp.cp_size == 1, "Context parallel is not supported for GDP dynamic inference" - assert ( - not context.is_chunked_prefill_enabled() - ), "GDP dynamic inference does not support chunked prefill yet." - - # GDP-style layers register as Mamba layers, so the same (conv_state, - # ssm_state) accessor and per-layer slab layout apply. - conv_state, ssm_state = context.mamba_states_cache(self.layer_number - self.pp_layer_offset) - - padded_dims = context.padded_batch_dimensions - token_count = padded_dims.token_count - decode_req_count = padded_dims.decode_req_count - prefill_req_count = padded_dims.prefill_req_count - metadata = context.mamba_metadata - - # Input projection over the full packed batch. - zVKQba, _ = self.in_proj(hidden_states) - - y_decode = None - y_prefill = None - - # --- Decode partition (placed first in the packed batch) --------- - if decode_req_count > 0: - # MVP: exactly one token per decode request (no speculative tokens). - zVKQba_decode = zVKQba[:decode_req_count] if prefill_req_count > 0 else zVKQba - y_decode = self._ssm_decode( - zVKQba_decode.transpose(0, 1), conv_state, ssm_state, metadata.batch_indices_decode - ).transpose(0, 1) - - # --- Prefill partition ------------------------------------------- - if prefill_req_count > 0: - if decode_req_count > 0: - # Mixed batch: gather the prefill tokens out of the packed tensor. - zVKQba_prefill = torch.empty_like(zVKQba) - tensor_get_slice_after( - zVKQba, zVKQba_prefill, metadata.device_decode_prefill, check_bounds=False - ) - else: - zVKQba_prefill = zVKQba - y_prefill = self._ssm_prefill( - zVKQba_prefill, - conv_state=conv_state, - ssm_state=ssm_state, - seq_idx=metadata.seq_idx, - cu_seqlens=metadata.cu_seqlens, - batch_indices=metadata.batch_indices_prefill, - ) - - # --- Merge back into packed token order -------------------------- - if y_decode is not None and y_prefill is not None: - y = torch.empty( - [token_count, 1, y_prefill.shape[-1]], - dtype=y_prefill.dtype, - device=y_prefill.device, - ) - tensor_merge(y_decode, y_prefill, metadata.device_decode_prefill, output_tensor=y) - elif y_decode is not None: - y = y_decode - elif y_prefill is not None: - y = y_prefill - else: - raise RuntimeError("Dynamic inference called with 0 decode and 0 prefill requests") - - # Zero padding positions to avoid corrupting quantization amax calculations. - if is_using_quantization_scales(self.config): - y[context.padding_slice] = 0.0 - - out, out_bias = self.out_proj(y) - return out, out_bias - - def _ssm_decode( + def ssm_decode( self, zVKQba: torch.Tensor, conv_state: torch.Tensor, ssm_state: torch.Tensor, batch_indices: Optional[torch.Tensor] = None, + intermediate_conv_state: Optional[torch.Tensor] = None, + intermediate_ssm_state: Optional[torch.Tensor] = None, ) -> torch.Tensor: - """Single-token-per-request decode. ``zVKQba`` is ``[1, decode_req_count, - proj_dim]``; returns ``[1, decode_req_count, d_inner]``. The conv and SSM - states are read/written in place at the slots named by ``batch_indices`` - (``-1`` marks padding slots).""" - seq_len, _, _ = zVKQba.shape + """Single-token-per-request decode. ``zVKQba`` is ``[n, seq_len, + proj_dim]``; returns ``[n, seq_len, d_inner]``. The conv and SSM states + are read/written in place at the slots named by ``batch_indices`` + (``-1`` marks padding slots); ``batch_indices=None`` means static + batching, where the caches are already in request order.""" + _, seq_len, _ = zVKQba.shape assert seq_len == 1, "GDP decode supports one token per request" - zVKQba = zVKQba.squeeze(0) # [n, proj_dim] + assert ( + intermediate_conv_state is None and intermediate_ssm_state is None + ), "GDP decode does not support speculative decoding yet" + zVKQba = zVKQba.squeeze(1) # [n, proj_dim] M = self.num_householder z, VKQ, ba = torch.split( @@ -729,11 +653,14 @@ def _ssm_decode( query_new[:, :, -1] = query query = rearrange(query_new, "n t m h d -> n (t m) h d") - # Gather this step's per-request initial states. ``.clamp`` (NOT in-place) - # returns a new tensor, so ``batch_indices`` keeps its -1 padding sentinels - # for the scatter below; the padding rows' outputs are never scattered back. - gather_idx = batch_indices.clamp(min=0) - initial_state = ssm_state[gather_idx] + if batch_indices is None: + # Static batching: the cache rows are already in request order. + initial_state = ssm_state + else: + # Gather this step's per-request initial states. ``.clamp`` (NOT in-place) + # returns a new tensor, so ``batch_indices`` keeps its -1 padding sentinels + # for the scatter below; the padding rows' outputs are never scattered back. + initial_state = ssm_state[batch_indices.clamp(min=0)] core_attn_out, last_recurrent_state = fused_recurrent_gated_delta_rule( query, @@ -749,29 +676,37 @@ def _ssm_decode( ..., -1, :, : ].contiguous() # [n, 1, h, d] - # Scatter updated states back into the cache (skips -1 padding slots). - tensor_masked_update(ssm_state, batch_indices, last_recurrent_state) + if batch_indices is None: + ssm_state.copy_(last_recurrent_state) + else: + # Scatter updated states back into the cache (skips -1 padding slots). + tensor_masked_update(ssm_state, batch_indices, last_recurrent_state) - y = rearrange(core_attn_out, "n t h p -> t n (h p)").contiguous() # [1, n, d_inner] + y = rearrange(core_attn_out, "n t h p -> n t (h p)").contiguous() # [n, 1, d_inner] if self.rmsnorm: - z = rearrange(z, "n t h p -> t n (h p)").contiguous() + z = rearrange(z, "n t h p -> n t (h p)").contiguous() y = self.norm(y, z) return y - def _ssm_prefill( + def ssm_prefill( self, zVKQba: torch.Tensor, - conv_state: Optional[torch.Tensor] = None, - ssm_state: Optional[torch.Tensor] = None, - seq_idx: Optional[torch.Tensor] = None, - cu_seqlens: Optional[torch.Tensor] = None, - batch_indices: Optional[torch.Tensor] = None, + conv_state: torch.Tensor, + ssm_state: torch.Tensor, + context: DynamicInferenceContext, ) -> torch.Tensor: """Variable-length prefill over all prefill requests in one varlen call. ``zVKQba`` is ``[l, 1, proj_dim]``; returns ``[l, 1, d_inner]``. Fresh requests start from a zero recurrent state (no prefix caching in the MVP); the resulting final conv/SSM states are written back into the caches.""" - is_dynamic_batching = seq_idx is not None + assert ( + not context.is_chunked_prefill_enabled() + ), "GDP dynamic inference does not support chunked prefill yet." + + metadata = context.mamba_metadata + seq_idx = metadata.seq_idx + cu_seqlens = metadata.cu_seqlens + batch_indices = metadata.batch_indices_prefill M = self.num_householder # l b d -> b l d @@ -788,18 +723,14 @@ def _ssm_prefill( dim=-1, ) - if conv_state is not None and is_dynamic_batching: - assert batch_indices is not None - # Capture per-request final conv states (before the conv consumes the - # inputs) and write them into the prefill requests' cache rows. - conv_varlen_states = causal_conv1d_varlen_states( - VKQ.squeeze(0), cu_seqlens, state_len=conv_state.shape[-1] - ) - tensor_masked_update(conv_state, batch_indices, conv_varlen_states) - # Maintain channels-last memory layout so causal_conv1d_fn can use seq_idx. - VKQ = VKQ.transpose(1, 2) - else: - VKQ = rearrange(VKQ, "b l d -> b d l").contiguous() + # Capture per-request final conv states (before the conv consumes the + # inputs) and write them into the prefill requests' cache rows. + conv_varlen_states = causal_conv1d_varlen_states( + VKQ.squeeze(0), cu_seqlens, state_len=conv_state.shape[-1] + ) + tensor_masked_update(conv_state, batch_indices, conv_varlen_states) + # Maintain channels-last memory layout so causal_conv1d_fn can use seq_idx. + VKQ = VKQ.transpose(1, 2) seqlen = VKQ.size(2) if causal_conv1d_fn is None: @@ -846,15 +777,14 @@ def _ssm_prefill( g=g, beta=beta, initial_state=None, - output_final_state=ssm_state is not None, + output_final_state=True, num_householder=M, use_qk_l2norm_in_kernel=True, cu_seqlens=cu_seqlens, ) # Write per-request final SSM states into the cache for subsequent decode. - if ssm_state is not None and is_dynamic_batching: - tensor_masked_update(ssm_state, batch_indices, last_recurrent_state) + tensor_masked_update(ssm_state, batch_indices, last_recurrent_state) y = rearrange(core_attn_out, "b l h p -> l b (h p)").contiguous() if self.rmsnorm: From 3ce4b4ac0f2165032839e86b1f56de8c1ce1657f Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 13 Aug 2026 03:55:23 +0000 Subject: [PATCH 274/290] test(fsdp): mark default overlap check flaky (#6493) Signed-off-by: Jingyue Wu --- .../distributed/mfsdp_v2/test_fully_shard.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py index 8b8cbcfabbc..1886945f3be 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_fully_shard.py @@ -594,7 +594,20 @@ def test_root_backward_returns_to_resting_memory(distributed_setup): ) -@pytest.mark.parametrize("use_symmetric_memory", [False, True], ids=["default", "symmetric_memory"]) +@pytest.mark.parametrize( + "use_symmetric_memory", + [ + # Both variants' all-gathers are launch-timing sensitive, but default-CTA + # kernels occupy more compute CTAs, making the profiler overlap count less + # stable across ranks (see + # https://github.com/NVIDIA/Megatron-LM/actions/runs/31615942188). + # The symmetric-memory variant uses zero-CTA all-gather kernels, so its overlap + # measurement is more stable and remains enabled. + pytest.param(False, marks=(pytest.mark.flaky, pytest.mark.flaky_in_dev)), + pytest.param(True), + ], + ids=["default", "symmetric_memory"], +) def test_overlaps_communication_and_compute(distributed_setup, use_symmetric_memory): """Forward and backward communication should overlap GEMM compute.""" world_size = distributed_setup.world_size From 301e0d658f23c34e8455d2a877d2ff48e7f81cd5 Mon Sep 17 00:00:00 2001 From: Philip Petrakian Date: Thu, 13 Aug 2026 05:35:43 +0000 Subject: [PATCH 275/290] Paged stashing no longer assumes single layer config (#6419) Signed-off-by: Philip Petrakian --- megatron/core/transformer/moe/paged_stash.py | 29 ++- .../moe/test_paged_stash_runner.py | 206 ++++++++++++++++++ 2 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/transformer/moe/test_paged_stash_runner.py diff --git a/megatron/core/transformer/moe/paged_stash.py b/megatron/core/transformer/moe/paged_stash.py index c38aac11bd2..450aa857d8c 100644 --- a/megatron/core/transformer/moe/paged_stash.py +++ b/megatron/core/transformer/moe/paged_stash.py @@ -986,15 +986,15 @@ def __init__(self, config, copy_main_params, model, optimizer, forward_backward_ # them (NCCL EP only). Both set by check_moe_overflow. self._required_recv_capacity = None self._required_capacity_factor = None - # TransformerConfig objects that must stay in sync for moe_paged_stash: the training - # loop `config` (schedules / paged_stash_reset) plus each VP chunk's GPT root config - # (GPTModel.forward). MoE mlps use the same config reference as that root, so we do - # not track mlp.config separately. + # Config objects that must stay in sync for moe_paged_stash: the training loop config + # (schedules / paged_stash_reset), each model chunk's root config (model forward), and + # every MoE layer config (expert forward). Some models may use a distinct config for + # each layer. seen_cfg_ids = set() self._configs_to_sync_moe_paged_stash = [] def _track_cfg(c): - if c is None: + if c is None or not hasattr(c, 'moe_paged_stash'): return cid = id(c) if cid not in seen_cfg_ids: @@ -1012,6 +1012,16 @@ def _track_cfg(c): model_chunk, "decoder", allow_none=False, return_model_obj=True ) _track_cfg(model_with_decoder.config) + + # Track MoE configs independently from the existing structural discovery below. + # This keeps overflow and retry behavior unchanged for models whose modules share + # the root config while allowing distinct module configs to stay synchronized. + for module in model_with_decoder.modules(): + token_dispatcher = getattr(module, 'token_dispatcher', None) + if token_dispatcher is None or not hasattr(token_dispatcher, 'check_over_budget'): + continue + _track_cfg(getattr(module, 'config', None)) + for layer in model_with_decoder.decoder.layers: transformer_layer = ( layer.mtp_model_layer if isinstance(layer, MultiTokenPredictionLayer) else layer @@ -1039,7 +1049,7 @@ def _track_cfg(c): self.moe_layers.append(mlp) def _set_moe_paged_stash_all(self, value: bool) -> None: - """Set moe_paged_stash on every tracked config (train + per VP chunk root).""" + """Set moe_paged_stash on every tracked training, model, and MoE config.""" for c in self._configs_to_sync_moe_paged_stash: c.moe_paged_stash = value @@ -1234,7 +1244,9 @@ def __call__(self, *args, **kwargs): training = not kwargs['forward_only'] data_iterator = kwargs['data_iterator'] - saved_moe_paged_stash = self.config.moe_paged_stash + saved_moe_paged_stash_values = [ + (config, config.moe_paged_stash) for config in self._configs_to_sync_moe_paged_stash + ] num_tries = 0 while True: num_tries += 1 @@ -1271,7 +1283,8 @@ def __call__(self, *args, **kwargs): for mlp in self.moe_layers: mlp.token_dispatcher.invalidate_ep_bootstrap() nccl_ep_release_context() - self._set_moe_paged_stash_all(saved_moe_paged_stash) + for config, value in saved_moe_paged_stash_values: + config.moe_paged_stash = value break # Overflow or over-budget: prepare_for_rerun clears capacity factor and paged stash. diff --git a/tests/unit_tests/transformer/moe/test_paged_stash_runner.py b/tests/unit_tests/transformer/moe/test_paged_stash_runner.py new file mode 100644 index 00000000000..aee4203cb1b --- /dev/null +++ b/tests/unit_tests/transformer/moe/test_paged_stash_runner.py @@ -0,0 +1,206 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +from types import SimpleNamespace + +import torch + +from megatron.core.transformer.moe.paged_stash import PagedStashManager, PagedStashRunner + + +def _config(moe_paged_stash): + return SimpleNamespace(moe_paged_stash=moe_paged_stash, moe_expert_rank_capacity_factor=1.5) + + +class _FakeTokenDispatcher: + + def __init__(self, config): + self.config = config + self._comm_manager = SimpleNamespace(moe_expert_rank_capacity_factor=1.5) + self.invalidate_count = 0 + self.reset_count = 0 + + def check_over_budget(self): + return None + + def reset_over_budget(self): + self.reset_count += 1 + + def invalidate_ep_bootstrap(self): + self.invalidate_count += 1 + + +class _FakeMoELayer(torch.nn.Module): + + def __init__(self, config): + super().__init__() + self.config = config + self.token_dispatcher = _FakeTokenDispatcher(config) + + +class _FakeTransformerLayer(torch.nn.Module): + + def __init__(self, mlp): + super().__init__() + self.mlp = mlp + + +class _FakeStack(torch.nn.Module): + + def __init__(self, layer): + super().__init__() + self.layers = torch.nn.ModuleList([layer]) + + +class _FakeMTPPredictionLayer(torch.nn.Module): + + def __init__(self, mtp_model_layer): + super().__init__() + self.mtp_model_layer = mtp_model_layer + + +class _FakeModelChunk(torch.nn.Module): + + def __init__(self, config, decoder_moe, mtp_moe, nested_mtp): + super().__init__() + self.config = config + self.decoder = _FakeStack(_FakeTransformerLayer(decoder_moe)) + mtp_model_layer = _FakeTransformerLayer(mtp_moe) + if nested_mtp: + mtp_model_layer = _FakeStack(mtp_model_layer) + self.mtp = _FakeStack(_FakeMTPPredictionLayer(mtp_model_layer)) + self.mtp_process = True + # Register the decoder MoE through a second path to verify identity deduplication. + self.duplicate_decoder_moe = decoder_moe + self.zero_grad_count = 0 + + def zero_grad_buffer(self): + self.zero_grad_count += 1 + + +def _run_retry( + monkeypatch, training_config, model_config, decoder_config, mtp_config, nested_mtp=False +): + monkeypatch.setattr( + "megatron.core.transformer.multi_token_prediction.MultiTokenPredictionLayer", + _FakeMTPPredictionLayer, + ) + decoder_moe = _FakeMoELayer(decoder_config) + mtp_moe = _FakeMoELayer(mtp_config) + model = _FakeModelChunk(model_config, decoder_moe, mtp_moe, nested_mtp=nested_mtp) + + values_seen_by_forward = [] + + def forward_backward_func(**_): + values_seen_by_forward.append( + ( + training_config.moe_paged_stash, + model_config.moe_paged_stash, + decoder_config.moe_paged_stash, + mtp_config.moe_paged_stash, + ) + ) + return len(values_seen_by_forward) + + release_stash_buffer_calls = [] + fake_stash_manager = SimpleNamespace( + overflow=None, + host_spill=None, + release_stash_buffers=lambda: release_stash_buffer_calls.append(None), + ) + monkeypatch.setattr(PagedStashManager, 'STASH_MGR', fake_stash_manager) + runner = PagedStashRunner( + config=training_config, + copy_main_params=False, + model=[model], + optimizer=None, + forward_backward_func=forward_backward_func, + ) + overflow_results = iter([(1, 0, 0), (0, 0, 0)]) + runner.check_moe_overflow = lambda: next(overflow_results) + + result = runner( + model=[model], data_iterator=None, num_microbatches=1, seq_length=1, forward_only=False + ) + + return SimpleNamespace( + runner=runner, + result=result, + model=model, + decoder_moe=decoder_moe, + mtp_moe=mtp_moe, + values_seen_by_forward=values_seen_by_forward, + release_stash_buffer_calls=release_stash_buffer_calls, + ) + + +def test_retry_preserves_shared_root_config_behavior(monkeypatch): + """Models whose MoE modules share the root config retain their existing behavior.""" + training_config = _config(True) + model_config = _config(True) + + run = _run_retry( + monkeypatch, + training_config=training_config, + model_config=model_config, + decoder_config=model_config, + mtp_config=model_config, + ) + + assert run.runner.moe_layers == [run.decoder_moe, run.mtp_moe] + assert [id(config) for config in run.runner._configs_to_sync_moe_paged_stash] == [ + id(training_config), + id(model_config), + ] + assert run.values_seen_by_forward == [(True, True, True, True), (False, False, False, False)] + assert run.result == 2 + assert run.model.zero_grad_count == 1 + assert len(run.release_stash_buffer_calls) == 1 + assert run.decoder_moe.token_dispatcher.reset_count == 1 + assert run.mtp_moe.token_dispatcher.reset_count == 1 + assert run.decoder_moe.token_dispatcher.invalidate_count == 2 + assert run.mtp_moe.token_dispatcher.invalidate_count == 2 + assert run.decoder_moe.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor == 1.5 + assert run.mtp_moe.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor == 1.5 + assert training_config.moe_paged_stash is True + assert model_config.moe_paged_stash is True + + +def test_retry_disables_and_restores_per_module_configs(monkeypatch): + """Retry must disable direct and nested-MTP MoE configs, then restore each value.""" + training_config = _config(True) + model_config = _config(True) + decoder_moe_config = _config(True) + mtp_moe_config = _config(False) + + run = _run_retry( + monkeypatch, + training_config=training_config, + model_config=model_config, + decoder_config=decoder_moe_config, + mtp_config=mtp_moe_config, + nested_mtp=True, + ) + + assert run.runner.moe_layers == [run.decoder_moe] + assert [id(config) for config in run.runner._configs_to_sync_moe_paged_stash] == [ + id(training_config), + id(model_config), + id(decoder_moe_config), + id(mtp_moe_config), + ] + assert run.values_seen_by_forward == [(True, True, True, False), (False, False, False, False)] + assert run.result == 2 + assert run.model.zero_grad_count == 1 + assert len(run.release_stash_buffer_calls) == 1 + assert run.decoder_moe.token_dispatcher.reset_count == 1 + assert run.mtp_moe.token_dispatcher.reset_count == 0 + assert run.decoder_moe.token_dispatcher.invalidate_count == 2 + assert run.mtp_moe.token_dispatcher.invalidate_count == 0 + assert run.decoder_moe.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor == 1.5 + assert run.mtp_moe.token_dispatcher._comm_manager.moe_expert_rank_capacity_factor == 1.5 + assert ( + training_config.moe_paged_stash, + model_config.moe_paged_stash, + decoder_moe_config.moe_paged_stash, + mtp_moe_config.moe_paged_stash, + ) == (True, True, True, False) From 5714a206e89dcc08f4ac6ed3d8057c6c1d8dcfa2 Mon Sep 17 00:00:00 2001 From: Maanu Grover Date: Thu, 13 Aug 2026 07:04:11 +0000 Subject: [PATCH 276/290] Respect `profile_ranks` on log interval memory snapshots (#6354) Signed-off-by: Maanu Grover --- megatron/training/training.py | 13 +++++++------ megatron/training/utils/utils.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/megatron/training/training.py b/megatron/training/training.py index ebfa93f6b87..0e9417b8fbe 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -41,6 +41,7 @@ _LEGACY_TRAIN_START_TIME = time.time() # NOTE(asolergi-nv): Legacy timestamp # First-party. +from megatron.core._rank_utils import safe_get_rank from megatron.core import mpu, nccl_allocator, tensor_parallel from megatron.core.datasets.data_schedule import HybridCPDataLoaderWrapper from megatron.core.distributed import DistributedDataParallel as DDP @@ -2928,12 +2929,12 @@ def training_log( # Dump memory snapshot and print metrics to stdout. if iteration % args.log_interval == 0 or is_first_iteration: - if args.record_memory_history and (is_last_rank() or torch.distributed.get_backend() == 'fake'): - snapshot = torch.cuda.memory._snapshot() - from pickle import dump - - with open(args.memory_snapshot_path, 'wb') as f: - dump(snapshot, f) + should_prof_rank = (args.profile_ranks == [] or safe_get_rank() in args.profile_ranks) # [] is all ranks + if args.record_memory_history and (should_prof_rank or torch.distributed.get_backend() == 'fake'): + rank = safe_get_rank() + base, ext = os.path.splitext(args.memory_snapshot_path) + snapshot_filename = f"{base}_{rank}{ext}" + torch.cuda.memory._dump_snapshot(snapshot_filename) elapsed_time = timers('interval-time').elapsed(barrier=True, reset=should_reset) elapsed_time_per_iteration = elapsed_time / total_iterations diff --git a/megatron/training/utils/utils.py b/megatron/training/utils/utils.py index 6c5b3454798..108a92d585c 100644 --- a/megatron/training/utils/utils.py +++ b/megatron/training/utils/utils.py @@ -44,7 +44,7 @@ def _oom_observer( """Dump a snapshot on OOM so we can inspect what was live at the failure.""" rank = safe_get_rank() base, ext = os.path.splitext(profiling.memory_snapshot_path) - filename = f"{base}_oom_rank-{rank}{ext}" + filename = f"{base}_oom_rank_{rank}{ext}" torch.cuda.memory._dump_snapshot(filename) # logger.info so the message reaches stderr on any profiled rank, not just rank 0. logger.info(f"[OOM] rank {rank} saved memory snapshot to {filename}") From fecb29f99265d5d26907988e1d87f5335df761bc Mon Sep 17 00:00:00 2001 From: Keshav Santhanam Date: Thu, 13 Aug 2026 08:43:19 +0000 Subject: [PATCH 277/290] Add unit tests for GDP inference (#6505) Signed-off-by: Deepak Narayanan Signed-off-by: Keshav Santhanam Signed-off-by: Mikail Khona Signed-off-by: Mikail Khona (NVIDIA) Signed-off-by: Kezhi Kong Co-authored-by: Kezhi Kong Co-authored-by: Deepak Narayanan Co-authored-by: Claude Opus 4.6 Co-authored-by: Mikail Khona (NVIDIA) Co-authored-by: Mikail Khona Co-authored-by: root Co-authored-by: Roger Waleffe Co-authored-by: Kezhi Kong --- megatron/core/ssm/gated_delta_product.py | 11 +- .../ssm/test_gdp_dynamic_inference.py | 629 ++++++++++++++++++ 2 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/ssm/test_gdp_dynamic_inference.py diff --git a/megatron/core/ssm/gated_delta_product.py b/megatron/core/ssm/gated_delta_product.py index 1baaea0ea3a..3fa69813680 100644 --- a/megatron/core/ssm/gated_delta_product.py +++ b/megatron/core/ssm/gated_delta_product.py @@ -21,6 +21,9 @@ ) from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.ssm.gdp_context_parallel import GDPContextParallel + +# Decode uses the in-repo Triton conv update, which accepts int64 slot indices. +from megatron.core.ssm.ops.causal_conv1d_triton import causal_conv1d_update from megatron.core.ssm.packed_seq_helpers import ( build_packed_seq_idx, check_fla_sequence_packing_support, @@ -38,11 +41,10 @@ from megatron.core.utils import deprecate_inference_params try: - from causal_conv1d import causal_conv1d_fn, causal_conv1d_update + from causal_conv1d import causal_conv1d_fn from causal_conv1d.causal_conv1d_varlen import causal_conv1d_varlen_states except ImportError: causal_conv1d_fn = None - causal_conv1d_update = None causal_conv1d_varlen_states = None try: @@ -604,9 +606,8 @@ def ssm_decode( dim=-1, ) - # Indexed conv update: reads/writes the per-request conv state rows - # selected by ``batch_indices``, in place. ``self.activation`` must be the - # activation *string* so the kernel enables SiLU (a bool would disable it). + # Indexed conv update into the per-request state rows (``batch_indices`` + # is None for static batching, where the cache is already in order). VKQ = causal_conv1d_update( VKQ, conv_state, diff --git a/tests/unit_tests/ssm/test_gdp_dynamic_inference.py b/tests/unit_tests/ssm/test_gdp_dynamic_inference.py new file mode 100644 index 00000000000..cbb4fbc3821 --- /dev/null +++ b/tests/unit_tests/ssm/test_gdp_dynamic_inference.py @@ -0,0 +1,629 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""GDP (Gated Delta Product) inference equivalence tests. + +`GatedDeltaProductMixer` supports two inference paths: static batching +(`StaticInferenceContext`) and dynamic batching (`DynamicInferenceContext`). +These tests assert that both are numerically equivalent to a plain +full-sequence forward, and therefore to each other. + +The reference is a single full-sequence `model.forward` (the +`chunk_gated_delta_product` path). Dynamic prefill runs the same chunk kernel +over a packed var-len layout, and static-batching prefill runs the same chunk +kernel with the recurrent cache seeded; both must reproduce the reference's +last-token logits. + +The single-forward equivalence tests run at TP=1 (they compare raw logits that +are sequence-sharded under sequence-parallel, and the static path does not +support SP). The end-to-end engine tests sweep TP (`_TP_SIZES`) with SP enabled +at TP>1, covering dynamic inference under tensor + sequence parallelism; TP>1 +variants skip when the world has too few GPUs. +""" + +from __future__ import annotations + +import random +import types +from typing import Dict, List, Optional, Sequence, Tuple + +import pytest +import torch + +from megatron.core import parallel_state +from megatron.core.inference.config import InferenceConfig, MambaInferenceStateConfig +from megatron.core.inference.contexts import StaticInferenceContext +from megatron.core.inference.contexts.dynamic_context import DynamicInferenceContext +from megatron.core.inference.engines import DynamicInferenceEngine +from megatron.core.inference.inference_request import DynamicInferenceRequest, Status +from megatron.core.inference.model_inference_wrappers.gpt.gpt_inference_wrapper import ( + GPTInferenceWrapper, +) +from megatron.core.inference.sampling_params import SamplingParams +from megatron.core.inference.text_generation_controllers.text_generation_controller import ( + TextGenerationController, +) +from megatron.core.inference.utils import InferenceMode +from megatron.core.models.hybrid.hybrid_layer_specs import gated_delta_product_inference_stack_spec +from megatron.core.models.hybrid.hybrid_model import HybridModel +from megatron.core.ssm.gated_delta_product import GatedDeltaProductMixer +from megatron.core.ssm.packed_seq_helpers import check_fla_sequence_packing_support +from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer import TransformerConfig +from megatron.core.transformer.cuda_graphs import delete_cuda_graphs +from megatron.core.utils import is_fa_min_version +from tests.unit_tests.test_utilities import Utils, clear_nvte_env_vars + +try: + import einops # noqa: F401 + import fla # noqa: F401 + import mamba_ssm # noqa: F401 + + HAVE_GDP_DEPS = True +except ImportError: + HAVE_GDP_DEPS = False + +# GDP dynamic inference relies on the same packed-sequence conv1d kernel as the +# training/prefill path (`causal_conv1d_fn(seq_idx=...)`, added in 1.4.0). +_PACKING_OK, _PACKING_REASON = check_fla_sequence_packing_support() + +pytestmark = [ + pytest.mark.internal, + pytest.mark.skipif(not HAVE_GDP_DEPS, reason="GDP requires fla, mamba_ssm, and einops"), + pytest.mark.skipif(not _PACKING_OK, reason=_PACKING_REASON or "packed-seq support missing"), +] + + +# A short single-chunk prompt is enough to exercise the packed-varlen dynamic +# path; the sizes are kept small to keep the test fast. +_VOCAB_SIZE = 128 +_MAX_SEQ_LEN = 512 +_PROMPT_LEN = 64 + +# bf16 chunk-vs-chunk tolerance. Full-forward and prefill run the same +# `chunk_gated_delta_product` kernel, so they differ only by the packed var-len +# layout and floating-point accumulation order. +_ATOL = 5e-2 +_RTOL = 5e-2 + +# Looser tolerance for the decode-step check: it compares the recurrent decode +# kernel against a full-sequence chunk-kernel recompute (different kernels), so +# it drifts more than the chunk-vs-chunk prefill comparison. Still far tighter +# than the O(1)+ deviations a genuinely broken decode/state-handoff would show. +_DECODE_ATOL = 1e-1 +_DECODE_RTOL = 1e-1 + +# `DynamicInferenceContext` requires at least one attention layer, so the model +# pattern is GDP mixer + attention + MLP. Dynamic batching needs a recent +# flash-attention. +_LAYER_PATTERN = "M*-" +_NUM_LAYERS = len(_LAYER_PATTERN) +requires_dynamic_batching = pytest.mark.skipif( + not is_fa_min_version("2.7.3"), reason="need flash-attn >= 2.7.3 for dynamic batching" +) + +# Tensor-parallel sizes swept by the end-to-end engine tests. TP>1 requires +# sequence-parallel (the inference-optimized linears assert it), which GDP's +# dynamic path supports; static inference does not, so the single-forward +# equivalence tests above stay TP=1. +_TP_SIZES = [1, 2] + + +def _make_config(tp: int = 1) -> TransformerConfig: + """A small but shape-valid GDP config, sharded across `tp` tensor-parallel ranks. + + The in_proj output width (`zVKQba`) is column-parallel, so each rank sees + `proj_dim / tp` channels. The packed-prefill conv slices a channels-last view + out of that and `causal_conv1d_fn` requires its stride (the per-rank width) to + be a multiple of 8. With mamba_num_heads=16 the full width is + `(1+M)*d_inner + (M+1)*ngroups*d_state + (M+1)*nheads = 3*256 + 3*64 + 3*16 + = 1008`, so per-rank widths are 1008 (tp=1) and 504 (tp=2), both aligned. + Production configs satisfy this by having much larger, aligned dimensions. + """ + return TransformerConfig( + num_layers=_NUM_LAYERS, + hidden_size=64, + num_attention_heads=4, + num_query_groups=4, + ffn_hidden_size=128, + normalization="RMSNorm", + bf16=True, + params_dtype=torch.bfloat16, + mamba_num_heads=16, + mamba_head_dim=16, + mamba_num_groups=4, + mamba_state_dim=16, + gdp_num_householder=2, + is_hybrid_model=True, # needed for correct out_proj init + tensor_model_parallel_size=tp, + sequence_parallel=False, + context_parallel_size=1, + ) + + +def _build_model(tp: int = 1) -> HybridModel: + """Build a small GDP hybrid model (mixer + attention + MLP), eval on CUDA.""" + model_parallel_cuda_manual_seed(123) + model = HybridModel( + config=_make_config(tp), + hybrid_stack_spec=gated_delta_product_inference_stack_spec, + vocab_size=_VOCAB_SIZE, + max_sequence_length=_MAX_SEQ_LEN, + hybrid_layer_pattern=_LAYER_PATTERN, + ) + return model.cuda().eval() + + +@requires_dynamic_batching +class TestGDPDynamicInference: + """Static/dynamic GDP inference equivalence against a full-sequence forward. + + These compare raw `model.forward` logits, which are sequence-sharded under + sequence-parallel; combined with the static path not supporting SP, they run + at TP=1 only. TP>1 dynamic inference is covered end-to-end by the engine + tests below, which handle SP through the inference wrapper. + """ + + def setup_method(self, method): + Utils.initialize_model_parallel(1, 1) + self.model = _build_model(tp=1) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _input_ids(self) -> torch.Tensor: + """A deterministic single-request prompt: shape [1, _PROMPT_LEN].""" + return torch.arange(_PROMPT_LEN, device="cuda", dtype=torch.long).unsqueeze(0) + + @torch.inference_mode() + def _full_forward_last_logits(self, input_ids: torch.Tensor) -> torch.Tensor: + """Reference: plain full-sequence forward -> last-token logits [1, V].""" + # No inference_context: GDP runs the training / chunk_gated_delta_product + # path. This is the ground truth both inference modes must reproduce. + InferenceMode.unset_active() + position_ids = torch.arange(input_ids.shape[1], device="cuda").unsqueeze(0) + logits = self.model( + input_ids=input_ids, + position_ids=position_ids, + attention_mask=None, + runtime_gather_output=True, + ) + return logits[:, -1, :].float() + + def _build_dynamic_context(self) -> DynamicInferenceContext: + mamba_config = MambaInferenceStateConfig.from_model(self.model) + assert mamba_config is not None, "GDP hybrid model should expose Mamba inference state" + return DynamicInferenceContext( + model_config=self.model.config, + inference_config=InferenceConfig( + max_sequence_length=_MAX_SEQ_LEN, + buffer_size_gb=1.0, + block_size_tokens=256, + # Materialize all tokens so we can read the prompt's last-token + # logits directly (static batching always uses last-token only). + materialize_only_last_token_logits=False, + mamba_inference_state_config=mamba_config, + num_cuda_graphs=0, + use_cuda_graphs_for_non_decode_steps=False, + max_requests=4, + max_tokens=128, + ), + ) + + @torch.inference_mode() + def _dynamic_prefill_last_logits(self, input_ids: torch.Tensor) -> torch.Tensor: + """Dynamic-batching prefill -> last-token logits [1, V].""" + ctx = self._build_dynamic_context() + request = DynamicInferenceRequest( + request_id=0, + prompt_tokens=input_ids.cpu().squeeze(0), + sampling_params=SamplingParams(num_tokens_to_generate=1, termination_id=-1), + ) + ctx.add_request(request) + ctx.initialize_attention_state() + with InferenceMode.active(): + logits = self.model( + input_ids=input_ids, + position_ids=None, + attention_mask=None, + inference_context=ctx, + runtime_gather_output=True, + ) + # materialize_only_last_token_logits=False -> [1, prompt_len, V]. + return logits[:, -1, :].float() + + @torch.inference_mode() + def _static_prefill_last_logits(self, input_ids: torch.Tensor) -> torch.Tensor: + """Static-batching prefill -> last-token logits [1, V].""" + ctx = StaticInferenceContext(max_batch_size=1, max_sequence_length=_MAX_SEQ_LEN) + ctx.sequence_len_offset = 0 + position_ids = torch.arange(input_ids.shape[1], device="cuda").unsqueeze(0) + with InferenceMode.active(): + logits = self.model( + input_ids=input_ids, + position_ids=position_ids, + attention_mask=None, + inference_context=ctx, + runtime_gather_output=True, + ) + # StaticInferenceContext forces materialize_only_last_token_logits=True, + # so the sequence dimension is already collapsed to the last token. + assert logits.shape[1] == 1 + return logits[:, 0, :].float() + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + def test_constructor(self): + """The GDP stack spec wires a GatedDeltaProductMixer into the mamba layer.""" + assert isinstance(self.model, HybridModel) + mixers = [ + layer.mixer + for layer in self.model.decoder.layers + if hasattr(layer, "mixer") and layer.mixer is not None + ] + assert len(mixers) == 1, f"pattern {_LAYER_PATTERN!r} should yield exactly one mixer layer" + assert isinstance(mixers[0], GatedDeltaProductMixer) + + def test_full_forward_shape(self): + """Sanity check: plain forward returns [batch, seq, vocab].""" + input_ids = self._input_ids() + InferenceMode.unset_active() + position_ids = torch.arange(_PROMPT_LEN, device="cuda").unsqueeze(0) + with torch.inference_mode(): + logits = self.model( + input_ids=input_ids, + position_ids=position_ids, + attention_mask=None, + runtime_gather_output=True, + ) + assert logits.shape == (1, _PROMPT_LEN, _VOCAB_SIZE) + + def test_dynamic_prefill_matches_full_forward(self): + """Dynamic-batching prefill reproduces the full-sequence forward.""" + input_ids = self._input_ids() + reference = self._full_forward_last_logits(input_ids) + dynamic = self._dynamic_prefill_last_logits(input_ids) + torch.testing.assert_close(dynamic, reference, atol=_ATOL, rtol=_RTOL) + + def test_static_prefill_matches_full_forward(self): + """Static-batching prefill reproduces the full-sequence forward.""" + input_ids = self._input_ids() + reference = self._full_forward_last_logits(input_ids) + static = self._static_prefill_last_logits(input_ids) + torch.testing.assert_close(static, reference, atol=_ATOL, rtol=_RTOL) + + def test_static_and_dynamic_prefill_agree(self): + """Static and dynamic inference produce equivalent logits. + + This is the central invariant: the two batching strategies must agree. + Anchoring each to the full-sequence forward (above) guarantees this + transitively, but assert it directly as well so a regression in either + path that happens to drift in the same direction is still caught. + """ + input_ids = self._input_ids() + static = self._static_prefill_last_logits(input_ids) + dynamic = self._dynamic_prefill_last_logits(input_ids) + torch.testing.assert_close(static, dynamic, atol=_ATOL, rtol=_RTOL) + + @torch.inference_mode() + def test_decode_step_matches_recompute(self): + """One decode step matches a full-sequence recompute (decode-path check). + + This validates the recurrent decode kernel and the prefill->decode + conv/SSM state handoff independently of any golden snapshot: after + prefilling the prompt, decoding one more token must produce the same + next-token logits as a plain forward over prompt+token. Compared at the + logit level with tolerance, so it is robust to the bf16 numerics that + make exact greedy token equality across the recurrent/chunk kernels + fragile. Uses `StaticInferenceContext`, whose decode calls the same + `ssm_decode` recurrent kernel as the dynamic engine. + """ + prompt = self._input_ids() # [1, P] + + # Ground truth: the next token from the prompt, and the full-recompute + # distribution for the token after it (chunk kernel over prompt+token). + next_token = int(self._full_forward_last_logits(prompt).argmax(dim=-1).item()) + extended = torch.cat( + [prompt, torch.tensor([[next_token]], dtype=torch.int64, device="cuda")], dim=1 + ) + recompute = self._full_forward_last_logits(extended) # [1, V] + + # Incremental path: prefill the prompt, then a single decode step. + ctx = StaticInferenceContext(max_batch_size=1, max_sequence_length=_MAX_SEQ_LEN) + prompt_length = prompt.shape[1] + with InferenceMode.active(): + ctx.sequence_len_offset = 0 + self.model( + input_ids=prompt, + position_ids=torch.arange(prompt_length, device="cuda").unsqueeze(0), + attention_mask=None, + inference_context=ctx, + runtime_gather_output=True, + ) + ctx.sequence_len_offset = prompt_length + decode_logits = self.model( + input_ids=torch.tensor([[next_token]], dtype=torch.int64, device="cuda"), + position_ids=torch.tensor([[prompt_length]], dtype=torch.int64, device="cuda"), + attention_mask=None, + inference_context=ctx, + runtime_gather_output=True, + ) + decode_last = decode_logits[:, -1, :].float() + + torch.testing.assert_close(decode_last, recompute, atol=_DECODE_ATOL, rtol=_DECODE_RTOL) + + +# ====================================================================== +# End-to-end engine tests. +# +# The tests above exercise a single forward pass. These drive the full +# `DynamicInferenceEngine` (add requests -> schedule -> prefill -> decode -> +# finish) so that GDP is validated through the same runtime path production +# inference uses: the text-generation controller, the inference-wrapped model, +# the KV/Mamba-state cache, and the request scheduler. +# +# Decoding is greedy (`top_k=1`) so outputs are deterministic. Decode +# correctness (the recurrent kernel `fused_recurrent_gated_delta_rule` plus the +# slot-indexed conv/SSM cache, distinct from the prefill chunk kernel) is +# validated by a byte-for-byte match against committed golden token ids, +# mirroring the Mamba2 `test_dynamic_engine.py::test_simple` style. The golden +# constant is captured from a reference GPU run (see `_GOLDEN_*` below); until it +# is populated the test self-captures and skips with the observed ids. +# ====================================================================== + +# Fixed prompts for the golden-token test. Deterministic (not random) so the +# committed golden ids below are reproducible across machines. Varying lengths +# exercise the scheduler's mixed-length prefill batching. +_GOLDEN_PROMPTS: List[List[int]] = [ + [3, 14, 15, 92, 65, 35, 89, 79], + [2, 71, 82, 81, 8], + [11, 22, 33, 44, 55, 66], + [7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7], +] +_GOLDEN_NUM_TOKENS_TO_GENERATE = 12 + +# Golden generated-token ids per TP size, one list per prompt in `_GOLDEN_PROMPTS`, +# captured from a reference GPU run. TP shards the weights differently, so each TP +# size has its own goldens. Environment-sensitive (FLA / causal_conv1d kernel +# build, GPU arch); re-capture if the kernels or config change. While an entry is +# None, the test self-captures: it prints the observed ids and skips instead of +# failing. Paste them in (see the skip message) to turn it into a hard assertion. +_GOLDEN_GENERATED_TOKENS: Dict[int, Optional[List[List[int]]]] = { + 1: [ + [32, 126, 35, 125, 52, 116, 55, 38, 39, 4, 53, 100], + [88, 10, 105, 95, 105, 44, 2, 100, 127, 59, 23, 18], + [29, 9, 61, 2, 100, 2, 69, 75, 36, 80, 103, 26], + [2, 55, 4, 108, 116, 120, 24, 113, 100, 48, 111, 22], + ], + 2: [ + [12, 51, 63, 22, 2, 40, 45, 30, 55, 10, 31, 29], + [38, 54, 29, 17, 33, 13, 10, 45, 1, 22, 21, 37], + [24, 55, 38, 55, 35, 61, 26, 25, 31, 20, 62, 56], + [53, 26, 31, 61, 16, 19, 42, 41, 49, 18, 53, 26], + ], +} + + +def _make_engine_config(tp: int = 1) -> TransformerConfig: + """GDP config for the engine tests: same shape as `_make_config`, plus the + deterministic inference sampling knobs greedy decoding needs. TP>1 turns on + sequence-parallel, which the inference-optimized linears require.""" + config = _make_config(tp) + config.sequence_parallel = tp > 1 + config.inference_rng_tracker = True + config.inference_sampling_seed = 123 + return config + + +@pytest.mark.internal +@requires_dynamic_batching +@pytest.mark.skipif(not HAVE_GDP_DEPS, reason="GDP requires fla, mamba_ssm, and einops") +@pytest.mark.skipif(not _PACKING_OK, reason=_PACKING_REASON or "packed-seq support missing") +class TestGDPDynamicInferenceEngine: + """End-to-end GDP decoding through `DynamicInferenceEngine`.""" + + SEED = 123 + VOCAB_SIZE = _VOCAB_SIZE + + def teardown_method(self, method): + delete_cuda_graphs() + Utils.destroy_model_parallel() + + # ------------------------------------------------------------------ + # Harness + # ------------------------------------------------------------------ + + def _build_engine( + self, + *, + num_tokens_to_generate: int, + tp: int = 1, + num_requests: Optional[int] = None, + prompt_length: Optional[int] = None, + prompts: Optional[Sequence[Sequence[int]]] = None, + ) -> Tuple[DynamicInferenceEngine, List[DynamicInferenceRequest]]: + """Build a greedy GDP engine plus its requests at TP=`tp`. + + Either pass explicit `prompts` (deterministic token lists) or + `num_requests` + `prompt_length` (random prompts of a fixed length). + Skips if the world is too small for the requested TP size. + """ + if Utils.world_size < tp: + pytest.skip(f"TP={tp} requires at least {tp} GPUs") + Utils.initialize_model_parallel( + tensor_model_parallel_size=tp, pipeline_model_parallel_size=1 + ) + clear_nvte_env_vars() + random.seed(self.SEED) + torch.manual_seed(self.SEED) + model_parallel_cuda_manual_seed( + seed=self.SEED, inference_rng_tracker=True, force_reset_rng=True + ) + + if prompts is not None: + prompt_tensors = [torch.tensor(p, dtype=torch.int64, device="cuda") for p in prompts] + else: + assert num_requests is not None and prompt_length is not None + prompt_tensors = [ + torch.randint( + 0, self.VOCAB_SIZE - 1, (prompt_length,), dtype=torch.int64, device="cuda" + ) + for _ in range(num_requests) + ] + + max_prompt_length = max(int(p.numel()) for p in prompt_tensors) + max_sequence_length = max_prompt_length + num_tokens_to_generate + config = _make_engine_config(tp) + model = HybridModel( + config=config, + hybrid_stack_spec=gated_delta_product_inference_stack_spec, + vocab_size=self.VOCAB_SIZE, + max_sequence_length=max_sequence_length, + parallel_output=True, + hybrid_layer_pattern=_LAYER_PATTERN, + pre_process=parallel_state.is_pipeline_first_stage(), + post_process=parallel_state.is_pipeline_last_stage(), + ).cuda() + for param in model.parameters(): + param.data = param.data.to(config.params_dtype) + model.eval() + + context = DynamicInferenceContext( + model_config=config, + inference_config=InferenceConfig( + max_sequence_length=max_sequence_length, + buffer_size_gb=0.1, + block_size_tokens=256, + materialize_only_last_token_logits=True, + mamba_inference_state_config=MambaInferenceStateConfig.from_model(model), + num_cuda_graphs=None, + use_cuda_graphs_for_non_decode_steps=False, + max_requests=32, + max_tokens=1024, + ), + ) + + wrapped_model = GPTInferenceWrapper(model, context) + wrapped_model.model_is_pipeline_parallel = not ( + parallel_state.is_pipeline_first_stage() and parallel_state.is_pipeline_last_stage() + ) + controller = TextGenerationController( + inference_wrapped_model=wrapped_model, + tokenizer=types.SimpleNamespace( + vocab_size=self.VOCAB_SIZE, detokenize=lambda tokens: "tokenized_prompt" + ), + ) + delete_cuda_graphs() + engine = DynamicInferenceEngine(controller, context) + + requests = [ + DynamicInferenceRequest( + request_id=request_id, + prompt_tokens=prompt_tokens, + sampling_params=SamplingParams( + num_tokens_to_generate=num_tokens_to_generate, + termination_id=-1, # never terminate early -> fixed output length + top_k=1, # greedy -> deterministic + ), + ) + for request_id, prompt_tokens in enumerate(prompt_tensors) + ] + return engine, requests + + @staticmethod + @torch.inference_mode() + def _run_to_completion( + engine: DynamicInferenceEngine, requests: List[DynamicInferenceRequest] + ) -> Dict[int, DynamicInferenceRequest]: + """Add every request, step until the engine drains, return finished requests by id.""" + for request in requests: + engine._add_request(request) + + finished: Dict[int, DynamicInferenceRequest] = {} + # Bound the loop so a scheduling regression fails loudly instead of hanging. + for _ in range(1000): + result = engine.step_modern() + for record in result["finished_request_records"]: + merged = record.merge() + finished[merged.request_id] = merged + if not engine.has_unfinished_requests(): + break + assert not engine.has_unfinished_requests(), "engine did not drain within step budget" + return finished + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("tp", _TP_SIZES) + def test_engine_runs_to_completion(self, tp): + """Every request completes and yields exactly the requested token count.""" + num_tokens_to_generate = 8 + engine, requests = self._build_engine( + tp=tp, num_requests=4, prompt_length=8, num_tokens_to_generate=num_tokens_to_generate + ) + finished = self._run_to_completion(engine, requests) + + assert len(finished) == len(requests) + for request in requests: + merged = finished[request.request_id] + assert merged.status == Status.COMPLETED + # termination_id=-1 disables early stop, so the length is exact. + assert len(merged.generated_tokens) == num_tokens_to_generate + + @pytest.mark.parametrize("tp", _TP_SIZES) + def test_engine_greedy_matches_golden(self, tp): + """Greedy decode reproduces committed golden token ids (Mamba2-style). + + Deterministic fixed prompts + greedy sampling make the output a stable + fingerprint of the GDP prefill+decode path. Until the TP entry in + `_GOLDEN_GENERATED_TOKENS` is captured from a reference GPU run, the test + prints the observed ids and skips instead of failing. + """ + engine, requests = self._build_engine( + tp=tp, prompts=_GOLDEN_PROMPTS, num_tokens_to_generate=_GOLDEN_NUM_TOKENS_TO_GENERATE + ) + finished = self._run_to_completion(engine, requests) + observed = [finished[r.request_id].generated_tokens for r in requests] + + golden = _GOLDEN_GENERATED_TOKENS.get(tp) + if golden is None: + pytest.skip( + f"golden tokens for TP={tp} not captured yet; paste the following into " + f"_GOLDEN_GENERATED_TOKENS[{tp}]:\n{observed!r}" + ) + + assert observed == golden, ( + f"generated tokens != golden (TP={tp}):\n golden = {golden}\n" + f" observed = {observed}" + ) + + @pytest.mark.parametrize("tp", _TP_SIZES) + def test_generate_over_multiple_prompts(self, tp): + """`engine.generate` drives several prompts through to completion at once.""" + engine, requests = self._build_engine( + tp=tp, num_requests=4, prompt_length=8, num_tokens_to_generate=4 + ) + + prompts = [f"prompt{i}" for i in range(len(requests))] + + def mock_tokenize_prompt(tokenizer, prompt, add_BOS=False): + prompt_num = int(prompt[-1]) + return [10 + i for i in range(prompt_num + 2)] + + engine.controller.tokenize_prompt = mock_tokenize_prompt + + finished_records = engine.generate(prompts, requests[0].sampling_params) + finished = [record.merge() for record in finished_records] + + assert len(finished) == len(prompts) + # generate() returns finished requests in request-id order. + assert [r.request_id for r in finished] == sorted(r.request_id for r in finished) + for request in finished: + assert request.status == Status.COMPLETED + assert len(request.generated_tokens) > 0 From 8ba5c60d2aba1c958bdd5c000ff2b241908ad2b2 Mon Sep 17 00:00:00 2001 From: Shiqing Fan Date: Thu, 13 Aug 2026 08:50:41 +0000 Subject: [PATCH 278/290] [fix] fix GTP+DCP ckpt saving/loading for GDP module (#6503) Signed-off-by: Shiqing Fan --- .../core/generalized_tensor_parallel.md | 8 +- megatron/core/ssm/gated_delta_product.py | 81 ++++- .../test_gtp_dcp.py | 278 ++++++++++++++++++ 3 files changed, 363 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/core/generalized_tensor_parallel.md b/docs/api-guide/core/generalized_tensor_parallel.md index 11c01b8a504..eca9e2b7eb8 100644 --- a/docs/api-guide/core/generalized_tensor_parallel.md +++ b/docs/api-guide/core/generalized_tensor_parallel.md @@ -182,7 +182,7 @@ GTP_remat runs under both the standard **Adam** `DistributedOptimizer` and **Muo - **Adam** shards optimizer state over the gtp_remat/egtp_remat-excluded replicate group, like any GTP_remat run (§3.2). - **Muon** keeps matrix params *whole* (Newton–Schulz needs the full 2D weight). A GTP_remat-replicated whole param (e.g. MoE router, latent-proj MLPs by default) then lands on one checkpoint key shared by all GTP_remat peers, so the LayerWise optimizer folds `gtp_rank` into its `replica_id` — exactly one peer writes (the optimizer-state analog of the model-side fold in §3.3). -- **Native-FP8 optimizer-state matching (Muon path).** The save-side dequantize (§3.3) hands DCP a *fresh* BF16 tensor, which breaks the id-based optimizer-param → model-`ShardedTensor` match for every native-FP8 GTP_remat weight. The dequantized copy carries a `_gtp_dequant_src` backlink to the live FP8 param, and `_backfill_gtp_sharded_param_map` reuses the model's **own** entry (backlink first, tagged-name second) — preserving its full offsets (expert axes included) and `replica_id`. Only truly-unmatched params (Mamba `in_proj`, a gathered+split factory) take the per-shard rebuild, which refuses expert-parallel params rather than emit EP-colliding shards. +- **Native-FP8 optimizer-state matching (Muon path).** The save-side dequantize (§3.3) hands DCP a *fresh* BF16 tensor, which breaks the id-based optimizer-param → model-`ShardedTensor` match for every native-FP8 GTP_remat weight. The dequantized copy carries a `_gtp_dequant_src` backlink to the live FP8 param, and `_backfill_gtp_sharded_param_map` reuses the model's **own** entry (backlink first, tagged-name second) — preserving its full offsets (expert axes included) and `replica_id`. Only truly-unmatched params (the SSM `in_proj` weights, gathered+split factories) take the per-shard rebuild, which refuses expert-parallel params rather than emit EP-colliding shards. Neither path adds a GTP_remat-specific checkpoint format or call site. @@ -543,7 +543,9 @@ Because the offsets reconstruct the global shape, the checkpoint is independent **Alignment padding & cross-topology reshard.** When `_gtp_slice_one_param` pads `out_features` to a multiple of `gtp_remat_size · pad_for_alignment`, the saved global describes the *padded* shape, so the helper sets `allow_shape_mismatch=True`. DCP then tolerates a load-side topology whose alignment yields a different padded size — the unpadded data overlaps and the tail pad rows are zeros GTP_remat recomputes. -> Note: Mamba's `in_proj` is a special case: it **all-gathers its GTP_remat shards** back to the logical TP-local size and strips the pad *before* saving, so its global is topology-independent and needs no `allow_shape_mismatch`. +> Note: the SSM `in_proj` weights — Mamba's (`mamba_mixer.py`, split `[z|x|B|C|dt]`) and gated-delta-product's (`gated_delta_product.py`, split householder-major into `z|V*|K*|Q|b*|a`) — are a special case: each **all-gathers its GTP_remat shards** back to the logical TP-local size and strips the pad *before* saving, so its global is topology-independent and needs no `allow_shape_mismatch`. This is required, not just tidier: the split-chunk boundaries do not line up with the GTP_remat slice boundaries, so a raw shard cannot be split at all. The checkpoint therefore matches a non-GTP_remat run byte-for-byte. +> +> On **load**, the split factory's `merge_fn` is wrapped to invert this: it cats the chunks back to the unpadded TP-local width, re-pads with zeros up to `gtp_remat_local_size · gtp_remat_size`, and slices by the GTP_remat rank — mirroring `_gtp_slice_one_param` so the tensor lands in the live shard's layout. `gtp_remat_size == 1` skips both the gather and the pad/slice. **Optimizer state.** The distributed optimizer's master/moment `ShardedObject`s are keyed by `dp_group_idx`. Under GTP_remat/EGTP_remat each peer owns a *different* master shard (the optimizer shards over the gtp_remat/egtp_remat-**excluded** replicate group), so the index is taken from the gtp_remat/egtp_remat-**merged** model-parallel group (`mp_group` for dense, `expt_tp_pp_with_egtp_remat_group` for expert) — giving every peer a distinct key while replicate-group ranks remain true replicas under that key. @@ -739,7 +741,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall | `test_gtp_grad_correctness.py` | Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. Also the fp32-accumulation reduce-scatter (§2.6): gtp_remat-axis and DDP-axis parity, plus the size-2 bypass. | | `test_gtp_cudagraph_grad.py` | Capture-step grad-norm guard (§1.2): `_backup_grads_before_capture`/`_restore_grads_after_capture` keep a graph capture from clobbering finalized `main_grad` (own params + cross-graph `next_w`, incl. routed-expert `weight_list`). | | `test_gtp_partial_cg.py` | Four-layer partial-CG loss and eager-vs-replay grad-norm parity with two-slot ring reuse across independently replayed graphs (§3.5). | -| `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. | +| `test_gtp_dcp.py` | DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, `replica_id`, native-FP8 save/load. Also the SSM `in_proj` gather+split: the gated-delta-product mixer's factory build/merge at MXFP8 alignment, and a full DCP save→load roundtrip of that mixer. | | `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. | | `test_gtp_recompute_chain.py` | Recompute-chain buffers (§3.1): adjacent nodes never share a gather buffer, dense and grouped, plus dgrad/wgrad parity vs no-recompute. | | `test_gtp_mtp.py` | GTP_remat + MTP shared weights (§3.5), 14 cases over `mtp_use_repeated_layer` × dense/MoE. Both MTP hazards are silent, so each needs its own guard: the async reduce-scatter path is compared numerically against the sync path on an identical model/sharding/batch, and all-gathers issued are tallied against consumes to catch a consume reading a buffer nothing gathered into. | diff --git a/megatron/core/ssm/gated_delta_product.py b/megatron/core/ssm/gated_delta_product.py index 3fa69813680..b85a5a4756c 100644 --- a/megatron/core/ssm/gated_delta_product.py +++ b/megatron/core/ssm/gated_delta_product.py @@ -31,14 +31,21 @@ ) from megatron.core.ssm.ssm_inference import SSMDynamicInferenceMixin from megatron.core.tensor_parallel import get_cuda_rng_tracker +from megatron.core.tensor_parallel.gtp_api import HAVE_GTP from megatron.core.transformer import TransformerConfig from megatron.core.transformer.module import MegatronModule from megatron.core.transformer.spec_utils import ModuleSpec, build_module from megatron.core.transformer.utils import ( + ensure_metadata_has_dp_cp_group, make_sharded_tensors_for_checkpoint, sharded_state_dict_default, ) -from megatron.core.utils import deprecate_inference_params +from megatron.core.utils import deprecate_inference_params, make_tp_sharded_tensor_for_checkpoint + +if HAVE_GTP: + from megatron.core.tensor_parallel.gtp_api import is_gtp_param +else: + is_gtp_param = None try: from causal_conv1d import causal_conv1d_fn @@ -863,6 +870,9 @@ def _get_states_from_cache(self, inference_context, batch_size, *, inference_par def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): """Provide a sharded state dictionary for distributed checkpointing.""" + # Guard for cases metadata is not provided + metadata = ensure_metadata_has_dp_cp_group(metadata) + sharded_state_dict = {} # Parameters self._save_to_state_dict(sharded_state_dict, "", keep_vars=True) @@ -899,6 +909,41 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): + (1 + self.num_householder) * self.ngroups_local_tp * self.d_state + self.nheads_local_tp * (1 + self.num_householder) ) + # Under GTP, in_proj.weight is GTP-sliced along axis 0. The [z|V*|K*|Q|b*|a] split + # boundaries don't line up with GTP slice boundaries, so gather the shards back to + # TP-local size (strip the trailing pad rows from the gathered tail) and fall through + # to the same split path the non-GTP run uses — saved ckpt matches a non-GTP run. + in_proj_gtp_remat_size = getattr(self.in_proj.weight, "gtp_remat_size", 1) + in_proj_is_gtp = ( + in_proj_gtp_remat_size > 1 and HAVE_GTP and is_gtp_param(self.in_proj.weight) + ) + if in_proj_is_gtp: + gtp_remat_group = self.in_proj.weight.group + # in_proj.weight was already built at the sharded size by the submodule + # sharded_state_dict above — and, for native-FP8 GTP, dequantized to BF16 there + # (make_tp_sharded_tensor_for_checkpoint). Gather those (BF16) shards back to the + # full TP-local size so the [z|V*|K*|Q|b*|a] split below matches a non-GTP run. + local = sharded_state_dict[f"{prefix}in_proj.weight"].data.contiguous() + gathered = torch.empty( + (local.shape[0] * in_proj_gtp_remat_size,) + local.shape[1:], + dtype=local.dtype, + device=local.device, + ) + torch.distributed.all_gather_into_tensor(gathered, local, group=gtp_remat_group) + if gathered.shape[0] != in_proj_dim: + gathered = gathered[:in_proj_dim].contiguous() + # Gathered weight is replicated across full dp_cp; replica_id needs only the DP slot. + dp_cp_rank = torch.distributed.get_rank(metadata["dp_cp_group"]) + sharded_state_dict[f"{prefix}in_proj.weight"] = make_tp_sharded_tensor_for_checkpoint( + gathered, + f"{prefix}in_proj.weight", + tp_axis=0, + replica_id=(0, 0, dp_cp_rank), + prepend_offsets=sharded_offsets, + tp_group=self.pg_collection.tp, + dp_cp_group=metadata["dp_cp_group"], + ) + assert sharded_state_dict[f"{prefix}in_proj.weight"].data.size(0) == in_proj_dim, ( in_proj_dim, sharded_state_dict[f"{prefix}in_proj.weight"], @@ -930,6 +975,40 @@ def sharded_state_dict(self, prefix="", sharded_offsets=(), metadata=None): sharded_state_dict[key], in_proj_split_sections, in_proj_split_names, 0 ) + # GTP load-side inverse of the save-time all-gather (see + # docs/api-guide/core/generalized_tensor_parallel.md §3.3, in_proj note): the checkpoint + # stores the FULL TP-local in_proj.weight (pad stripped) under the per-householder split + # keys, so the default merge_fn cats them back to ``in_proj_dim`` rows with no + # padding. To reload into the live GTP param we must mirror init + # (``_gtp_slice_one_param``): F.pad the merged tensor with zeros up to + # ``gtp_remat_local_size * gtp_remat_size``, then slice by ``gtp_remat_local_rank``. + # gtp_remat_size=1 has no pad/slice. + if in_proj_is_gtp: + factory = sharded_state_dict[f"{prefix}in_proj.weight"] + gtp_remat_local_rank = torch.distributed.get_rank(self.in_proj.weight.group) + gtp_remat_local_size = self.in_proj.weight.data.size(0) + original_merge_fn = factory.merge_fn + + @torch.no_grad() + def _gtp_slice_after_cat( + sub_state_dict, + _orig=original_merge_fn, + _rank=gtp_remat_local_rank, + _size=gtp_remat_local_size, + _gtp_remat_size=in_proj_gtp_remat_size, + ): + full = _orig(sub_state_dict) + aligned_total = _size * _gtp_remat_size + pad_rows = aligned_total - full.shape[0] + if pad_rows > 0: + full = torch.nn.functional.pad(full, (0, 0, 0, pad_rows)) + start = _rank * _size + return full[start : start + _size].contiguous() + + sharded_state_dict[f"{prefix}in_proj.weight"] = replace( + factory, merge_fn=_gtp_slice_after_cat + ) + conv_dim = ( self.d_inner_local_tp * self.num_householder + (1 + self.num_householder) * self.ngroups_local_tp * self.d_state diff --git a/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py b/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py index da42a086a91..af62304c5f8 100644 --- a/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py +++ b/tests/unit_tests/generalized_tensor_parallel/test_gtp_dcp.py @@ -942,6 +942,272 @@ def _worker_mamba_inproj_optim_param_map(rank, world_size, port): GTPShardedParam._chain_state = {} +# --------------------------------------------------------------------------- +# Gated-delta-product (GDP) in_proj: gather+split under GTP_remat +# +# GDP's ``in_proj.weight`` is GTP-sliced along axis 0 and zero-padded to an alignment multiple, +# while the checkpoint splits it into householder-major chunks whose boundaries do NOT line up with +# the GTP slice boundaries. ``GatedDeltaProductMixer.sharded_state_dict`` therefore all-gathers the +# shards back to the TP-local width and strips the pad before splitting (§3.3), and wraps the +# factory's merge_fn to re-pad + re-slice on load. The three workers below cover that contract. +# --------------------------------------------------------------------------- + +# in_proj width = d_inner(256)*4 + 4*ngroups(2)*d_state(128) + nheads(4)*4 = 2064. With +# pad_for_alignment=32 (what setup_gtp_remat_from_recipe picks for MXFP8) and gtp_remat_size=2 the +# alignment block is 64, so 48 pad rows fire -- the padded-shard case the split path must handle. +_GDP_HIDDEN_SIZE = 256 + + +def _build_gdp_mixer(required_pgs): + """Build a 1-layer GatedDeltaProductMixer. Returns ``(mixer, pg, in_proj_dim)``. + + Callers must have set ``update_gtp_config(pad_for_alignment=32)`` and initialized model + parallel with ``gtp_remat_size=2`` first. + """ + from megatron.core.models.hybrid.hybrid_layer_specs import gdp_stack_spec + from megatron.core.ssm.gated_delta_product import GatedDeltaProductMixer + + pg = ProcessGroupCollection.use_mpu_process_groups(required_pgs=required_pgs) + config = TransformerConfig( + num_layers=1, + hidden_size=_GDP_HIDDEN_SIZE, + num_attention_heads=4, + mamba_num_heads=4, + mamba_head_dim=64, + mamba_num_groups=2, + mamba_state_dim=128, + params_dtype=torch.bfloat16, + bf16=True, + ) + mixer = GatedDeltaProductMixer( + config, + gdp_stack_spec.submodules.mamba_layer.submodules.mixer.submodules, + config.hidden_size, + layer_number=1, + pg_collection=pg, + ).cuda() + in_proj_dim = ( + mixer.d_inner_local_tp * (1 + mixer.num_householder) + + (1 + mixer.num_householder) * mixer.ngroups_local_tp * mixer.d_state + + mixer.nheads_local_tp * (1 + mixer.num_householder) + ) + in_proj_w = mixer.in_proj.weight + assert isinstance(in_proj_w, GTPShardedParam), "in_proj.weight should be GTP_remat-sharded" + assert in_proj_w.data.size(0) * 2 > in_proj_dim, ( + f"expected GTP alignment padding to fire (got {in_proj_w.data.size(0)} * 2 == " + f"{in_proj_dim}); these tests must cover the strip-pad / re-pad path" + ) + return mixer, pg, in_proj_dim + + +def _gdp_valid_rows(in_proj_w, in_proj_dim): + """Rows of this rank's GTP shard that hold real weights (the rest are alignment pad).""" + local_rows = in_proj_w.data.size(0) + gtp_remat_rank = torch.distributed.get_rank(in_proj_w.group) + return max(0, min(local_rows, in_proj_dim - gtp_remat_rank * local_rows)) + + +def _worker_gdp_inproj_gather_split(rank, world_size, port): + """GatedDeltaProductMixer.sharded_state_dict under GTP_remat. + + Regression for the GDP save crash: the raw GTP shard neither matches ``in_proj_dim`` nor lines + up with the in_proj split boundaries -- the pre-fix code asserted here. Verify the mixer + gathers back to TP-local size, splits into the 6 chunks a non-GTP_remat run would write, and + that the load-side merge_fn re-pads + re-slices back to the live GTP shard. + """ + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + try: + model_parallel_cuda_manual_seed(42) + update_gtp_config(pad_for_alignment=32) # MXFP8 alignment + mixer, _, in_proj_dim = _build_gdp_mixer(['tp', 'cp', 'gtp_remat']) + in_proj_w = mixer.in_proj.weight + + metadata = {'dp_cp_group': ps.get_data_parallel_group(with_context_parallel=True)} + # Pre-fix this raised AssertionError((in_proj_dim, ShardedTensor(...))). + sd = mixer.sharded_state_dict(prefix='mixer.', metadata=metadata) + + factory = sd['mixer.in_proj.weight'] + assert isinstance(factory, ShardedTensorFactory), type(factory) + # Save side: the gathered tensor is the full TP-local width, pad stripped. + assert factory.data.size(0) == in_proj_dim, (factory.data.size(0), in_proj_dim) + + from megatron.core.ssm.gated_delta_product import _get_in_proj_checkpoint_split_layout + + # The chunk names/sizes come from _get_in_proj_checkpoint_split_layout (householder-major: + # z, V0..V(M-1), K0..K(M-1), Q, b0..b(M-1), a). Derive the expectation from that helper so + # this pins "GTP splits exactly like a non-GTP_remat run" rather than a frozen key list. + _, expected_names = _get_in_proj_checkpoint_split_layout( + mixer.d_inner_local_tp, + mixer.ngroups_local_tp * mixer.d_state, + mixer.nheads_local_tp, + mixer.num_householder, + ) + chunks = factory.build_fn(factory.key, factory.data, factory.replica_id, None) + assert [t.key.rsplit('.', 1)[-1] for t in chunks] == expected_names + assert sum(t.data.size(0) for t in chunks) == in_proj_dim + + # Load side: cat the chunks, re-pad, re-slice -> exactly this rank's live GTP shard. + merged = factory.merge_fn([t.data for t in chunks]) + assert tuple(merged.shape) == tuple(in_proj_w.data.shape), ( + tuple(merged.shape), + tuple(in_proj_w.data.shape), + ) + # The pad rows the last GTP rank carries are never written to the ckpt -> back as zeros. + n_valid = _gdp_valid_rows(in_proj_w, in_proj_dim) + torch.testing.assert_close(merged[:n_valid], in_proj_w.data[:n_valid], rtol=0, atol=0) + assert torch.equal(merged[n_valid:], torch.zeros_like(merged[n_valid:])) + finally: + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + +def _worker_gdp_save_load_roundtrip(rank, world_size, ckpt_base): + """End-to-end DCP save->load of a GatedDeltaProductMixer under GTP_remat. + + Companion to ``_worker_gdp_inproj_gather_split``, which only checks the factory build/merge + functions in isolation. This drives the real ``save``/``load`` so the load-side merge_fn + (re-pad + re-slice back to the live GTP shard) is exercised through DCP, and so a + duplicate-writer replica_id would surface as an 'Invalid access pattern'. + """ + from megatron.core.dist_checkpointing import load, save + from tests.unit_tests.dist_checkpointing import TempNamedDir + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + try: + model_parallel_cuda_manual_seed(42) + update_gtp_config(pad_for_alignment=32) # MXFP8 alignment + mixer, pg, in_proj_dim = _build_gdp_mixer( + ['tp', 'cp', 'gtp_remat', 'dp_cp', 'dp_cp_gtp_remat'] + ) + in_proj_w = mixer.in_proj.weight + + # ``save_checkpoint_and_time`` threads the gtp_remat-INCLUSIVE group; using the + # gtp_remat-excluding pg.dp_cp here collides replica_ids across gtp_remat peers. + metadata = {'dp_cp_group': pg.dp_cp_gtp_remat} + golden = {k: v.detach().clone() for k, v in mixer.state_dict().items()} + + with TempNamedDir(ckpt_base / 'gdp_gtp_dcp_roundtrip', sync=True) as ckpt_dir: + save(mixer.sharded_state_dict(prefix='mixer.', metadata=metadata), ckpt_dir) + + # Scribble over every param so a no-op load cannot pass. + with torch.no_grad(): + for p in mixer.parameters(): + p.data.fill_(float(rank + 1)) + loaded = load(mixer.sharded_state_dict(prefix='mixer.', metadata=metadata), ckpt_dir) + + # in_proj comes back through the 6-way split + the GTP re-pad/re-slice merge_fn. + merged = loaded['mixer.in_proj.weight'] + assert tuple(merged.shape) == tuple(in_proj_w.data.shape), ( + tuple(merged.shape), + tuple(in_proj_w.data.shape), + ) + n_valid = _gdp_valid_rows(in_proj_w, in_proj_dim) + torch.testing.assert_close( + merged[:n_valid].cpu(), golden['in_proj.weight'][:n_valid].cpu(), rtol=0, atol=0 + ) + + # The rest of the mixer must round-trip too -- a colliding replica_id across gtp_remat + # peers would either fail the load or return another rank's data. + for name in ( + 'A_log', + 'dt_bias', + 'conv1d.weight', + 'norm.weight', + 'out_proj.weight', + 'in_proj.layer_norm_weight', + ): + key = f'mixer.{name}' + assert key in loaded, f"{key} missing from the loaded state dict: {sorted(loaded)}" + torch.testing.assert_close( + loaded[key].cpu(), golden[name].cpu(), rtol=0, atol=0, msg=f"{name} drifted" + ) + finally: + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + +def _worker_gdp_inproj_optim_param_map(rank, world_size, port): + """GDP ``in_proj`` must survive the optimizer id->ShardedTensor match (Muon path, §1.6). + + Same gap as the Mamba case (``_worker_mamba_inproj_optim_param_map``): the model entry for a + gathered+split ``in_proj`` exposes the *gathered* tensor, so it never id-matches the per-shard + GTP optimizer param and ``get_param_id_to_sharded_param_map`` drops it -> KeyError in + ``Float16OptimizerWithFloat16Params.sharded_state_dict``. Unlike that test, this one drives the + real production backfill (``_backfill_gtp_sharded_param_map``) rather than reproducing its + rebuild, so it also pins that GDP takes the per-shard rebuild branch, not the EP refusal. + """ + from megatron.core.dist_checkpointing.optimizer import ( + get_param_id_to_sharded_param_map, + make_sharded_optimizer_tensor, + ) + from megatron.core.optimizer.optimizer import _backfill_gtp_sharded_param_map + from megatron.core.tensor_parallel.generalized_tensor_parallelism import ( + tag_gtp_params_with_names, + ) + + ps.destroy_model_parallel() + ps.initialize_model_parallel( + tensor_model_parallel_size=1, pipeline_model_parallel_size=1, gtp_remat_size=2 + ) + try: + model_parallel_cuda_manual_seed(42) + update_gtp_config(pad_for_alignment=32) # MXFP8 alignment + mixer, pg, _ = _build_gdp_mixer(['tp', 'cp', 'gtp_remat', 'dp_cp', 'dp_cp_gtp_remat']) + tag_gtp_params_with_names(mixer) # sets _debug_name, mirrors production setup + in_proj_w = mixer.in_proj.weight + + metadata = {'dp_cp_group': pg.dp_cp_gtp_remat} + model_sd = mixer.sharded_state_dict(prefix='mixer.', metadata=metadata) + + # The gap: the gathered+split factory does not id-match the per-shard optimizer param. + id_map = get_param_id_to_sharded_param_map(model_sd, [in_proj_w]) + assert 0 not in id_map, "expected in_proj to be MISSING from the id map (the KeyError gap)" + + # The production backfill must fill it via the per-shard rebuild. An expert-parallel param + # would raise instead; in_proj is dense, so it must rebuild cleanly. + _backfill_gtp_sharded_param_map(id_map, [[in_proj_w]], model_sd) + assert 0 in id_map, "backfill did not restore in_proj" + entry = id_map[0] + # A plain per-shard ShardedTensor keyed by the tagged name -- NOT the model's gathered+split + # factory (reusing that would hand the optimizer the wrong shape). + assert isinstance(entry, ShardedTensor), type(entry) + assert entry is not model_sd['mixer.in_proj.weight'] + assert entry.key == in_proj_w._debug_name, (entry.key, in_proj_w._debug_name) + assert tuple(entry.local_shape) == tuple(in_proj_w.shape), ( + f"rebuilt local_shape {tuple(entry.local_shape)} != param shape " + f"{tuple(in_proj_w.shape)}" + ) + # The rebuilt entry must describe this rank's GTP slice of the PADDED global (what the + # optimizer shard actually is), not the gathered/pad-stripped width the model entry uses. + gtp_remat_rank = torch.distributed.get_rank(in_proj_w.group) + assert entry.global_offset[0] == gtp_remat_rank * in_proj_w.shape[0], ( + entry.global_offset, + gtp_remat_rank, + ) + assert entry.global_shape[0] == in_proj_w.shape[0] * in_proj_w.gtp_remat_size, ( + entry.global_shape, + in_proj_w.shape, + ) + + # make_sharded_optimizer_tensor must accept it for a same-shape optimizer state tensor. + opt_state = torch.zeros_like(in_proj_w) + osh = make_sharded_optimizer_tensor(entry, opt_state, prefix='optimizer.state.exp_avg') + assert osh is not None + assert tuple(osh.local_shape) == tuple(in_proj_w.shape) + finally: + ps.destroy_model_parallel() + ps.initialize_model_parallel() + GTPShardedParam._chain_state = {} + + def _worker_save_load_roundtrip_needs_gtp_inclusive_group(rank, world_size, ckpt_base): """Save->load roundtrip: save and load must use the gtp_remat-INCLUSIVE replica group. @@ -1022,6 +1288,18 @@ def test_mamba_inproj_optim_param_map(self): _require_world_size(4) _worker_mamba_inproj_optim_param_map(dist.get_rank(), 4, None) + def test_gdp_inproj_gather_split(self): + _require_world_size(4) + _worker_gdp_inproj_gather_split(dist.get_rank(), 4, None) + + def test_gdp_save_load_roundtrip(self, tmp_path_dist_ckpt): + _require_world_size(4) + _worker_gdp_save_load_roundtrip(dist.get_rank(), 4, tmp_path_dist_ckpt) + + def test_gdp_inproj_optim_param_map(self): + _require_world_size(4) + _worker_gdp_inproj_optim_param_map(dist.get_rank(), 4, None) + def test_replicated_param_needs_gtp_inclusive_dp_cp(self): _require_world_size(4) _worker_replicated_param_needs_gtp_inclusive_dp_cp(dist.get_rank(), 4, None) From 281075d81f54c2788d37a55dffd331b8e5b9f67a Mon Sep 17 00:00:00 2001 From: Jingyue Wu Date: Thu, 13 Aug 2026 09:05:05 +0000 Subject: [PATCH 279/290] feat(fsdp): support expert parallelism in MFSDP v2 adapter (#6450) Signed-off-by: Jingyue Wu Co-authored-by: svcnvidia-nemo-ci --- .../distributed/fsdp/mcore_fsdp_adapter.py | 47 ++++- .../mfsdp_v2/test_mcore_adapter.py | 191 +++++++++++++++++- 2 files changed, 221 insertions(+), 17 deletions(-) diff --git a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py index 284beef606e..0b1c8a9d5f2 100644 --- a/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py +++ b/megatron/core/distributed/fsdp/mcore_fsdp_adapter.py @@ -43,6 +43,7 @@ from megatron.core.distributed.distributed_data_parallel_config import DistributedDataParallelConfig from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.ssm.mamba_layer import MambaLayer +from megatron.core.transformer.moe.moe_layer import MoELayer from megatron.core.transformer.transformer_config import TransformerConfig from megatron.core.transformer.transformer_layer import MoETransformerLayer, TransformerLayer from megatron.core.utils import is_te_min_version, log_single_rank @@ -560,7 +561,12 @@ def __init__( dp_group = pg_collection.dp_cp device_type = device.type if device is not None else "cuda" - mesh = DeviceMesh.from_group(dp_group, device_type=device_type, mesh_dim_names=("dp",)) + dp_mesh = DeviceMesh.from_group(dp_group, device_type=device_type, mesh_dim_names=("dp",)) + expert_dp_mesh = None + if config.expert_model_parallel_size > 1: + expert_dp_mesh = DeviceMesh.from_group( + pg_collection.expt_dp, device_type=device_type, mesh_dim_names=("expert_dp",) + ) placements = Placements( dp_axes=[0], parameter=[Flat()], gradient=[Flat()], optimizer=[Flat()] ) @@ -569,6 +575,19 @@ def __init__( # ncclCommWindowRegister: # https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/bufferreg.html#window-registration with fully_shard_context(device=device, use_symmetric_memory=ddp_config.nccl_ub): + if expert_dp_mesh is not None: + # Expert parameters are replicated over expert-DP, not the full DP group. + # Their gradients need the EP divisor because the same expert receives + # contributions after dispatch from every EP rank. + for submodule in module.modules(): + if isinstance(submodule, MoELayer): + fully_shard( + submodule.experts, + mesh=expert_dp_mesh, + placements=placements, + mixed_precision_policy=self.mp_policy, + grad_divisor=config.expert_model_parallel_size, + ) for submodule in reversed(list(module.modules())): if submodule is module: # The root is always sharded after selected child units so it is not @@ -577,12 +596,12 @@ def __init__( if any(isinstance(submodule, module_type) for module_type in fsdp_unit_modules): fully_shard( submodule, - mesh=mesh, + mesh=dp_mesh, placements=placements, mixed_precision_policy=self.mp_policy, ) fully_shard( - module, mesh=mesh, placements=placements, mixed_precision_policy=self.mp_policy + module, mesh=dp_mesh, placements=placements, mixed_precision_policy=self.mp_policy ) super().__init__(config=config, module=module) @@ -617,7 +636,6 @@ def _validate_config( "tensor_model_parallel_size", "pipeline_model_parallel_size", "context_parallel_size", - "expert_model_parallel_size", ] if any(getattr(config, parallelism) != 1 for parallelism in unsupported_parallelisms): raise ValueError( @@ -630,7 +648,7 @@ def _validate_config( # The config validates the requested topology, while these checks validate the # materialized topology supplied by the caller's process-group collection. - for group_name in ("tp", "pp", "cp", "ep"): + for group_name in ("tp", "pp", "cp"): group = getattr(pg_collection, group_name, None) if group is not None and group.size() != 1: raise ValueError( @@ -638,10 +656,21 @@ def _validate_config( f"got {group.size()}." ) - if getattr(config, "num_moe_experts", None) is not None or any( - not getattr(parameter, "allreduce", True) for parameter in module.parameters() - ): - raise ValueError("MFSDP v2 does not currently support expert parameters.") + if config.expert_model_parallel_size > 1: + if ( + pg_collection.ep is None + or pg_collection.ep.size() != config.expert_model_parallel_size + ): + actual_ep_size = None if pg_collection.ep is None else pg_collection.ep.size() + raise ValueError( + "MFSDP v2 requires an EP process group matching " + f"expert_model_parallel_size={config.expert_model_parallel_size}, " + f"got {actual_ep_size}." + ) + if pg_collection.expt_dp is None: + raise ValueError("MFSDP v2 with EP requires an explicit expert-DP process group.") + if not any(isinstance(submodule, MoELayer) for submodule in module.modules()): + raise ValueError("MFSDP v2 with EP requires MoE transformer layers.") if ddp_config.data_parallel_sharding_strategy != "optim_grads_params": raise ValueError( "MFSDP v2 requires data_parallel_sharding_strategy='optim_grads_params'." diff --git a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py index 3892a354540..597a0a7fe56 100644 --- a/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py +++ b/tests/unit_tests/distributed/mfsdp_v2/test_mcore_adapter.py @@ -2,6 +2,8 @@ """MCore adapter and optimizer integration tests for experimental MFSDP v2.""" +import logging +import os from dataclasses import replace import pytest @@ -12,15 +14,20 @@ from megatron.core.distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallel from megatron.core.distributed.fsdp.src.megatron_fsdp.experimental.module import FsdpModule from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_local_spec +from megatron.core.models.hybrid.hybrid_layer_specs import hybrid_stack_spec +from megatron.core.models.hybrid.hybrid_model import HybridModel from megatron.core.optimizer import OptimizerConfig, get_megatron_optimizer from megatron.core.optimizer.fully_sharded_optimizer import FullyShardedOptimizer from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.tensor_parallel.random import model_parallel_cuda_manual_seed +from megatron.core.transformer.enums import AttnBackend from megatron.core.transformer.transformer_block import TransformerBlock from megatron.core.transformer.transformer_config import TransformerConfig -from megatron.core.transformer.transformer_layer import TransformerLayer +from megatron.core.transformer.transformer_layer import MoETransformerLayer, TransformerLayer from tests.unit_tests.test_utilities import Utils +logger = logging.getLogger(__name__) + def _build_layer(config: TransformerConfig) -> TransformerLayer: return TransformerLayer( @@ -37,13 +44,11 @@ def _build_block(config: TransformerConfig) -> TransformerBlock: ) -class TestMcoreAdapter: +class TestMcoreAdapterDense: """Exercise a dense MCore transformer block over two data-parallel ranks.""" def setup_method(self): Utils.initialize_model_parallel(1, 1) - if torch.distributed.get_world_size() < 2: - pytest.skip("MFSDP v2 MCore integration test requires at least two ranks.") self.pg_collection = ProcessGroupCollection.use_mpu_process_groups() model_parallel_cuda_manual_seed(1234) @@ -72,8 +77,6 @@ def test_wraps_fsdp_unit_modules_before_root(self): megatron_fsdp_version=2, use_distributed_optimizer=False, data_parallel_sharding_strategy="optim_grads_params", - megatron_fsdp_main_params_dtype=torch.float32, - megatron_fsdp_main_grads_dtype=torch.float32, ), module=model, fsdp_unit_modules=[TransformerLayer], @@ -160,8 +163,6 @@ def test_build_train_and_step(self): megatron_fsdp_version=2, use_distributed_optimizer=False, data_parallel_sharding_strategy="optim_grads_params", - megatron_fsdp_main_params_dtype=torch.float32, - megatron_fsdp_main_grads_dtype=torch.bfloat16, ), module=model, pg_collection=self.pg_collection, @@ -232,3 +233,177 @@ def test_build_train_and_step(self): assert torch.isfinite(losses).all() assert torch.isfinite(reference_losses).all() torch.testing.assert_close(losses, reference_losses, rtol=1e-3, atol=0) + + +class TestMcoreAdapterExpertParallel: + """Exercise the MFSDP v2 adapter over an MoE model with EP=2.""" + + def setup_method(self): + self.world_size = int(os.environ.get("WORLD_SIZE", "1")) + if self.world_size < 2 or self.world_size % 2: + pytest.skip("MFSDP v2 EP adapter test requires an even world size of at least two.") + Utils.initialize_model_parallel(1, 1, expert_model_parallel_size=2) + self.pg_collection = ProcessGroupCollection.use_mpu_process_groups() + assert self.pg_collection.ep.size() == 2 + assert self.pg_collection.expt_dp.size() == self.world_size // 2 + self.reference_group = torch.distributed.new_group( + [torch.distributed.get_rank()], use_local_synchronization=True + ) + self.reference_pg_collection = ProcessGroupCollection( + tp=self.reference_group, + expt_tp=self.reference_group, + cp=self.reference_group, + pp=self.reference_group, + tp_cp=self.reference_group, + tp_dp_cp=self.reference_group, + ep=self.reference_group, + tp_ep=self.reference_group, + expt_dp=self.reference_group, + dp=self.reference_group, + dp_cp=self.reference_group, + embd=None, + pos_embd=None, + ) + model_parallel_cuda_manual_seed(1234) + + def teardown_method(self): + torch.distributed.destroy_process_group(self.reference_group) + Utils.destroy_model_parallel() + + def test_build_train_and_step(self): + """Shard experts over expert-DP and dense parameters over full DP.""" + # The in-process EP=1 reference needs rank-invariant initialization. GPU expert + # initialization instead uses the globally configured EP=2 rank in its RNG seed. + config = TransformerConfig( + num_layers=2, + hidden_size=64, + num_attention_heads=4, + num_moe_experts=4, + expert_model_parallel_size=2, + moe_layer_freq=[0, 1], + moe_token_dispatcher_type="alltoall", + moe_router_topk=2, + moe_grouped_gemm=True, + moe_ffn_hidden_size=128, + add_bias_linear=False, + use_cpu_initialization=True, + params_dtype=torch.float32, + attention_dropout=0.0, + hidden_dropout=0.0, + gradient_accumulation_fusion=False, + attention_backend=AttnBackend.unfused, + ) + # Pair CPU initialization with an explicit common seed for the reference and EP model. + torch.manual_seed(123) + reference_config = replace(config, expert_model_parallel_size=1) + reference_model = HybridModel( + config=reference_config, + hybrid_stack_spec=hybrid_stack_spec, + vocab_size=128, + max_sequence_length=8, + hybrid_layer_pattern="*E", + pg_collection=self.reference_pg_collection, + ).cuda() + model = HybridModel( + config=config, + hybrid_stack_spec=hybrid_stack_spec, + vocab_size=128, + max_sequence_length=8, + hybrid_layer_pattern="*E", + pg_collection=self.pg_collection, + ).cuda() + model.load_state_dict(reference_model.state_dict(), strict=False) + for model_layer, reference_layer in zip( + model.decoder.layers, reference_model.decoder.layers + ): + if not isinstance(model_layer, MoETransformerLayer): + continue + for fc in ("linear_fc1", "linear_fc2"): + model_fc = getattr(model_layer.mlp.experts, fc) + reference_fc = getattr(reference_layer.mlp.experts, fc) + for local, global_ in enumerate(model_layer.mlp.local_expert_indices): + for parameter_name in ("weight", "bias"): + model_parameter = getattr(model_fc, f"{parameter_name}{local}", None) + reference_parameter = getattr( + reference_fc, f"{parameter_name}{global_}", None + ) + if model_parameter is not None: + model_parameter.data.copy_(reference_parameter.data) + reference_model.ddp_config = DistributedDataParallelConfig(use_distributed_optimizer=False) + model = FullyShardedDataParallel( + config=config, + ddp_config=DistributedDataParallelConfig( + use_megatron_fsdp=True, + megatron_fsdp_version=2, + use_distributed_optimizer=False, + data_parallel_sharding_strategy="optim_grads_params", + fsdp_all_gather_in_start_param_sync=False, + ), + module=model, + pg_collection=self.pg_collection, + ) + assert isinstance(model.module, FsdpModule) + assert isinstance(model.module.decoder.layers[1].mlp.experts, FsdpModule) + + optimizer_config = OptimizerConfig( + lr=1.0e-3, weight_decay=0.0, use_distributed_optimizer=False, clip_grad=0.0 + ) + reference_optimizer = get_megatron_optimizer( + optimizer_config, [reference_model], use_gloo_process_groups=False + ) + optimizer = get_megatron_optimizer( + replace(optimizer_config), [model], use_gloo_process_groups=False + ) + assert isinstance(optimizer, FullyShardedOptimizer) + optimizer.reload_model_params() + + local_batch_size = 2 + torch.manual_seed(4321) + input_ids = torch.randint(0, 128, (self.world_size * local_batch_size, 8), device="cuda") + position_ids = torch.arange(8, device="cuda").repeat(self.world_size * local_batch_size, 1) + targets = torch.randn(self.world_size * local_batch_size, 8, 128, device="cuda") + input_slice = slice( + torch.distributed.get_rank() * local_batch_size, + (torch.distributed.get_rank() + 1) * local_batch_size, + ) + reference_losses = [] + for _ in range(5): + reference_optimizer.zero_grad(set_to_none=True) + reference_loss = torch.nn.functional.mse_loss( + reference_model( + input_ids=input_ids, position_ids=position_ids, attention_mask=None + ), + targets, + ) + reference_loss.backward() + reference_success, _, _ = reference_optimizer.step() + assert reference_success + reference_losses.append(reference_loss.detach()) + + losses = [] + for _ in range(5): + optimizer.zero_grad(set_to_none=True) + loss = torch.nn.functional.mse_loss( + model( + input_ids=input_ids[input_slice], + position_ids=position_ids[input_slice], + attention_mask=None, + ), + targets[input_slice], + ) + loss.backward() + success, _, _ = optimizer.step() + assert success + loss = loss.detach() + torch.distributed.all_reduce(loss, op=torch.distributed.ReduceOp.AVG) + losses.append(loss) + + losses = torch.stack(losses) + reference_losses = torch.stack(reference_losses) + if torch.distributed.get_rank() == 0: + logger.info("MFSDP v2 EP loss curve: %s", losses.tolist()) + logger.info("MFSDP v2 EP reference loss curve: %s", reference_losses.tolist()) + assert torch.isfinite(losses).all() + assert torch.isfinite(reference_losses).all() + assert losses[-1] < losses[0] + torch.testing.assert_close(losses, reference_losses) From 2a75ac12c54ba6a42b34024756820bf819a2e25f Mon Sep 17 00:00:00 2001 From: Deyu Fu Date: Thu, 13 Aug 2026 09:09:29 +0000 Subject: [PATCH 280/290] Complete clamped SwiGLU across expert paths (#5940) Signed-off-by: Deyu Fu --- .../core/extensions/transformer_engine.py | 23 ++- megatron/core/fusions/fused_bias_swiglu.py | 116 ++++++++++-- megatron/core/transformer/mlp.py | 2 + megatron/core/transformer/moe/experts.py | 44 +++-- .../core/transformer/moe/shared_experts.py | 32 +++- .../core/transformer/transformer_config.py | 29 ++- .../unit_tests/fusions/test_swiglu_fusion.py | 161 +++++++++++++++++ .../transformer/moe/test_grouped_mlp.py | 102 ++++++++++- .../transformer/moe/test_shared_experts.py | 165 ++++++++++++++++++ 9 files changed, 631 insertions(+), 43 deletions(-) diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 1f67b1a5b5b..8dd339f3cef 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -2434,12 +2434,23 @@ def __init__( tp_group_for_te = None if is_te_min_version("2.14.0"): - extra_kwargs["single_grouped_weight"] = getattr( - config, "moe_single_grouped_weight", False - ) - extra_kwargs["single_grouped_bias"] = getattr( - config, "moe_single_grouped_bias", False - ) + # Some TE 2.14 builds predate these keyword arguments. Cache the + # installed constructor signature instead of relying on the version alone. + global _TE_GROUPED_LINEAR_INIT_PARAMS + try: + grouped_linear_init_params = _TE_GROUPED_LINEAR_INIT_PARAMS + except NameError: + grouped_linear_init_params = _TE_GROUPED_LINEAR_INIT_PARAMS = set( + inspect.signature(te.pytorch.GroupedLinear.__init__).parameters + ) + if "single_grouped_weight" in grouped_linear_init_params: + extra_kwargs["single_grouped_weight"] = getattr( + config, "moe_single_grouped_weight", False + ) + if "single_grouped_bias" in grouped_linear_init_params: + extra_kwargs["single_grouped_bias"] = getattr( + config, "moe_single_grouped_bias", False + ) self.te_quant_params: Optional[TEQuantizationParams] = None quant_config = get_quant_config_or_none(name, config.quant_recipe) diff --git a/megatron/core/fusions/fused_bias_swiglu.py b/megatron/core/fusions/fused_bias_swiglu.py index 632470876c9..8d64e3016a9 100644 --- a/megatron/core/fusions/fused_bias_swiglu.py +++ b/megatron/core/fusions/fused_bias_swiglu.py @@ -48,6 +48,31 @@ def weighted_swiglu(y, weights): return res.to(dtype) +@jit_fuser +def clamped_swiglu(y, clamp_value): + """Perform SwiGLU after clamping both halves of the input.""" + dtype = y.dtype + y_1, y_2 = torch.chunk(y.to(torch.float32), 2, -1) + y_1 = y_1.clamp(min=None, max=clamp_value) + y_2 = y_2.clamp(min=-clamp_value, max=clamp_value) + res = F.silu(y_1) * y_2 + return res.to(dtype) + + +@jit_fuser +def bias_clamped_swiglu(y, bias, clamp_value): + """Perform clamped SwiGLU after bias addition.""" + return clamped_swiglu(y + bias, clamp_value) + + +@jit_fuser +def clamped_weighted_swiglu(y, weights, clamp_value): + """Perform token-weighted clamped SwiGLU.""" + dtype = y.dtype + res = clamped_swiglu(y, clamp_value) * weights + return res.to(dtype) + + # gradient of tanh approximation of gelu # gradient of actual gelu is: # 0.5 * (1. + torch.erf(x * 0.70710678)) + 0.3989423 * x * torch.exp(-0.5 * x * x) @@ -97,12 +122,50 @@ def weighted_swiglu_back(g, y, weights): return input_grad.to(input_dtype), weights_grad.to(w_dtype) +@jit_fuser +def clamped_swiglu_back(g, y, clamp_value): + """Compute the input gradient for clamped SwiGLU.""" + dtype = y.dtype + y_1, y_2 = torch.chunk(y.to(torch.float32), 2, -1) + y_1c = y_1.clamp(min=None, max=clamp_value) + y_2c = y_2.clamp(min=-clamp_value, max=clamp_value) + res = torch.cat( + ( + g + * torch.sigmoid(y_1c) + * (1 + y_1c * (1 - torch.sigmoid(y_1c))) + * y_2c + * (y_1 <= clamp_value).to(g.dtype), + g * F.silu(y_1c) * ((y_2 >= -clamp_value) & (y_2 <= clamp_value)).to(g.dtype), + ), + -1, + ) + return res.to(dtype) + + +@jit_fuser +def bias_clamped_swiglu_back(g, y, bias, clamp_value): + """Compute the input gradient for clamped SwiGLU with bias.""" + return clamped_swiglu_back(g, y + bias, clamp_value) + + +@jit_fuser +def clamped_weighted_swiglu_back(g, y, weights, clamp_value): + """Compute input and weight gradients for token-weighted clamped SwiGLU.""" + input_dtype = y.dtype + w_dtype = weights.dtype + input_grad = clamped_swiglu_back(g * weights, y, clamp_value) + weights_grad = clamped_swiglu(y, clamp_value) * g.to(w_dtype) + weights_grad = torch.sum(weights_grad, dim=-1, keepdim=True) + return input_grad.to(input_dtype), weights_grad.to(w_dtype) + + class BiasSwiGLUFunction(torch.autograd.Function): """Custom autograd function for SwiGLU activation with bias support.""" @staticmethod @nvtx_decorator() - def forward(ctx, input, bias, fp8_input_store, cpu_offload_input): + def forward(ctx, input, bias, fp8_input_store, cpu_offload_input, clamp_value): """Forward pass of biased SwiGLU activation. Args: @@ -121,6 +184,9 @@ def forward(ctx, input, bias, fp8_input_store, cpu_offload_input): ctx.save_for_backward(input_for_backward, bias) ctx.ori_input_dtype = input.dtype ctx.fp8_input_store = fp8_input_store + ctx.clamp_value = clamp_value + if clamp_value is not None and clamp_value > 0: + return bias_clamped_swiglu(input, bias, clamp_value) return bias_swiglu(input, bias) @staticmethod @@ -140,8 +206,11 @@ def backward(ctx, grad_output): """ input, bias = ctx.saved_tensors input = input.to(ctx.ori_input_dtype) if ctx.fp8_input_store else input - tmp = bias_swiglu_back(grad_output, input, bias) - return tmp, tmp, None, None + if ctx.clamp_value is not None and ctx.clamp_value > 0: + tmp = bias_clamped_swiglu_back(grad_output, input, bias, ctx.clamp_value) + else: + tmp = bias_swiglu_back(grad_output, input, bias) + return tmp, tmp, None, None, None class SwiGLUFunction(torch.autograd.Function): @@ -149,7 +218,7 @@ class SwiGLUFunction(torch.autograd.Function): @staticmethod @nvtx_decorator() - def forward(ctx, input, fp8_input_store, cpu_offload_input): + def forward(ctx, input, fp8_input_store, cpu_offload_input, clamp_value): """Forward pass of SwiGLU activation. Args: @@ -166,6 +235,9 @@ def forward(ctx, input, fp8_input_store, cpu_offload_input): ctx.save_for_backward(input_for_backward) ctx.ori_input_dtype = input.dtype ctx.fp8_input_store = fp8_input_store + ctx.clamp_value = clamp_value + if clamp_value is not None and clamp_value > 0: + return clamped_swiglu(input, clamp_value) return swiglu(input) @staticmethod @@ -184,29 +256,37 @@ def backward(ctx, grad_output): """ input = ctx.saved_tensors[0] input = input.to(ctx.ori_input_dtype) if ctx.fp8_input_store else input - tmp = swiglu_back(grad_output, input) - return tmp, None, None + if ctx.clamp_value is not None and ctx.clamp_value > 0: + tmp = clamped_swiglu_back(grad_output, input, ctx.clamp_value) + else: + tmp = swiglu_back(grad_output, input) + return tmp, None, None, None class WeightedSwiGLUFunction(torch.autograd.Function): @staticmethod - # bias is an optional argument - def forward(ctx, input, weights, fp8_input_store): + def forward(ctx, input, weights, fp8_input_store, clamp_value): input_for_backward = input.to(torch.float8_e4m3fn) if fp8_input_store else input ctx.save_for_backward(input_for_backward, weights) ctx.ori_input_dtype = input.dtype ctx.fp8_input_store = fp8_input_store + ctx.clamp_value = clamp_value + if clamp_value is not None and clamp_value > 0: + return clamped_weighted_swiglu(input, weights, clamp_value) return weighted_swiglu(input, weights) @staticmethod def backward(ctx, grad_output): input, weights = ctx.saved_tensors input = input.to(ctx.ori_input_dtype) if ctx.fp8_input_store else input - tmp, wgrad = weighted_swiglu_back(grad_output, input, weights) - return tmp, wgrad, None + if ctx.clamp_value is not None and ctx.clamp_value > 0: + tmp, wgrad = clamped_weighted_swiglu_back(grad_output, input, weights, ctx.clamp_value) + else: + tmp, wgrad = weighted_swiglu_back(grad_output, input, weights) + return tmp, wgrad, None, None -def bias_swiglu_impl(input, bias, fp8_input_store=False, cpu_offload_input=False): +def bias_swiglu_impl(input, bias, fp8_input_store=False, cpu_offload_input=False, clamp_value=None): """Implementation of biased SwiGLU that handles different input shapes. This function reshapes the input if necessary, applies the SwiGLU activation @@ -218,6 +298,10 @@ def bias_swiglu_impl(input, bias, fp8_input_store=False, cpu_offload_input=False uses the bias-free SwiGLU variant. fp8_input_store (bool, optional): Whether to store intermediate values in FP8 format. Defaults to False. + cpu_offload_input (bool, optional): Whether to mark saved activation inputs for CPU + offloading. Defaults to False. + clamp_value (float, optional): Maximum gate value and absolute linear value. When None, + preserve the legacy unclamped SwiGLU behavior. Returns: torch.Tensor: Result of biased SwiGLU activation. @@ -229,14 +313,16 @@ def bias_swiglu_impl(input, bias, fp8_input_store=False, cpu_offload_input=False assert len(ori_shape) in [2, 3] input = input.view(-1, ori_shape[-1]) if bias is not None: - output = BiasSwiGLUFunction.apply(input, bias, fp8_input_store, cpu_offload_input) + output = BiasSwiGLUFunction.apply( + input, bias, fp8_input_store, cpu_offload_input, clamp_value + ) else: - output = SwiGLUFunction.apply(input, fp8_input_store, cpu_offload_input) + output = SwiGLUFunction.apply(input, fp8_input_store, cpu_offload_input, clamp_value) return output if len(ori_shape) == 2 else output.view(ori_shape[0], ori_shape[1], -1) -def weighted_bias_swiglu_impl(input, bias, weights, fp8_input_store=False): +def weighted_bias_swiglu_impl(input, bias, weights, fp8_input_store=False, clamp_value=None): """ Token-wise-weighted bias swiglu fusion. """ @@ -246,7 +332,7 @@ def weighted_bias_swiglu_impl(input, bias, weights, fp8_input_store=False): if bias is not None: raise NotImplementedError("Bias is not supported for weighted swiglu fusion") else: - output = WeightedSwiGLUFunction.apply(input, weights, fp8_input_store) + output = WeightedSwiGLUFunction.apply(input, weights, fp8_input_store, clamp_value) return output if len(ori_shape) == 2 else output.view(ori_shape[0], ori_shape[1], -1) diff --git a/megatron/core/transformer/mlp.py b/megatron/core/transformer/mlp.py index cb3b1b0be82..66a69ff3f37 100644 --- a/megatron/core/transformer/mlp.py +++ b/megatron/core/transformer/mlp.py @@ -281,6 +281,7 @@ def forward( bias_parallel, per_token_scale.unsqueeze(-1), self.config.activation_func_fp8_input_store, + self.config.activation_func_clamp_value, ) elif self.activation_func == quick_gelu and self.config.gated_linear_unit: intermediate_parallel = weighted_bias_quick_geglu_impl( @@ -312,6 +313,7 @@ def forward( self.config.cpu_offloading and self.config.cpu_offloading_activations and HAVE_TE, + self.config.activation_func_clamp_value, ) else: raise ValueError("Only support fusion of gelu and swiglu") diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py index 59f8deffeca..35817f05e6a 100644 --- a/megatron/core/transformer/moe/experts.py +++ b/megatron/core/transformer/moe/experts.py @@ -377,7 +377,13 @@ def _is_fused_impl_supported(self) -> bool: if not (use_glu_fusion or use_srelu_fusion): return False if self.config.activation_func == F.silu: - pass + if self.config.activation_func_clamp_value is not None: + if not is_te_min_version("2.17.0.dev0"): + return False + try: + from transformer_engine.pytorch.ops import ScaledClampedQGeGLU # noqa: F401 + except ImportError: + return False elif self.config.activation_func == quick_gelu: try: from transformer_engine.pytorch.ops import ScaledClampedQGeGLU # noqa: F401 @@ -479,20 +485,35 @@ def register_grouped_linear_params( ) ops.append(op) - # Activation and post-multiply probs (SwiGLU, clamped quick-GeGLU, or SReLU) + # Activation and post-multiply probs (SwiGLU, clamped GLU, or SReLU). glu_interleave = self.config.moe_mlp_glu_interleave_size activation_recompute_in_mlp = bool(getattr(self, "activation_recompute", False)) if self.config.activation_func == F.silu and self.config.gated_linear_unit: - if ( - "activation_recompute_in_mlp" - in inspect.signature(te.pytorch.ops.ScaledSwiGLU).parameters - ): - op = te.pytorch.ops.ScaledSwiGLU( - glu_interleave_size=glu_interleave, - activation_recompute_in_mlp=activation_recompute_in_mlp, - ) + clamp_value = self.config.activation_func_clamp_value + if clamp_value is not None: + clamped_glu_kwargs = { + "glu_interleave_size": glu_interleave, + "alpha": 1.0, + "limit": clamp_value, + "glu_linear_offset": 0.0, + } + if ( + "activation_recompute_in_mlp" + in inspect.signature(te.pytorch.ops.ScaledClampedQGeGLU).parameters + ): + clamped_glu_kwargs["activation_recompute_in_mlp"] = activation_recompute_in_mlp + op = te.pytorch.ops.ScaledClampedQGeGLU(**clamped_glu_kwargs) else: - op = te.pytorch.ops.ScaledSwiGLU(glu_interleave_size=glu_interleave) + if ( + "activation_recompute_in_mlp" + in inspect.signature(te.pytorch.ops.ScaledSwiGLU).parameters + ): + op = te.pytorch.ops.ScaledSwiGLU( + glu_interleave_size=glu_interleave, + activation_recompute_in_mlp=activation_recompute_in_mlp, + ) + else: + op = te.pytorch.ops.ScaledSwiGLU(glu_interleave_size=glu_interleave) elif self.config.activation_func == quick_gelu and self.config.gated_linear_unit: clamp = self.config.activation_func_clamp_value if clamp is not None: @@ -832,6 +853,7 @@ def bias_act_func(intermediate_parallel, bias_parallel, permuted_probs): bias_parallel, permuted_probs, self.config.activation_func_fp8_input_store, + self.config.activation_func_clamp_value, ) elif self.activation_func == quick_gelu and self.config.gated_linear_unit: intermediate_parallel = weighted_bias_quick_geglu_impl( diff --git a/megatron/core/transformer/moe/shared_experts.py b/megatron/core/transformer/moe/shared_experts.py index 038a162f899..6eef5dee9ff 100644 --- a/megatron/core/transformer/moe/shared_experts.py +++ b/megatron/core/transformer/moe/shared_experts.py @@ -287,6 +287,7 @@ def linear_fc1_forward_and_act(self, overlapped_comm_output=None): intermediate_parallel, bias_parallel, self.config.activation_func_fp8_input_store, + clamp_value=self.config.activation_func_clamp_value, ) else: raise ValueError("Only support fusion of gelu and swiglu") @@ -296,8 +297,13 @@ def linear_fc1_forward_and_act(self, overlapped_comm_output=None): if self.config.gated_linear_unit: def glu(x): - x = torch.chunk(x, 2, dim=-1) - return self.config.activation_func(x[0]) * x[1] + x_glu, x_linear = torch.chunk(x, 2, dim=-1) + if (clamp_value := self.config.activation_func_clamp_value) is not None: + x_glu = x_glu.clamp(min=None, max=clamp_value) + x_linear = x_linear.clamp(min=-clamp_value, max=clamp_value) + return self.config.activation_func(x_glu) * ( + x_linear + self.config.glu_linear_offset + ) intermediate_parallel = glu(intermediate_parallel) else: @@ -416,6 +422,15 @@ def _validate_fused_grouped_swiglu(self) -> None: f"fused kernel, but got activation_func={self.config.activation_func}, " f"gated_linear_unit={self.config.gated_linear_unit}." ) + if self.config.activation_func_clamp_value is not None and ( + not is_te_min_version("2.17.0.dev0") + or not hasattr(te.pytorch.ops, "ScaledClampedQGeGLU") + ): + raise RuntimeError( + f"{self.__class__.__name__} requires Transformer Engine >= 2.17.0.dev0 " + "with pytorch.ops.ScaledClampedQGeGLU when " + "activation_func_clamp_value is set." + ) if self.config.moe_shared_expert_glu_interleave_size is None: raise ValueError( f"{self.__class__.__name__} requires " @@ -452,7 +467,7 @@ def _get_fused_grouped_swiglu_recipe(self): return self._fused_grouped_swiglu_recipe def _make_fused_grouped_swiglu_ops(self) -> torch.nn.Module: - """Construct GroupedLinear(num_groups=1) -> ScaledSwiGLU -> GroupedLinear.""" + """Construct the grouped-linear shared-expert MLP operations.""" ops = te.pytorch.ops.Sequential() tp_world_size = get_pg_size(self.tp_group) rng_state_tracker_function = None @@ -475,7 +490,16 @@ def _make_fused_grouped_swiglu_ops(self) -> torch.nn.Module: op._glu_interleave_size = glu_interleave_size ops.append(op) - activation_op = te.pytorch.ops.ScaledSwiGLU(glu_interleave_size=glu_interleave_size) + clamp_value = self.config.activation_func_clamp_value + if clamp_value is None: + activation_op = te.pytorch.ops.ScaledSwiGLU(glu_interleave_size=glu_interleave_size) + else: + activation_op = te.pytorch.ops.ScaledClampedQGeGLU( + glu_interleave_size=glu_interleave_size, + alpha=1.0, + limit=clamp_value, + glu_linear_offset=0.0, + ) # Shared experts are not router-gated. Mark this fused-op instance so # TE can omit the optional forward cuDNN probability tensor without # changing the semantics of routed single-group MLPs. diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index aa8af1b5c18..2a35f8b585f 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -227,7 +227,7 @@ class TransformerConfig(ModelParallelConfig): activation_func_clamp_value: Optional[float] = None """Clamp the output of the linear_fc1 in the activation function. Only used when activation_func - is quick_gelu.""" + is quick_gelu or SwiGLU (MoE only).""" num_moe_experts: Optional[int] = None """Number of experts to use for MoE layer. When set, it replaces MLP with MoE layer. Set to None @@ -2227,6 +2227,33 @@ def __post_init__(self): if self.activation_func != F.silu or not self.gated_linear_unit: raise ValueError("Storing activation input in FP8 is supported only for SwiGLU.") + if ( + self.activation_func_clamp_value is not None + and self.activation_func == F.silu + and self.gated_linear_unit + ): + if ( + not math.isfinite(self.activation_func_clamp_value) + or self.activation_func_clamp_value <= 0 + ): + raise ValueError( + "activation_func_clamp_value for SwiGLU must be finite and greater than zero." + ) + if self.num_moe_experts is None: + raise ValueError( + "activation_func_clamp_value for SwiGLU is only supported with MoE." + ) + if self.glu_linear_offset != 0.0: + raise ValueError( + "glu_linear_offset must be zero when activation_func_clamp_value " + "is set for SwiGLU." + ) + if self.use_te_activation_func: + raise ValueError( + "use_te_activation_func must be False " + "when activation_func_clamp_value is not None for SwiGLU" + ) + if self.apply_rope_fusion: if self.multi_latent_attention: warnings.warn( diff --git a/tests/unit_tests/fusions/test_swiglu_fusion.py b/tests/unit_tests/fusions/test_swiglu_fusion.py index c72679cd047..1ba9b3eb891 100644 --- a/tests/unit_tests/fusions/test_swiglu_fusion.py +++ b/tests/unit_tests/fusions/test_swiglu_fusion.py @@ -1,7 +1,51 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + import pytest import torch +import torch.nn.functional as F from megatron.core.fusions.fused_bias_swiglu import bias_swiglu_impl, weighted_bias_swiglu_impl +from megatron.core.transformer.transformer_config import TransformerConfig + + +def _clamped_swiglu_config(**kwargs): + defaults = dict( + num_layers=1, + hidden_size=16, + num_attention_heads=4, + num_moe_experts=4, + gated_linear_unit=True, + activation_func=F.silu, + activation_func_clamp_value=10.0, + ) + return TransformerConfig(**(defaults | kwargs)) + + +def test_clamped_swiglu_config_accepts_positive_moe_clamp(): + assert _clamped_swiglu_config().activation_func_clamp_value == 10.0 + + +@pytest.mark.parametrize("clamp_value", [0.0, -1.0, float("nan"), float("inf"), float("-inf")]) +def test_clamped_swiglu_config_requires_positive_clamp(clamp_value): + with pytest.raises(ValueError, match="greater than zero"): + _clamped_swiglu_config(activation_func_clamp_value=clamp_value) + + +def test_clamped_swiglu_config_rejects_linear_offset(): + with pytest.raises(ValueError, match="glu_linear_offset must be zero"): + _clamped_swiglu_config(glu_linear_offset=1.0) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"num_moe_experts": None}, "only supported with MoE"), + ({"use_te_activation_func": True}, "use_te_activation_func must be False"), + ], +) +def test_clamped_swiglu_config_rejects_unsupported_paths(kwargs, match): + with pytest.raises(ValueError, match=match): + _clamped_swiglu_config(**kwargs) @pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float32]) @@ -39,3 +83,120 @@ def test_weighted_bias_swiglu(input_dtype): assert weights_2.grad.dtype == weights.grad.dtype if input_dtype == torch.float32: assert torch.allclose(weights.grad, weights_2.grad, **tols) + + +@pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float32]) +def test_clamped_weighted_bias_swiglu(input_dtype): + clamp_value = 10.0 + + if input_dtype == torch.float32: + tols = dict(rtol=1.0e-6, atol=1.0e-6) + elif input_dtype == torch.bfloat16: + tols = dict(rtol=2.0e-2, atol=1.0e-3) + else: + raise ValueError(f"Invalid input dtype: {input_dtype}") + + x = (torch.randn(16, 64, dtype=input_dtype, device="cuda") * 5.0).requires_grad_(True) + weights = torch.randn(16, 1, dtype=torch.float32, device="cuda", requires_grad=True) + bwd_input = torch.randn(16, 32, dtype=input_dtype, device="cuda") + + # Reference: clamp and activate in FP32, then restore the input dtype. + y_1, y_2 = torch.chunk(x.to(torch.float32), 2, -1) + y = ( + F.silu(y_1.clamp(min=None, max=clamp_value)) + * y_2.clamp(min=-clamp_value, max=clamp_value) + * weights + ).to(input_dtype) + y.backward(bwd_input) + + x_fused = x.detach().clone().requires_grad_(True) + weights_fused = weights.detach().clone().requires_grad_(True) + y_fused = weighted_bias_swiglu_impl(x_fused, None, weights_fused, clamp_value=clamp_value) + y_fused.backward(bwd_input.detach().clone()) + + assert y_fused.dtype == y.dtype + assert torch.allclose(y, y_fused, **tols) + assert x_fused.grad.dtype == x.grad.dtype + assert torch.allclose(x.grad, x_fused.grad, **tols) + assert weights_fused.grad.dtype == weights.grad.dtype + if input_dtype == torch.float32: + assert torch.allclose(weights.grad, weights_fused.grad, **tols) + + +@pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float32]) +@pytest.mark.parametrize("with_bias", [False, True]) +def test_clamped_bias_swiglu_impl(input_dtype, with_bias): + clamp_value = 10.0 + + if input_dtype == torch.float32: + tols = dict(rtol=1.0e-6, atol=1.0e-6) + elif input_dtype == torch.bfloat16: + tols = dict(rtol=2.0e-2, atol=1.0e-3) + else: + raise ValueError(f"Invalid input dtype: {input_dtype}") + + x = (torch.randn(16, 64, dtype=input_dtype, device="cuda") * 5.0).requires_grad_(True) + bias = ( + torch.randn(64, dtype=input_dtype, device="cuda").requires_grad_(True) + if with_bias + else None + ) + bwd_input = torch.randn(16, 32, dtype=input_dtype, device="cuda") + + x_fp32 = x.to(torch.float32) + x_effective = x_fp32 + bias.to(torch.float32) if with_bias else x_fp32 + y_1, y_2 = torch.chunk(x_effective, 2, -1) + y = ( + F.silu(y_1.clamp(min=None, max=clamp_value)) * y_2.clamp(min=-clamp_value, max=clamp_value) + ).to(input_dtype) + y.backward(bwd_input) + + x_fused = x.detach().clone().requires_grad_(True) + bias_fused = bias.detach().clone().requires_grad_(True) if with_bias else None + y_fused = bias_swiglu_impl(x_fused, bias_fused, clamp_value=clamp_value) + y_fused.backward(bwd_input.detach().clone()) + + assert y_fused.dtype == y.dtype + assert torch.allclose(y, y_fused, **tols) + assert x_fused.grad.dtype == x.grad.dtype + assert torch.allclose(x.grad, x_fused.grad, **tols) + if with_bias: + assert bias_fused.grad.dtype == bias.grad.dtype + bias_grad_cos = F.cosine_similarity( + bias.grad.flatten().float().unsqueeze(0), bias_fused.grad.flatten().float().unsqueeze(0) + ).item() + assert bias_grad_cos > 0.999, f"bias.grad cosine similarity = {bias_grad_cos:.6f}" + + +@pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float32]) +@pytest.mark.parametrize("with_bias", [False, True]) +def test_bias_swiglu_impl_clamp_none_matches_unclamped(input_dtype, with_bias): + if input_dtype == torch.float32: + tols = dict(rtol=1.0e-6, atol=1.0e-6) + else: + tols = dict(rtol=2.0e-2, atol=1.0e-3) + + x = torch.randn(16, 64, dtype=input_dtype, device="cuda").requires_grad_(True) + bias = ( + torch.randn(64, dtype=input_dtype, device="cuda").requires_grad_(True) + if with_bias + else None + ) + bwd_input = torch.randn(16, 32, dtype=input_dtype, device="cuda") + + y = bias_swiglu_impl(x, bias) + y.backward(bwd_input) + + x_explicit = x.detach().clone().requires_grad_(True) + bias_explicit = bias.detach().clone().requires_grad_(True) if with_bias else None + y_explicit = bias_swiglu_impl(x_explicit, bias_explicit, clamp_value=None) + y_explicit.backward(bwd_input.detach().clone()) + + assert torch.allclose(y, y_explicit, **tols) + assert torch.allclose(x.grad, x_explicit.grad, **tols) + if with_bias: + bias_grad_cos = F.cosine_similarity( + bias.grad.flatten().float().unsqueeze(0), + bias_explicit.grad.flatten().float().unsqueeze(0), + ).item() + assert bias_grad_cos > 0.999, f"bias.grad cosine similarity = {bias_grad_cos:.6f}" diff --git a/tests/unit_tests/transformer/moe/test_grouped_mlp.py b/tests/unit_tests/transformer/moe/test_grouped_mlp.py index b9e7fa346d2..6b3531b090e 100644 --- a/tests/unit_tests/transformer/moe/test_grouped_mlp.py +++ b/tests/unit_tests/transformer/moe/test_grouped_mlp.py @@ -38,6 +38,23 @@ def test_op_fuser_transformer_config_args_are_exposed(): assert args.moe_mlp_glu_interleave_size == 16 +def test_clamped_swiglu_allows_te_op_fuser(): + config = TransformerConfig( + num_layers=1, + hidden_size=16, + num_attention_heads=4, + num_moe_experts=4, + moe_grouped_gemm=True, + gated_linear_unit=True, + activation_func=F.silu, + activation_func_clamp_value=10.0, + use_transformer_engine_op_fuser=True, + ) + + assert config.activation_func_clamp_value == 10.0 + assert config.use_transformer_engine_op_fuser is True + + def test_remove_glu_interleaving_restores_contiguous_gate_and_linear_halves(): interleaved = torch.tensor([[1, 2, 5, 6, 3, 4, 7, 8], [11, 12, 15, 16, 13, 14, 17, 18]]) expected = torch.tensor([[1, 2, 3, 4, 5, 6, 7, 8], [11, 12, 13, 14, 15, 16, 17, 18]]) @@ -452,11 +469,21 @@ def __init__(self, glu_interleave_size, *, activation_recompute_in_mlp=False): self.activation_recompute_in_mlp = activation_recompute_in_mlp class FakeScaledClampedQGeGLU(torch.nn.Module): - def __init__(self, glu_interleave_size, *, activation_recompute_in_mlp=False, limit=None): + def __init__( + self, + glu_interleave_size, + *, + activation_recompute_in_mlp=False, + limit=None, + alpha=1.702, + glu_linear_offset=1.0, + ): super().__init__() self.glu_interleave_size = glu_interleave_size self.activation_recompute_in_mlp = activation_recompute_in_mlp self.limit = limit + self.alpha = alpha + self.glu_linear_offset = glu_linear_offset class FakeScaledSReLU(torch.nn.Module): def __init__(self, *, activation_recompute_in_mlp=False): @@ -484,8 +511,15 @@ def register_forward_pre_hook(self, hook): ) -def test_make_fused_ops_uses_clamped_qgeglu_for_quick_gelu(monkeypatch): - """quick_gelu + clamp value → ScaledClampedQGeGLU(limit=clamp).""" +@pytest.mark.parametrize( + ("activation_func", "expected_alpha", "expected_offset"), + [(quick_gelu, 1.702, 1.0), (F.silu, 1.0, 0.0)], + ids=("quick-geglu", "clamped-swiglu"), +) +def test_make_fused_ops_uses_clamped_qgeglu( + monkeypatch, activation_func, expected_alpha, expected_offset +): + """Clamped quick GeGLU and SwiGLU use the appropriate TE parameters.""" fake_te, FakeGroupedLinear = _make_fake_te_namespace() monkeypatch.setattr(experts_module, "te", fake_te) @@ -495,10 +529,10 @@ def test_make_fused_ops_uses_clamped_qgeglu_for_quick_gelu(monkeypatch): moe_mlp_glu_interleave_size=4, delay_wgrad_compute=False, activation_func_clamp_value=7.0, - activation_func=quick_gelu, + activation_func=activation_func, gated_linear_unit=True, ) - module.activation_func = quick_gelu + module.activation_func = activation_func module.activation_recompute = True common = dict( device="cuda", @@ -520,6 +554,8 @@ def test_make_fused_ops_uses_clamped_qgeglu_for_quick_gelu(monkeypatch): assert activation.glu_interleave_size == 4 assert activation.activation_recompute_in_mlp is True assert activation.limit == 7.0 + assert activation.alpha == expected_alpha + assert activation.glu_linear_offset == expected_offset def test_make_fused_ops_uses_scaled_srelu_for_weighted_squared_relu(monkeypatch): @@ -610,12 +646,18 @@ def _install_fake_te_ops_modules( def _make_fused_impl_support_module( - FakeGroupedLinear, *, activation_func, gated_linear_unit, use_fused_weighted_squared_relu=False + FakeGroupedLinear, + *, + activation_func, + gated_linear_unit, + use_fused_weighted_squared_relu=False, + activation_func_clamp_value=None, ): module = TEGroupedMLP.__new__(TEGroupedMLP) torch.nn.Module.__init__(module) module.config = SimpleNamespace( activation_func=activation_func, + activation_func_clamp_value=activation_func_clamp_value, gated_linear_unit=gated_linear_unit, use_fused_weighted_squared_relu=use_fused_weighted_squared_relu, moe_apply_probs_on_input=False, @@ -649,6 +691,27 @@ def test_is_fused_impl_supported_uses_config_activation_for_swiglu(monkeypatch): assert module._is_fused_impl_supported() is True +@pytest.mark.parametrize("activation_func_clamp_value", [None, 10.0], ids=("unclamped", "clamped")) +def test_is_fused_impl_supported_requires_cutedsl_for_swiglu( + monkeypatch, activation_func_clamp_value +): + fake_te, FakeGroupedLinear = _make_fake_te_namespace() + monkeypatch.setattr(experts_module, "te", fake_te) + monkeypatch.setattr(experts_module, "HAVE_TE", True) + monkeypatch.setattr(experts_module, "is_te_min_version", lambda _: True) + monkeypatch.delenv("NVTE_CUTEDSL_FUSED_GROUPED_MLP", raising=False) + _install_fake_te_ops_modules(monkeypatch, fake_te) + + module = _make_fused_impl_support_module( + FakeGroupedLinear, + activation_func=F.silu, + gated_linear_unit=True, + activation_func_clamp_value=activation_func_clamp_value, + ) + + assert module._is_fused_impl_supported() is False + + def test_is_fused_impl_supported_requires_scaled_fc2_bias(monkeypatch): fake_te, FakeGroupedLinear = _make_fake_te_namespace() @@ -671,6 +734,33 @@ def __init__(self, *args, **kwargs): assert module._is_fused_impl_supported() is False +@pytest.mark.parametrize( + ("te_217_or_later", "include_clamped_qgeglu", "expected"), + [(True, True, True), (False, True, False), (True, False, False)], +) +def test_is_fused_impl_supported_gates_clamped_swiglu( + monkeypatch, te_217_or_later, include_clamped_qgeglu, expected +): + fake_te, FakeGroupedLinear = _make_fake_te_namespace() + monkeypatch.setattr(experts_module, "te", fake_te) + monkeypatch.setattr(experts_module, "HAVE_TE", True) + monkeypatch.setattr( + experts_module, "is_te_min_version", lambda version: version == "2.14.0" or te_217_or_later + ) + _install_fake_te_ops_modules( + monkeypatch, fake_te, include_clamped_qgeglu=include_clamped_qgeglu + ) + + module = _make_fused_impl_support_module( + FakeGroupedLinear, + activation_func=F.silu, + gated_linear_unit=True, + activation_func_clamp_value=10.0, + ) + + assert module._is_fused_impl_supported() is expected + + @pytest.mark.parametrize( ("use_fused_weighted_squared_relu", "gated_linear_unit", "expected"), [(True, False, True), (False, False, False), (True, True, False)], diff --git a/tests/unit_tests/transformer/moe/test_shared_experts.py b/tests/unit_tests/transformer/moe/test_shared_experts.py index 798313f188c..c71f665259f 100644 --- a/tests/unit_tests/transformer/moe/test_shared_experts.py +++ b/tests/unit_tests/transformer/moe/test_shared_experts.py @@ -52,6 +52,15 @@ def __init__(self, glu_interleave_size): self.glu_interleave_size = glu_interleave_size +class _FakeTEScaledClampedQGeGLU(torch.nn.Module): + def __init__(self, glu_interleave_size, *, alpha, limit, glu_linear_offset): + super().__init__() + self.glu_interleave_size = glu_interleave_size + self.alpha = alpha + self.limit = limit + self.glu_linear_offset = glu_linear_offset + + class _FakeTESequential(torch.nn.Module): def append(self, module): self.add_module(str(len(self._modules)), module) @@ -90,6 +99,7 @@ def _fake_te_module(linear_cls=_FakeTELinear): ops=SimpleNamespace( GroupedLinear=_FakeTEGroupedLinear, ScaledSwiGLU=_FakeTEScaledSwiGLU, + ScaledClampedQGeGLU=_FakeTEScaledClampedQGeGLU, Sequential=_FakeTESequential, ), fp8_autocast=_FakeFP8Autocast, @@ -123,6 +133,7 @@ def _fake_shared_expert(**config_kwargs): add_bias_linear=False, gated_linear_unit=True, activation_func=F.silu, + activation_func_clamp_value=None, moe_shared_expert_glu_interleave_size=32, delay_wgrad_compute=False, sequence_parallel=False, @@ -185,6 +196,21 @@ def test_validate_fused_grouped_swiglu_requires_te(monkeypatch): shared_expert._validate_fused_grouped_swiglu() +@pytest.mark.parametrize("has_clamped_op", [True, False]) +def test_validate_fused_grouped_swiglu_requires_clamped_te_support(monkeypatch, has_clamped_op): + fake_te = _patch_fake_shared_expert_te(monkeypatch) + if has_clamped_op: + monkeypatch.setattr( + shared_experts_module, "is_te_min_version", lambda version: version != "2.17.0.dev0" + ) + else: + del fake_te.pytorch.ops.ScaledClampedQGeGLU + shared_expert = _fake_shared_expert(activation_func_clamp_value=7.0) + + with pytest.raises(RuntimeError, match="ScaledClampedQGeGLU"): + shared_expert._validate_fused_grouped_swiglu() + + @pytest.mark.parametrize( ("config_kwargs", "bad_linear", "match"), [ @@ -241,6 +267,22 @@ def test_make_fused_grouped_swiglu_ops_builds_grouped_pipeline(monkeypatch): assert fc2_op.weight0 is shared_expert.linear_fc2.weight +def test_make_fused_grouped_swiglu_ops_builds_clamped_activation(monkeypatch): + _patch_fake_shared_expert_te(monkeypatch) + shared_expert = _fake_shared_expert(activation_func_clamp_value=7.0) + + shared_expert._validate_fused_grouped_swiglu() + ops = shared_expert._make_fused_grouped_swiglu_ops() + + activation_op = list(ops.children())[1] + assert isinstance(activation_op, _FakeTEScaledClampedQGeGLU) + assert activation_op.glu_interleave_size == 32 + assert activation_op.alpha == 1.0 + assert activation_op.limit == 7.0 + assert activation_op.glu_linear_offset == 0.0 + assert activation_op._grouped_mlp_unit_activation_scale is True + + def test_fused_grouped_swiglu_ops_replay_linear_pre_forward_hooks(monkeypatch): _patch_fake_shared_expert_te(monkeypatch) shared_expert = _fake_shared_expert() @@ -415,3 +457,126 @@ def test_shared_expert_forward_backward(self, dispatcher_type: str, tp_size, ep_ assert torch.allclose( p_overlap.grad, p_no_overlap.grad ), f"max diff: {torch.max(torch.abs(p_overlap.grad - p_no_overlap.grad))}" + + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + def test_shared_expert_glu_linear_offset_overlap_parity(self): + """Nonzero GLU offsets produce identical overlap and synchronous results.""" + Utils.initialize_model_parallel(tensor_model_parallel_size=1, expert_model_parallel_size=1) + shared_expert_kwargs = { + "moe_token_dispatcher_type": "alltoall", + "bias_activation_fusion": False, + "activation_func_clamp_value": None, + "glu_linear_offset": 0.5, + } + + _set_random_seed(seed_=123, data_parallel_random_init=False) + moe_layer_overlap = self.get_moe_layer( + moe_shared_expert_overlap=True, **shared_expert_kwargs + ).to(dtype=torch.bfloat16) + + _set_random_seed(seed_=123, data_parallel_random_init=False) + moe_layer_no_overlap = self.get_moe_layer( + moe_shared_expert_overlap=False, **shared_expert_kwargs + ).to(dtype=torch.bfloat16) + moe_layer_no_overlap.load_state_dict(moe_layer_overlap.state_dict()) + + hidden_states = torch.randn( + (32, 2, self.config.hidden_size), + requires_grad=True, + device="cuda", + dtype=torch.bfloat16, + ) + hidden_states_no_overlap = hidden_states.detach().clone().requires_grad_(True) + + shared_expert_overlap = moe_layer_overlap.shared_experts + shared_expert_no_overlap = moe_layer_no_overlap.shared_experts + assert shared_expert_overlap is not None + assert shared_expert_no_overlap is not None + + # Isolate shared-expert parity from nondeterministic routed-token unpermutation. + shared_expert_overlap.pre_forward_comm(hidden_states) + shared_expert_overlap.linear_fc1_forward_and_act() + shared_expert_overlap.linear_fc2_forward() + shared_expert_overlap.post_forward_comm() + output_overlap = shared_expert_overlap.get_output() + output_no_overlap = shared_expert_no_overlap(hidden_states_no_overlap) + torch.testing.assert_close(output_overlap, output_no_overlap) + + output_overlap.mean().backward() + output_no_overlap.mean().backward() + + torch.testing.assert_close(hidden_states.grad, hidden_states_no_overlap.grad) + for p_overlap, p_no_overlap in zip( + shared_expert_overlap.parameters(), shared_expert_no_overlap.parameters() + ): + torch.testing.assert_close(p_overlap.grad, p_no_overlap.grad) + + @pytest.mark.internal + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") + @pytest.mark.parametrize("bias_activation_fusion", [True, False]) + def test_shared_expert_clamped_swiglu(self, bias_activation_fusion): + """Verify clamped SwiGLU parity for overlapped and synchronous shared experts.""" + Utils.initialize_model_parallel(tensor_model_parallel_size=1, expert_model_parallel_size=1) + clamp_value = 1.0 + + _set_random_seed(seed_=123, data_parallel_random_init=False) + moe_layer_overlap = self.get_moe_layer( + moe_shared_expert_overlap=True, + moe_token_dispatcher_type="alltoall", + activation_func_clamp_value=clamp_value, + bias_activation_fusion=bias_activation_fusion, + ).to(dtype=torch.bfloat16) + + _set_random_seed(seed_=123, data_parallel_random_init=False) + moe_layer_no_overlap = self.get_moe_layer( + moe_shared_expert_overlap=False, + moe_token_dispatcher_type="alltoall", + activation_func_clamp_value=clamp_value, + bias_activation_fusion=bias_activation_fusion, + ).to(dtype=torch.bfloat16) + moe_layer_no_overlap.load_state_dict(moe_layer_overlap.state_dict()) + + hidden_states = ( + torch.randn((32, 2, self.config.hidden_size), device="cuda", dtype=torch.bfloat16) * 5.0 + ).requires_grad_(True) + hidden_states_no_overlap = hidden_states.detach().clone().requires_grad_(True) + + output_overlap, _ = moe_layer_overlap(hidden_states) + output_no_overlap, _ = moe_layer_no_overlap(hidden_states_no_overlap) + + cos_out = F.cosine_similarity( + output_overlap.flatten().unsqueeze(0).float(), + output_no_overlap.flatten().unsqueeze(0).float(), + ).item() + assert cos_out > 0.999, ( + f"shared-expert clamp output mismatch (fusion={bias_activation_fusion}): " + f"cos sim = {cos_out:.6f}" + ) + + output_overlap.mean().backward() + output_no_overlap.mean().backward() + + for p_overlap, p_no_overlap in zip( + moe_layer_overlap.parameters(), moe_layer_no_overlap.parameters() + ): + assert torch.allclose(p_overlap.grad, p_no_overlap.grad), ( + f"shared-expert clamp mismatch (fusion={bias_activation_fusion}); " + f"max diff: {torch.max(torch.abs(p_overlap.grad - p_no_overlap.grad))}" + ) + + _set_random_seed(seed_=123, data_parallel_random_init=False) + moe_layer_unclamped = self.get_moe_layer( + moe_shared_expert_overlap=False, + moe_token_dispatcher_type="alltoall", + activation_func_clamp_value=None, + bias_activation_fusion=bias_activation_fusion, + ).to(dtype=torch.bfloat16) + moe_layer_unclamped.load_state_dict(moe_layer_overlap.state_dict()) + + hidden_states_unclamped = hidden_states.detach().clone().requires_grad_(True) + output_unclamped, _ = moe_layer_unclamped(hidden_states_unclamped) + assert not torch.allclose(output_no_overlap, output_unclamped), ( + "Clamping had no observable effect on shared-expert output; " + "activation_func_clamp_value may not be plumbed through." + ) From 79edfdb3744164737a6ac5f931baed895a4c24b3 Mon Sep 17 00:00:00 2001 From: Kimbrian Date: Fri, 22 May 2026 20:34:56 +0000 Subject: [PATCH 281/290] [baseten] fix(tensor-parallel): avoid int32 stride overflow in frozen linear dgrad For a 3D grad_output, .matmul(weight) can be dispatched to a batched-GEMM whose strideA argument is stored as int32 in the cuBLAS API. When grad_output is a non-contiguous view (e.g. Megatron's standard [s, b, h] layout on a [b, s, h]-contiguous storage), torch cannot collapse it to 2D without a copy and falls back to bmm. At long sequence and large out-per-partition the resulting strideA = seq_len * out_per_partition exceeds INT32_MAX and cuBLAS raises: RuntimeError: at::cuda::blas::bgemm argument ldb must be positive and less than 2147483647 but got 2860646400 Repro: a frozen LM head under LoRA at seq=46080, vocab=248320, TP=4 (strideA = 46080 * 62080 = 2,860,646,400 > 2^31 - 1). Flatten the leading dims into the M axis before the matmul so torch routes through a single regular GEMM. The common Megatron-layout case recovers the underlying [b, s, h]-contiguous view via a free .transpose(0, 1) and the subsequent reshape becomes a pure view; for any other 3D non-contiguous layout, fall back to an explicit reshape that calls .contiguous() internally. The 2D path is unchanged. Adds tests/unit_tests/tensor_parallel/test_layers.py ::test_LinearWithFrozenWeight_3d_non_contiguous_grad_output to defend the dispatch path (the overflow itself only fires at sizes too large for unit-test memory budgets; the test exercises the new code path at small sizes against the same non-contiguous layout shape). Signed-off-by: Kimbrian (cherry picked from commit 721e79830ed3d4abf37230b9dcddbb94331c0557) --- megatron/core/tensor_parallel/layers.py | 25 ++++++++++++- .../unit_tests/tensor_parallel/test_layers.py | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/megatron/core/tensor_parallel/layers.py b/megatron/core/tensor_parallel/layers.py index e842e5d9f39..206741afad1 100644 --- a/megatron/core/tensor_parallel/layers.py +++ b/megatron/core/tensor_parallel/layers.py @@ -425,7 +425,30 @@ def backward(ctx, grad_output): """Backward with frozen weight.""" (weight,) = ctx.saved_tensors grad_output = grad_output.to(ctx.input_dtype) - if grad_output.dim() > 2: + # For Megatron's [s, b, h] layout, .transpose(0, 1) recovers the + # contiguous [b, s, h] storage as a free view, so we can flatten + # to 2D and route through a single regular GEMM (avoids a + # batched-GEMM dispatch whose int32 strideA can overflow at long + # sequence × large out-per-partition). Other 3D layouts fall back + # to an explicit reshape (which copies if non-contiguous). + if grad_output.dim() == 3: + swapped = grad_output.transpose(0, 1) + if swapped.is_contiguous(): + seq_len, batch_size, in_features = grad_output.shape + grad_input = ( + swapped.reshape(batch_size * seq_len, in_features) + .matmul(weight) + .view(batch_size, seq_len, -1) + .transpose(0, 1) + ) + else: + in_features = grad_output.shape[-1] + grad_input = ( + grad_output.reshape(-1, in_features) + .matmul(weight) + .view(*grad_output.shape[:-1], -1) + ) + elif grad_output.dim() > 2: # Work around PyTorch matmul not folding some size-1 leading dims to mm. # Remove this once https://github.com/pytorch/pytorch/issues/186148 is fixed. grad_output_2d = grad_output.reshape(-1, grad_output.size(-1)) diff --git a/tests/unit_tests/tensor_parallel/test_layers.py b/tests/unit_tests/tensor_parallel/test_layers.py index cf1e8185017..c25e5381e39 100644 --- a/tests/unit_tests/tensor_parallel/test_layers.py +++ b/tests/unit_tests/tensor_parallel/test_layers.py @@ -198,5 +198,42 @@ def test_linear_fp32_output_matches_plain_te_general_gemm(): wrapped_output.contiguous().view(torch.int32), plain_te_output.contiguous().view(torch.int32), ) +def test_LinearWithFrozenWeight_3d_non_contiguous_grad_output(): + """Backward must handle a 3D non-contiguous grad_output without + crashing in the batched-GEMM dispatch.""" + Utils.initialize_model_parallel(1, 1) + + seq_length, batch_size, in_features, out_features = 4, 2, 8, 6 + + # [B, S, K] contig leaf; transpose downstream so backward produces a + # non-contiguous [S, B, K] grad_output into the linear. + input_bsk = torch.arange( + batch_size * seq_length * in_features, dtype=torch.float32 + ).reshape(batch_size, seq_length, in_features).cuda() + input_bsk.requires_grad = True + input_sbk = input_bsk.transpose(0, 1) + assert not input_sbk.is_contiguous() + + weight = torch.ones((out_features, in_features)).cuda() + bias = torch.zeros(out_features).cuda() + + output_parallel = linear_with_frozen_weight( + input_sbk, + weight, + bias, + False, # gradient_accumulation_fusion + False, # allreduce_dgrad + False, # sequence_parallel + None, # grad_output_buffer + None, # wgrad_deferral_limit + ) + output = gather_from_tensor_model_parallel_region(output_parallel) + output.transpose(0, 1).sum().backward() + + # weight=ones means each input element contributes once per output + # column, so its grad equals the number of output columns. + expected_grad = torch.full_like(input_bsk, float(out_features)) + assert input_bsk.grad is not None + assert torch.allclose(input_bsk.grad, expected_grad) Utils.destroy_model_parallel() From 64aaa7aa42a6fd9e852c72b07a1edb736d9da933 Mon Sep 17 00:00:00 2001 From: Kimbrian Date: Wed, 24 Jun 2026 23:19:22 -0700 Subject: [PATCH 282/290] [baseten] moe: clear dispatcher forward state after combine (cherry picked from commit 6e90d30759ec1cd07ecca775ef53bbd69f53cf1d) --- .../core/transformer/moe/token_dispatcher.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/moe/token_dispatcher.py b/megatron/core/transformer/moe/token_dispatcher.py index 8fc4acedfef..ce501942cd2 100644 --- a/megatron/core/transformer/moe/token_dispatcher.py +++ b/megatron/core/transformer/moe/token_dispatcher.py @@ -228,6 +228,12 @@ def get_expert_zero_copy_buffers(self): """ return None, None + def _clear_forward_state(self, *attr_names: str) -> None: + """Drop per-forward hand-off references once the dispatcher has consumed them.""" + for attr_name in attr_names: + if hasattr(self, attr_name): + setattr(self, attr_name, None) + class MoEAllGatherTokenDispatcher(MoETokenDispatcher): """ @@ -904,6 +910,22 @@ def combine_postprocess(self, permutated_local_input_tokens): if self.shared_experts is not None: shared_expert_output = self.shared_experts.get_output() output += shared_expert_output + + self._clear_forward_state( + "hidden_shape", + "hidden_shape_before_permute", + "probs", + "routing_map", + "reversed_local_input_permutation_mapping", + "tokens_per_expert", + "input_splits", + "output_splits", + "output_splits_tp", + "num_out_tokens", + "num_global_tokens_per_local_expert", + "capacity", + "d2h_event", + ) return output def _maybe_update_cuda_sync_point(self, point: str): @@ -1205,6 +1227,11 @@ def combine( self.num_permuted_tokens = None self._original_num_tokens = None self._padded_num_tokens = None + self.routing_map = None + self.token_probs = None + self.dispatched_probs = None + self.tokens_per_expert = None + self.pad_multiple = None return hidden_states def get_permuted_hidden_states_by_experts(self, hidden_states: torch.Tensor) -> torch.Tensor: @@ -1386,6 +1413,15 @@ def combine( # Manually release the metadata to avoid memory leak. self.dispatched_indices = None self.dispatched_probs = None + # These are forward-only hand-off references; autograd Functions own + # anything needed for backward after combine/restoration has consumed them. + self.reversed_mapping_for_combine = None + self.pad_offsets = None + self.dispatched_routing_map = None + self.hidden_shape_before_permute = None + self.token_indices = None + self.token_probs = None + self.tokens_per_expert = None return hidden_states def _pad_routing_map( @@ -2042,7 +2078,9 @@ def combine_postprocess(self, hidden_states: torch.Tensor): self.shared_experts.linear_fc2_forward(hidden_states) self.shared_experts.post_forward_comm() hidden_states += self.shared_experts.get_output() - return hidden_states.view(self.hidden_shape) + hidden_states = hidden_states.view(self.hidden_shape) + self._clear_forward_state("hidden_shape") + return hidden_states def check_over_budget(self): """Check if the dispatcher has exceeded its budget.""" From e884215356bb57b77b7d3c16fa4ed8dd8197113d Mon Sep 17 00:00:00 2001 From: Paras Stefanopoulos Date: Thu, 9 Jul 2026 10:35:14 -0700 Subject: [PATCH 283/290] [baseten] fix(mla): use int64 row offsets in fused RoPE kernels --- megatron/core/fusions/fused_mla_yarn_rope_apply.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/megatron/core/fusions/fused_mla_yarn_rope_apply.py b/megatron/core/fusions/fused_mla_yarn_rope_apply.py index 1fd5dcfae37..7cb4bb8e90b 100644 --- a/megatron/core/fusions/fused_mla_yarn_rope_apply.py +++ b/megatron/core/fusions/fused_mla_yarn_rope_apply.py @@ -94,7 +94,7 @@ def rotary_fwd_q_kernel( seq_num: number of sequences for thd format, not used for sbhd format cu_seqlens_q: [seq_num + 1] accumulated sequence lengths for thd format """ - pid_m = tl.program_id(axis=0) + pid_m = tl.program_id(axis=0).to(tl.int64) pid_head = tl.program_id(axis=1) if cu_seqlens_q is None: @@ -172,7 +172,7 @@ def rotary_bwd_q_kernel( batch_size, seq_num, and cu_seqlens_q are the same as in the forward pass """ - pid_m = tl.program_id(axis=0) + pid_m = tl.program_id(axis=0).to(tl.int64) pid_head = tl.program_id(axis=1) if cu_seqlens_q is None: @@ -421,7 +421,7 @@ def rotary_fwd_kv_kernel( or [total_seq_len, head_num, emb_dim + k_dim] O_VALUE: [seq_len, batch_size, head_num, v_dim] or [total_seq_len, head_num, v_dim] """ - pid_m = tl.program_id(axis=0) + pid_m = tl.program_id(axis=0).to(tl.int64) pid_head = tl.program_id(axis=1) if cu_seqlens_kv is None: @@ -525,7 +525,7 @@ def rotary_bwd_kv_kernel( or [total_seq_len, head_num, k_dim + v_dim] dEMB: [seq_len, batch_size, emb_dim] or [total_seq_len, emb_dim] """ - pid_m = tl.program_id(axis=0) + pid_m = tl.program_id(axis=0).to(tl.int64) pid_head = tl.program_id(axis=1) if cu_seqlens_kv is None: From a7a618cd681bc305c3e84ef28d92fade28e8f80f Mon Sep 17 00:00:00 2001 From: Paras Stefanopoulos Date: Thu, 9 Jul 2026 11:23:02 -0700 Subject: [PATCH 284/290] [baseten] support LoRA on absorbed GLM MLA Restore the trainers-main GLM LoRA path on top of current NVIDIA main. Absorbed MLA reads linear_kv_up_proj as a raw weight because K is folded into the query and V is applied after core attention, so a normal AdapterWrapper forward would never run. Add a small override hook for the effective KV up-projection weight and use a GLM absorbed-MLA subclass that folds AdapterWrapper LoRA factors into that weight. The subclass is behaviorally identical when the projection is not LoRA-wrapped. LoRA on this absorbed KV up-projection remains limited to TP=1, matching the trainers-main support. --- ...rimental_attention_variant_module_specs.py | 6 ++- .../absorbed_mla.py | 16 ++++++-- .../glm_absorbed_mla.py | 40 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 megatron/core/transformer/experimental_attention_variant/glm_absorbed_mla.py diff --git a/megatron/core/models/gpt/experimental_attention_variant_module_specs.py b/megatron/core/models/gpt/experimental_attention_variant_module_specs.py index 87c63a126bb..20dcb0fe344 100644 --- a/megatron/core/models/gpt/experimental_attention_variant_module_specs.py +++ b/megatron/core/models/gpt/experimental_attention_variant_module_specs.py @@ -8,7 +8,6 @@ from megatron.core.ssm.gated_delta_net import GatedDeltaNet, GatedDeltaNet2, GatedDeltaNetSubmodules from megatron.core.transformer.enums import AttnMaskType, LayerType from megatron.core.transformer.experimental_attention_variant.absorbed_mla import ( - AbsorbedMLASelfAttention, AbsorbedMLASelfAttentionSubmodules, ) from megatron.core.transformer.experimental_attention_variant.dsa import ( @@ -19,6 +18,9 @@ is_dsa_skip_topk_layer, source_dsa_compute_layer, ) +from megatron.core.transformer.experimental_attention_variant.glm_absorbed_mla import ( + GlmAbsorbedMLASelfAttention, +) from megatron.core.transformer.identity_op import IdentityOp from megatron.core.transformer.spec_utils import ModuleSpec from megatron.core.transformer.transformer_block import ( @@ -127,7 +129,7 @@ def get_dsa_module_spec_for_backend( ) attention = ModuleSpec( - module=AbsorbedMLASelfAttention, + module=GlmAbsorbedMLASelfAttention, params={"attn_mask_type": AttnMaskType.causal}, submodules=AbsorbedMLASelfAttentionSubmodules( linear_q_proj=backend.column_parallel_linear(), diff --git a/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py b/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py index e0b6af7aa7f..d965a270096 100644 --- a/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py +++ b/megatron/core/transformer/experimental_attention_variant/absorbed_mla.py @@ -678,14 +678,24 @@ def _get_v_up_weight(self) -> torch.Tensor: _, v_up_weight = self._get_kv_up_weights() return v_up_weight + def _kv_up_proj_weight(self) -> torch.Tensor: + """Raw combined KV up-projection weight consumed by the absorbed path. + + The absorbed path uses this weight directly instead of calling + ``linear_kv_up_proj.forward``. Subclasses can override this to return an + effective weight, for example with a LoRA adapter folded in. + """ + return self.linear_kv_up_proj.weight + def _get_kv_up_weights(self) -> tuple[torch.Tensor, torch.Tensor]: """Return K and V up-projection weights from the combined per-head MLA layout.""" expected_rows = self.num_attention_heads_per_partition * ( self.config.qk_head_dim + self.config.v_head_dim ) - assert self.linear_kv_up_proj.weight.size(0) == expected_rows - assert self.linear_kv_up_proj.weight.size(1) == self.config.kv_lora_rank - kv_up_weight = self.linear_kv_up_proj.weight.view( + weight = self._kv_up_proj_weight() + assert weight.size(0) == expected_rows + assert weight.size(1) == self.config.kv_lora_rank + kv_up_weight = weight.view( self.num_attention_heads_per_partition, self.config.qk_head_dim + self.config.v_head_dim, self.config.kv_lora_rank, diff --git a/megatron/core/transformer/experimental_attention_variant/glm_absorbed_mla.py b/megatron/core/transformer/experimental_attention_variant/glm_absorbed_mla.py new file mode 100644 index 00000000000..b45af8c643d --- /dev/null +++ b/megatron/core/transformer/experimental_attention_variant/glm_absorbed_mla.py @@ -0,0 +1,40 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. + +"""GLM LoRA-aware absorbed MLA. + +The absorbed path consumes ``linear_kv_up_proj`` as a raw weight rather than +calling its ``forward``. When Bridge wraps that projection with a LoRA adapter, +fold the adapter into the effective weight so the adapter participates in the +absorbed K/V path. +""" + +import torch + +from megatron.core.transformer.experimental_attention_variant.absorbed_mla import ( + AbsorbedMLASelfAttention, +) + + +class GlmAbsorbedMLASelfAttention(AbsorbedMLASelfAttention): + """Absorbed MLA that folds a LoRA adapter into the KV up-projection weight.""" + + def _kv_up_proj_weight(self) -> torch.Tensor: + module = self.linear_kv_up_proj + if not hasattr(module, "to_wrap"): + return module.weight + + weight = module.to_wrap.weight + if not getattr(module, "_adapter_enabled", True): + return weight + if self.config.tensor_model_parallel_size != 1: + raise NotImplementedError( + "LoRA on the absorbed KV up-projection is only supported with TP=1." + ) + + adapter = module.adapter + lora_a = adapter.linear_in.weight + lora_b = adapter.linear_out.weight + scale = getattr(adapter, "scale", None) + if scale is None: + scale = adapter.alpha / adapter.dim + return weight + scale * (lora_b @ lora_a) From 42a45740d6475d7736fdb9f5e03a50f906483c5c Mon Sep 17 00:00:00 2001 From: JackRao123 <112158010+JackRao123@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:58:45 -0700 Subject: [PATCH 285/290] fix(dsa): align packed-CP indexer causal masks (#16) * fix(dsa): pass q_causal_offsets to the cuDNN indexer in packed-CP top-k paths The cuDNN indexer_forward_wrapper applies TOP-LEFT-aligned causal masking by default (row i keeps keys j <= i), but both packed-CP indexer top-k paths hand it query chunks whose rows sit at ABSOLUTE causal positions inside a key prefix cropped to key_end: - _indexer_topk_from_score_chunks (single packed THD sequence, CP front/back segments): each row chunk's q[0] sits at global key index bottom_right_key_start + row_start; - _indexer_topk_multi_packed_cp_thd (multi-document packed THD): each THD segment's q[0] sits at doc-local key index segment_k_lengths - segment_q_lengths. Without the offsets, every zigzag chunk except cp_rank 0's front chunk is masked to a chunk-local window (~seq/2cp keys) instead of its true causal prefix, so the downstream top-k silently selects from the wrong keys (measured 2-25% overlap vs an exact fp32 torch reference at cp_size=32, GLM-5.2 indexer dims), and at 131k tokens the mismatched -inf pattern surfaces as cudaErrorIllegalAddress inside indexer_top_k. Training does NOT crash at short sequence lengths - it just learns on a wrong sparse-attention pattern. Passing the kernel's q_causal_offsets argument ("global uncompressed token index for each batch/THD segment's local q[0]", cudnn 1.25.0) at both call sites makes index parity exact (overlap 1.0000, zero causally-out-of-bounds indices) vs the torch reference for single-doc rows at 8k/32k/131k across cp ranks 0/15/31 and multi-doc packs [8192,4096]/[65536,65536]/[131008,64], and eliminates the 131k IMA (reproduced standalone at docs=[65536,65536], cp_rank=31 before the fix). Note test_cudnn_indexer_topk_single_packed_cp_real_kernel_uses_bottom_right_alignment (the one test that runs the real kernel on this path) is currently disabled as flaky (cutlass ThrMma build issue); the remaining tests mock the kernel and mask this defect. Co-Authored-By: Claude Fable 5 * docs(dsa): clarify packed CP causal offset comments Keep the per-path invariants close to the code without duplicating the failure-mode explanation at both call sites. Signed-off-by: Jack Rao * docs(dsa): remove redundant causal offset comments Keep the packed-CP rationale in the LM#14 description instead of duplicating it beside both call sites. Signed-off-by: Jack Rao --------- Signed-off-by: Jack Rao Co-authored-by: Claude Fable 5 --- .../dsa_cudnn_kernels.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py b/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py index 640db5c91ec..72a3e136252 100644 --- a/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py +++ b/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py @@ -612,8 +612,19 @@ def _indexer_topk_from_score_chunks( None if score_seq_lens is None else score_seq_lens[row_start:row_end].contiguous() ) if bottom_right_key_start is not None: + q_causal_offsets = torch.full( + (b,), + bottom_right_key_start + row_start, + dtype=torch.int32, + device=q_chunk.device, + ) scores_chunk = _cudnn_dsa.indexer_forward_wrapper( - q_chunk, score_k_bshd, w_chunk, ratio=indexer_ratio, sm_scale=_INDEXER_SOFTMAX_SCALE + q_chunk, + score_k_bshd, + w_chunk, + ratio=indexer_ratio, + sm_scale=_INDEXER_SOFTMAX_SCALE, + q_causal_offsets=q_causal_offsets, )["scores"] elif score_seq_lens is None and row_start == 0 and row_end == sq: scores_chunk = _cudnn_dsa.indexer_forward_wrapper( @@ -738,6 +749,9 @@ def _indexer_topk_multi_packed_cp_thd( max_segment_q = packed_max_seqlen_q // segment_divisor max_k_half = packed_max_seqlen_k // segment_divisor max_segment_k = max((cp_rank + 1) * max_k_half, packed_max_seqlen_k - cp_rank * max_k_half) + segment_q_causal_offsets = (segment_k_lengths - segment_q_lengths).to( + dtype=torch.int32, device=device + ) scores = _cudnn_dsa.indexer_forward_wrapper( q_bshd[0], segmented_k, @@ -748,6 +762,7 @@ def _indexer_topk_multi_packed_cp_thd( cu_seqlens_k=layout.segment_cu_k.to(dtype=torch.int32), max_seqlen_q=max_segment_q, max_seqlen_k=max_segment_k, + q_causal_offsets=segment_q_causal_offsets, )["scores"] segment_topk = min(topk_k, max_segment_k) From 8254c775a9a2e050e7b06b457a0652b2d585a8af Mon Sep 17 00:00:00 2001 From: JackRao123 <112158010+JackRao123@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:56:59 -0700 Subject: [PATCH 286/290] fix(dsa): fall back for unsupported odd cuDNN top-k (#19) Keep fused score generation and sparse attention while selecting odd top-k values with PyTorch, avoiding cuDNN Frontend's vector-width assertion for packed CP segments. Co-authored-by: Cursor --- .../dsa_cudnn_kernels.py | 41 +++++++-- .../test_dsa_native_parity.py | 88 +++++++++++++++++++ 2 files changed, 120 insertions(+), 9 deletions(-) diff --git a/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py b/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py index 72a3e136252..f8e93b85b0d 100644 --- a/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py +++ b/megatron/core/transformer/experimental_attention_variant/dsa_cudnn_kernels.py @@ -460,10 +460,36 @@ def _remove_indexer_topk_tie_break( return torch.where(topk_indices >= 0, topk_scores - bias, topk_scores) +def _indexer_top_k_one_chunk( + scores_flat: Tensor, seq_lens: Tensor, topk_k: int, return_topk_scores: bool +) -> dict: + """Run top-k for one row chunk, avoiding cuDNN's unsupported odd-K specialization.""" + if topk_k % 2 == 0: + return _cudnn_dsa.indexer_top_k_wrapper( + scores_flat, seq_lens, top_k=topk_k, next_n=1, return_val=return_topk_scores + ) + + # cuDNN Frontend 1.26.0 selects a vector-width-2 output path for some odd K values + # and asserts during JIT compilation. Its public contract permits any K <= 2048, so + # keep cuDNN indexer scoring and use PyTorch only for the unsupported selection step. + seq_lens = seq_lens.to(device=scores_flat.device).clamp(min=0, max=scores_flat.size(1)) + key_positions = torch.arange(scores_flat.size(1), device=scores_flat.device) + scores_flat.masked_fill_(key_positions.unsqueeze(0) >= seq_lens.unsqueeze(1), float("-inf")) + topk_scores, topk_indices = torch.topk(scores_flat, topk_k, dim=-1, sorted=False) + + valid = topk_indices < seq_lens.unsqueeze(1) + topk_indices = topk_indices.to(torch.int32).masked_fill_(~valid, -1) + if return_topk_scores: + topk_scores.masked_fill_(~valid, float("-inf")) + else: + topk_scores = None + return {"indices": topk_indices, "values": topk_scores} + + def _indexer_top_k_wrapper_chunked( scores_flat: Tensor, seq_lens: Tensor, topk_k: int, return_topk_scores: bool ) -> dict: - """Run cuDNN top-k in row chunks to bound wrapper scratch allocation.""" + """Run indexer top-k in row chunks to bound selection scratch allocation.""" n_rows, sk = scores_flat.shape scratch_bytes_per_row = max(1, sk) * torch.iinfo(torch.int32).bits // 8 scratch_bytes_per_row *= _TOPK_WRAPPER_SCRATCH_INT32_FACTOR @@ -472,26 +498,23 @@ def _indexer_top_k_wrapper_chunked( ) if chunk_rows >= n_rows: - return _cudnn_dsa.indexer_top_k_wrapper( - scores_flat, seq_lens, top_k=topk_k, next_n=1, return_val=return_topk_scores - ) + return _indexer_top_k_one_chunk(scores_flat, seq_lens, topk_k, return_topk_scores) indices_chunks = [] values_chunks = [] if return_topk_scores else None for row_start in range(0, n_rows, chunk_rows): row_end = min(row_start + chunk_rows, n_rows) - tk_result = _cudnn_dsa.indexer_top_k_wrapper( + tk_result = _indexer_top_k_one_chunk( scores_flat[row_start:row_end].contiguous(), seq_lens[row_start:row_end].contiguous(), - top_k=topk_k, - next_n=1, - return_val=return_topk_scores, + topk_k, + return_topk_scores, ) indices_chunks.append(tk_result["indices"]) if return_topk_scores: values = tk_result["values"] if values is None: - raise RuntimeError("cuDNN indexer_top_k_wrapper did not return values.") + raise RuntimeError("Indexer top-k selection did not return values.") values_chunks.append(values) return { diff --git a/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_native_parity.py b/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_native_parity.py index cf9db4a2fb3..6ec82db0603 100644 --- a/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_native_parity.py +++ b/tests/unit_tests/transformer/experimental_attention_variant/test_dsa_native_parity.py @@ -234,6 +234,94 @@ def _reference_absorbed_output_and_sparse_loss(case, topk_indices: torch.Tensor) return output, loss +@pytest.mark.parametrize("return_topk_scores", [False, True]) +def test_cudnn_indexer_topk_odd_k_uses_torch_fallback_for_cp_regression_shape( + monkeypatch, return_topk_scores +): + class FailingDSA: + @staticmethod + def indexer_top_k_wrapper(*_args, **_kwargs): + raise AssertionError("cuDNN top-k must not be called for odd K") + + monkeypatch.setattr(dsa_cudnn_kernels, "_cudnn_dsa", FailingDSA) + scores = torch.arange(705, dtype=torch.float32).view(1, 705).expand(235, 705).clone() + seq_lens = torch.full((235,), 705, dtype=torch.int32) + + result = dsa_cudnn_kernels._indexer_top_k_wrapper_chunked( + scores, seq_lens, topk_k=705, return_topk_scores=return_topk_scores + ) + + expected_indices = torch.arange(705, dtype=torch.int32).view(1, 705).expand(235, 705) + torch.testing.assert_close(result["indices"].sort(dim=-1).values, expected_indices) + if return_topk_scores: + assert result["values"] is not None + selected_scores = torch.gather(scores, dim=1, index=result["indices"].long()) + torch.testing.assert_close(result["values"], selected_scores) + else: + assert result["values"] is None + + +def test_cudnn_indexer_topk_odd_k_torch_fallback_respects_seq_lens(monkeypatch): + class FailingDSA: + @staticmethod + def indexer_top_k_wrapper(*_args, **_kwargs): + raise AssertionError("cuDNN top-k must not be called for odd K") + + monkeypatch.setattr(dsa_cudnn_kernels, "_cudnn_dsa", FailingDSA) + scores = torch.tensor( + [ + [1.0, 3.0, 2.0, 100.0, 99.0, 98.0, 97.0], + [1.0, 9.0, 3.0, 8.0, 2.0, 7.0, 6.0], + ] + ) + seq_lens = torch.tensor([3, 7], dtype=torch.int32) + + result = dsa_cudnn_kernels._indexer_top_k_wrapper_chunked( + scores, seq_lens, topk_k=5, return_topk_scores=True + ) + + assert result["values"] is not None + assert (result["indices"][0] == -1).sum().item() == 2 + assert set(result["indices"][0][result["indices"][0] >= 0].tolist()) == {0, 1, 2} + assert torch.isneginf(result["values"][0][result["indices"][0] == -1]).all() + assert set(result["indices"][1].tolist()) == {1, 2, 3, 5, 6} + + +def test_cudnn_indexer_topk_even_k_still_uses_cudnn(monkeypatch): + seen = {} + + class FakeDSA: + @staticmethod + def indexer_top_k_wrapper(scores, seq_lens, top_k, next_n, return_val): + seen.update( + scores=scores, + seq_lens=seq_lens, + top_k=top_k, + next_n=next_n, + return_val=return_val, + ) + return { + "indices": torch.zeros((scores.size(0), top_k), dtype=torch.int32), + "values": None, + } + + monkeypatch.setattr(dsa_cudnn_kernels, "_cudnn_dsa", FakeDSA) + scores = torch.randn(3, 8) + seq_lens = torch.full((3,), 8, dtype=torch.int32) + + result = dsa_cudnn_kernels._indexer_top_k_wrapper_chunked( + scores, seq_lens, topk_k=4, return_topk_scores=False + ) + + assert seen["scores"] is scores + assert seen["seq_lens"] is seq_lens + assert seen["top_k"] == 4 + assert seen["next_n"] == 1 + assert seen["return_val"] is False + assert result["indices"].shape == (3, 4) + assert result["values"] is None + + def test_cudnn_indexer_topk_varlen_uses_logical_query_positions(monkeypatch): class FakeDSA: @staticmethod From 6258e429e8e63e1525d7dfa4015117fb5a7a64f3 Mon Sep 17 00:00:00 2001 From: Kimbrian Date: Mon, 3 Aug 2026 19:42:45 -0700 Subject: [PATCH 287/290] build: drop the fast-hadamard-transform git source pin [tool.uv.sources] entries propagate to consumers that vendor this repo as a path dependency; the git pin overrode their prebuilt-wheel routing for fht. The plain requirement stays; consumers pick the source (the dependency-metadata stanza keeps lock-anywhere working). --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 26c0fbc33be..7169784aaf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -237,7 +237,6 @@ deep_gemm = { git = "https://github.com/deepseek-ai/DeepGEMM.git", rev = "714dd1 transformer-engine = { git = "https://github.com/NVIDIA/TransformerEngine.git", rev = "e7c550c5f80636cf841a8204b1d6f85a5f3f28b7" } nemo-run = { git = "https://github.com/NVIDIA-NeMo/Run.git", rev = "e3935393a290aed1822af52139b4b8ee270fed1f" } emerging_optimizers = { git = "https://github.com/NVIDIA-NeMo/Emerging-Optimizers.git", rev = "v0.2.0" } -fast-hadamard-transform = { git = "https://github.com/Dao-AILab/fast-hadamard-transform.git", rev = "f134af63deb2df17e1171a9ec1ea4a7d8604d5ca" } mamba-ssm = { git = "https://github.com/state-spaces/mamba.git", rev = "0048fbf2e7b2f214dcbe703ea3dec2b9647595e1" } [tool.isort] From 0208c98a1d0e2416433c2e0b4217b091e79db56c Mon Sep 17 00:00:00 2001 From: JackRao123 <112158010+JackRao123@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:22:52 -0700 Subject: [PATCH 288/290] fix(te): pass pad_between_seqs explicitly for tail-padded THD under CP (#25) Released TE's pad_between_seqs auto-detect ignores padding after the last sequence; under context parallelism that silently corrupts chunk-boundary rows (nondeterministic forward/gradients + wrong attention). Compute the tail-inclusive answer at the call site and pass it explicitly. Details: basetenlabs/Megatron-LM#25. Co-authored-by: Claude Fable 5 --- megatron/core/extensions/transformer_engine.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 8dd339f3cef..67dcc701dc8 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -2251,6 +2251,24 @@ def forward( ) qkv_format = packed_seq_kwargs.get('qkv_format', self.qkv_format) + # Released TE mis-detects tail-only padding as "no padding", which + # under context parallelism silently corrupts chunk-boundary rows. + # Pass the answer explicitly instead of relying on TE's auto-detect. + # Details: basetenlabs/Megatron-LM#25. + if qkv_format == "thd" and self.config.context_parallel_size > 1: + cu_q = packed_seq_kwargs.get("cu_seqlens_q") + cu_q_padded = packed_seq_kwargs.get("cu_seqlens_q_padded") + cu_kv = packed_seq_kwargs.get("cu_seqlens_kv") + cu_kv_padded = packed_seq_kwargs.get("cu_seqlens_kv_padded") + if ( + cu_q_padded is not None and cu_q is not None and not torch.equal(cu_q_padded, cu_q) + ) or ( + cu_kv_padded is not None + and cu_kv is not None + and not torch.equal(cu_kv_padded, cu_kv) + ): + packed_seq_kwargs["pad_between_seqs"] = True + attention_bias_kwargs = {} if attention_bias is not None: assert is_te_min_version("1.2.0"), ( From 17fb6aa615a22a4636da748a54f9fdb316eca46d Mon Sep 17 00:00:00 2001 From: Xiaohan Zhang Date: Thu, 30 Jul 2026 14:47:53 -0700 Subject: [PATCH 289/290] build(deps): relax the flash-linear-attention ceiling to admit 0.5.x The ~=0.4.0 cap excluded all of 0.5.x, making megatron-bridge's flash-linear-attention>=0.5.2 floor (needed by Kimi-K3's KDA kernel) unsatisfiable. MCore's own fla surface -- causal_conv1d, l2norm, chunk_gated_delta_rule in ssm/gated_delta_net.py -- is signature-compatible across 0.4.2 and 0.5.2, so this only stops MCore from excluding 0.5.x; it does not require it. --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7169784aaf3..a95d416bc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,11 @@ dev = [ "tensorstore~=0.1,!=0.1.46,!=0.1.72", "multi-storage-client~=0.50", "opentelemetry-api~=1.33.1", - "flash-linear-attention==0.5.1", + # Upstream moved to ==0.5.1; Kimi-K3 needs >=0.5.2, because chunk_kda only + # started reading A_log/dt_bias from **kwargs in 0.5.2 and silently discards + # them before that -- training a different KDA forget gate with nothing + # raised. Keep upstream's 0.5.x line, floored where K3 becomes correct. + "flash-linear-attention>=0.5.2,<0.6", "megatron-energon[av_decode]~=7.0", "av", "flashinfer-python>=0.5.0,<0.7.0", From 2f3c7fdf7e83923f1b7042e4bc6348ac46b403b4 Mon Sep 17 00:00:00 2001 From: Xiaohan Zhang Date: Tue, 18 Aug 2026 01:50:54 +0000 Subject: [PATCH 290/290] fix(pp): honour the explicit pp_rank under a custom pipeline layout `get_transformer_layer_offset(config, vp_stage, pp_rank)` accepts a rank and every branch uses it except the custom-layout one, which called `PipelineParallelLayerLayout.get_layer_offset` without passing it on. That helper then fell back to the local rank, so asking for another stage's first layer returned this stage's answer. `get_layer_offset` already takes `pp_rank`, so this forwards the argument the caller supplied. It matters for callers that enumerate every stage's first layer from one process. Kimi-K3 does exactly that to decide which layers sit on a pipeline boundary and therefore have to pack the AttnRes snapshot bank into the payload: with the offsets collapsed to one value, the sending and receiving stages disagree about the payload width. Signed-off-by: Xiaohan Zhang --- .../core/transformer/transformer_layer.py | 2 +- .../transformer/test_transformer_block.py | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/megatron/core/transformer/transformer_layer.py b/megatron/core/transformer/transformer_layer.py index 0f7be974418..69304c84af8 100644 --- a/megatron/core/transformer/transformer_layer.py +++ b/megatron/core/transformer/transformer_layer.py @@ -67,7 +67,7 @@ def get_transformer_layer_offset( if config.pipeline_model_parallel_layout: offset = config.pipeline_model_parallel_layout.get_layer_offset( - layer_type=LayerType.decoder, vp_stage=vp_stage + layer_type=LayerType.decoder, vp_stage=vp_stage, pp_rank=pp_rank ) elif ( config.num_layers_in_first_pipeline_stage is not None diff --git a/tests/unit_tests/transformer/test_transformer_block.py b/tests/unit_tests/transformer/test_transformer_block.py index 0f19bc3dc95..5027394fe29 100644 --- a/tests/unit_tests/transformer/test_transformer_block.py +++ b/tests/unit_tests/transformer/test_transformer_block.py @@ -20,7 +20,10 @@ from megatron.core.transformer.spec_utils import build_module from megatron.core.transformer.transformer_block import TransformerBlock, get_num_layers_to_build from megatron.core.transformer.transformer_config import TransformerConfig -from megatron.core.transformer.transformer_layer import TransformerLayer +from megatron.core.transformer.transformer_layer import ( + TransformerLayer, + get_transformer_layer_offset, +) from tests.unit_tests.test_utilities import Utils @@ -959,3 +962,35 @@ def test_repr_returns_string(self, pp_size, input_layout): f"Expected: {expected_repr!r}\n" f"Got: {repr_result!r}" ) + + @pytest.mark.parametrize( + "layout_str, pp_size, num_layers, expected_offsets", + [ + # 3 stages holding 2, 3 and 2 decoder layers. + ("Et*2|t*3|t*2,L", 3, 7, [0, 2, 5]), + # 4 stages holding 1, 3, 3 and 1 decoder layers. + ("Et|t*3|t*3|t,L", 4, 8, [0, 1, 4, 7]), + ], + ) + def test_layer_offset_honours_explicit_pp_rank( + self, layout_str, pp_size, num_layers, expected_offsets + ): + """get_transformer_layer_offset must answer for the rank it is given. + + A caller that needs every stage's first layer index asks for each rank in + turn from one process. The custom-layout branch dropped the argument and + answered for the local rank every time, collapsing the answers to one + value. + """ + config = TransformerConfig( + num_layers=num_layers, + hidden_size=128, + num_attention_heads=8, + pipeline_model_parallel_size=pp_size, + pipeline_dtype=torch.bfloat16, + pipeline_model_parallel_layout=PipelineParallelLayerLayout.from_str( + layout_str, pp_size + ), + ) + offsets = [get_transformer_layer_offset(config, None, rank) for rank in range(pp_size)] + assert offsets == expected_offsets

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≦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