Files
sglang/test/registered/debug_utils/test_dumper.py
T

4407 lines
159 KiB
Python

import io
import multiprocessing
import os
import re
import sys
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
import pytest
import requests
import torch
import torch.distributed as dist
from sglang.srt.debug_utils.dumper import (
DumperConfig,
_collect_parallel_rank_tags,
_collective_with_timeout,
_compare_tensors_quick,
_deepcopy_or_clone,
_detect_recompute_status,
_Dumper,
_format_tags,
_get_default_exp_name,
_Grafter,
_load_function,
_log,
_map_tensor,
_materialize_value,
_MegatronPlugin,
_obj_to_dict,
_RecomputeStatus,
_register_forward_hook_or_replace_fn,
_SGLangPlugin,
_torch_save,
dumper,
get_tensor_info,
get_truncated_value,
)
from sglang.srt.distributed.parallel_state import get_default_distributed_backend
from sglang.srt.utils import get_device, get_device_module, kill_process_tree
from sglang.srt.utils.common import temp_set_env
from sglang.test.ci.ci_register import (
register_amd_ci,
register_cuda_ci,
register_xpu_ci,
)
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
find_available_port,
popen_launch_server,
run_distributed_test,
)
register_cuda_ci(est_time=30, stage="nightly", runner_config="2-gpu-large")
register_amd_ci(est_time=60, suite="nightly-amd", nightly=True)
register_xpu_ci(est_time=400, suite="nightly-xpu-2-gpu", nightly=True)
@contextmanager
def _capture_stdout():
captured = io.StringIO()
old_stdout = sys.stdout
sys.stdout = captured
try:
yield captured
finally:
sys.stdout = old_stdout
class TestDumperConfig:
def test_from_env_defaults_match_dataclass_defaults(self):
assert DumperConfig.from_env() == DumperConfig()
def test_from_env_bool(self):
with temp_set_env(DUMPER_ENABLE="1"):
assert DumperConfig.from_env().enable is True
with temp_set_env(DUMPER_ENABLE="false"):
assert DumperConfig.from_env().enable is False
def test_from_env_str(self):
with temp_set_env(DUMPER_FILTER="layer_id=0"):
assert DumperConfig.from_env().filter == "layer_id=0"
def test_from_env_dir(self):
with temp_set_env(DUMPER_DIR="/my/dir"):
assert DumperConfig.from_env().dir == "/my/dir"
def test_from_env_int(self):
with temp_set_env(DUMPER_COLLECTIVE_TIMEOUT="120"):
assert DumperConfig.from_env().collective_timeout == 120
def test_configure_overrides(self):
d = _make_test_dumper("/tmp")
d.configure(enable=False)
assert d._config.enable is False
d.configure(enable=True)
assert d._config.enable is True
def test_type_validation(self):
with pytest.raises(TypeError, match="enable.*expected bool.*got str"):
DumperConfig(enable="yes")
with pytest.raises(
TypeError, match="collective_timeout.*expected int.*got str"
):
DumperConfig(collective_timeout="abc")
with pytest.raises(TypeError, match="filter.*expected str.*got int"):
DumperConfig(filter=123)
def test_configure_default_skips_when_env_set(self):
with temp_set_env(DUMPER_FILTER="from_env"):
d = _Dumper(config=DumperConfig.from_env())
d.configure_default(filter="from_code")
assert d._config.filter == "from_env"
def test_configure_default_applies_when_no_env(self):
d = _Dumper(config=DumperConfig.from_env())
d.configure_default(filter="from_code")
assert d._config.filter == "from_code"
def test_from_env_whitespace_treated_as_unset(self):
with temp_set_env(DUMPER_FILTER=" "):
assert DumperConfig.from_env().filter is None
def test_may_enable_default_false(self):
d = _Dumper(config=DumperConfig())
assert d.may_enable is False
def test_may_enable_true_when_enabled(self):
d = _Dumper(config=DumperConfig(enable=True))
assert d.may_enable is True
def test_may_enable_true_when_server_port_set(self):
d = _Dumper(config=DumperConfig(server_port="40000"))
assert d.may_enable is True
d2 = _Dumper(config=DumperConfig(server_port="reuse"))
assert d2.may_enable is True
class TestServerPortParsed:
def test_negative_returns_none(self):
assert DumperConfig(server_port="-1").server_port_parsed is None
def test_zero_returns_none(self):
assert DumperConfig(server_port="0").server_port_parsed is None
def test_positive_returns_int(self):
result = DumperConfig(server_port="40000").server_port_parsed
assert result == 40000
assert isinstance(result, int)
def test_reuse_returns_string(self):
assert DumperConfig(server_port="reuse").server_port_parsed == "reuse"
class TestDefaultExpName:
def test_starts_with_prefix(self):
name = _get_default_exp_name(timeout_seconds=5)
assert name.startswith("dump_")
def test_suffix_format(self):
name = _get_default_exp_name(timeout_seconds=5)
suffix = name[len("dump_") :]
assert len(suffix) == 22
assert suffix[8] == "_"
class TestKvPairsParsing:
def test_from_kv_pairs_none_returns_defaults(self):
assert DumperConfig.from_kv_pairs(None) == DumperConfig()
def test_from_kv_pairs_empty_returns_defaults(self):
assert DumperConfig.from_kv_pairs([]) == DumperConfig()
def test_from_kv_pairs_bool_field(self):
cfg = DumperConfig.from_kv_pairs(["enable=true"])
assert cfg.enable is True
assert cfg.dir == "/tmp/dumper"
def test_from_kv_pairs_bool_numeric(self):
assert DumperConfig.from_kv_pairs(["enable=1"]).enable is True
assert DumperConfig.from_kv_pairs(["enable=0"]).enable is False
def test_from_kv_pairs_int_field(self):
cfg = DumperConfig.from_kv_pairs(["collective_timeout=120"])
assert cfg.collective_timeout == 120
assert type(cfg.collective_timeout) is int
def test_from_kv_pairs_int_field_zero_stays_int(self):
cfg = DumperConfig.from_kv_pairs(["collective_timeout=0"])
assert cfg.collective_timeout == 0
assert type(cfg.collective_timeout) is int
def test_from_kv_pairs_str_field_not_coerced(self):
cfg = DumperConfig.from_kv_pairs(["server_port=0"])
assert cfg.server_port == "0"
assert type(cfg.server_port) is str
def test_from_kv_pairs_str_field_one_stays_str(self):
cfg = DumperConfig.from_kv_pairs(["server_port=1"])
assert cfg.server_port == "1"
assert type(cfg.server_port) is str
def test_from_kv_pairs_optional_str_field(self):
cfg = DumperConfig.from_kv_pairs(
["filter=layer_id is not None and layer_id < 3"]
)
assert cfg.filter == "layer_id is not None and layer_id < 3"
def test_from_kv_pairs_optional_str_exp_name(self):
cfg = DumperConfig.from_kv_pairs(["exp_name=my_experiment"])
assert cfg.exp_name == "my_experiment"
def test_from_kv_pairs_multiple_fields(self):
cfg = DumperConfig.from_kv_pairs(
[
"enable=true",
"dir=/my/dir",
"filter=name == 'foo'",
"collective_timeout=30",
"enable_grad=1",
]
)
assert cfg.enable is True
assert cfg.dir == "/my/dir"
assert cfg.filter == "name == 'foo'"
assert cfg.collective_timeout == 30
assert cfg.enable_grad is True
def test_from_kv_pairs_missing_equals_raises(self):
with pytest.raises(ValueError, match="missing '='"):
DumperConfig.from_kv_pairs(["enable"])
def test_from_kv_pairs_unknown_key_raises(self):
with pytest.raises(ValueError, match="Unknown config key"):
DumperConfig.from_kv_pairs(["nonexistent=true"])
def test_kv_pairs_to_dict_returns_only_explicit(self):
d = DumperConfig._kv_pairs_to_dict(["enable=true", "dir=/x"])
assert d == {"enable": True, "dir": "/x"}
assert "filter" not in d
assert "collective_timeout" not in d
def test_kv_pairs_to_dict_none_returns_empty(self):
assert DumperConfig._kv_pairs_to_dict(None) == {}
def test_kv_pairs_to_dict_empty_returns_empty(self):
assert DumperConfig._kv_pairs_to_dict([]) == {}
def test_from_kv_pairs_value_with_equals_in_value(self):
cfg = DumperConfig.from_kv_pairs(["filter=name == 'foo'"])
assert cfg.filter == "name == 'foo'"
def test_from_kv_pairs_type_validation_still_works(self):
with pytest.raises(TypeError, match="collective_timeout.*expected int"):
DumperConfig.from_kv_pairs(["collective_timeout=not_a_number"])
class TestDumperPureFunctions:
def test_get_truncated_value(self):
assert get_truncated_value(None) is None
assert get_truncated_value(42) == 42
assert len(get_truncated_value((torch.randn(10), torch.randn(20)))) == 2
assert get_truncated_value(torch.randn(10, 10)).shape == (10, 10)
assert get_truncated_value(torch.randn(100, 100)).shape == (5, 5)
def test_obj_to_dict(self):
assert _obj_to_dict({"a": 1}) == {"a": 1}
class Obj:
x, y = 10, 20
def method(self):
pass
result = _obj_to_dict(Obj())
assert result["x"] == 10
assert "method" not in result
def test_deepcopy_or_clone_tensor(self):
original = torch.randn(3, 3)
cloned = _deepcopy_or_clone(original)
assert torch.equal(cloned, original)
original.fill_(999.0)
assert not torch.equal(cloned, original)
def test_deepcopy_or_clone_non_tensor(self):
original = {"a": [1, 2, 3]}
cloned = _deepcopy_or_clone(original)
assert cloned == original
assert cloned is not original
original["a"].append(4)
assert len(cloned["a"]) == 3
def test_get_tensor_info(self):
info = get_tensor_info(torch.randn(10, 10))
for key in ["shape=", "dtype=", "min=", "max=", "mean="]:
assert key in info
assert "value=42" in get_tensor_info(42)
assert "min=None" in get_tensor_info(torch.tensor([]))
class TestMapTensor:
def test_bare_tensor(self):
t = torch.randn(4)
result = _map_tensor(t, lambda x: x * 2)
assert torch.equal(result, t * 2)
def test_bare_tensor_no_change(self):
t = torch.randn(4)
result = _map_tensor(t, lambda x: x)
assert result is t
def test_dict_with_tensor_values(self):
t1 = torch.randn(3)
t2 = torch.randn(5)
value = {"a": t1, "b": t2, "meta": "not a tensor"}
result = _map_tensor(value, lambda x: x.clone())
assert torch.equal(result["a"], t1)
assert torch.equal(result["b"], t2)
assert result["a"] is not t1
assert result["b"] is not t2
assert result["meta"] == "not a tensor"
def test_dict_no_tensors(self):
value = {"a": 1, "b": "hello"}
result = _map_tensor(value, lambda x: x.clone())
assert result == value
def test_nested_dict(self):
inner_t = torch.randn(3)
value = {"outer": {"inner": inner_t, "label": "ok"}, "top": torch.randn(2)}
result = _map_tensor(value, lambda x: x.clone())
assert torch.equal(result["outer"]["inner"], inner_t)
assert result["outer"]["inner"] is not inner_t
assert result["outer"]["label"] == "ok"
assert result is not value
assert result["outer"] is not value["outer"]
def test_non_tensor_non_dict(self):
result = _map_tensor(42, lambda x: x.clone())
assert result == 42
class TestTorchSave:
def test_normal(self, tmp_path):
path = str(tmp_path / "a.pt")
tensor = torch.randn(3, 3)
_torch_save(tensor, path)
assert torch.equal(torch.load(path, weights_only=True), tensor)
def test_parameter_fallback(self, tmp_path):
class BadParam(torch.nn.Parameter):
def __reduce_ex__(self, protocol):
raise RuntimeError("not pickleable")
path = str(tmp_path / "b.pt")
param = BadParam(torch.randn(4))
_torch_save(param, path)
assert torch.equal(torch.load(path, weights_only=True), param.data)
def test_shared_storage_not_bloated(self, tmp_path):
big = torch.randn(1000, 1000)
view = big[0]
path = str(tmp_path / "view.pt")
_torch_save({"value": view, "meta": {}}, path)
file_size = Path(path).stat().st_size
expected_max = view.nelement() * view.element_size() * 10
assert file_size < expected_max, (
f"File {file_size} bytes but view is only "
f"{view.nelement() * view.element_size()} bytes — "
f"torch.save likely serialized the full "
f"{big.nelement() * big.element_size()} byte storage"
)
def test_silent_skip(self, tmp_path, capsys):
path = str(tmp_path / "c.pt")
_torch_save({"fn": lambda: None}, path)
captured = capsys.readouterr()
assert "[Dumper, rank=" in captured.out
assert "Observe error=" in captured.out
assert "skip the tensor" in captured.out
class TestCollectiveTimeout:
def test_watchdog_fires_on_timeout(self):
block_event = threading.Event()
output = ""
def run_with_timeout():
nonlocal output
with _capture_stdout() as captured:
_collective_with_timeout(
lambda: block_event.wait(),
operation_name="test_blocked_op",
timeout_seconds=2,
)
output = captured.getvalue()
worker = threading.Thread(target=run_with_timeout)
worker.start()
time.sleep(4)
block_event.set()
worker.join(timeout=5)
print(f"Captured output: {output!r}")
assert "WARNING" in output
assert "test_blocked_op" in output
assert "2s" in output
class TestDumperDistributed:
def test_basic(self, tmp_path):
with temp_set_env(
DUMPER_ENABLE="1",
DUMPER_DIR=str(tmp_path),
):
run_distributed_test(
self._test_basic_func,
tmpdir=str(tmp_path),
backend=get_default_distributed_backend(get_device()),
)
@staticmethod
def _test_basic_func(rank, tmpdir):
tensor = torch.randn(10, 10, device=get_device(rank))
dumper.dump("tensor_a", tensor, arg=100)
dumper.step()
dumper.set_ctx(ctx_arg=200)
dumper.dump("tensor_b", tensor)
dumper.set_ctx(ctx_arg=None)
dumper.step()
dumper.configure(filter="False")
dumper.dump("tensor_skip", tensor)
dumper.configure(filter=None)
dumper.step()
dumper.dump_dict("obj", {"a": torch.randn(3, device=get_device(rank)), "b": 42})
dumper.step()
dist.barrier()
filenames = _get_filenames(tmpdir)
_assert_files(
filenames,
exist=["tensor_a", "tensor_b", "arg=100", "ctx_arg=200", "obj_a", "obj_b"],
not_exist=["tensor_skip"],
)
def test_collective_timeout(self):
with temp_set_env(DUMPER_ENABLE="1"):
run_distributed_test(
self._test_collective_timeout_func,
backend=get_default_distributed_backend(get_device()),
)
@staticmethod
def _test_collective_timeout_func(rank):
dumper = _Dumper(
config=DumperConfig(
enable=True,
collective_timeout=3,
),
)
with _capture_stdout() as captured:
if rank != 0:
time.sleep(6)
dumper.step()
output = captured.getvalue()
print(f"Rank {rank} captured output: {output!r}")
if rank == 0:
assert "WARNING" in output, f"Expected WARNING in rank 0 output: {output}"
assert "has not completed after 3s" in output
def test_file_content_correctness(self, tmp_path):
with temp_set_env(
DUMPER_ENABLE="1",
DUMPER_DIR=str(tmp_path),
):
run_distributed_test(
self._test_file_content_func,
tmpdir=str(tmp_path),
backend=get_default_distributed_backend(get_device()),
)
@staticmethod
def _test_file_content_func(rank, tmpdir):
tensor = torch.arange(12, device=get_device(rank)).reshape(3, 4).float()
dumper.dump("content_check", tensor)
dumper.step()
dist.barrier()
path = _find_dump_file(tmpdir, rank=rank, name="content_check")
raw = _load_dump(path)
assert isinstance(raw, dict), f"Expected dict, got {type(raw)}"
assert "value" in raw and "meta" in raw
assert torch.equal(raw["value"], tensor.cpu())
assert raw["meta"]["name"] == "content_check"
assert raw["meta"]["rank"] == rank
class TestDumperFileWriteControl:
def test_filter(self, tmp_path):
with temp_set_env(
DUMPER_ENABLE="1",
DUMPER_DIR=str(tmp_path),
DUMPER_FILTER="name.startswith('keep')",
):
run_distributed_test(
self._test_filter_func,
tmpdir=str(tmp_path),
backend=get_default_distributed_backend(get_device()),
)
@staticmethod
def _test_filter_func(rank, tmpdir):
dumper.dump("keep_this", torch.randn(5, device=get_device(rank)))
dumper.dump("skip_this", torch.randn(5, device=get_device(rank)))
dumper.dump("not_keep_this", torch.randn(5, device=get_device(rank)))
dumper.step()
dist.barrier()
filenames = _get_filenames(tmpdir)
_assert_files(
filenames,
exist=["keep_this"],
not_exist=["skip_this", "not_keep_this"],
)
def test_save_false(self, tmp_path):
with temp_set_env(
DUMPER_ENABLE="1",
DUMPER_DIR=str(tmp_path),
):
run_distributed_test(
self._test_save_false_func,
tmpdir=str(tmp_path),
backend=get_default_distributed_backend(get_device()),
)
@staticmethod
def _test_save_false_func(rank, tmpdir):
dumper.dump(
"no_save_tensor", torch.randn(5, device=get_device(rank)), save=False
)
dumper.step()
dist.barrier()
assert len(_get_filenames(tmpdir)) == 0
class TestDumpEnableFlags:
def test_all_enables_false_no_output(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_value=False, enable_grad=False)
d.dump("should_skip", torch.randn(3, 3))
assert len(_get_filenames(tmp_path)) == 0
class TestOutputControl:
def test_file_enabled_by_default(self, tmp_path):
d = _make_test_dumper(tmp_path)
d.dump("file_on", torch.randn(3, 3))
_assert_files(_get_filenames(tmp_path), exist=["file_on"])
def test_file_disabled(self, tmp_path, capsys):
d = _make_test_dumper(tmp_path, enable_output_file=False)
d.dump("file_off", torch.randn(3, 3))
assert len(_get_filenames(tmp_path)) == 0
assert "file_off" in capsys.readouterr().out
def test_console_enabled_by_default(self, tmp_path, capsys):
d = _make_test_dumper(tmp_path)
d.dump("console_on", torch.randn(3, 3))
captured = capsys.readouterr()
assert "[Dumper.Value]" in captured.out
assert "console_on" in captured.out
def test_console_disabled(self, tmp_path, capsys):
d = _make_test_dumper(tmp_path, enable_output_console=False)
d.dump("console_off", torch.randn(3, 3))
assert "console_off" not in capsys.readouterr().out
_assert_files(_get_filenames(tmp_path), exist=["console_off"])
def test_capture_output_basic(self, tmp_path):
d = _make_test_dumper(tmp_path)
tensor = torch.randn(4, 4)
with d.capture_output() as captured:
d.dump("cap_basic", tensor)
assert "cap_basic" in captured
assert set(captured["cap_basic"].keys()) == {"value", "meta"}
assert torch.equal(captured["cap_basic"]["value"], tensor)
assert captured["cap_basic"]["meta"]["name"] == "cap_basic"
def test_capture_output_no_file(self, tmp_path):
d = _make_test_dumper(tmp_path)
with d.capture_output() as captured:
d.dump("cap_no_file", torch.randn(3, 3))
assert "cap_no_file" in captured
assert len(_get_filenames(tmp_path)) == 0
def test_capture_output_multiple(self, tmp_path):
d = _make_test_dumper(tmp_path)
with d.capture_output() as captured:
d.dump("first", torch.randn(2, 2))
d.dump("second", torch.randn(3, 3))
assert set(captured.keys()) == {"first", "second"}
assert captured["first"]["value"].shape == (2, 2)
assert captured["second"]["value"].shape == (3, 3)
def test_capture_output_value_cloned(self, tmp_path):
d = _make_test_dumper(tmp_path)
tensor = torch.zeros(3, 3)
with d.capture_output() as captured:
d.dump("clone_check", tensor)
tensor.fill_(999.0)
assert torch.equal(captured["clone_check"]["value"], torch.zeros(3, 3))
def test_capture_output_nested_raises(self, tmp_path):
d = _make_test_dumper(tmp_path)
with d.capture_output():
with pytest.raises(AssertionError):
with d.capture_output():
pass
def test_capture_output_respects_filter(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="'keep' in name")
with d.capture_output() as captured:
d.dump("keep_this", torch.randn(3, 3))
d.dump("skip_this", torch.randn(3, 3))
assert "keep_this" in captured
assert "skip_this" not in captured
class TestDumpDictFormat:
"""Verify that dump files use the dict output format: {"value": ..., "meta": {...}}."""
def test_dict_format_structure(self, tmp_path):
dumper = _make_test_dumper(tmp_path)
tensor = torch.randn(4, 4)
dumper.dump("fmt_test", tensor, custom_key="hello")
path = _find_dump_file(str(tmp_path), rank=0, name="fmt_test")
raw = _load_dump(path)
assert isinstance(raw, dict)
assert set(raw.keys()) == {"value", "meta"}
assert torch.equal(raw["value"], tensor)
meta = raw["meta"]
assert meta["name"] == "fmt_test"
assert meta["custom_key"] == "hello"
assert "step" in meta
assert "rank" in meta
assert "dump_index" in meta
def test_dict_format_with_context(self, tmp_path):
dumper = _make_test_dumper(tmp_path)
dumper.set_ctx(ctx_val=42)
tensor = torch.randn(2, 2)
dumper.dump("ctx_fmt", tensor)
path = _find_dump_file(str(tmp_path), rank=0, name="ctx_fmt")
raw = _load_dump(path)
assert raw["meta"]["ctx_val"] == 42
assert torch.equal(raw["value"], tensor)
def _make_test_dumper(tmp_path, **overrides) -> _Dumper:
"""Create a _Dumper for CPU testing without distributed."""
defaults = dict(
enable=True,
dir=str(tmp_path),
exp_name="test",
)
defaults.update(overrides)
config = DumperConfig(**defaults)
return _Dumper(config=config)
def _get_filenames(tmpdir):
return {f.name for f in Path(tmpdir).glob("*/*.pt")}
def _assert_files(filenames, *, exist=(), not_exist=()):
for p in exist:
assert any(p in f for f in filenames), f"{p} not found in {filenames}"
for p in not_exist:
assert not any(p in f for f in filenames), (
f"{p} should not exist in {filenames}"
)
def _load_dump(path: Path) -> dict:
"""Load a dump file and return the raw dict (with 'value' and 'meta' keys)."""
return torch.load(path, map_location="cpu", weights_only=False)
def _find_dump_file(tmpdir, *, rank: int = 0, name: str) -> Path:
matches = [
f
for f in Path(tmpdir).glob("*/*.pt")
if f"rank={rank}" in f.name and name in f.name
]
assert len(matches) == 1, (
f"Expected 1 file matching rank={rank} name={name}, got {matches}"
)
return matches[0]
class TestMaterializeValue:
def test_materialize_value_callable(self):
tensor = torch.randn(3, 3)
result = _materialize_value(lambda: tensor)
assert torch.equal(result, tensor)
def test_materialize_value_passthrough(self):
tensor = torch.randn(3, 3)
result = _materialize_value(tensor)
assert result is tensor
def test_dump_with_callable_value(self, tmp_path):
d = _make_test_dumper(tmp_path)
tensor = torch.randn(4, 4)
d.dump("lazy_tensor", lambda: tensor)
_assert_files(_get_filenames(tmp_path), exist=["name=lazy_tensor"])
path = _find_dump_file(tmp_path, rank=0, name="lazy_tensor")
assert torch.equal(_load_dump(path)["value"], tensor)
class TestSaveValue:
def test_dump_output_format(self, tmp_path):
dumper = _make_test_dumper(tmp_path)
tensor = torch.randn(4, 4)
dumper.dump("dict_test", tensor)
path = _find_dump_file(tmp_path, rank=0, name="dict_test")
loaded = _load_dump(path)
assert torch.equal(loaded["value"], tensor)
assert loaded["meta"]["name"] == "dict_test"
assert loaded["meta"]["rank"] == 0
class TestStaticMetadata:
def test_static_meta_contains_world_info(self):
dumper = _make_test_dumper("/tmp")
meta = dumper._static_meta
assert "world_rank" in meta
assert "world_size" in meta
assert meta["world_rank"] == 0
assert meta["world_size"] == 1
def test_static_meta_caching(self):
dumper = _make_test_dumper("/tmp")
meta1 = dumper._static_meta
meta2 = dumper._static_meta
assert meta1 is meta2
def test_parallel_info_graceful_fallback(self):
sglang_info = _SGLangPlugin().collect_parallel_info()
assert isinstance(sglang_info, dict)
megatron_info = _MegatronPlugin().collect_parallel_info()
assert isinstance(megatron_info, dict)
def test_dump_includes_static_meta(self, tmp_path):
dumper = _make_test_dumper(tmp_path)
tensor = torch.randn(2, 2)
dumper.dump("meta_test", tensor)
path = _find_dump_file(tmp_path, rank=0, name="meta_test")
loaded = _load_dump(path)
meta = loaded["meta"]
assert "world_rank" in meta
assert "world_size" in meta
class TestDumpGrad:
def test_dump_grad_basic(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_grad=True)
x = torch.randn(3, 3, requires_grad=True)
y = (x * 2).sum()
d.dump("test_tensor", x)
y.backward()
filenames = _get_filenames(tmp_path)
assert any("name=test_tensor" in f and "grad__" not in f for f in filenames)
_assert_files(filenames, exist=["grad__test_tensor"])
def test_dump_grad_non_tensor_skipped(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_grad=True)
d.dump("not_tensor", 42)
_assert_files(_get_filenames(tmp_path), not_exist=["grad__"])
def test_dump_grad_no_requires_grad_skipped(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_grad=True)
x = torch.randn(3, 3, requires_grad=False)
d.dump("no_grad_tensor", x)
_assert_files(
_get_filenames(tmp_path),
exist=["name=no_grad_tensor"],
not_exist=["grad__"],
)
def test_dump_grad_captures_step(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_grad=True)
d._state.step = 42
x = torch.randn(3, 3, requires_grad=True)
y = (x * 2).sum()
d.dump("id_test", x)
d._state.step = 999
y.backward()
grad_file = _find_dump_file(tmp_path, name="grad__id_test")
assert "step=42" in grad_file.name
def test_dump_grad_file_content(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_grad=True)
x = torch.tensor([[1.0, 2.0], [3.0, 4.0]], requires_grad=True)
y = (x * 3).sum()
d.dump("content_check", x)
y.backward()
grad_path = _find_dump_file(tmp_path, name="grad__content_check")
expected_grad = torch.full((2, 2), 3.0)
assert torch.equal(_load_dump(grad_path)["value"], expected_grad)
def test_disable_value(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_value=False, enable_grad=True)
x = torch.randn(3, 3, requires_grad=True)
y = (x * 2).sum()
d.dump("fwd_disabled", x)
y.backward()
filenames = _get_filenames(tmp_path)
assert not any(
"name=fwd_disabled" in f and "grad__" not in f for f in filenames
)
_assert_files(filenames, exist=["grad__fwd_disabled"])
def test_disable_grad(self, tmp_path):
d = _make_test_dumper(tmp_path, enable_grad=False)
x = torch.randn(3, 3, requires_grad=True)
y = (x * 2).sum()
d.dump("grad_disabled", x)
y.backward()
_assert_files(
_get_filenames(tmp_path),
exist=["name=grad_disabled"],
not_exist=["grad__"],
)
class TestKvFilter:
def test_format_tags(self):
assert _format_tags({"a": 1, "b": "hello"}) == "a=1___b=hello"
assert _format_tags({}) == ""
def test_filter_matches_extra_kwargs(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="layer_id == 0")
d.dump("tensor_a", torch.randn(3), layer_id=0)
d.dump("tensor_b", torch.randn(3), layer_id=1)
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["tensor_a"], not_exist=["tensor_b"])
def test_filter_matches_global_ctx(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="ctx_arg == 200")
d.set_ctx(ctx_arg=200)
d.dump("tensor_a", torch.randn(3))
d.set_ctx(ctx_arg=None)
d.dump("tensor_b", torch.randn(3))
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["tensor_a"], not_exist=["tensor_b"])
def test_filter_matches_name(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="'keep' in name")
d.dump("keep_this", torch.randn(3))
d.dump("skip_this", torch.randn(3))
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["keep_this"], not_exist=["skip_this"])
def test_filter_expr_range(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="layer_id is not None and layer_id < 3")
d.dump("t0", torch.randn(3), layer_id=0)
d.dump("t1", torch.randn(3), layer_id=1)
d.dump("t5", torch.randn(3), layer_id=5)
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["name=t0", "name=t1"], not_exist=["name=t5"])
def test_filter_expr_with_none(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="layer_id is None or layer_id < 3")
d.dump("no_layer", torch.randn(3))
d.dump("layer0", torch.randn(3), layer_id=0)
d.dump("layer5", torch.randn(3), layer_id=5)
filenames = _get_filenames(tmp_path)
_assert_files(
filenames,
exist=["no_layer", "layer0"],
not_exist=["layer5"],
)
def test_filter_expr_with_re_search(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="search(r'attn|mlp', name)")
d.dump("self_attn", torch.randn(3))
d.dump("mlp_proj", torch.randn(3))
d.dump("layernorm", torch.randn(3))
filenames = _get_filenames(tmp_path)
_assert_files(
filenames,
exist=["self_attn", "mlp_proj"],
not_exist=["layernorm"],
)
def test_filter_expr_syntax_error(self, tmp_path):
d = _make_test_dumper(tmp_path, filter="layer_id ===")
with pytest.raises(SyntaxError):
d.dump("tensor", torch.randn(3))
def test_no_filter_dumps_all(self, tmp_path):
d = _make_test_dumper(tmp_path)
d.dump("a", torch.randn(3))
d.dump("b", torch.randn(3))
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["name=a", "name=b"])
class TestDumpModel:
def test_grad_basic(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2)
x = torch.randn(3, 4)
y = model(x).sum()
y.backward()
d.dump_model(model, name_prefix="model")
_assert_files(
_get_filenames(tmp_path),
exist=["grad__model__weight", "grad__model__bias"],
)
def test_value_basic(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
model = torch.nn.Linear(4, 2, bias=False)
d.dump_model(model, name_prefix="model")
_assert_files(
_get_filenames(tmp_path),
exist=["model__weight"],
)
def test_no_grad_skipped(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2)
d.dump_model(model, name_prefix="model")
filenames = _get_filenames(tmp_path)
assert len(filenames) == 0
def test_filter(self, tmp_path):
d = _make_test_dumper(
tmp_path,
enable_model_value=True,
enable_model_grad=True,
filter="'weight' in name",
)
model = torch.nn.Linear(4, 2)
x = torch.randn(3, 4)
y = model(x).sum()
y.backward()
d.dump_model(model, name_prefix="model")
_assert_files(
_get_filenames(tmp_path),
exist=["model__weight", "grad__model__weight"],
not_exist=["model__bias", "grad__model__bias"],
)
def test_grad_file_content(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2, bias=False)
x = torch.ones(1, 4)
y = model(x).sum()
y.backward()
d.dump_model(model, name_prefix="p")
path = _find_dump_file(tmp_path, name="grad__p__weight")
assert torch.equal(_load_dump(path)["value"], model.weight.grad)
def test_disable_model_grad(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
model = torch.nn.Linear(4, 2)
x = torch.randn(3, 4)
y = model(x).sum()
y.backward()
d.dump_model(model, name_prefix="model")
filenames = _get_filenames(tmp_path)
assert all("grad" not in f for f in filenames)
def test_parameter_saved_as_parameter(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
model = torch.nn.Linear(4, 2, bias=False)
d.dump_model(model, name_prefix="p")
path = _find_dump_file(tmp_path, name="p__weight")
loaded = _load_dump(path)
assert isinstance(loaded["value"], torch.nn.Parameter)
assert torch.equal(loaded["value"], model.weight)
def test_unpicklable_parameter_falls_back_to_data(self, tmp_path):
class BadParam(torch.nn.Parameter):
def __reduce_ex__(self, protocol):
raise RuntimeError("not pickleable")
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
model = torch.nn.Linear(4, 2, bias=False)
model.weight = BadParam(model.weight.data)
d.dump_model(model, name_prefix="p")
path = _find_dump_file(tmp_path, name="p__weight")
loaded = _load_dump(path)
assert isinstance(loaded["value"], torch.Tensor)
assert not isinstance(loaded["value"], torch.nn.Parameter)
assert torch.equal(loaded["value"], model.weight.data)
def test_disable_model_value(self, tmp_path):
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2, bias=False)
x = torch.ones(1, 4)
y = model(x).sum()
y.backward()
d.dump_model(model, name_prefix="model")
filenames = _get_filenames(tmp_path)
assert all("grad" in f for f in filenames)
class TestDumpModelGradInjection:
def test_get_grad_overrides_param_grad(self, tmp_path):
"""dump_model dumps the tensor returned by get_grad instead of param.grad."""
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2, bias=False)
y = model(torch.ones(1, 4)).sum()
y.backward()
injected = torch.full_like(model.weight, 7.0)
d.dump_model(model, name_prefix="p", get_grad=lambda param: injected)
path = _find_dump_file(tmp_path, name="grad__p__weight")
assert torch.equal(_load_dump(path)["value"], injected)
def test_get_grad_reads_custom_grad_storage(self, tmp_path):
"""get_grad lets callers surface grads living outside param.grad (e.g. main_grad)."""
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2, bias=False)
assert model.weight.grad is None
model.weight.main_grad = torch.full_like(model.weight, 3.0)
d.dump_model(
model,
name_prefix="p",
get_grad=lambda param: getattr(param, "main_grad", None),
)
path = _find_dump_file(tmp_path, name="grad__p__weight")
assert torch.equal(_load_dump(path)["value"], model.weight.main_grad)
def test_get_grad_returning_none_skips_grad_dump(self, tmp_path):
"""A get_grad returning None suppresses the grad dump for that param."""
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2, bias=False)
y = model(torch.ones(1, 4)).sum()
y.backward()
d.dump_model(model, name_prefix="p", get_grad=lambda param: None)
assert len(_get_filenames(tmp_path)) == 0
def test_default_uses_param_grad_without_main_grad_fallback(self, tmp_path):
"""Without get_grad, only param.grad is dumped; a main_grad attribute is ignored."""
d = _make_test_dumper(
tmp_path, enable_model_grad=True, enable_model_value=False
)
model = torch.nn.Linear(4, 2, bias=False)
assert model.weight.grad is None
model.weight.main_grad = torch.full_like(model.weight, 3.0)
d.dump_model(model, name_prefix="p")
assert len(_get_filenames(tmp_path)) == 0
class TestDumpModelStepOverride:
def test_step_override_in_filenames(self, tmp_path):
"""An explicit step overrides the dumper's ambient step for value and grad files."""
d = _make_test_dumper(tmp_path, enable_model_value=True, enable_model_grad=True)
d._state.step = 3
model = torch.nn.Linear(4, 2, bias=False)
y = model(torch.ones(1, 4)).sum()
y.backward()
d.dump_model(model, name_prefix="p", step=7)
filenames = _get_filenames(tmp_path)
assert len(filenames) == 2
assert all("step=7" in f for f in filenames)
def test_step_none_uses_ambient_step(self, tmp_path):
"""Without an explicit step, dump_model records the dumper's current step."""
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
d._state.step = 3
model = torch.nn.Linear(4, 2, bias=False)
d.dump_model(model, name_prefix="p")
filenames = _get_filenames(tmp_path)
assert filenames and all("step=3" in f for f in filenames)
class TestParallelRankInFilename:
def test_config_default_false(self):
"""include_parallel_rank_in_filename defaults to False."""
assert DumperConfig().include_parallel_rank_in_filename is False
def test_config_from_kv_pairs(self):
"""include_parallel_rank_in_filename is parsed as a bool from kv pairs."""
cfg = DumperConfig.from_kv_pairs(["include_parallel_rank_in_filename=true"])
assert cfg.include_parallel_rank_in_filename is True
def test_collect_tags_merges_keys_across_plugins(self, monkeypatch):
"""_collect_parallel_rank_tags keeps only rank keys, merging across plugins."""
plugin_a = type(
"PluginA",
(),
{"collect_parallel_info": lambda self: {"pp_rank": 1, "ignored": 9}},
)()
plugin_b = type(
"PluginB",
(),
{"collect_parallel_info": lambda self: {"tp_rank": 2, "cp_rank": 3}},
)()
monkeypatch.setattr(
"sglang.srt.debug_utils.dumper._plugins", [plugin_a, plugin_b]
)
tags = _collect_parallel_rank_tags()
assert tags == {"pp_rank": 1, "tp_rank": 2, "cp_rank": 3}
def test_collect_tags_first_plugin_wins_on_conflict(self, monkeypatch):
"""When two plugins report the same rank key, the first plugin wins."""
plugin_a = type(
"PluginA", (), {"collect_parallel_info": lambda self: {"pp_rank": 1}}
)()
plugin_b = type(
"PluginB", (), {"collect_parallel_info": lambda self: {"pp_rank": 7}}
)()
monkeypatch.setattr(
"sglang.srt.debug_utils.dumper._plugins", [plugin_a, plugin_b]
)
assert _collect_parallel_rank_tags() == {"pp_rank": 1}
def test_collect_tags_skips_empty_plugin_info(self, monkeypatch):
"""Plugins that report no parallel info are skipped without error."""
plugin_empty = type(
"PluginEmpty", (), {"collect_parallel_info": lambda self: {}}
)()
plugin_real = type(
"PluginReal", (), {"collect_parallel_info": lambda self: {"tp_rank": 4}}
)()
monkeypatch.setattr(
"sglang.srt.debug_utils.dumper._plugins", [plugin_empty, plugin_real]
)
assert _collect_parallel_rank_tags() == {"tp_rank": 4}
def test_disabled_filename_has_no_rank_tags(self, tmp_path, monkeypatch):
"""When disabled, dump filenames do not include parallel-rank tags."""
monkeypatch.setattr(
_SGLangPlugin,
"collect_parallel_info",
lambda self: {"pp_rank": 2, "tp_rank": 3},
)
d = _make_test_dumper(tmp_path, include_parallel_rank_in_filename=False)
d.dump("hidden", torch.randn(3))
filenames = _get_filenames(tmp_path)
assert filenames
assert all("pp_rank=" not in f and "tp_rank=" not in f for f in filenames)
def test_enabled_filename_includes_rank_tags(self, tmp_path, monkeypatch):
"""When enabled, dump filenames include the collected parallel-rank tags."""
monkeypatch.setattr(
_SGLangPlugin,
"collect_parallel_info",
lambda self: {"pp_rank": 2, "tp_rank": 3},
)
d = _make_test_dumper(tmp_path, include_parallel_rank_in_filename=True)
d.dump("hidden", torch.randn(3))
filenames = _get_filenames(tmp_path)
assert any("pp_rank=2" in f and "tp_rank=3" in f for f in filenames)
class TestTransformModelParamName:
def test_base_plugin_returns_none(self):
"""The default plugin hook keeps the original name (returns None)."""
plugin = _SGLangPlugin()
assert (
plugin.transform_model_param_name(torch.nn.Linear(2, 2), "layers.0.weight")
is None
)
def test_dump_model_keeps_name_without_transform(self, tmp_path):
"""With no plugin rewriting names, dump_model uses the original param name."""
model = torch.nn.Module()
model.layers = torch.nn.ModuleList([torch.nn.Linear(2, 2, bias=False)])
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
d.dump_model(model, name_prefix="m")
_assert_files(_get_filenames(tmp_path), exist=["m__layers.0.weight"])
def test_dump_model_applies_plugin_transform(self, tmp_path, monkeypatch):
"""dump_model rewrites param names through the plugin transform hook."""
def _shift_layers(self, model, param_name):
return re.sub(
r"layers\.(\d+)", lambda m: f"layers.{int(m.group(1)) + 4}", param_name
)
monkeypatch.setattr(_SGLangPlugin, "transform_model_param_name", _shift_layers)
model = torch.nn.Module()
model.layers = torch.nn.ModuleList([torch.nn.Linear(2, 2, bias=False)])
d = _make_test_dumper(
tmp_path, enable_model_value=True, enable_model_grad=False
)
d.dump_model(model, name_prefix="m")
_assert_files(
_get_filenames(tmp_path),
exist=["m__layers.4.weight"],
not_exist=["m__layers.0.weight"],
)
def test_get_model_config_unwraps_module_chain(self):
"""_get_model_config peels nested .module wrappers to find .config."""
config = object()
leaf = type("Leaf", (), {"config": config})()
wrapped = type("W", (), {"module": type("W2", (), {"module": leaf})()})()
assert _MegatronPlugin._get_model_config(wrapped) is config
def test_get_model_config_returns_none_when_absent(self):
"""_get_model_config returns None when no .config is reachable."""
assert _MegatronPlugin._get_model_config(object()) is None
class _FakeMpu:
_pp_size = 2
@classmethod
def get_pipeline_model_parallel_world_size(cls):
return cls._pp_size
class TestMegatronTransformModelParamName:
@pytest.fixture(autouse=True)
def _patch_megatron(self, monkeypatch):
monkeypatch.setattr(_MegatronPlugin, "_available", True)
monkeypatch.setattr(_MegatronPlugin, "_mpu", _FakeMpu, raising=False)
monkeypatch.setattr(
_MegatronPlugin,
"_get_model_config",
staticmethod(lambda model: object()),
)
monkeypatch.setattr(
_MegatronPlugin,
"_get_transformer_layer_offset",
staticmethod(lambda config: 4),
)
def test_remaps_local_layer_index_to_global(self):
"""layers.N is shifted by the PP-stage offset; other tokens untouched."""
plugin = _MegatronPlugin()
result = plugin.transform_model_param_name(object(), "decoder.layers.0.weight")
assert result == "decoder.layers.4.weight"
def test_remaps_all_layer_occurrences(self):
"""Every ``layers.N`` occurrence in the name is shifted."""
plugin = _MegatronPlugin()
result = plugin.transform_model_param_name(object(), "layers.1.x.layers.2.y")
assert result == "layers.5.x.layers.6.y"
def test_returns_none_when_not_available(self, monkeypatch):
"""No transform when megatron is unavailable."""
monkeypatch.setattr(_MegatronPlugin, "_available", False)
plugin = _MegatronPlugin()
assert plugin.transform_model_param_name(object(), "layers.0.weight") is None
def test_returns_none_when_pp_size_one(self, monkeypatch):
"""No transform without pipeline parallelism (pp_size == 1)."""
monkeypatch.setattr(_FakeMpu, "_pp_size", 1)
plugin = _MegatronPlugin()
assert plugin.transform_model_param_name(object(), "layers.0.weight") is None
monkeypatch.setattr(_FakeMpu, "_pp_size", 2)
def test_returns_none_when_offset_zero(self, monkeypatch):
"""A zero offset (e.g. first PP stage) leaves the name unchanged (None)."""
monkeypatch.setattr(
_MegatronPlugin,
"_get_transformer_layer_offset",
staticmethod(lambda config: 0),
)
plugin = _MegatronPlugin()
assert plugin.transform_model_param_name(object(), "layers.0.weight") is None
class TestCleanup:
def test_cleanup_removes_old_dumps(self, tmp_path):
old_dir = tmp_path / "dump_old"
old_dir.mkdir()
(old_dir / "dummy.pt").touch()
dumper = _make_test_dumper(tmp_path, cleanup_previous=True)
dumper.dump("new_tensor", torch.randn(3, 3))
assert not old_dir.exists()
_assert_files(_get_filenames(tmp_path), exist=["new_tensor"])
def test_cleanup_removes_exp_name_dir(self, tmp_path):
exp_name = "my_custom_exp"
old_exp_dir = tmp_path / exp_name
old_exp_dir.mkdir()
(old_exp_dir / "old_data.pt").touch()
dumper = _make_test_dumper(tmp_path, exp_name=exp_name, cleanup_previous=True)
dumper.dump("new_tensor", torch.randn(3, 3))
assert not (tmp_path / exp_name / "old_data.pt").exists()
_assert_files(_get_filenames(tmp_path), exist=["new_tensor"])
def test_cleanup_removes_both_dump_prefix_and_exp_name(self, tmp_path):
old_dump = tmp_path / "dump_old"
old_dump.mkdir()
(old_dump / "dummy.pt").touch()
exp_name = "custom_run"
old_exp = tmp_path / exp_name
old_exp.mkdir()
(old_exp / "stale.pt").touch()
dumper = _make_test_dumper(tmp_path, exp_name=exp_name, cleanup_previous=True)
dumper.dump("new_tensor", torch.randn(3, 3))
assert not old_dump.exists()
assert not (tmp_path / exp_name / "stale.pt").exists()
_assert_files(_get_filenames(tmp_path), exist=["new_tensor"])
def test_no_cleanup_by_default(self, tmp_path):
old_dir = tmp_path / "dump_old"
old_dir.mkdir()
(old_dir / "dummy.pt").touch()
dumper = _make_test_dumper(tmp_path)
dumper.dump("new_tensor", torch.randn(3, 3))
assert old_dir.exists()
_assert_files(_get_filenames(tmp_path), exist=["new_tensor"])
class TestReset:
def test_reset_clears_state(self, tmp_path):
d = _make_test_dumper(tmp_path)
d.set_ctx(layer_id=1)
d.dump("before_reset", torch.randn(3, 3))
d.reset()
assert d._state.dump_index == 0
assert d._state.step == 0
assert d._state.global_ctx == {}
def test_dump_works_after_reset(self, tmp_path):
d = _make_test_dumper(tmp_path)
d.dump("pre", torch.randn(3, 3))
d.reset()
d.dump("post", torch.randn(3, 3))
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["pre", "post"])
post_file = _find_dump_file(tmp_path, name="post")
assert "dump_index=1" in post_file.name
def test_cleanup_previous_re_triggers_after_reset(self, tmp_path):
"""Miles pattern: reset() + configure(cleanup_previous=True) should re-clean."""
exp_alpha = "exp_alpha"
exp_beta = "exp_beta"
(tmp_path / exp_alpha).mkdir()
(tmp_path / exp_alpha / "stale.pt").touch()
(tmp_path / exp_beta).mkdir()
(tmp_path / exp_beta / "stale.pt").touch()
d = _make_test_dumper(tmp_path, exp_name=exp_alpha, cleanup_previous=True)
d.dump("phase1", torch.randn(2, 2))
d.reset()
d.configure(exp_name=exp_beta, cleanup_previous=True)
d.dump("phase2", torch.randn(2, 2))
assert not (tmp_path / exp_alpha / "stale.pt").exists()
assert not (tmp_path / exp_beta / "stale.pt").exists()
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["phase1", "phase2"])
def test_no_cleanup_when_config_false(self, tmp_path):
"""cleanup_previous=False: handled stays False but no cleanup runs."""
old_dir = tmp_path / "dump_old"
old_dir.mkdir()
(old_dir / "dummy.pt").touch()
d = _make_test_dumper(tmp_path, cleanup_previous=False)
d.dump("tensor", torch.randn(2, 2))
assert old_dir.exists()
assert d._state.cleanup_previous_handled is False
def test_multi_phase_switch(self, tmp_path):
"""Simulate Miles multi-phase: configure → dump → reset → configure new phase → dump."""
d = _make_test_dumper(tmp_path, cleanup_previous=True)
d.configure(exp_name="fwd_only")
d.dump("weight", torch.randn(2, 2))
d.step()
d.configure(enable=False)
d.reset()
d.configure(exp_name="fwd_bwd", enable=True, cleanup_previous=True)
d.dump("weight", torch.randn(2, 2))
d.step()
fwd_only_files = list(Path(tmp_path).glob("fwd_only/*.pt"))
fwd_bwd_files = list(Path(tmp_path).glob("fwd_bwd/*.pt"))
assert len(fwd_only_files) > 0
assert len(fwd_bwd_files) > 0
assert d._state.step == 1
assert d._state.dump_index == 1
def test_reset_removes_non_intrusive_hooks(self, tmp_path):
model = torch.nn.Sequential(
torch.nn.Linear(4, 4),
torch.nn.ReLU(),
torch.nn.Linear(4, 4),
)
d = _make_test_dumper(tmp_path, non_intrusive_mode="all")
d.register_non_intrusive_dumper(model)
x = torch.randn(2, 4)
with d.capture_output() as captured:
model(x)
assert len(captured) > 0
d.reset()
d.configure(enable=True, dir=str(tmp_path), non_intrusive_mode="all")
with d.capture_output() as captured_after:
model(x)
assert len(captured_after) == 0
def test_reset_removes_non_intrusive_hooks_multiple_models(self, tmp_path):
model_a = torch.nn.Sequential(
torch.nn.Linear(4, 4),
torch.nn.ReLU(),
)
model_b = torch.nn.Sequential(
torch.nn.Linear(4, 4),
torch.nn.ReLU(),
)
d = _make_test_dumper(tmp_path, non_intrusive_mode="all")
d.register_non_intrusive_dumper(model_a)
d.register_non_intrusive_dumper(model_b)
x = torch.randn(2, 4)
with d.capture_output() as captured:
model_a(x)
model_b(x)
assert len(captured) > 0
d.reset()
d.configure(enable=True, dir=str(tmp_path), non_intrusive_mode="all")
with d.capture_output() as captured_a:
model_a(x)
assert len(captured_a) == 0
with d.capture_output() as captured_b:
model_b(x)
assert len(captured_b) == 0
def _dumper_worker(rank, http_port: int, stop_event):
"""Minimal distributed dumper worker: configure, step (triggers ZMQ setup), then wait."""
dumper.configure(enable=False, server_port=str(http_port))
dumper.step()
stop_event.wait()
def _wait_for_dumper_http(url: str, timeout: float = 30) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
requests.post(f"{url}/dumper/configure", json={}, timeout=2)
return
except requests.ConnectionError:
time.sleep(0.5)
raise TimeoutError(f"Dumper HTTP server not reachable at {url}")
class TestZmqPortIsolation:
"""Multiple independent dumper instances (each with 2 ranks) must not conflict on ZMQ ports."""
NUM_INSTANCES = 3
def test_concurrent_instances_no_port_conflict(self):
ports = [
find_available_port(40000 + i * 1000) for i in range(self.NUM_INSTANCES)
]
stop_events = []
threads = []
ctx = multiprocessing.get_context("spawn")
for port in ports:
stop_event = ctx.Event()
stop_events.append(stop_event)
thread = threading.Thread(
target=run_distributed_test,
args=(_dumper_worker,),
kwargs={
"http_port": port,
"stop_event": stop_event,
"backend": get_default_distributed_backend(get_device()),
},
)
thread.start()
threads.append(thread)
try:
for port in ports:
_wait_for_dumper_http(f"http://127.0.0.1:{port}")
for i, port in enumerate(ports):
resp = requests.post(
f"http://127.0.0.1:{port}/dumper/get_state", json={}
)
resp.raise_for_status()
states = resp.json()
assert len(states) == 2, (
f"Instance {i} (port {port}): expected 2 ranks, got {len(states)}"
)
finally:
for event in stop_events:
event.set()
for thread in threads:
thread.join(timeout=10)
class TestDumperHttp:
"""Test /dumper/* HTTP control — parametrized over standalone vs sglang server."""
@pytest.fixture(scope="class", params=["standalone", "sglang"])
def dumper_http_url(self, request):
if request.param == "standalone":
http_port = find_available_port(40000)
base_url = f"http://127.0.0.1:{http_port}"
stop_event = multiprocessing.get_context("spawn").Event()
thread = threading.Thread(
target=run_distributed_test,
args=(_dumper_worker,),
kwargs={
"http_port": http_port,
"stop_event": stop_event,
"backend": get_default_distributed_backend(get_device()),
},
)
thread.start()
try:
_wait_for_dumper_http(base_url)
yield base_url
finally:
stop_event.set()
thread.join(timeout=10)
else:
base_url = DEFAULT_URL_FOR_TEST
env = {**os.environ, "DUMPER_SERVER_PORT": "reuse"}
proc = popen_launch_server(
"Qwen/Qwen3-0.6B",
base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=["--max-total-tokens", "128"],
env=env,
)
try:
yield base_url
finally:
kill_process_tree(proc.pid)
@staticmethod
def _post(base_url: str, method: str, **kwargs) -> list[dict]:
resp = requests.post(f"{base_url}/dumper/{method}", json=kwargs or None)
resp.raise_for_status()
states = resp.json()
assert isinstance(states, list) and len(states) >= 1
return states
@staticmethod
def _assert_all_ranks(states: list[dict], path: str, expected):
"""Assert that ``state[path]`` equals ``expected`` on every rank."""
keys = path.split(".")
for rank, state in enumerate(states):
val = state
for k in keys:
val = val[k]
assert val == expected, (
f"rank {rank}: {path}={val!r}, expected {expected!r}"
)
def test_configure_enable_toggle(self, dumper_http_url: str):
for enable in [True, False]:
self._post(dumper_http_url, "configure", enable=enable)
states = self._post(dumper_http_url, "get_state")
self._assert_all_ranks(states, "config.enable", enable)
def test_configure_multi_field(self, dumper_http_url: str):
self._post(
dumper_http_url,
"configure",
enable=True,
filter="layer_id == 0",
dir="/tmp/test_http",
)
states = self._post(dumper_http_url, "get_state")
self._assert_all_ranks(states, "config.enable", True)
self._assert_all_ranks(states, "config.filter", "layer_id == 0")
self._assert_all_ranks(states, "config.dir", "/tmp/test_http")
def test_configure_clear_optional(self, dumper_http_url: str):
self._post(dumper_http_url, "configure", filter="layer_id == 0")
self._post(dumper_http_url, "configure", filter=None)
states = self._post(dumper_http_url, "get_state")
self._assert_all_ranks(states, "config.filter", None)
def test_reset(self, dumper_http_url: str):
self._post(dumper_http_url, "configure", enable=True)
self._post(dumper_http_url, "reset")
states = self._post(dumper_http_url, "get_state")
self._assert_all_ranks(states, "dump_index", 0)
self._assert_all_ranks(states, "step", 0)
def test_get_state(self, dumper_http_url: str):
self._post(
dumper_http_url,
"configure",
enable=True,
filter="layer_id is not None and layer_id < 3",
)
states = self._post(dumper_http_url, "get_state")
self._assert_all_ranks(states, "config.enable", True)
self._assert_all_ranks(
states, "config.filter", "layer_id is not None and layer_id < 3"
)
for state in states:
assert "dump_index" in state
assert "step" in state
def test_all_ranks_consistent(self, dumper_http_url: str):
self._post(dumper_http_url, "configure", enable=True, dir="/tmp/multi")
states = self._post(dumper_http_url, "get_state")
configs = [s["config"] for s in states]
for rank_config in configs[1:]:
assert rank_config == configs[0], f"rank configs diverged: {configs}"
def test_error_unknown_field(self, dumper_http_url: str):
resp = requests.post(
f"{dumper_http_url}/dumper/configure",
json={"nonexistent_field": 123},
)
assert resp.status_code == 400
def test_error_unknown_method(self, dumper_http_url: str):
resp = requests.post(
f"{dumper_http_url}/dumper/nonexistent",
json={},
)
assert resp.status_code == 400
def test_error_wrong_type(self, dumper_http_url: str):
resp = requests.post(
f"{dumper_http_url}/dumper/configure",
json={"enable": "not_a_bool"},
)
assert resp.status_code == 400
class TestRegisterForwardHookOrReplaceFn:
def test_unknown_mode_raises(self):
module = torch.nn.Linear(4, 4)
with pytest.raises(ValueError, match="Unknown mode"):
_register_forward_hook_or_replace_fn(
module,
pre_hook=lambda _mod, _input: None,
hook=lambda _mod, _input, _output: None,
mode="bad",
)
class _NonIntrusiveTestBase:
_PREFIX = "non_intrusive__"
@staticmethod
def _assert_captured_contains(
captured: dict, expected: list[str], prefix: str = "non_intrusive__"
) -> None:
for suffix in expected:
key = f"{prefix}{suffix}"
assert key in captured, f"missing {key}"
@staticmethod
def _wrap_as_outer(inner_cls: type) -> torch.nn.Module:
"""Wrap an inner module class as OuterModel.model, mimicking typical model nesting."""
class OuterModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = inner_cls()
def forward(self, *args, **kwargs):
return self.model(*args, **kwargs)
return OuterModel()
@staticmethod
def _make_dumper(tmp_path, **overrides) -> "_Dumper":
return _make_test_dumper(tmp_path, non_intrusive_mode="all", **overrides)
def _run(self, tmp_path, inner_cls, **dumper_overrides):
d = self._make_dumper(tmp_path, **dumper_overrides)
model = self._wrap_as_outer(inner_cls)
d.register_non_intrusive_dumper(model)
x = torch.randn(2, 4)
with d.capture_output() as captured:
output = model(x)
return captured, x, output
class TestNonIntrusiveDumper(_NonIntrusiveTestBase):
"""Tests for mode='all' — hooks on every module, non_intrusive__ prefix."""
def test_basic_inputs_and_outputs(self, tmp_path):
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
self.relu = torch.nn.ReLU()
def forward(self, x):
return self.relu(self.linear(x))
captured, x, output = self._run(tmp_path, Inner)
self._assert_captured_contains(
captured,
[
"output",
"inputs.0",
"model.output",
"model.inputs.0",
"model.linear.output",
"model.linear.inputs.0",
"model.relu.output",
"model.relu.inputs.0",
],
)
P = self._PREFIX
assert torch.allclose(captured[f"{P}output"]["value"], output)
def test_inputs_dumped_before_forward(self, tmp_path):
"""Inputs are captured *before* forward(); in-place mutation must not affect them."""
class Mutator(torch.nn.Module):
def forward(self, x):
x.fill_(999.0)
return x
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.mutator = Mutator()
def forward(self, x):
return self.mutator(x)
d = self._make_dumper(tmp_path)
model = self._wrap_as_outer(Inner)
d.register_non_intrusive_dumper(model)
x = torch.randn(2, 4)
original_x = x.clone()
with d.capture_output() as captured:
model(x)
P = self._PREFIX
dumped_input = captured[f"{P}model.mutator.inputs.0"]["value"]
assert torch.allclose(dumped_input, original_x), (
f"pre-hook should capture inputs before forward mutates them; "
f"got {dumped_input} but expected {original_x}"
)
dumped_output = captured[f"{P}model.mutator.output"]["value"]
assert (dumped_output == 999.0).all(), (
"post-hook should capture outputs after forward"
)
def test_hooks_all_module_levels(self, tmp_path):
class Attention(torch.nn.Module):
def __init__(self):
super().__init__()
self.qkv_proj = torch.nn.Linear(4, 12)
self.o_proj = torch.nn.Linear(4, 4)
def forward(self, x):
_qkv = self.qkv_proj(x)
return self.o_proj(x)
class Layer(torch.nn.Module):
def __init__(self):
super().__init__()
self.self_attn = Attention()
self.mlp = torch.nn.Linear(4, 4)
def forward(self, x):
x = self.self_attn(x)
return self.mlp(x)
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.layers = torch.nn.ModuleList([Layer()])
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
captured, x, output = self._run(tmp_path, Inner)
self._assert_captured_contains(
captured,
[
"output",
"model.output",
"model.layers.0.output",
"model.layers.0.self_attn.output",
"model.layers.0.self_attn.qkv_proj.output",
"model.layers.0.self_attn.o_proj.output",
"model.layers.0.mlp.output",
"model.layers.0.self_attn.qkv_proj.inputs.0",
"model.layers.0.self_attn.o_proj.inputs.0",
"model.layers.0.mlp.inputs.0",
],
)
P = self._PREFIX
assert f"{P}model.layers.output" not in captured
def test_multi_tensor_tuple_output(self, tmp_path):
class TupleModule(torch.nn.Module):
def forward(self, x):
return x, x * 2
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.split = TupleModule()
self.linear = torch.nn.Linear(4, 4)
def forward(self, x):
a, b = self.split(x)
return self.linear(a + b)
captured, x, output = self._run(tmp_path, Inner)
assert "non_intrusive__model.split.output.0" in captured
assert "non_intrusive__model.split.output.1" in captured
assert torch.allclose(
captured["non_intrusive__model.split.output.0"]["value"], x
)
def test_single_tensor_tuple_collapses(self, tmp_path):
class SingleTupleModule(torch.nn.Module):
def forward(self, x):
return (x * 3,)
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.wrap = SingleTupleModule()
def forward(self, x):
return self.wrap(x)[0]
captured, x, output = self._run(tmp_path, Inner)
assert "non_intrusive__model.wrap.output" in captured
assert "non_intrusive__model.wrap.output.0" not in captured
def test_multiple_forward_inputs(self, tmp_path):
class TwoInputModule(torch.nn.Module):
def forward(self, x, mask):
return x * mask
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.mul = TwoInputModule()
def forward(self, x):
mask = torch.ones_like(x)
return self.mul(x, mask)
captured, x, output = self._run(tmp_path, Inner)
assert "non_intrusive__model.mul.inputs.0" in captured
assert "non_intrusive__model.mul.inputs.1" in captured
def test_none_output_only_dumps_inputs(self, tmp_path):
class NoneModule(torch.nn.Module):
def forward(self, x):
return None
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.sink = NoneModule()
def forward(self, x):
self.sink(x)
return x
captured, x, output = self._run(tmp_path, Inner)
assert "non_intrusive__model.sink.inputs.0" in captured
assert not any(
k.startswith("non_intrusive__model.sink.output") for k in captured
)
def test_non_tensor_value_silently_skipped(self, tmp_path):
class IntModule(torch.nn.Module):
def forward(self, x):
return 42
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.const = IntModule()
def forward(self, x):
self.const(x)
return x
captured, x, output = self._run(tmp_path, Inner)
assert "non_intrusive__model.const.inputs.0" in captured
assert not any(
k.startswith("non_intrusive__model.const.output") for k in captured
)
def test_root_module_name_no_malformed_dots(self, tmp_path):
d = self._make_dumper(tmp_path)
model = torch.nn.Linear(4, 4)
d.register_non_intrusive_dumper(model)
x = torch.randn(2, 4)
with d.capture_output() as captured:
model(x)
for key in captured:
assert not key.startswith("non_intrusive__."), f"malformed key: {key}"
assert ".." not in key, f"double dot in key: {key}"
assert "non_intrusive__output" in captured
assert "non_intrusive__inputs.0" in captured
def test_respects_dumper_filter(self, tmp_path):
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
self.relu = torch.nn.ReLU()
def forward(self, x):
return self.relu(self.linear(x))
captured, x, output = self._run(
tmp_path, Inner, filter="name == 'non_intrusive__model.linear.output'"
)
assert "non_intrusive__model.linear.output" in captured
assert "non_intrusive__model.relu.output" not in captured
assert "non_intrusive__model.linear.inputs.0" not in captured
def test_disabled_dumper_no_output(self, tmp_path):
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
def forward(self, x):
return self.linear(x)
d = self._make_dumper(tmp_path)
d.configure(enable=False)
model = self._wrap_as_outer(Inner)
d.register_non_intrusive_dumper(model)
x = torch.randn(2, 4)
with d.capture_output() as captured:
model(x)
assert len(captured) == 0
def _make_forward_batch():
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
return ForwardBatch(
forward_mode=ForwardMode.DECODE,
batch_size=2,
input_ids=torch.tensor([10, 20]),
req_pool_indices=torch.zeros(2, dtype=torch.long),
seq_lens=torch.tensor([5, 6]),
out_cache_loc=torch.zeros(2, dtype=torch.long),
seq_lens_sum=11,
positions=torch.tensor([0, 1]),
)
class TestNonIntrusiveDumperConfigMode(_NonIntrusiveTestBase):
@staticmethod
def _build_model() -> torch.nn.Module:
class SubLayer(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
def forward(self, forward_batch):
return self.linear(
forward_batch.input_ids.float().unsqueeze(-1).expand(-1, 4)
)
class Root(torch.nn.Module):
def __init__(self):
super().__init__()
self.layer = SubLayer()
def forward(self, forward_batch):
return self.layer(forward_batch)
return Root()
def _run(self, tmp_path, mode: str) -> tuple:
d = _make_test_dumper(tmp_path, non_intrusive_mode=mode)
model = self._build_model()
d.register_non_intrusive_dumper(model)
forward_batch = _make_forward_batch()
with d.capture_output() as captured:
model(forward_batch)
return captured, forward_batch
def test_off_mode(self, tmp_path):
captured, _ = self._run(tmp_path, "off")
assert len(captured) == 0
def test_core_mode(self, tmp_path):
captured, fb = self._run(tmp_path, "core")
# core fields dumped with clean names
assert "input_ids" in captured
assert "positions" in captured
assert "seq_lens" in captured
assert torch.equal(captured["input_ids"]["value"], fb.input_ids)
assert torch.equal(captured["positions"]["value"], fb.positions)
assert torch.equal(captured["seq_lens"]["value"], fb.seq_lens)
# nothing with non_intrusive__ prefix
assert not any(k.startswith("non_intrusive__") for k in captured)
def test_all_mode(self, tmp_path):
captured, fb = self._run(tmp_path, "all")
# core fields dumped with clean names
assert "input_ids" in captured
assert "positions" in captured
assert "seq_lens" in captured
assert torch.equal(captured["input_ids"]["value"], fb.input_ids)
assert torch.equal(captured["positions"]["value"], fb.positions)
assert torch.equal(captured["seq_lens"]["value"], fb.seq_lens)
# core fields NOT duplicated with prefix
for field in ("input_ids", "positions", "seq_lens"):
assert not any(
k.startswith("non_intrusive__") and k.endswith(field) for k in captured
)
# ForwardBatch skipped on sub-modules (no duplication)
assert not any(
k.startswith("non_intrusive__layer.inputs.") and "seq_lens" in k
for k in captured
), f"ForwardBatch skipped on sub-module, got: {list(captured.keys())}"
# regular tensor outputs on sub-modules still dumped
assert "non_intrusive__layer.linear.output" in captured
assert "non_intrusive__layer.output" in captured
class _LayerWithNumber(torch.nn.Module):
"""Test helper: module with a ``layer_number`` attribute (Megatron style)."""
def __init__(self, layer_number: int):
super().__init__()
self.layer_number = layer_number
self.linear = torch.nn.Linear(4, 4)
def forward(self, x):
return self.linear(x)
class TestNonIntrusiveLayerIdCtx(_NonIntrusiveTestBase):
"""Tests for automatic layer_id context injection via set_ctx."""
def test_layer_id_from_layer_number(self, tmp_path):
"""Megatron PP: layer_number (1-based global) -> layer_id = layer_number - 1."""
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.layers = torch.nn.ModuleList(
[_LayerWithNumber(10), _LayerWithNumber(11)]
)
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
captured, x, output = self._run(tmp_path, Inner)
layer0_key = "non_intrusive__model.layers.0.linear.output"
layer1_key = "non_intrusive__model.layers.1.linear.output"
assert layer0_key in captured
assert layer1_key in captured
assert captured[layer0_key]["meta"]["layer_id"] == 9
assert captured[layer1_key]["meta"]["layer_id"] == 10
root_key = "non_intrusive__output"
assert root_key in captured
assert "layer_id" not in captured[root_key]["meta"]
def test_layer_id_from_layer_id_attr(self, tmp_path):
"""SGLang style: module has layer_id attribute directly."""
class Layer(torch.nn.Module):
def __init__(self, layer_id: int):
super().__init__()
self.layer_id = layer_id
self.linear = torch.nn.Linear(4, 4)
def forward(self, x):
return self.linear(x)
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.layers = torch.nn.ModuleList([Layer(5)])
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
captured, x, output = self._run(tmp_path, Inner)
layer_key = "non_intrusive__model.layers.0.linear.output"
assert layer_key in captured
assert captured[layer_key]["meta"]["layer_id"] == 5
def test_layer_id_fallback_from_module_name(self, tmp_path):
"""layers.N modules without layer_number/layer_id -> layer_id from module name."""
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.layers = torch.nn.ModuleList(
[torch.nn.Linear(4, 4), torch.nn.Linear(4, 4)]
)
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
captured, x, output = self._run(tmp_path, Inner)
assert len(captured) > 0
input_keys: list[str] = [
k for k in captured if "model.layers." in k and "inputs" in k
]
assert len(input_keys) > 0
for key in input_keys:
meta = captured[key]["meta"]
assert "layer_id" in meta, f"{key} missing layer_id"
if "layers.0" in key:
assert meta["layer_id"] == 0
elif "layers.1" in key:
assert meta["layer_id"] == 1
def test_filter_by_layer_id(self, tmp_path):
"""filter='layer_id == 0' keeps only layer 0 dumps."""
class Inner(torch.nn.Module):
def __init__(self):
super().__init__()
self.layers = torch.nn.ModuleList(
[_LayerWithNumber(1), _LayerWithNumber(2)]
)
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
captured, x, output = self._run(tmp_path, Inner, filter="layer_id == 0")
layer0_keys = [k for k in captured if "layers.0" in k]
layer1_keys = [k for k in captured if "layers.1" in k]
assert len(layer0_keys) > 0, "layer 0 dumps should be kept"
assert len(layer1_keys) == 0, f"layer 1 dumps should be filtered: {layer1_keys}"
class TestDumperE2E:
def test_step_and_non_intrusive_hooks(self, tmp_path):
base_url = DEFAULT_URL_FOR_TEST
dump_dir = str(tmp_path)
env = {
**os.environ,
"DUMPER_SERVER_PORT": "reuse",
}
proc = popen_launch_server(
"Qwen/Qwen3-0.6B",
base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=["--tp", "2", "--max-total-tokens", "128"],
env=env,
)
try:
states = requests.post(f"{base_url}/dumper/get_state", json={}).json()
assert len(states) == 2, f"Expected 2 ranks (tp=2), got {len(states)}"
for state in states:
assert state["config"]["enable"] is False
assert state["step"] == 0
requests.post(
f"{base_url}/dumper/configure",
json={"enable": True, "dir": dump_dir},
).raise_for_status()
states = requests.post(f"{base_url}/dumper/get_state", json={}).json()
assert len(states) == 2
for rank, state in enumerate(states):
assert state["config"]["enable"] is True, (
f"rank {rank}: enable should be True after configure"
)
assert state["config"]["dir"] == dump_dir
resp = requests.post(
f"{base_url}/generate",
json={"text": "Hello", "sampling_params": {"max_new_tokens": 8}},
)
assert resp.status_code == 200, f"Generate failed: {resp.text}"
states = requests.post(f"{base_url}/dumper/get_state", json={}).json()
assert len(states) == 2
steps = [s["step"] for s in states]
for rank, step in enumerate(steps):
assert step > 0, f"rank {rank}: step should be > 0, got {step}"
assert steps[0] == steps[1], f"step mismatch across ranks: {steps}"
dump_files = list(Path(dump_dir).glob("dump_*/*.pt"))
assert len(dump_files) > 0, f"No dump files in {dump_dir}"
filenames = {f.name for f in dump_files}
for field in ("input_ids", "positions", "rids"):
assert any(f"name={field}" in f for f in filenames), (
f"Missing {field} dump from non-intrusive hooks, "
f"got: {sorted(filenames)[:10]}"
)
for rank in range(2):
assert any(f"rank={rank}" in f for f in filenames), (
f"No dump files for rank {rank}"
)
sample_file = dump_files[0]
loaded = torch.load(sample_file, map_location="cpu", weights_only=False)
assert isinstance(loaded, dict), f"Expected dict, got {type(loaded)}"
assert "value" in loaded and "meta" in loaded, (
f"Missing value/meta keys: {loaded.keys()}"
)
assert "name" in loaded["meta"]
assert "rank" in loaded["meta"]
assert "step" in loaded["meta"]
par = loaded["meta"].get("sglang_parallel_info", {})
expected_keys = [
"tp_rank",
"tp_size",
"pp_rank",
"pp_size",
"moe_ep_rank",
"moe_ep_size",
"moe_tp_rank",
"moe_tp_size",
"moe_dp_rank",
"moe_dp_size",
"enable_dp_attention",
"attn_tp_rank",
"attn_tp_size",
"attn_dp_rank",
"attn_dp_size",
"attn_cp_rank",
"attn_cp_size",
]
for key in expected_keys:
assert key in par, (
f"Missing {key} in sglang_parallel_info, got: {sorted(par)}"
)
rids_files = [f for f in dump_files if "name=rids" in f.name]
rids_loaded = torch.load(
rids_files[0], map_location="cpu", weights_only=False
)
rids_value = rids_loaded["value"]
assert isinstance(rids_value, list), (
f"rids should be a list, got {type(rids_value)}"
)
assert len(rids_value) > 0, "rids should be non-empty"
assert all(isinstance(r, str) for r in rids_value), (
f"each rid should be a str, got {[type(r) for r in rids_value]}"
)
finally:
kill_process_tree(proc.pid)
class TestRegisterForwardHook:
@pytest.mark.parametrize("mode", ["hook", "replace_fn"])
def test_handles_removable(self, mode):
call_log: list[str] = []
def pre_hook(_module, _args, _kwargs):
call_log.append("pre")
def hook(_module, _input, _output):
call_log.append("post")
module = torch.nn.Linear(4, 4)
handles = _register_forward_hook_or_replace_fn(
module,
pre_hook=pre_hook,
hook=hook,
mode=mode,
)
x = torch.randn(2, 4)
if mode == "hook":
module(x)
else:
module.forward(x)
assert call_log == ["pre", "post"]
call_log.clear()
for h in handles:
h.remove()
if mode == "hook":
module(x)
else:
module.forward(x)
assert call_log == []
@pytest.mark.parametrize("mode", ["hook", "replace_fn"])
def test_kwargs_passed_to_pre_hook(self, mode):
received: list[tuple] = []
class KwargsModule(torch.nn.Module):
def forward(self, x, *, scale=1.0):
return x * scale
def pre_hook(_module, _args, _kwargs):
received.append((_args, _kwargs))
def hook(_module, _input, _output):
pass
module = KwargsModule()
_register_forward_hook_or_replace_fn(
module,
pre_hook=pre_hook,
hook=hook,
mode=mode,
)
x = torch.randn(2, 4)
if mode == "hook":
module(x, scale=2.0)
else:
module.forward(x, scale=2.0)
assert len(received) == 1
args, kwargs = received[0]
assert len(args) == 1
assert torch.equal(args[0], x)
assert kwargs == {"scale": 2.0}
def test_replace_fn_remove_asserts_on_rewrap(self):
module = torch.nn.Linear(4, 4)
handles = _register_forward_hook_or_replace_fn(
module,
pre_hook=lambda _m, _a, _kw: None,
hook=lambda _m, _i, _o: None,
mode="replace_fn",
)
module.forward = lambda *a, **kw: None
with pytest.raises(AssertionError):
handles[0].remove()
class TestPluginCoreFields:
def test_sglang_core_fields(self):
plugin = _SGLangPlugin()
assert plugin.core_fields() == frozenset(
{"input_ids", "positions", "seq_lens", "req_pool_indices", "rids"}
)
def test_megatron_core_fields(self):
plugin = _MegatronPlugin()
assert plugin.core_fields() == frozenset(
{"input_ids", "position_ids", "cu_seqlens_q", "cu_seqlens_kv", "qkv_format"}
)
class TestMegatronConvertValue:
@pytest.fixture(autouse=True)
def _patch_megatron(self, monkeypatch):
class FakePackedSeqParams:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
monkeypatch.setattr(_MegatronPlugin, "_available", True)
monkeypatch.setattr(
_MegatronPlugin, "PackedSeqParams", FakePackedSeqParams, raising=False
)
self._FakePackedSeqParams = FakePackedSeqParams
def test_extracts_packed_seq_params(self):
plugin = _MegatronPlugin()
cu_q = torch.tensor([0, 3, 7])
cu_kv = torch.tensor([0, 3, 7])
value = self._FakePackedSeqParams(
cu_seqlens_q=cu_q, cu_seqlens_kv=cu_kv, qkv_format="thd"
)
result = plugin.convert_value(value, skip_forward_batch=False)
assert set(result.keys()) == {"cu_seqlens_q", "cu_seqlens_kv", "qkv_format"}
assert torch.equal(result["cu_seqlens_q"], cu_q)
assert torch.equal(result["cu_seqlens_kv"], cu_kv)
assert result["qkv_format"] == "thd"
def test_non_packed_returns_none(self):
plugin = _MegatronPlugin()
assert plugin.convert_value(torch.randn(4), skip_forward_batch=False) is None
assert plugin.convert_value("hello", skip_forward_batch=False) is None
class TestNonIntrusiveKwargsModel(_NonIntrusiveTestBase):
def test_kwargs_core_fields(self, tmp_path):
class KwargsModel(torch.nn.Module):
def forward(self, *, input_ids, position_ids):
return input_ids + position_ids
model = KwargsModel()
d = _make_test_dumper(tmp_path, non_intrusive_mode="core")
d.register_non_intrusive_dumper(model)
ids = torch.randn(4)
pos = torch.randn(4)
with d.capture_output() as captured:
model(input_ids=ids, position_ids=pos)
assert "input_ids" in captured
assert "position_ids" in captured
assert torch.equal(captured["input_ids"]["value"], ids)
assert torch.equal(captured["position_ids"]["value"], pos)
def test_kwargs_all_mode(self, tmp_path):
class KwargsModel(torch.nn.Module):
def forward(self, *, input_ids, position_ids, custom_value):
return input_ids + position_ids + custom_value
model = KwargsModel()
d = _make_test_dumper(tmp_path, non_intrusive_mode="all")
d.register_non_intrusive_dumper(model)
ids = torch.randn(4)
pos = torch.randn(4)
custom = torch.randn(4)
with d.capture_output() as captured:
model(input_ids=ids, position_ids=pos, custom_value=custom)
assert "input_ids" in captured
assert "position_ids" in captured
P = self._PREFIX
assert f"{P}inputs.custom_value" in captured
def test_mixed_args_and_kwargs(self, tmp_path):
class MixedModel(torch.nn.Module):
def forward(self, x, *, input_ids):
return x + input_ids
model = MixedModel()
d = _make_test_dumper(tmp_path, non_intrusive_mode="all")
d.register_non_intrusive_dumper(model)
x = torch.randn(4)
ids = torch.randn(4)
with d.capture_output() as captured:
model(x, input_ids=ids)
assert "input_ids" in captured
P = self._PREFIX
assert f"{P}inputs.0" in captured
def test_packed_seq_params_core_fields(self, tmp_path, monkeypatch):
class FakePackedSeqParams:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
monkeypatch.setattr(_MegatronPlugin, "_available", True)
monkeypatch.setattr(
_MegatronPlugin, "PackedSeqParams", FakePackedSeqParams, raising=False
)
class MegatronLikeModel(torch.nn.Module):
def forward(self, *, input_ids, packed_seq_params):
return input_ids
model = MegatronLikeModel()
d = _make_test_dumper(tmp_path, non_intrusive_mode="core")
d.register_non_intrusive_dumper(model)
ids = torch.randn(4)
cu_q = torch.tensor([0, 3, 7])
cu_kv = torch.tensor([0, 3, 7])
psp = FakePackedSeqParams(
cu_seqlens_q=cu_q, cu_seqlens_kv=cu_kv, qkv_format="thd"
)
with d.capture_output() as captured:
model(input_ids=ids, packed_seq_params=psp)
assert "input_ids" in captured
assert torch.equal(captured["input_ids"]["value"], ids)
assert "cu_seqlens_q" in captured
assert torch.equal(captured["cu_seqlens_q"]["value"], cu_q)
assert "cu_seqlens_kv" in captured
assert torch.equal(captured["cu_seqlens_kv"]["value"], cu_kv)
assert "qkv_format" in captured
assert captured["qkv_format"]["value"] == "thd"
class TestDumperDims:
def test_dims_in_meta_not_filename(self, tmp_path) -> None:
dumper = _make_test_dumper(tmp_path)
tensor = torch.randn(4, 8)
dumper.dump("hidden", tensor, dims="b h(tp)")
dumper.step()
exp_dir = tmp_path / dumper._config.exp_name
pt_files = list(exp_dir.glob("*.pt"))
assert len(pt_files) == 1
assert "dims" not in pt_files[0].stem
data = torch.load(pt_files[0], weights_only=False)
assert "dims" in data["meta"]
assert data["meta"]["dims"] == "b h(tp)"
def test_dims_grad_override(self, tmp_path) -> None:
dumper = _Dumper(
config=DumperConfig(
enable=True,
dir=str(tmp_path),
enable_grad=True,
)
)
tensor = torch.randn(4, 8, requires_grad=True)
dumper.dump("hidden", tensor, dims="b h(tp)", dims_grad="b h(tp:partial)")
dumper.step()
tensor.backward(torch.ones_like(tensor))
exp_dir = tmp_path / dumper._config.exp_name
pt_files = sorted(exp_dir.glob("*.pt"))
assert len(pt_files) == 2
value_file = [f for f in pt_files if "grad__" not in f.stem][0]
grad_file = [f for f in pt_files if "grad__" in f.stem][0]
value_data = torch.load(value_file, weights_only=False)
assert value_data["meta"]["dims"] == "b h(tp)"
assert value_data["meta"]["dims_grad"] == "b h(tp:partial)"
grad_data = torch.load(grad_file, weights_only=False)
assert grad_data["meta"]["dims"] == "b h(tp:partial)"
def test_dims_grad_inherits(self, tmp_path) -> None:
dumper = _Dumper(
config=DumperConfig(
enable=True,
dir=str(tmp_path),
enable_grad=True,
)
)
tensor = torch.randn(4, 8, requires_grad=True)
dumper.dump("hidden", tensor, dims="b h(tp)")
dumper.step()
tensor.backward(torch.ones_like(tensor))
exp_dir = tmp_path / dumper._config.exp_name
grad_file = [f for f in exp_dir.glob("*.pt") if "grad__" in f.stem][0]
grad_data = torch.load(grad_file, weights_only=False)
assert grad_data["meta"]["dims"] == "b h(tp)"
class TestCtxDecorator:
def test_ctx_dynamic_lambda(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path)
class FakeLayer:
def __init__(self, layer_id: int) -> None:
self.layer_id = layer_id
@d.ctx(lambda self: dict(layer_id=self.layer_id))
def forward(self, x: torch.Tensor) -> torch.Tensor:
d.dump("hidden", x)
return x
layer = FakeLayer(layer_id=42)
layer.forward(torch.randn(3))
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["layer_id=42"])
def test_ctx_static_kwargs(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path)
@d.ctx(phase="decode")
def decode_step(x: torch.Tensor) -> torch.Tensor:
d.dump("step_out", x)
return x
decode_step(torch.randn(3))
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["phase=decode"])
def test_ctx_clears_on_exception(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path)
@d.ctx(phase="train")
def buggy_fn() -> None:
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
buggy_fn()
assert d._state.global_ctx == {}
def test_ctx_rejects_mixed_args(self) -> None:
d = _make_test_dumper("/tmp")
with pytest.raises(ValueError, match="cannot mix"):
d.ctx(lambda self: dict(a=1), phase="x")
def test_ctx_rejects_empty_args(self) -> None:
d = _make_test_dumper("/tmp")
with pytest.raises(ValueError, match="must provide"):
d.ctx()
class TestRecomputeStatus:
def test_disabled_by_default(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path)
tensor = torch.randn(3, 3)
d.dump("test_tensor", tensor)
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["recompute_status=disabled"])
def test_recompute_status_in_embedded_meta(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path)
tensor = torch.randn(3, 3)
d.dump("test_tensor", tensor)
path = _find_dump_file(tmp_path, rank=0, name="test_tensor")
raw = _load_dump(path)
assert raw["meta"]["recompute_status"] == "disabled"
def test_recompute_status_recompute(self, tmp_path: Path, monkeypatch) -> None:
import sglang.srt.debug_utils.dumper as dumper_mod
monkeypatch.setattr(
dumper_mod, "_detect_recompute_status", lambda: _RecomputeStatus.RECOMPUTE
)
d = _make_test_dumper(tmp_path)
tensor = torch.randn(3, 3)
d.dump("test_tensor", tensor)
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["recompute_status=recompute"])
path = _find_dump_file(tmp_path, rank=0, name="test_tensor")
raw = _load_dump(path)
assert raw["meta"]["recompute_status"] == "recompute"
assert raw["meta"]["recompute_pseudo_rank"] == 1
assert raw["meta"]["recompute_pseudo_size"] == 2
def test_recompute_status_original(self, tmp_path: Path, monkeypatch) -> None:
import sglang.srt.debug_utils.dumper as dumper_mod
monkeypatch.setattr(
dumper_mod,
"_detect_recompute_status",
lambda: _RecomputeStatus.ORIGINAL,
)
d = _make_test_dumper(tmp_path)
tensor = torch.randn(3, 3)
d.dump("test_tensor", tensor)
filenames = _get_filenames(tmp_path)
_assert_files(filenames, exist=["recompute_status=original"])
path = _find_dump_file(tmp_path, rank=0, name="test_tensor")
raw = _load_dump(path)
assert raw["meta"]["recompute_status"] == "original"
assert raw["meta"]["recompute_pseudo_rank"] == 0
assert raw["meta"]["recompute_pseudo_size"] == 2
def test_disabled_no_recompute_pseudo_fields(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path)
tensor = torch.randn(3, 3)
d.dump("test_tensor", tensor)
path = _find_dump_file(tmp_path, rank=0, name="test_tensor")
raw = _load_dump(path)
assert "recompute_pseudo_rank" not in raw["meta"]
assert "recompute_pseudo_size" not in raw["meta"]
def test_grad_hook_has_no_recompute_status(self, tmp_path: Path) -> None:
d = _make_test_dumper(tmp_path, enable_grad=True)
x = torch.randn(3, 3, requires_grad=True)
y = (x * 2).sum()
d.dump("test_tensor", x)
y.backward()
grad_files = [f for f in _get_filenames(tmp_path) if "grad__test_tensor" in f]
assert len(grad_files) == 1
assert "recompute_status" not in grad_files[0]
def test_non_intrusive_hooks_have_recompute_status(self, tmp_path: Path) -> None:
class Simple(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.linear(x)
model = Simple()
d = _make_test_dumper(tmp_path, non_intrusive_mode="all")
d.register_non_intrusive_dumper(model)
with d.capture_output() as captured:
model(torch.randn(2, 4))
for key, data in captured.items():
assert "recompute_status" in data["meta"], (
f"missing recompute_status in {key}"
)
assert data["meta"]["recompute_status"] == "disabled"
def test_detect_recompute_status_default(self) -> None:
assert _detect_recompute_status() == _RecomputeStatus.DISABLED
class TestGrafterConfig:
def test_from_env_parses_filters(self):
with temp_set_env(
DUMPER_GRAFTER_B2T_FILTER="name == 'x'",
DUMPER_GRAFTER_T2B_FILTER="name == 'y'",
):
cfg = DumperConfig.from_env()
assert cfg.grafter_b2t_filter == "name == 'x'"
assert cfg.grafter_t2b_filter == "name == 'y'"
def test_from_env_parses_int_fields(self):
with temp_set_env(
DUMPER_GRAFTER_BASELINE_WORLD_SIZE="8",
DUMPER_GRAFTER_TARGET_WORLD_SIZE="8",
DUMPER_GRAFTER_MASTER_PORT="29999",
DUMPER_GRAFTER_TIMEOUT="120",
):
cfg = DumperConfig.from_env()
assert cfg.grafter_baseline_world_size == 8
assert type(cfg.grafter_baseline_world_size) is int
assert cfg.grafter_target_world_size == 8
assert cfg.grafter_master_port == 29999
assert cfg.grafter_timeout == 120
def test_from_env_role(self):
with temp_set_env(DUMPER_GRAFTER_ROLE="baseline"):
assert DumperConfig.from_env().grafter_role == "baseline"
def test_from_env_enable_flag(self):
# enable=True requires all of role, master_address/port, world sizes,
# and at least one filter per DumperConfig.__post_init__.
with temp_set_env(
DUMPER_GRAFTER_ENABLE="1",
DUMPER_GRAFTER_ROLE="baseline",
DUMPER_GRAFTER_MASTER_ADDRESS="127.0.0.1",
DUMPER_GRAFTER_MASTER_PORT="29999",
DUMPER_GRAFTER_BASELINE_WORLD_SIZE="1",
DUMPER_GRAFTER_TARGET_WORLD_SIZE="1",
DUMPER_GRAFTER_B2T_FILTER="name == 'x'",
):
assert DumperConfig.from_env().grafter_enable is True
with temp_set_env(DUMPER_GRAFTER_ENABLE="false"):
assert DumperConfig.from_env().grafter_enable is False
def test_enable_without_required_fields_raises(self):
with pytest.raises(AssertionError, match=r"grafter_role"):
DumperConfig(grafter_enable=True)
with pytest.raises(AssertionError, match=r"grafter_master_address"):
DumperConfig(grafter_enable=True, grafter_role="baseline")
with pytest.raises(AssertionError, match=r"grafter_master_port"):
DumperConfig(
grafter_enable=True,
grafter_role="baseline",
grafter_master_address="127.0.0.1",
)
with pytest.raises(AssertionError, match=r"grafter_baseline_world_size"):
DumperConfig(
grafter_enable=True,
grafter_role="baseline",
grafter_master_address="127.0.0.1",
grafter_master_port=12345,
)
with pytest.raises(AssertionError, match=r"neither grafter_b2t_filter nor"):
DumperConfig(
grafter_enable=True,
grafter_role="baseline",
grafter_master_address="127.0.0.1",
grafter_master_port=12345,
grafter_baseline_world_size=1,
grafter_target_world_size=1,
)
def test_env_name_for_grafter_field(self):
assert (
DumperConfig._env_name("grafter_b2t_filter") == "DUMPER_GRAFTER_B2T_FILTER"
)
def _unit_grafter_config(**overrides) -> DumperConfig:
"""Build a fully-valid DumperConfig for unit-test use.
All grafter_* required fields default to dummy values; overrides patch
individual fields (e.g., grafter_enable=False or filter strings).
Dummy values are never reached because these unit tests short-circuit
before _ensure_group runs.
"""
base = dict(
grafter_enable=True,
grafter_role="baseline",
grafter_master_address="127.0.0.1",
grafter_master_port=12345,
grafter_baseline_world_size=1,
grafter_target_world_size=1,
grafter_b2t_filter="name == 'x'",
)
base.update(overrides)
return DumperConfig(**base)
class TestLog:
def test_log_format(self):
with _capture_stdout() as captured:
_log("hello")
out = captured.getvalue()
assert "hello" in out, out
assert "[Dumper, rank=" in out, out
assert ", t=" in out, out
class TestCompareTensorsQuick:
def test_identical(self):
a = torch.tensor([1.0, 2.0, 3.0])
s = _compare_tensors_quick(a, a.clone())
assert "rel_diff=0" in s, s
assert "max_abs=0" in s, s
def test_diverged(self):
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.tensor([1.0, 2.0, 4.0]) # last element differs by 1
s = _compare_tensors_quick(a, b)
# rel_diff > 0 implies divergence; max_abs should equal 1.0
assert "max_abs=1" in s, s
assert "rel_diff=" in s, s
def test_shape_mismatch(self):
s = _compare_tensors_quick(torch.zeros(3), torch.zeros(4))
assert "shape mismatch" in s, s
def test_dtype_unified(self):
# Different dtypes should NOT error — both are cast to fp32 internally.
s = _compare_tensors_quick(
torch.zeros(3, dtype=torch.float32),
torch.zeros(3, dtype=torch.float64),
)
assert "rel_diff=" in s, s
assert "max_abs=" in s, s
def test_empty(self):
s = _compare_tensors_quick(torch.zeros(0), torch.zeros(0))
assert s == "empty"
class TestGrafterFilterMatching:
"""Unit tests for the filter-matching short-circuit logic.
These don't initialize a process group, so the network-related fields
are dummy values via _unit_grafter_config.
"""
def test_disabled_returns_silently(self):
grafter = _Grafter(config=_unit_grafter_config(grafter_enable=False))
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x"})
assert grafter._pg is None # never initialized
def test_unmatched_non_tensor_silent(self):
"""Non-tensor + unmatched name → silent skip, no print."""
grafter = _Grafter(config=_unit_grafter_config())
with _capture_stdout() as captured:
grafter.maybe_intercept(value=42, tags={"name": "other"})
assert grafter._pg is None
assert "[Grafter]" not in captured.getvalue(), captured.getvalue()
def test_matched_non_tensor_prints_and_skips(self):
"""Non-tensor that matches a filter → print explanation, then skip.
This catches misconfigured filters (e.g. matching a name that maps to a
dict/list at some call sites) without silently masking the issue."""
grafter = _Grafter(config=_unit_grafter_config())
with _capture_stdout() as captured:
grafter.maybe_intercept(value={"not": "a tensor"}, tags={"name": "x"})
output = captured.getvalue()
assert grafter._pg is None # still no PG init
assert "value is not a torch.Tensor" in output, output
assert "type=dict" in output, output
def test_unmatched_name_returns_silently(self):
grafter = _Grafter(
config=_unit_grafter_config(grafter_t2b_filter="name == 'y'")
)
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "z"})
assert grafter._pg is None
def test_overlap_filters_raise(self):
grafter = _Grafter(
config=_unit_grafter_config(
grafter_b2t_filter="name == 'x'",
grafter_t2b_filter="name == 'x'",
)
)
with pytest.raises(
RuntimeError,
match=r"matched BOTH grafter_b2t_filter and grafter_t2b_filter",
):
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x"})
def test_filter_expression_uses_extra_tags(self):
"""Filter expressions can reference any tag key, not just 'name'."""
grafter = _Grafter(
config=_unit_grafter_config(
grafter_b2t_filter="name == 'x' and layer_id < 3",
grafter_t2b_filter="name == 'x' and layer_id < 3",
)
)
# layer_id=1 → both filters match → overlap raise (proves filter saw layer_id).
with pytest.raises(RuntimeError, match=r"matched BOTH"):
grafter.maybe_intercept(
value=torch.zeros(2),
tags={"name": "x", "layer_id": 1},
)
# layer_id=5 → neither filter matches → silent skip.
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x", "layer_id": 5})
assert grafter._pg is None
def test_load_function_bad_module(self):
with pytest.raises(ModuleNotFoundError):
_load_function("no_such_pkg.no_such_module.transform")
def test_load_function_missing_attr(self):
# `os.path` exists but has no `definitely_no_such_attr`.
with pytest.raises(AttributeError):
_load_function("os.path.definitely_no_such_attr")
def test_load_function_no_dotted_prefix(self):
with pytest.raises(ValueError, match=r"missing dotted prefix"):
_load_function("only_one_segment")
def test_load_function_non_callable_resolves_but_call_fails(self):
"""`_load_function` itself only does attribute lookup — it doesn't
verify the result is callable. A non-callable target manifests at
call time as TypeError; we still want the failure to be debuggable."""
sep = _load_function("os.path.sep") # str, not a callable
assert isinstance(sep, str)
with pytest.raises(TypeError):
sep()
def test_filter_expression_only_uses_non_name_tag(self):
"""A filter that doesn't reference `name` at all is still valid; it
should match purely on the other tag(s)."""
grafter = _Grafter(
config=_unit_grafter_config(
grafter_b2t_filter=None,
grafter_t2b_filter="layer_id < 3",
)
)
# layer_id absent → resolves to None; `None < 3` raises TypeError in py3.
with pytest.raises(TypeError):
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x"})
def test_filter_expression_unknown_tag_resolves_to_none(self):
"""Unknown tag keys resolve to None inside filter expressions, so
`layer_id is None` works as an "absent" probe without raising."""
grafter = _Grafter(
config=_unit_grafter_config(
grafter_b2t_filter=None,
grafter_t2b_filter="layer_id is None and name == 'x'",
)
)
# No `layer_id` in tags → resolves to None → filter matches → tries
# to init the recv group (which we can't actually do here without a
# real PG, so we expect the assertion failure from _ensure_group).
with pytest.raises(AssertionError, match="default torch.distributed"):
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x"})
def test_filter_expression_syntax_error_raises(self):
"""A filter string that isn't valid Python should surface as a
SyntaxError so the misconfiguration is loud, not silent."""
grafter = _Grafter(
config=_unit_grafter_config(grafter_b2t_filter="name == "),
)
with pytest.raises(SyntaxError):
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x"})
def test_filter_expression_undefined_helper_raises(self):
"""Referencing an undefined helper inside a filter (e.g. a function
the user expected to be in scope) should NOT be silently treated as
False. The filter namespace is a `_DefaultNoneDict` (unknown keys
resolve to None), so calling an undefined helper raises TypeError
(`'NoneType' object is not callable`) — loud enough to surface the
misconfiguration."""
grafter = _Grafter(
config=_unit_grafter_config(
grafter_b2t_filter="totally_undefined_helper(name)"
),
)
with pytest.raises(TypeError, match=r"NoneType.* not callable"):
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "x"})
def test_filter_can_use_re_search(self):
"""`re.search` is exposed inside filter expressions as `search()`."""
grafter = _Grafter(
config=_unit_grafter_config(
grafter_b2t_filter="search(r'attn.*', name) is not None",
grafter_t2b_filter=None,
)
)
# name='attn_input' matches /attn.*/ → tries to init group (hits
# the no-default-PG assertion, proving the regex matched).
with pytest.raises(AssertionError, match="default torch.distributed"):
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "attn_input"})
# name='other' does not match → silent skip.
grafter.maybe_intercept(value=torch.zeros(2), tags={"name": "other"})
assert grafter._pg is None
def _run_graft_test(worker_func, **kwargs):
"""Spawn one GPU-using process per role (rank 0 = baseline, rank 1 = target).
Limited to 1+1 because CI machines we can rely on have only 2 GPUs.
Each process initializes its OWN default PG (nccl, world_size=1) from
the start, mirroring production where baseline and target are
independently launched.
For asymmetric / multi-rank coverage that doesn't need GPU, see
`_run_graft_test_cpu_multi` below.
"""
import torch.multiprocessing as mp
role_ports = [find_available_port(29700 + i * 100) for i in range(2)]
ctx = mp.get_context("spawn")
result_queue = ctx.Queue()
processes = []
for rank in range(2):
p = ctx.Process(
target=_graft_worker_entry,
args=(rank, role_ports[rank], worker_func, result_queue, kwargs),
)
p.start()
processes.append(p)
for p in processes:
p.join()
errors = [result_queue.get() for _ in range(2)]
errors = [e for e in errors if e]
if errors:
raise AssertionError("\n".join(errors))
def _graft_worker_entry(rank, role_port, worker_func, result_queue, kwargs):
import traceback
get_device_module().set_device(rank)
dist.init_process_group(
backend=get_default_distributed_backend(get_device()),
init_method=f"tcp://127.0.0.1:{role_port}",
world_size=1,
rank=0,
)
try:
worker_func(rank=rank, **kwargs)
result_queue.put(None)
except Exception as e:
result_queue.put(f"rank={rank}: {e}\n{traceback.format_exc()}")
finally:
dist.destroy_process_group()
def _run_graft_test_split(worker_baseline, worker_target, **kwargs) -> dict:
"""Like `_run_graft_test`, but each role runs its OWN dedicated worker
function (no `if rank == 0:` branching) and stdout is captured per role.
Returns ``{"baseline": stdout_str, "target": stdout_str}`` so tests can
snapshot/assert on the per-role logs. Used by the E2E example for
educational clarity (each role's logic reads top-to-bottom) and to assert
the user-visible log output matches expectations.
"""
import torch.multiprocessing as mp
role_ports = {
"baseline": find_available_port(29700),
"target": find_available_port(29800),
}
ctx = mp.get_context("spawn")
result_queue = ctx.Queue()
processes = []
for global_rank, (role, worker) in enumerate(
[("baseline", worker_baseline), ("target", worker_target)]
):
p = ctx.Process(
target=_graft_split_worker_entry,
args=(global_rank, role, role_ports[role], worker, result_queue, kwargs),
)
p.start()
processes.append(p)
for p in processes:
p.join()
outputs: dict = {}
errors: list = []
for _ in range(2):
role, error, captured = result_queue.get()
outputs[role] = captured
if error:
errors.append(f"role={role}: {error}")
if errors:
raise AssertionError(
"\n".join(errors)
+ "\nCaptured outputs:\n"
+ f"--- baseline ---\n{outputs.get('baseline', '')}\n"
+ f"--- target ---\n{outputs.get('target', '')}"
)
return outputs
def _graft_split_worker_entry(
global_rank, role, role_port, worker_func, result_queue, kwargs
):
import io
import traceback
captured = io.StringIO()
old_stdout = sys.stdout
sys.stdout = captured
error = None
try:
# Set per-role env BEFORE we (re)build the module-level `dumper`. The
# parent left DUMPER_GRAFTER_ENABLE/ROLE unset because they vary per
# child; we set them here, then rebuild the global so that worker
# code can simply call `from sglang.srt.debug_utils.dumper import dumper`
# and get a properly-configured Grafter — exactly mirroring how
# production code uses the global.
os.environ["DUMPER_GRAFTER_ENABLE"] = "1"
os.environ["DUMPER_GRAFTER_ROLE"] = role
import sglang.srt.debug_utils.dumper as _dumper_module
_dumper_module.dumper = _dumper_module._Dumper(
config=_dumper_module.DumperConfig.from_env()
)
get_device_module().set_device(global_rank)
dist.init_process_group(
backend=get_default_distributed_backend(get_device()),
init_method=f"tcp://127.0.0.1:{role_port}",
world_size=1,
rank=0,
)
try:
worker_func(**kwargs)
except Exception as e:
error = f"{e}\n{traceback.format_exc()}"
finally:
try:
dist.destroy_process_group()
except Exception:
pass
finally:
sys.stdout = old_stdout
result_queue.put((role, error, captured.getvalue()))
def _run_graft_test_cpu_multi(
worker_func, *, baseline_world: int, target_world: int, **kwargs
):
"""Spawn (baseline_world + target_world) CPU-only processes (gloo backend).
Used to exercise asymmetric multi-rank cases (e.g. 4 baseline ranks and
2 target ranks) that we can't run on the 2-GPU CI fleet. Each role gets
its OWN default PG (gloo, world=role_world); the graft cross-system PG
spans all ranks.
The worker function receives (role, local_rank, **kwargs).
"""
import torch.multiprocessing as mp
# One default-PG port per role (baseline-side ranks share one PG, target
# ranks share another). Allocated up-front to avoid child races.
role_ports = {
"baseline": find_available_port(29800),
"target": find_available_port(29900),
}
ctx = mp.get_context("spawn")
result_queue = ctx.Queue()
processes = []
total = baseline_world + target_world
for global_rank in range(total):
if global_rank < baseline_world:
role = "baseline"
local_rank = global_rank
local_world = baseline_world
else:
role = "target"
local_rank = global_rank - baseline_world
local_world = target_world
p = ctx.Process(
target=_graft_cpu_worker_entry,
args=(
role,
local_rank,
local_world,
role_ports[role],
worker_func,
result_queue,
kwargs,
),
)
p.start()
processes.append(p)
for p in processes:
p.join()
errors = [result_queue.get() for _ in range(total)]
errors = [e for e in errors if e]
if errors:
raise AssertionError("\n".join(errors))
def _graft_cpu_worker_entry(
role, local_rank, local_world, port, worker_func, result_queue, kwargs
):
import traceback
dist.init_process_group(
backend="gloo",
init_method=f"tcp://127.0.0.1:{port}",
world_size=local_world,
rank=local_rank,
)
try:
worker_func(role=role, local_rank=local_rank, **kwargs)
result_queue.put(None)
except Exception as e:
result_queue.put(
f"role={role} local_rank={local_rank}: {e}\n{traceback.format_exc()}"
)
finally:
dist.destroy_process_group()
def _make_grafter_test_config(
*,
rank: int,
graft_port: int,
group_name: str,
timeout: int = 30,
transform_path: Optional[str] = None,
b2t_filter: Optional[str] = "name == 'x'",
t2b_filter: Optional[str] = None,
) -> DumperConfig:
"""Helper for distributed grafter tests.
Same b2t/t2b filters on both sides; only `grafter_role` differs (rank 0 =
baseline, rank 1 = target). Both sides are world_size=1 within their own
role's default PG.
"""
role = "baseline" if rank == 0 else "target"
return DumperConfig(
grafter_enable=True,
grafter_backend=get_default_distributed_backend(get_device()),
grafter_role=role,
grafter_b2t_filter=b2t_filter,
grafter_t2b_filter=t2b_filter,
grafter_master_address="127.0.0.1",
grafter_master_port=graft_port,
grafter_baseline_world_size=1,
grafter_target_world_size=1,
grafter_group_name=group_name,
grafter_timeout=timeout,
# Loading the user transform on the recv side; for b2t the recv is
# the target side (rank 1).
grafter_transform_path=transform_path if rank == 1 else None,
)
class TestGrafterDistributed:
def test_b2t_copy_roundtrip(self):
"""Baseline (rank 0) sends 'x' to target (rank 1), target.copy_'s it."""
graft_port = find_available_port(29600)
_run_graft_test(
self._test_b2t_func, graft_port=graft_port, group_name="grafter_b2t"
)
@staticmethod
def _test_b2t_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank, graft_port=graft_port, group_name=group_name
)
)
try:
if rank == 0:
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.zeros(3, device=get_device(1))
with _capture_stdout() as captured:
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [1.0, 2.0, 3.0], f"got {target.tolist()}"
# Success log must include the pre/new diff summary.
assert "diff_pre_vs_new=" in captured.getvalue(), captured.getvalue()
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_t2b_copy_roundtrip(self):
"""Target (rank 1) sends 'x' to baseline (rank 0), baseline.copy_'s it."""
graft_port = find_available_port(29605)
_run_graft_test(
self._test_t2b_func, graft_port=graft_port, group_name="grafter_t2b"
)
@staticmethod
def _test_t2b_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank,
graft_port=graft_port,
group_name=group_name,
b2t_filter=None,
t2b_filter="name == 'x'",
)
)
try:
if rank == 1:
tensor = torch.tensor([4.0, 5.0, 6.0], device=get_device(1))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.zeros(3, device=get_device(0))
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [4.0, 5.0, 6.0], f"got {target.tolist()}"
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_recv_with_user_transform(self, tmp_path: Path):
# Write a tiny module that defines `transform(graft_input)`. The
# worker prepends tmp_path to sys.path so import_module sees it.
module_name = "_xform_user_basic"
(tmp_path / f"{module_name}.py").write_text(
"def transform(graft_input):\n return graft_input.received_list[0] * 2\n"
)
graft_port = find_available_port(29610)
_run_graft_test(
self._test_user_transform_func,
graft_port=graft_port,
group_name="grafter_transform",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
)
@staticmethod
def _test_user_transform_func(
rank, graft_port, group_name, transform_dir, transform_path
):
sys.path.insert(0, transform_dir)
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank,
graft_port=graft_port,
group_name=group_name,
transform_path=transform_path,
)
)
try:
if rank == 0:
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.zeros(3, device=get_device(1))
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [2.0, 4.0, 6.0], f"got {target.tolist()}"
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_unmatched_name_skipped(self):
graft_port = find_available_port(29620)
_run_graft_test(
self._test_unmatched_func,
graft_port=graft_port,
group_name="grafter_unmatched",
)
@staticmethod
def _test_unmatched_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank, graft_port=graft_port, group_name=group_name
)
)
try:
target = torch.tensor([7.0, 7.0, 7.0], device=get_device(rank))
grafter.maybe_intercept(value=target, tags={"name": "other"})
assert target.tolist() == [7.0, 7.0, 7.0], "tensor must not be modified"
assert grafter._pg is None, "group must not init for unmatched name"
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_default_fallback_shape_mismatch_does_not_crash(self):
"""When sender shape != target shape, default identity fallback raises;
the grafter must catch it, log, and leave target unchanged."""
graft_port = find_available_port(29615)
_run_graft_test(
self._test_shape_mismatch_func,
graft_port=graft_port,
group_name="grafter_shape_mismatch",
)
@staticmethod
def _test_shape_mismatch_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank, graft_port=graft_port, group_name=group_name
)
)
try:
if rank == 0:
# Baseline sends shape=(3,)
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
# Target's local target has shape=(4,) — mismatch with sender.
target = torch.tensor([7.0, 7.0, 7.0, 7.0], device=get_device(1))
# No exception should propagate; tensor must stay unchanged.
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [
7.0,
7.0,
7.0,
7.0,
], (
f"target should be unchanged after shape-mismatch graft, got {target.tolist()}"
)
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_user_transform_exception_does_not_crash(self, tmp_path: Path):
"""A user transform that raises must NOT bring down the system; the
grafter logs and skips the copy_, leaving target unchanged."""
module_name = "_xform_throws"
(tmp_path / f"{module_name}.py").write_text(
"def transform(graft_input):\n"
" raise RuntimeError('intentional test error from user transform')\n"
)
graft_port = find_available_port(29635)
_run_graft_test(
self._test_transform_throws_func,
graft_port=graft_port,
group_name="grafter_throws",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
module_name=module_name,
)
@staticmethod
def _test_transform_throws_func(
rank, graft_port, group_name, transform_dir, transform_path, module_name
):
sys.path.insert(0, transform_dir)
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank,
graft_port=graft_port,
group_name=group_name,
transform_path=transform_path,
)
)
try:
if rank == 0:
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.tensor([9.0, 9.0, 9.0], device=get_device(1))
with _capture_stdout() as captured:
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [
9.0,
9.0,
9.0,
], (
f"target must be unchanged when transform throws, got {target.tolist()}"
)
output = captured.getvalue()
assert "transform/copy_ raised RuntimeError" in output, output
assert "intentional test error" in output, output
# Full traceback must be included so the bug is debuggable.
assert "Traceback (most recent call last)" in output, output
assert f"{module_name}.py" in output, output
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_extras_flow_to_recv_transform(self, tmp_path: Path):
"""Sender attaches per-call grafter_extras; recv transform reads them
and uses them to compute the override value."""
module_name = "_xform_uses_extras"
(tmp_path / f"{module_name}.py").write_text(
"import torch\n"
"def transform(graft_input):\n"
" fill = graft_input.received_extras_list[0]['fill_value']\n"
" return torch.full_like(graft_input.target, fill)\n"
)
graft_port = find_available_port(29645)
_run_graft_test(
self._test_extras_func,
graft_port=graft_port,
group_name="grafter_extras",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
)
@staticmethod
def _test_extras_func(rank, graft_port, group_name, transform_dir, transform_path):
sys.path.insert(0, transform_dir)
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank,
graft_port=graft_port,
group_name=group_name,
transform_path=transform_path,
)
)
try:
if rank == 0:
# Baseline (sender) attaches an extras dict.
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
grafter.maybe_intercept(
value=tensor,
tags={"name": "x"},
extras={"fill_value": 42.0},
)
else:
target = torch.zeros(3, device=get_device(1))
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [
42.0,
42.0,
42.0,
], f"target should be filled from sender extras, got {target.tolist()}"
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_init_timeout_warns(self):
graft_port = find_available_port(29630)
_run_graft_test(
self._test_init_timeout_func,
graft_port=graft_port,
group_name="grafter_timeout",
)
@staticmethod
def _test_init_timeout_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank, graft_port=graft_port, group_name=group_name, timeout=2
)
)
try:
with _capture_stdout() as captured:
if rank == 1:
time.sleep(4)
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(rank))
if rank == 0:
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.zeros(3, device=get_device(rank))
grafter.maybe_intercept(value=target, tags={"name": "x"})
output = captured.getvalue()
if rank == 0:
assert "WARNING" in output, (
f"expected WARNING in rank 0 output: {output}"
)
assert "has not completed after 2s" in output, output
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_extras_default_none_flow(self):
"""When the sender omits `grafter_extras`, the recv transform sees a
list of Nones — but len(received_extras_list) still matches n_senders."""
graft_port = find_available_port(29650)
_run_graft_test(
self._test_extras_none_func,
graft_port=graft_port,
group_name="grafter_extras_none",
)
@staticmethod
def _test_extras_none_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank, graft_port=graft_port, group_name=group_name
)
)
try:
if rank == 0:
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
# Note: extras kwarg omitted entirely → None on the wire.
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.zeros(3, device=get_device(1))
with _capture_stdout() as captured:
grafter.maybe_intercept(value=target, tags={"name": "x"})
# Default identity transform copies tensor through; recv log
# must reflect that received_extras_list == [None].
output = captured.getvalue()
assert "sender_extras=[None]" in output, output
assert target.tolist() == [1.0, 2.0, 3.0], target.tolist()
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_group_init_is_cached_across_calls(self):
"""The graft process group is initialized lazily on the first
matched dump() and cached afterwards — subsequent dumps must reuse
the same `_pg` object, not re-init."""
graft_port = find_available_port(29660)
_run_graft_test(
self._test_group_cache_func,
graft_port=graft_port,
group_name="grafter_cache",
)
@staticmethod
def _test_group_cache_func(rank, graft_port, group_name):
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank, graft_port=graft_port, group_name=group_name
)
)
try:
if rank == 0:
t1 = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
t2 = torch.tensor([4.0, 5.0, 6.0], device=get_device(0))
grafter.maybe_intercept(value=t1, tags={"name": "x"})
pg_after_first = grafter._pg
assert pg_after_first is not None
grafter.maybe_intercept(value=t2, tags={"name": "x"})
assert grafter._pg is pg_after_first, (
"_pg must be cached across calls, not re-initialized"
)
else:
target1 = torch.zeros(3, device=get_device(1))
target2 = torch.zeros(3, device=get_device(1))
grafter.maybe_intercept(value=target1, tags={"name": "x"})
pg_after_first = grafter._pg
assert pg_after_first is not None
grafter.maybe_intercept(value=target2, tags={"name": "x"})
assert grafter._pg is pg_after_first
assert target1.tolist() == [1.0, 2.0, 3.0]
assert target2.tolist() == [4.0, 5.0, 6.0]
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_copy_failure_does_not_crash(self, tmp_path: Path):
"""If the user transform returns a tensor whose shape doesn't match
target, `value.copy_(value_to_override)` raises — and that error
must be caught, logged with traceback, and target left unchanged
(same robustness contract as transform-throws)."""
module_name = "_xform_returns_wrong_shape"
(tmp_path / f"{module_name}.py").write_text(
"import torch\n"
"def transform(graft_input):\n"
" # Deliberately return a shape that copy_ will reject.\n"
" return torch.zeros(99, device=graft_input.target.device)\n"
)
graft_port = find_available_port(29665)
_run_graft_test(
self._test_copy_failure_func,
graft_port=graft_port,
group_name="grafter_copy_fail",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
)
@staticmethod
def _test_copy_failure_func(
rank, graft_port, group_name, transform_dir, transform_path
):
sys.path.insert(0, transform_dir)
grafter = _Grafter(
config=_make_grafter_test_config(
rank=rank,
graft_port=graft_port,
group_name=group_name,
transform_path=transform_path,
)
)
try:
if rank == 0:
tensor = torch.tensor([1.0, 2.0, 3.0], device=get_device(0))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.tensor([7.0, 7.0, 7.0], device=get_device(1))
with _capture_stdout() as captured:
grafter.maybe_intercept(value=target, tags={"name": "x"})
# target must be unchanged; error must be logged with traceback.
assert target.tolist() == [
7.0,
7.0,
7.0,
], f"target must be unchanged on copy_ failure, got {target.tolist()}"
output = captured.getvalue()
assert "transform/copy_ raised" in output, output
assert "Traceback (most recent call last)" in output, output
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
class TestGrafterMultiRankCpu:
"""Coverage of asymmetric multi-rank cases via CPU/gloo (CI fleet has
only 2 GPUs, which is too few for these cases)."""
def test_4_baseline_2_target_b2t_with_user_transform(self, tmp_path: Path):
"""4 baseline senders -> 2 target receivers via b2t graft.
The user transform asserts received_list has length 4 with each
sender's tensor matching its rank, then returns a marker tensor."""
module_name = "_xform_assert_4_senders"
(tmp_path / f"{module_name}.py").write_text(
"import torch\n"
"def transform(graft_input):\n"
" rl = graft_input.received_list\n"
" assert len(rl) == 4, f'expected 4 senders, got {len(rl)}'\n"
" for i, t in enumerate(rl):\n"
" v = float(t.flatten()[0].item())\n"
" assert v == float(i), f'rl[{i}][0]={v}, want {float(i)}'\n"
" return torch.full_like(graft_input.target, 999.0)\n"
)
graft_port = find_available_port(29655)
_run_graft_test_cpu_multi(
self._test_4b_2t_func,
baseline_world=4,
target_world=2,
graft_port=graft_port,
group_name="grafter_4b_2t",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
)
@staticmethod
def _test_4b_2t_func(
role, local_rank, graft_port, group_name, transform_dir, transform_path
):
sys.path.insert(0, transform_dir)
cfg = _make_multi_rank_config(
role=role,
graft_port=graft_port,
group_name=group_name,
baseline_world=4,
target_world=2,
transform_path=transform_path,
direction="b2t",
)
grafter = _Grafter(config=cfg)
try:
if role == "baseline":
# rank-i baseline contributes [i, i, i].
tensor = torch.full((3,), float(local_rank))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
# Target's local tensor (will be overwritten with 999s by transform).
target = torch.full((3,), 99.0)
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [999.0, 999.0, 999.0], target.tolist()
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_2_target_4_baseline_t2b_with_user_transform(self, tmp_path: Path):
"""Mirror image of the b2t case: 2 target senders -> 4 baseline
receivers via t2b graft. Confirms the (role, direction) algebra and
sender_slice work correctly when target is the SENDER side."""
module_name = "_xform_assert_2_senders_t2b"
(tmp_path / f"{module_name}.py").write_text(
"import torch\n"
"def transform(graft_input):\n"
" rl = graft_input.received_list\n"
" assert len(rl) == 2, f'expected 2 senders, got {len(rl)}'\n"
" for i, t in enumerate(rl):\n"
" v = float(t.flatten()[0].item())\n"
" assert v == float(i + 100), (\n"
" f'rl[{i}][0]={v}, want {float(i + 100)}'\n"
" )\n"
" return torch.full_like(graft_input.target, 7.0)\n"
)
graft_port = find_available_port(29670)
_run_graft_test_cpu_multi(
self._test_2t_4b_func,
baseline_world=4,
target_world=2,
graft_port=graft_port,
group_name="grafter_2t_4b",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
)
@staticmethod
def _test_2t_4b_func(
role, local_rank, graft_port, group_name, transform_dir, transform_path
):
sys.path.insert(0, transform_dir)
cfg = _make_multi_rank_config(
role=role,
graft_port=graft_port,
group_name=group_name,
baseline_world=4,
target_world=2,
transform_path=transform_path,
direction="t2b",
)
grafter = _Grafter(config=cfg)
try:
if role == "target":
# rank-i target contributes [i+100, i+100, i+100].
tensor = torch.full((3,), float(local_rank + 100))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.full((3,), 99.0)
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [7.0, 7.0, 7.0], target.tolist()
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_default_transform_with_asymmetric_world_logs_and_skips(self):
"""The default identity-by-rank fallback requires #senders == #recvs.
With baseline=4 and target=2 and no user transform, the recv side
must catch the RuntimeError, log it with traceback, and leave the
target unchanged."""
graft_port = find_available_port(29675)
_run_graft_test_cpu_multi(
self._test_default_asym_func,
baseline_world=4,
target_world=2,
graft_port=graft_port,
group_name="grafter_default_asym",
)
@staticmethod
def _test_default_asym_func(role, local_rank, graft_port, group_name):
cfg = _make_multi_rank_config(
role=role,
graft_port=graft_port,
group_name=group_name,
baseline_world=4,
target_world=2,
transform_path=None, # default identity-by-rank fallback
direction="b2t",
)
grafter = _Grafter(config=cfg)
try:
if role == "baseline":
tensor = torch.full((3,), float(local_rank))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
target = torch.full((3,), 42.0)
with _capture_stdout() as captured:
grafter.maybe_intercept(value=target, tags={"name": "x"})
assert target.tolist() == [42.0, 42.0, 42.0], (
f"target must be unchanged when default transform raises, "
f"got {target.tolist()}"
)
output = captured.getvalue()
assert "transform/copy_ raised RuntimeError" in output, output
# The error message must explain WHY the default fell through.
assert "#senders=4" in output and "#recvs=2" in output, output
assert "Traceback (most recent call last)" in output, output
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def test_mixed_shape_senders_via_user_transform(self, tmp_path: Path):
"""`all_gather_object` is pickle-routed, so sender ranks may
contribute tensors with DIFFERENT shapes. The user transform sees
the full list and is responsible for picking/reducing. Asserts that
rank-i baseline's tensor has shape (i+1,) and the transform
concatenates them on the recv side."""
module_name = "_xform_concat_mixed_shape"
(tmp_path / f"{module_name}.py").write_text(
"import torch\n"
"def transform(graft_input):\n"
" rl = graft_input.received_list\n"
" # Each baseline sent shape=(rank+1,) tensors filled with rank.\n"
" expected_shapes = [(i + 1,) for i in range(len(rl))]\n"
" actual_shapes = [tuple(t.shape) for t in rl]\n"
" assert actual_shapes == expected_shapes, (\n"
" f'shape mismatch: expected {expected_shapes}, got {actual_shapes}'\n"
" )\n"
" # Concat to length 1+2+3+4 = 10 == target's length.\n"
" return torch.cat(rl)\n"
)
graft_port = find_available_port(29680)
_run_graft_test_cpu_multi(
self._test_mixed_shape_func,
baseline_world=4,
target_world=2,
graft_port=graft_port,
group_name="grafter_mixed_shape",
transform_dir=str(tmp_path),
transform_path=f"{module_name}.transform",
)
@staticmethod
def _test_mixed_shape_func(
role, local_rank, graft_port, group_name, transform_dir, transform_path
):
sys.path.insert(0, transform_dir)
cfg = _make_multi_rank_config(
role=role,
graft_port=graft_port,
group_name=group_name,
baseline_world=4,
target_world=2,
transform_path=transform_path,
direction="b2t",
)
grafter = _Grafter(config=cfg)
try:
if role == "baseline":
# rank-i baseline contributes shape=(i+1,) filled with i.
tensor = torch.full((local_rank + 1,), float(local_rank))
grafter.maybe_intercept(value=tensor, tags={"name": "x"})
else:
# 1 + 2 + 3 + 4 = 10 elements after concat.
target = torch.zeros(10)
grafter.maybe_intercept(value=target, tags={"name": "x"})
expected = [0.0] + [1.0] * 2 + [2.0] * 3 + [3.0] * 4
assert target.tolist() == expected, target.tolist()
finally:
if grafter._pg is not None:
dist.destroy_process_group(grafter._pg)
def _make_multi_rank_config(
*,
role: str,
graft_port: int,
group_name: str,
baseline_world: int,
target_world: int,
transform_path: Optional[str],
direction: str,
) -> DumperConfig:
return DumperConfig(
grafter_enable=True,
grafter_role=role,
grafter_b2t_filter="name == 'x'" if direction == "b2t" else None,
grafter_t2b_filter="name == 'x'" if direction == "t2b" else None,
grafter_master_address="127.0.0.1",
grafter_master_port=graft_port,
grafter_baseline_world_size=baseline_world,
grafter_target_world_size=target_world,
grafter_backend="gloo",
grafter_group_name=group_name,
grafter_timeout=30,
grafter_transform_path=transform_path,
)
def _e2e_transform(graft_input):
"""User transform used by the E2E example test. Demonstrates the two
customization hooks reviewers should learn from:
1. The transform receives a `GraftTransformInput` and returns the
tensor that the recv side will `.copy_()` into its local target.
2. `graft_input.received_extras_list` carries whatever the sender
passed via `grafter_extras={...}` — useful for any per-call
metadata the recv side needs (layer ids, calibration knobs, ...).
Here we keep the example minimal: the sender attaches a single dummy
key/value so the recv side has something concrete to assert on, then
the transform is just identity. Real workflows would compute a
non-trivial override (scale, reshape, decode, ...) using the extras.
"""
assert graft_input.received_extras_list[0]["my_extra_key"] == "my_extra_value", (
graft_input.received_extras_list
)
return graft_input.received_list[0]
class TestGrafterE2eExample:
"""End-to-end example: target has a (suspected) buggy attention kernel.
Story: target's attention kernel produces wrong outputs and we want to
test "if we replace target's attention with baseline's, does the rest of
the model converge?". The full graft wiring is:
- At the attention call site, target sends its inputs (q/k/v) to
baseline → baseline's local inputs are overwritten by target's, so
baseline runs its (known-good) attention against the same inputs.
This is a t->b graft on `attn_input`.
- Both sides run the kernel.
- Baseline sends its outputs back to target → target's outputs are
overwritten by baseline's, so target's downstream sees baseline's
attention result. This is a b->t graft on `attn_output`.
Net effect: target's attention is semantically replaced by baseline's,
without modifying target's source beyond inserting `dumper.dump` at the
input/output sites. This test additionally demonstrates two recv-side
customization hooks via `_e2e_transform`:
* `grafter_extras={...}` per dump call — arbitrary per-call metadata
the recv side can consume.
* `DUMPER_GRAFTER_TRANSFORM_PATH` — a user-supplied function that
decides what value the recv side actually copy_'s in (defaults to
identity-by-rank when unset).
The remaining call-site code is exactly:
dumper.dump("attn_input", q, grafter_extras={"layer_id": 7}) # t -> b
out = target_attention_kernel(q, ...)
dumper.dump("attn_output", out, grafter_extras={"scale": 0.5}) # b -> t
"""
def test_e2e_buggy_attn_replaced_by_baseline(self):
graft_port = find_available_port(29640)
# All non-role env is shared by both sides; we set it in the parent
# so the spawned subprocesses inherit it. DUMPER_GRAFTER_ENABLE and
# DUMPER_GRAFTER_ROLE are deliberately *not* set here — they are set
# by `_run_graft_test_split` per-rank, after which the global
# `dumper` is rebuilt (so workers can use the global directly).
with temp_set_env(
DUMPER_ENABLE="1",
DUMPER_ENABLE_OUTPUT_FILE="false", # skip disk I/O for the test
DUMPER_ENABLE_OUTPUT_CONSOLE="false",
# Pin exp_name so the dumper doesn't auto-pick + log "Choose
# exp_name=..." into the captured snapshot.
DUMPER_EXP_NAME="grafter_e2e_test",
DUMPER_GRAFTER_MASTER_ADDRESS="127.0.0.1",
DUMPER_GRAFTER_MASTER_PORT=str(graft_port),
DUMPER_GRAFTER_BASELINE_WORLD_SIZE="1",
DUMPER_GRAFTER_TARGET_WORLD_SIZE="1",
DUMPER_GRAFTER_BACKEND=get_default_distributed_backend(get_device()),
DUMPER_GRAFTER_B2T_FILTER="name == 'attn_output'",
DUMPER_GRAFTER_T2B_FILTER="name == 'attn_input'",
DUMPER_GRAFTER_GROUP_NAME="grafter_e2e",
DUMPER_GRAFTER_TIMEOUT="30",
DUMPER_GRAFTER_TRANSFORM_PATH=f"{__name__}._e2e_transform",
):
outputs = _run_graft_test_split(self._worker_baseline, self._worker_target)
self._assert_e2e_snapshot(outputs)
@staticmethod
def _assert_e2e_snapshot(outputs: dict) -> None:
"""Snapshot of the FULL per-role log timeline.
Volatile fields (timestamps, ports, float diff values, tensor
min/max/mean/samples, struct addresses) are masked with ad-hoc regex
placeholders so the snapshot stays stable while still pinning
everything else. The snapshot doubles as documentation of the logs a
reader will see when running this E2E setup.
Captured logs are unconditionally printed before asserting so a
snapshot failure doesn't require a re-run.
"""
baseline_log = outputs["baseline"]
target_log = outputs["target"]
print("\n=========== captured baseline log ===========")
print(baseline_log)
print("=========== captured target log ===========")
print(target_log)
print("===========================================")
# Convenience tokens for verbose volatile substrings.
prefix = r"\[Dumper, rank=\d+, t=\d+\.\d+\] "
# `get_tensor_info(t)` for our tensors expands to a long line; we
# match the leading struct fields verbatim and let the trailing
# min/max/mean/sample fields wildcard out.
tinfo_f32_4 = (
r"type=<class 'torch\.Tensor'> shape=torch\.Size\(\[4\]\) "
r"dtype=torch\.float32 device=\w+:\d stride=\(1,\) "
r"req_grad=False .*"
)
diff = r"rel_diff=[-\d.eE+]+ max_abs=[-\d.eE+]+ mean_abs=[-\d.eE+]+"
# `_dump_inner` automatically annotates tags with `recompute_status`
# (always present, value depends on whether autograd recompute is
# active — "disabled" in this test env).
attn_input_tags = r"\{'name': 'attn_input', 'recompute_status': 'disabled'\}"
attn_output_tags = r"\{'name': 'attn_output', 'recompute_status': 'disabled'\}"
# Same dummy extras dict travels in both directions.
extras_lit = r"\{'my_extra_key': 'my_extra_value'\}"
baseline_pattern = (
r"\A"
f"{prefix}\\[Grafter\\] init group: role=baseline "
r"baseline_world=1 target_world=1 rank=0 "
r"init_method=tcp://127\.0\.0\.1:\d+ backend=\w+ "
r"name=grafter_e2e\n"
f"{prefix}\\[Grafter\\] recv role=baseline dir=t2b "
f"tags={attn_input_tags} n_senders=1 "
f"sender_extras=\\[{extras_lit}\\] "
f"before_overridden={tinfo_f32_4} "
f"to_override={tinfo_f32_4} "
f"diff_pre_vs_new={diff}\n"
f"{prefix}\\[Grafter\\] send role=baseline dir=b2t "
f"tags={attn_output_tags} extras={extras_lit} "
f"local={tinfo_f32_4}\n"
r"\Z"
)
target_pattern = (
r"\A"
f"{prefix}\\[Grafter\\] init group: role=target "
r"baseline_world=1 target_world=1 rank=1 "
r"init_method=tcp://127\.0\.0\.1:\d+ backend=\w+ "
r"name=grafter_e2e\n"
f"{prefix}\\[Grafter\\] send role=target dir=t2b "
f"tags={attn_input_tags} extras={extras_lit} "
f"local={tinfo_f32_4}\n"
f"{prefix}\\[Grafter\\] recv role=target dir=b2t "
f"tags={attn_output_tags} n_senders=1 "
f"sender_extras=\\[{extras_lit}\\] "
f"before_overridden={tinfo_f32_4} "
f"to_override={tinfo_f32_4} "
f"diff_pre_vs_new={diff}\n"
r"\Z"
)
assert re.fullmatch(baseline_pattern, baseline_log, flags=re.DOTALL), (
f"baseline log did not match snapshot.\n"
f"--- pattern ---\n{baseline_pattern}\n"
f"--- actual ---\n{baseline_log}"
)
assert re.fullmatch(target_pattern, target_log, flags=re.DOTALL), (
f"target log did not match snapshot.\n"
f"--- pattern ---\n{target_pattern}\n"
f"--- actual ---\n{target_log}"
)
@staticmethod
def _worker_baseline():
# In production code, callers just `from sglang.srt.debug_utils.dumper
# import dumper` and call `dumper.dump(name, value)` — the env
# configures the global Grafter for them. We do the same here.
from sglang.srt.debug_utils.dumper import dumper
# Step 1: graft input. target sends its q to baseline; baseline's
# `_e2e_transform` runs on the recv side, asserts the dummy extras
# made it across, then returns target's q so baseline's local
# placeholder is overwritten via .copy_().
q = torch.tensor([99.0, 99.0, 99.0, 99.0], device=get_device(0))
dumper.dump("attn_input", q)
assert q.tolist() == [1.0, 2.0, 3.0, 4.0], (
f"baseline's q should be overwritten by target's via the t->b graft, "
f"got {q.tolist()}"
)
# Step 2: baseline runs the known-good attention kernel.
attn_out = q * 10.0 # → [10, 20, 30, 40]
# Step 3: graft output. baseline sends attn_out to target with a
# dummy extras key the recv-side transform will assert on.
dumper.dump(
"attn_output",
attn_out,
grafter_extras={"my_extra_key": "my_extra_value"},
)
@staticmethod
def _worker_target():
from sglang.srt.debug_utils.dumper import dumper
# Step 1: graft input. target sends its real q to baseline along
# with a dummy extras key the recv-side transform will assert on.
q = torch.tensor([1.0, 2.0, 3.0, 4.0], device=get_device(1))
dumper.dump(
"attn_input",
q,
grafter_extras={"my_extra_key": "my_extra_value"},
)
# Step 2: target runs the (suspected buggy) attention kernel —
# here it returns all zeros to mimic a broken implementation.
attn_out = torch.zeros_like(q)
# Step 3: graft output. baseline sends its (good) attn_out to
# target; target's recv-side transform identity-passes it, so
# target's local attn_out ends up = baseline's [10, 20, 30, 40].
dumper.dump("attn_output", attn_out)
assert attn_out.tolist() == [10.0, 20.0, 30.0, 40.0], (
f"target's attn_out should be overwritten by baseline's via "
f"the b->t graft, got {attn_out.tolist()}"
)
if __name__ == "__main__":
sys.exit(pytest.main([__file__]))