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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ steps:
python tests/test_expert_routing.py
python tests/test_reloadable_process_group_memory_check.py
python tests/test_ppo_logprob_entropy.py
python -m pytest tests/test_dspark_rotary_embedding.py -q
python tests/utils/test_hf_checkpoint_saver.py
'

Expand Down
128 changes: 128 additions & 0 deletions tests/test_dspark_rotary_embedding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import copy
import math

import pytest
import torch

from vime.backends.megatron_utils.dspark.attention import DSparkParallelAttention, DSparkRotaryEmbedding


NUM_GPUS = 0


def _reference_rotary(position_ids, head_dim, rotary_base):
inv_freq = 1.0 / (
rotary_base ** (torch.arange(0, head_dim, 2, device=position_ids.device, dtype=torch.float32) / head_dim)
)
angles = position_ids.float().unsqueeze(-1) * inv_freq
angles = torch.cat([angles, angles], dim=-1).unsqueeze(1)
return angles.cos(), angles.sin()


def test_dspark_rotary_embedding_keeps_long_position_ids_out_of_output_dtype():
rotary = DSparkRotaryEmbedding(head_dim=8, rotary_base=10000.0)
position_ids = torch.tensor([[0, 1, 17, 128]], dtype=torch.long)

cos, sin = rotary(position_ids)

inv_freq = 1.0 / (10000.0 ** (torch.arange(0, 8, 2, dtype=torch.float32) / 8))
freqs = torch.einsum("i,bj->bji", inv_freq, position_ids.float())
emb = torch.cat([freqs, freqs], dim=-1)
expected_cos = emb.cos().unsqueeze(1)
expected_sin = emb.sin().unsqueeze(1)

assert cos.dtype == torch.float32
assert sin.dtype == torch.float32
torch.testing.assert_close(cos, expected_cos, rtol=1e-5, atol=1e-6)
torch.testing.assert_close(sin, expected_sin, rtol=1e-5, atol=1e-6)
assert torch.count_nonzero(sin).item() > 0
assert math.isclose(float(cos.abs().max()), 1.0, rel_tol=0, abs_tol=1e-7)


@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
@pytest.mark.parametrize("head_dim,rotary_base", [(64, 10000.0), (128, 500000.0)])
@pytest.mark.parametrize("roundtrip", [False, True])
def test_rotary_remains_fp32_after_parent_dtype_conversion(dtype, head_dim, rotary_base, roundtrip):
parent = torch.nn.Module()
parent.rotary = DSparkRotaryEmbedding(head_dim, rotary_base)
parent.to(dtype=dtype)
if roundtrip:
parent.float() # Widening an already-rounded buffer cannot recover it.
position_ids = torch.tensor([[0, 1, 128, 1024, 8192, 32768], [32769, 4096, 17, 2, 1, 0]], dtype=torch.long)

actual = parent.rotary(position_ids)
expected = _reference_rotary(position_ids, head_dim, rotary_base)

assert not parent.state_dict() # No persistent rotary checkpoint keys.
for value, reference in zip(actual, expected, strict=True):
assert value.dtype == torch.float32
torch.testing.assert_close(value, reference, rtol=1e-5, atol=1e-6)


@pytest.mark.parametrize("device", ["cpu", "cuda"])
@pytest.mark.parametrize("autocast_dtype", [torch.float16, torch.bfloat16])
def test_rotary_autocast_preserves_fp32_phases(device, autocast_dtype):
if device == "cuda" and not torch.cuda.is_available():
pytest.skip("CUDA is required for the CUDA autocast regression")
rotary = DSparkRotaryEmbedding(head_dim=64).to(device=device)
positions = torch.tensor([[0, 128, 8192, 32768]], dtype=torch.long, device=device)
expected = _reference_rotary(positions, 64, 10000.0)
with torch.autocast(device_type=device, dtype=autocast_dtype):
actual = rotary(positions)
for value, reference in zip(actual, expected, strict=True):
assert value.dtype == torch.float32
torch.testing.assert_close(value, reference, rtol=1e-5, atol=1e-6)


@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
def test_low_precision_attention_matches_fresh_fp32_rotary_forward_and_backward(dtype):
with torch.random.fork_rng(devices=[]):
torch.manual_seed(31)
attention = DSparkParallelAttention(
hidden_size=256, num_attention_heads=4, num_key_value_heads=2, head_dim=64
).to(dtype=dtype)
reference = copy.deepcopy(attention)
reference.rotary_emb = DSparkRotaryEmbedding(head_dim=64)
hidden = torch.randn(2, 3, 256, dtype=dtype, requires_grad=True)
context = torch.randn(2, 5, 256, dtype=dtype, requires_grad=True)
reference_hidden = hidden.detach().clone().requires_grad_()
reference_context = context.detach().clone().requires_grad_()
positions = torch.tensor([[0, 17, 128, 4096, 8192, 16384, 32768, 32769]], dtype=torch.long).expand(2, -1)
mask = torch.ones((2, 1, 3, 8), dtype=torch.bool)

actual = attention(hidden, context, positions, mask)
expected = reference(reference_hidden, reference_context, positions, mask)
assert actual.dtype == dtype
torch.testing.assert_close(actual.float(), expected.float(), rtol=1e-5, atol=1e-6)
actual.float().square().mean().backward()
expected.float().square().mean().backward()
for actual_input, expected_input in ((hidden, reference_hidden), (context, reference_context)):
assert actual_input.grad is not None and expected_input.grad is not None
torch.testing.assert_close(actual_input.grad.float(), expected_input.grad.float(), rtol=1e-5, atol=1e-6)
for actual_param, expected_param in zip(attention.parameters(), reference.parameters(), strict=True):
assert actual_param.grad is not None and expected_param.grad is not None
torch.testing.assert_close(actual_param.grad.float(), expected_param.grad.float(), rtol=1e-5, atol=1e-6)


