|
| 1 | +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Multi-GPU functional test for the DSpark loss data-parallel reduction. |
| 16 | +
|
| 17 | +The DSpark loss (:func:`megatron.bridge.training.post_training.dspark.loss.dspark_loss`) |
| 18 | +divides each per-token numerator by an all-reduced global denominator and scales |
| 19 | +the backward by ``world_size``, so that the data-parallel-averaged gradient equals |
| 20 | +the single-process full-batch gradient. That invariant cannot be exercised on a |
| 21 | +single process; this test runs two NCCL ranks and asserts the DP-averaged gradient |
| 22 | +through a shared head matches the single-process reference over the concatenated |
| 23 | +batch. It needs 2 GPUs and is intended for a GPU node / cluster runner (not the |
| 24 | +CPU unit-test lane). Run it directly on a 2-GPU node: ``pytest <this file>``. |
| 25 | +""" |
| 26 | + |
| 27 | +import os |
| 28 | +import socket |
| 29 | + |
| 30 | +import pytest |
| 31 | +import torch |
| 32 | +import torch.multiprocessing as mp |
| 33 | + |
| 34 | +from megatron.bridge.training.post_training.dspark.heads import VanillaMarkov |
| 35 | +from megatron.bridge.training.post_training.dspark.loss import DSparkForwardOutput, dspark_loss |
| 36 | + |
| 37 | + |
| 38 | +def _free_port() -> int: |
| 39 | + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 40 | + sock.bind(("127.0.0.1", 0)) |
| 41 | + port = sock.getsockname()[1] |
| 42 | + sock.close() |
| 43 | + return port |
| 44 | + |
| 45 | + |
| 46 | +def _grad_over(head, sl, batch, dp_group): |
| 47 | + """Loss backward over the block-slice ``sl``; return the shared head's grad. |
| 48 | +
|
| 49 | + Args: |
| 50 | + head: Shared ``VanillaMarkov`` whose ``markov_w2.weight`` grad is returned. |
| 51 | + sl: Slice into the block axis (dim 0) selecting this rank's blocks. |
| 52 | + batch: ``(base_logits, prev_ids, target_ids, aligned_target_logits, |
| 53 | + eval_mask, block_keep_mask)`` for the full batch; each is |
| 54 | + ``[total_blocks, num_blocks, block_size(, vocab)]``. |
| 55 | + dp_group: Data-parallel process group for the loss denominator reduction, |
| 56 | + or ``None`` for the single-process reference. |
| 57 | +
|
| 58 | + Returns: |
| 59 | + ``markov_w2.weight.grad`` after one backward. |
| 60 | + """ |
| 61 | + base, prev, target_ids, aligned, eval_mask, keep = batch |
| 62 | + head.zero_grad(set_to_none=True) |
| 63 | + draft = head.apply_block_logits(base[sl], token_ids=prev[sl], hidden_states=None) |
| 64 | + out = DSparkForwardOutput( |
| 65 | + draft_logits=draft, |
| 66 | + target_ids=target_ids[sl], |
| 67 | + eval_mask=eval_mask[sl], |
| 68 | + block_keep_mask=keep[sl], |
| 69 | + aligned_target_logits=aligned[sl], |
| 70 | + ) |
| 71 | + loss, _ = dspark_loss(out, ce_alpha=0.1, l1_alpha=0.9, confidence_alpha=0.0, dp_group=dp_group) |
| 72 | + loss.backward() |
| 73 | + return head.markov_w2.weight.grad.detach().clone() |
| 74 | + |
| 75 | + |
| 76 | +def _worker(rank: int, world_size: int, port: int) -> None: |
| 77 | + os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT=str(port), RANK=str(rank), WORLD_SIZE=str(world_size)) |
| 78 | + torch.cuda.set_device(rank) |
| 79 | + torch.distributed.init_process_group("nccl", rank=rank, world_size=world_size) |
| 80 | + try: |
| 81 | + dev = torch.device("cuda", rank) |
| 82 | + blocks_per_rank, num_blocks, block_size, vocab, rank_dim = 3, 2, 4, 32, 5 |
| 83 | + total = blocks_per_rank * world_size |
| 84 | + |
| 85 | + # Identical head and full batch on every rank (seeded on CPU, then moved), so |
| 86 | + # each rank can form the same single-process reference locally. |
| 87 | + torch.manual_seed(0) |
| 88 | + head = VanillaMarkov(vocab_size=vocab, markov_rank=rank_dim).to(dev) |
| 89 | + torch.manual_seed(1234) |
| 90 | + shape4 = (total, num_blocks, block_size, vocab) |
| 91 | + shape3 = (total, num_blocks, block_size) |
| 92 | + batch = ( |
| 93 | + torch.randn(*shape4).to(dev), # base logits |
| 94 | + torch.randint(0, vocab, shape3).to(dev), # prev token ids |
| 95 | + torch.randint(0, vocab, shape3).to(dev), # target ids |
| 96 | + torch.randn(*shape4).to(dev), # aligned target logits |
| 97 | + torch.ones(*shape3, dtype=torch.bool).to(dev), # eval mask |
| 98 | + torch.ones(total, num_blocks, dtype=torch.bool).to(dev), # block keep mask |
| 99 | + ) |
| 100 | + |
| 101 | + grad_ref = _grad_over(head, slice(0, total), batch, dp_group=None) |
| 102 | + |
| 103 | + sl = slice(rank * blocks_per_rank, (rank + 1) * blocks_per_rank) |
| 104 | + grad_local = _grad_over(head, sl, batch, dp_group=torch.distributed.group.WORLD) |
| 105 | + grad_dist = grad_local.clone() |
| 106 | + torch.distributed.all_reduce(grad_dist, op=torch.distributed.ReduceOp.SUM) |
| 107 | + grad_dist /= world_size |
| 108 | + |
| 109 | + rel = (grad_dist - grad_ref).abs().max() / grad_ref.abs().max().clamp_min(1e-6) |
| 110 | + assert rel < 1e-4, f"[rank {rank}] DP-averaged gradient mismatch rel={rel.item():.3e}" |
| 111 | + finally: |
| 112 | + if torch.distributed.is_initialized(): |
| 113 | + torch.distributed.destroy_process_group() |
| 114 | + |
| 115 | + |
| 116 | +@pytest.mark.skipif(torch.cuda.device_count() < 2, reason="DSpark loss DP-gradient test needs 2 GPUs") |
| 117 | +def test_dspark_loss_dp_gradient_matches_single_process(): |
| 118 | + mp.spawn(_worker, args=(2, _free_port()), nprocs=2, join=True) |
0 commit comments