def test_dspark_attention_casts_rotary_values_to_query_dtype():
attention = DSparkParallelAttention(
hidden_size=256,
num_attention_heads=4,
num_key_value_heads=2,
head_dim=64,
).to(dtype=torch.bfloat16)
hidden_states = torch.randn(2, 3, 256, dtype=torch.bfloat16)
target_hidden_states = torch.randn(2, 5, 256, dtype=torch.bfloat16)
position_ids = torch.arange(8, dtype=torch.long).unsqueeze(0).expand(2, -1)
attention_mask = torch.ones((2, 1, 3, 8), dtype=torch.bool)

output = attention(
hidden_states=hidden_states,
target_hidden_states=target_hidden_states,
position_ids=position_ids,
attention_mask=attention_mask,
)

assert output.dtype == torch.bfloat16
assert output.shape == hidden_states.shape
assert torch.isfinite(output).all()
21 changes: 12 additions & 9 deletions vime/backends/megatron_utils/dspark/attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def rotate_half(x):


class DSparkRotaryEmbedding(nn.Module):
"""Precompute rotary sin/cos for DSpark positions.
"""Compute rotary sin/cos for DSpark positions.

DSpark position ids cover both context (0..seq_len-1) and draft tokens
(anchor_pos..anchor_pos+block_size-1 per block). The rotary table must
Expand All @@ -58,11 +58,6 @@ def __init__(self, head_dim: int, rotary_base: float = 10000.0):
super().__init__()
self.head_dim = head_dim
self.rotary_base = rotary_base
# Precompute a large table; will be indexed as needed.
# Max position ~ seq_len + num_anchors * block_size, which is bounded
# by the model's max_position_embeddings.
inv_freq = 1.0 / (rotary_base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)

def forward(self, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""Compute (cos, sin) for the given position ids.
Expand All @@ -75,18 +70,24 @@ def forward(self, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tenso
# inv_freq: [head_dim/2]
# position_ids: [bsz, seq_len]
# freqs: [bsz, seq_len, head_dim/2]
inv_freq = self.inv_freq.float() # [head_dim/2]
# Module dtype conversions also round floating-point buffers. Recompute
# in FP32 on the input device rather than widening a rounded inv_freq.
inv_freq = 1.0 / (
self.rotary_base
** (torch.arange(0, self.head_dim, 2, device=position_ids.device, dtype=torch.float32) / self.head_dim)
)
positions = position_ids.float() # [bsz, seq_len]
# Outer product per batch: [bsz, seq_len, head_dim/2]
freqs = torch.einsum("i,bj->bji", inv_freq, positions)
# Elementwise multiplication stays FP32 even under CUDA autocast.
freqs = positions.unsqueeze(-1) * inv_freq
emb = torch.cat([freqs, freqs], dim=-1) # [bsz, seq_len, head_dim]
cos = emb.cos()
sin = emb.sin()

# Add head dim for broadcasting: [bsz, 1, seq_len, head_dim]
cos = cos.unsqueeze(1)
sin = sin.unsqueeze(1)
return cos.to(position_ids.dtype), sin.to(position_ids.dtype)
return cos, sin

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

While returning cos and sin in float32 prevents immediate casting to position_ids.dtype, there is a subtle precision issue with the inv_freq buffer.

When the parent module is cast to a lower precision (e.g., via attention.to(dtype=torch.bfloat16)), PyTorch recursively casts all registered buffers, including self.inv_freq, to bfloat16. In forward(), calling self.inv_freq.float() upcasts it back to float32, but the precision lost during the downcast to bfloat16 is already gone. This can lead to significant phase errors when multiplied by large position IDs in long-context scenarios.

To prevent this, you can compute inv_freq dynamically in forward() directly in float32 on the correct device, which also avoids any device mismatch issues:

inv_freq = 1.0 / (self.rotary_base ** (torch.arange(0, self.head_dim, 2, device=position_ids.device, dtype=torch.float32) / self.head_dim))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in signed follow-up cfac5c2b. Inverse frequencies are now generated directly in FP32 on the positions device, rather than recovered from a dtype-cast buffer. The phase outer product uses elementwise multiplication: the first attempt that only regenerated frequencies still diverged under CUDA autocast.

Added parent FP16/BF16 casts and cast-back, long-position, and attention forward/backward regressions. On this signed tree the CPU suite reran with 14 passed / 2 CUDA-only skips. Retained A100 RoPE-only checks on the same implementation passed 24/24 combinations, versus 0/24 for the prior implementation; full GPU attention/training was not run. The CPU CI now invokes pytest instead of only importing test definitions. Commands and environment limits are in the updated PR body.

AI assistance: ChatGPT assisted implementation, validation, and this response.



class DSparkParallelAttention(nn.Module):
Expand Down Expand Up @@ -169,6 +170,8 @@ def forward(
# Apply rotary embeddings
# position_ids: [bsz, ctx_len + q_len]
cos, sin = self.rotary_emb(position_ids)
cos = cos.to(dtype=q.dtype)
sin = sin.to(dtype=q.dtype)
# cos/sin: [1, 1, seq_len, head_dim] but we need to match k's shape
# k shape: [bsz, kv_heads, kv_len, head_dim]
# cos shape: [1, 1, kv_len, head_dim] -> broadcast over bsz and heads
Expand Down