chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,9 @@
from sglang.srt.debug_utils.comparator.aligner.entrypoint.traced_types import ( # noqa: F401
TracedAlignerPlan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import ( # noqa: F401
AlignerPlan,
)
from sglang.srt.debug_utils.comparator.output_types import ComparisonTensorRecord
ComparisonTensorRecord.model_rebuild()
@@ -0,0 +1,4 @@
from sglang.srt.debug_utils.comparator.entrypoint import main
if __name__ == "__main__":
main()
@@ -0,0 +1,219 @@
from __future__ import annotations
from typing import Optional
import torch
from einops import rearrange
from sglang.srt.debug_utils.comparator.dims_spec import (
_FUSED_NAME_SEP,
SEQ_DIM_NAME,
TOKEN_DIM_NAME,
DimSpec,
_SingletonDimUtil,
parse_dims,
without_dim_names,
)
from sglang.srt.debug_utils.comparator.log_sink import log_sink
from sglang.srt.debug_utils.comparator.utils import Pair, _FrozenBase
# --- types ---
class AxisAlignerPlan(_FrozenBase):
pattern: Pair[Optional[str]] # einops pattern per side, None = no-op
# --- planner ---
def compute_axis_aligner_plan(
dims_str_pair: Pair[Optional[str]],
) -> Optional[AxisAlignerPlan]:
if dims_str_pair.x is None or dims_str_pair.y is None:
return None
dims_pair: Pair[str] = Pair(x=dims_str_pair.x, y=dims_str_pair.y)
specs_pair: Pair[list[DimSpec]] = dims_pair.map(lambda s: parse_dims(s).dims)
if not _semantic_names_match(specs_pair):
return None
# Canonical dim order follows y; fused groups stay fused (flatten, not unflatten).
canonical_order: Optional[list[str]] = _build_canonical_order(specs_pair)
if canonical_order is None:
return None
pattern: Pair[Optional[str]] = specs_pair.map(
lambda specs: _build_side_pattern(specs=specs, canonical_order=canonical_order)
)
if pattern.x is None and pattern.y is None:
return None
return AxisAlignerPlan(pattern=pattern)
_SEQ_DIM_EQUIVALENCES: frozenset[frozenset[str]] = frozenset(
{
frozenset({SEQ_DIM_NAME, TOKEN_DIM_NAME}), # s ≡ t
}
)
def _normalize_dim_name(name: str) -> str:
for equiv_set in _SEQ_DIM_EQUIVALENCES:
if name in equiv_set:
return min(equiv_set)
return name
def _semantic_names_match(specs_pair: Pair[list[DimSpec]]) -> bool:
"""Check that both sides share the same semantic name set (ignoring squeeze dims)."""
names_pair: Pair[list[str]] = specs_pair.map(_expand_and_skip_squeeze)
if set(map(_normalize_dim_name, names_pair.x)) == set(
map(_normalize_dim_name, names_pair.y)
):
return True
# Local import to avoid circular dependency:
# output_types -> aligner/entrypoint/types -> axis_aligner -> output_types
from sglang.srt.debug_utils.comparator.output_types import ErrorLog
log_sink.add(
ErrorLog(
category="axis_aligner_dim_mismatch",
message=(
f"AxisAligner: dim name sets differ (x={names_pair.x}, y={names_pair.y}), "
f"skipping axis swap"
),
)
)
return False
def _expand_and_skip_squeeze(specs: list[DimSpec]) -> list[str]:
"""Expand DimSpecs to flat semantic names, skipping squeeze dims."""
return [
name
for spec in specs
if not _SingletonDimUtil.is_squeeze(spec)
for name in spec.sub_dims
]
def _build_canonical_order(specs_pair: Pair[list[DimSpec]]) -> Optional[list[str]]:
"""Build canonical dim order following y, preferring fused representation.
Each element is either a plain name (``"c"``) or a fused placeholder (``"a___b"``).
Fused groups from *either* side are merged — the separate side must flatten.
Squeeze dims are excluded.
Returns ``None`` if the two sides have overlapping but incompatible fused groups
(e.g. x fuses ``(a*b)`` while y fuses ``(b*c)``).
"""
# Map each sub-dim name → (placeholder, siblings) from both sides
fused_lookup: dict[str, tuple[str, frozenset[str]]] = {}
for spec in (*specs_pair.x, *specs_pair.y):
if spec.is_fused:
placeholder: str = spec.sanitized_name
siblings: frozenset[str] = frozenset(spec.sub_dims)
for sub_name in spec.sub_dims:
existing: Optional[tuple[str, frozenset[str]]] = fused_lookup.get(
sub_name
)
if existing is not None and existing[1] != siblings:
from sglang.srt.debug_utils.comparator.output_types import ErrorLog
log_sink.add(
ErrorLog(
category="axis_aligner_fused_conflict",
message=(
f"AxisAligner: overlapping fused groups for sub-dim {sub_name!r} "
f"({existing[0]} vs {placeholder}), skipping axis alignment"
),
)
)
return None
fused_lookup.setdefault(sub_name, (placeholder, siblings))
result: list[str] = []
consumed: set[str] = set()
for spec in specs_pair.y:
if _SingletonDimUtil.is_squeeze(spec):
continue
names: list[str] = spec.sub_dims
if any(n in consumed for n in names):
continue
entry: Optional[tuple[str, frozenset[str]]] = fused_lookup.get(names[0])
if entry is not None:
fused_placeholder, sibs = entry
result.append(fused_placeholder)
consumed.update(sibs)
else:
result.append(_normalize_dim_name(spec.name))
consumed.update(names)
return result
def _build_side_pattern(
*, specs: list[DimSpec], canonical_order: list[str]
) -> Optional[str]:
"""Build an einops pattern for one side to reach ``canonical_order``.
Fused specs become their placeholder; separate specs that belong to a fused group
stay as individual names on the LHS and become ``(a b)`` on the RHS (einops flatten).
Squeeze dims (``1``) appear on the LHS but are dropped from the RHS.
"""
source_tokens: list[str] = [spec.sanitized_name for spec in specs]
# Map normalized dim names back to this side's original names so that
# einops patterns use consistent identifiers on LHS and RHS.
norm_to_original: dict[str, str] = {
_normalize_dim_name(spec.name): spec.name for spec in specs
}
def _to_side_name(token: str) -> str:
return norm_to_original.get(token, token)
# Build per-side target: replace fused placeholders with ``(a b)`` only if this side
# has the sub-dims as separate (non-fused) names in the source
fused_placeholders: set[str] = {
spec.sanitized_name for spec in specs if spec.is_fused
}
translated_order: list[str] = [_to_side_name(t) for t in canonical_order]
target_tokens: list[str] = [
(
f"({t.replace(_FUSED_NAME_SEP, ' ')})"
if _FUSED_NAME_SEP in t and t not in fused_placeholders
else t
)
for t in translated_order
]
if source_tokens == target_tokens:
return None
return f"{' '.join(source_tokens)} -> {' '.join(target_tokens)}"
# --- executor ---
def execute_axis_aligner_plan(
tensor: torch.Tensor, plan: AxisAlignerPlan, *, side: str
) -> torch.Tensor:
if side not in ("x", "y"):
raise ValueError(f"side must be 'x' or 'y', got {side!r}")
pattern: Optional[str] = plan.pattern.x if side == "x" else plan.pattern.y
if pattern is not None:
tensor = rearrange(without_dim_names(tensor), pattern)
return tensor
@@ -0,0 +1,212 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import NamedTuple, Optional
import torch
from sglang.srt.debug_utils.comparator.aligner.axis_aligner import (
execute_axis_aligner_plan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.traced_types import (
TracedAlignerPlan,
TracedSidePlan,
TracedStepPlan,
TracedSubPlan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import (
AlignerPerStepPlan,
AlignerPerStepSubPlan,
AlignerPlan,
)
from sglang.srt.debug_utils.comparator.aligner.reorderer.executor import (
execute_reorderer_plan,
)
from sglang.srt.debug_utils.comparator.aligner.reorderer.types import ReordererPlan
from sglang.srt.debug_utils.comparator.aligner.token_aligner.concat_steps import (
execute_token_aligner_concat_steps,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.executor import (
execute_token_aligner,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.executor import (
UnsharderResult,
execute_unsharder_plan,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import UnsharderPlan
from sglang.srt.debug_utils.comparator.output_types import (
ReplicatedCheckResult,
ShapeSnapshot,
)
from sglang.srt.debug_utils.comparator.utils import Pair
class StepPlansResult(NamedTuple):
tensors: dict[int, torch.Tensor]
checks: list[ReplicatedCheckResult]
traced_side: TracedSidePlan
class SubPlansResult(NamedTuple):
tensor: Optional[torch.Tensor]
checks: list[ReplicatedCheckResult]
snapshots: list[ShapeSnapshot]
@dataclass(frozen=True)
class AlignerResult:
tensors: Optional[Pair[torch.Tensor]]
failed_side_xy: Optional[str] # "x" or "y"; None if success
replicated_checks: list[ReplicatedCheckResult] = field(default_factory=list)
traced_plan: Optional[TracedAlignerPlan] = None
def execute_aligner_plan(
*,
tensors_pair: Pair[list[torch.Tensor]],
plan: AlignerPlan,
) -> AlignerResult:
"""Execute unified unshard/reorder + token-align."""
all_checks: list[ReplicatedCheckResult] = []
# Per-side: unshard + reorder -> dict[step, tensor]
result_x: StepPlansResult = _execute_step_plans(
tensors=tensors_pair.x, step_plans=plan.per_step_plans.x
)
all_checks.extend(result_x.checks)
result_y: StepPlansResult = _execute_step_plans(
tensors=tensors_pair.y, step_plans=plan.per_step_plans.y
)
all_checks.extend(result_y.checks)
traced_plan: TracedAlignerPlan = TracedAlignerPlan(
plan=plan,
per_side=Pair(x=result_x.traced_side, y=result_y.traced_side),
)
if not result_x.tensors or not result_y.tensors:
failed_side_xy: str = "x" if not result_x.tensors else "y"
return AlignerResult(
tensors=None,
failed_side_xy=failed_side_xy,
replicated_checks=all_checks,
traced_plan=traced_plan,
)
# Cross-side: token alignment (or direct extraction for single-step)
step_pair: Pair[dict[int, torch.Tensor]] = Pair(
x=result_x.tensors, y=result_y.tensors
)
combined: Pair[torch.Tensor]
if plan.token_aligner_mode == "concat_steps":
combined = execute_token_aligner_concat_steps(tensor_of_step_pair=step_pair)
elif plan.token_aligner_mode == "smart":
assert plan.token_aligner_plan is not None
combined = execute_token_aligner(
plan=plan.token_aligner_plan,
tensor_of_step_pair=step_pair,
)
else:
assert len(result_x.tensors) == 1 and len(result_y.tensors) == 1
combined = Pair(
x=list(result_x.tensors.values())[0],
y=list(result_y.tensors.values())[0],
)
# Cross-side: axis alignment (squeeze singletons + rearrange dim order)
if (aligner_plan := plan.axis_aligner_plan) is not None:
combined = Pair(
x=execute_axis_aligner_plan(tensor=combined.x, plan=aligner_plan, side="x"),
y=execute_axis_aligner_plan(tensor=combined.y, plan=aligner_plan, side="y"),
)
return AlignerResult(
tensors=combined,
failed_side_xy=None,
replicated_checks=all_checks,
traced_plan=traced_plan,
)
def _execute_step_plans(
tensors: list[torch.Tensor],
step_plans: list[AlignerPerStepPlan],
) -> StepPlansResult:
result: dict[int, torch.Tensor] = {}
all_checks: list[ReplicatedCheckResult] = []
traced_steps: list[TracedStepPlan] = []
for step_plan in step_plans:
step_tensors: list[torch.Tensor] = [
tensors[i] for i in step_plan.input_object_indices
]
sub_result: SubPlansResult = execute_sub_plans(
tensors=step_tensors, plans=step_plan.sub_plans
)
all_checks.extend(sub_result.checks)
traced_subs: list[TracedSubPlan] = [
TracedSubPlan(plan=sub_plan, snapshot=snapshot)
for sub_plan, snapshot in zip(step_plan.sub_plans, sub_result.snapshots)
]
traced_steps.append(
TracedStepPlan(
step=step_plan.step,
input_object_indices=step_plan.input_object_indices,
sub_plans=traced_subs,
)
)
if sub_result.tensor is not None:
result[step_plan.step] = sub_result.tensor
return StepPlansResult(
tensors=result,
checks=all_checks,
traced_side=TracedSidePlan(step_plans=traced_steps),
)
def execute_sub_plans(
tensors: list[torch.Tensor],
plans: list[AlignerPerStepSubPlan],
) -> SubPlansResult:
if not tensors:
return SubPlansResult(tensor=None, checks=[], snapshots=[])
if not plans:
if len(tensors) != 1:
return SubPlansResult(tensor=None, checks=[], snapshots=[])
return SubPlansResult(tensor=tensors[0], checks=[], snapshots=[])
current: list[torch.Tensor] = tensors
all_checks: list[ReplicatedCheckResult] = []
all_snapshots: list[ShapeSnapshot] = []
for plan in plans:
input_shapes: list[list[int]] = [list(t.shape) for t in current]
current, checks = execute_sub_plan(tensors=current, plan=plan)
output_shapes: list[list[int]] = [list(t.shape) for t in current]
all_checks.extend(checks)
all_snapshots.append(
ShapeSnapshot(
input_shapes=input_shapes,
output_shapes=output_shapes,
)
)
assert len(current) == 1
return SubPlansResult(tensor=current[0], checks=all_checks, snapshots=all_snapshots)
def execute_sub_plan(
tensors: list[torch.Tensor],
plan: AlignerPerStepSubPlan,
) -> tuple[list[torch.Tensor], list[ReplicatedCheckResult]]:
if isinstance(plan, UnsharderPlan):
unsharder_result: UnsharderResult = execute_unsharder_plan(plan, tensors)
return unsharder_result.tensors, unsharder_result.replicated_checks
elif isinstance(plan, ReordererPlan):
return execute_reorderer_plan(plan, tensors), []
else:
raise NotImplementedError(f"Unknown {plan=}")
@@ -0,0 +1,134 @@
from __future__ import annotations
from typing import Any, Optional
from sglang.srt.debug_utils.comparator.aligner.axis_aligner import (
AxisAlignerPlan,
compute_axis_aligner_plan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import (
AlignerPerStepPlan,
AlignerPerStepSubPlan,
AlignerPlan,
)
from sglang.srt.debug_utils.comparator.aligner.reorderer.planner import (
compute_reorderer_plans,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerPlan,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.parallel_info import (
normalize_parallel_info,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.planner import (
compute_unsharder_plan,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
DimSpec,
DimsSpec,
ParallelAxis,
_SingletonDimUtil,
parse_dims,
)
from sglang.srt.debug_utils.comparator.utils import Pair
def compute_aligner_plan(
*,
metas_pair: Pair[list[dict[str, Any]]],
token_aligner_mode: Optional[str],
token_aligner_plan: Optional[TokenAlignerPlan],
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]] = Pair(
x=None, y=None
),
) -> AlignerPlan:
dims_str_pair: Pair[Optional[str]] = metas_pair.map(
lambda metas: metas[0].get("dims") if metas else None
)
axis_aligner_plan: Optional[AxisAlignerPlan] = compute_axis_aligner_plan(
dims_str_pair=dims_str_pair
)
return AlignerPlan(
per_step_plans=Pair(
x=_compute_per_step_plans(
metas=metas_pair.x,
thd_seq_lens_by_step=thd_seq_lens_by_step_pair.x,
),
y=_compute_per_step_plans(
metas=metas_pair.y,
thd_seq_lens_by_step=thd_seq_lens_by_step_pair.y,
),
),
token_aligner_mode=token_aligner_mode,
token_aligner_plan=token_aligner_plan,
axis_aligner_plan=axis_aligner_plan,
)
def _compute_per_step_plans(
metas: list[dict[str, Any]],
*,
thd_seq_lens_by_step: Optional[dict[int, list[int]]] = None,
) -> list[AlignerPerStepPlan]:
step_to_input_indices: dict[int, list[int]] = {}
for i, meta in enumerate(metas):
step: int = int(meta["step"])
step_to_input_indices.setdefault(step, []).append(i)
result: list[AlignerPerStepPlan] = []
for step in sorted(step_to_input_indices):
input_indices: list[int] = step_to_input_indices[step]
step_metas: list[dict[str, Any]] = [metas[idx] for idx in input_indices]
step_seq_lens: Optional[list[int]] = (
thd_seq_lens_by_step.get(step) if thd_seq_lens_by_step is not None else None
)
plans: list[AlignerPerStepSubPlan] = compute_per_step_sub_plans(
metas=step_metas,
thd_global_seq_lens=step_seq_lens,
)
result.append(
AlignerPerStepPlan(
step=step, input_object_indices=input_indices, sub_plans=plans
)
)
return result
def compute_per_step_sub_plans(
metas: list[dict[str, Any]],
*,
thd_global_seq_lens: Optional[list[int]] = None,
) -> list[AlignerPerStepSubPlan]:
if not metas or len(metas) == 1:
return []
dims_str = metas[0].get("dims")
if dims_str is None:
return []
dims_spec: DimsSpec = parse_dims(dims_str)
dim_specs: list[DimSpec] = _SingletonDimUtil.filter_out(dims_spec.dims)
replicated_axes: frozenset[ParallelAxis] = dims_spec.replicated_axes
parallel_infos = [normalize_parallel_info(meta) for meta in metas]
dp_axis: ParallelAxis = (
ParallelAxis(dims_spec.dp_group_alias)
if dims_spec.dp_group_alias
else ParallelAxis.DP
)
unsharder_plans = compute_unsharder_plan(
dim_specs=dim_specs,
parallel_infos=parallel_infos,
explicit_replicated_axes=replicated_axes,
thd_global_seq_lens=thd_global_seq_lens,
dp_filtered_axis=dims_spec.dp_axis,
)
reorderer_plans = compute_reorderer_plans(
dim_specs=dim_specs,
parallel_infos=parallel_infos,
thd_global_seq_lens=thd_global_seq_lens,
)
return [*unsharder_plans, *reorderer_plans]
@@ -0,0 +1,37 @@
"""Traced wrapper types that embed execution traces (ShapeSnapshots) into plan nodes.
These types are created *after* execution, pairing each sub-plan with its
observed shape snapshot so that downstream formatters never need to manually
zip plan + trace by index.
"""
from __future__ import annotations
from typing import Optional
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import (
AlignerPerStepSubPlan,
AlignerPlan,
)
from sglang.srt.debug_utils.comparator.output_types import ShapeSnapshot
from sglang.srt.debug_utils.comparator.utils import Pair, _StrictBase
class TracedSubPlan(_StrictBase):
plan: AlignerPerStepSubPlan
snapshot: Optional[ShapeSnapshot] = None
class TracedStepPlan(_StrictBase):
step: int
input_object_indices: list[int]
sub_plans: list[TracedSubPlan]
class TracedSidePlan(_StrictBase):
step_plans: list[TracedStepPlan]
class TracedAlignerPlan(_StrictBase):
plan: AlignerPlan
per_side: Pair[TracedSidePlan]
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated, Optional, Union
from pydantic import Discriminator
from sglang.srt.debug_utils.comparator.aligner.axis_aligner import AxisAlignerPlan
from sglang.srt.debug_utils.comparator.aligner.reorderer.types import ReordererPlan
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerPlan,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import UnsharderPlan
from sglang.srt.debug_utils.comparator.utils import Pair, _FrozenBase
AlignerPerStepSubPlan = Annotated[
Union[UnsharderPlan, ReordererPlan],
Discriminator("type"),
]
class AlignerPerStepPlan(_FrozenBase):
step: int
input_object_indices: list[int]
sub_plans: list[AlignerPerStepSubPlan]
class AlignerPlan(_FrozenBase):
per_step_plans: Pair[list[AlignerPerStepPlan]]
token_aligner_mode: Optional[str] = None # "concat_steps" | "smart" | None
token_aligner_plan: Optional[TokenAlignerPlan] = None
axis_aligner_plan: Optional[AxisAlignerPlan] = None
@@ -0,0 +1,103 @@
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.aligner.reorderer.types import (
ReordererPlan,
ZigzagToNaturalParams,
ZigzagToNaturalThdParams,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
apply_dim_names,
get_dim_names,
resolve_dim_by_name,
without_dim_names,
)
def execute_reorderer_plan(
plan: ReordererPlan,
tensors: list[torch.Tensor],
) -> list[torch.Tensor]:
if isinstance(plan.params, ZigzagToNaturalThdParams):
thd_dim: int = resolve_dim_by_name(tensors[0], plan.params.dim_name)
return [
_reorder_zigzag_to_natural_thd(
tensor,
dim=thd_dim,
cp_size=plan.params.cp_size,
seq_lens=plan.params.seq_lens,
)
for tensor in tensors
]
if isinstance(plan.params, ZigzagToNaturalParams):
dim: int = resolve_dim_by_name(tensors[0], plan.params.dim_name)
return [
_reorder_zigzag_to_natural(tensor, dim=dim, cp_size=plan.params.cp_size)
for tensor in tensors
]
raise ValueError(f"Unsupported reorderer params type: {type(plan.params).__name__}")
def _reorder_zigzag_to_natural_thd(
tensor: torch.Tensor, *, dim: int, cp_size: int, seq_lens: list[int]
) -> torch.Tensor:
"""Undo CP zigzag interleaving for THD (packed-seq) format.
Each seq in seq_lens is independently reordered from zigzag to natural order
along the given dim.
"""
names: tuple[Optional[str], ...] = get_dim_names(tensor)
stripped: torch.Tensor = without_dim_names(tensor)
split_sizes: list[int] = list(seq_lens)
remainder: int = stripped.shape[dim] - sum(split_sizes)
if remainder < 0:
raise ValueError(
f"sum(seq_lens)={sum(split_sizes)} exceeds tensor dim size "
f"{stripped.shape[dim]} along dim={dim}"
)
if remainder > 0:
split_sizes.append(remainder)
segments: list[torch.Tensor] = list(stripped.split(split_sizes, dim=dim))
reordered_segments: list[torch.Tensor] = [
_reorder_zigzag_to_natural(seg, dim=dim, cp_size=cp_size)
for seg in segments[: len(seq_lens)]
]
# Tail padding — pass through unchanged
if remainder > 0:
reordered_segments.append(segments[-1])
result: torch.Tensor = torch.cat(reordered_segments, dim=dim)
if names[0] is not None:
result = apply_dim_names(result, list(names))
return result
def _reorder_zigzag_to_natural(
tensor: torch.Tensor, *, dim: int, cp_size: int
) -> torch.Tensor:
"""Undo CP zigzag interleaving, restoring natural chunk order.
Generalized from Megatron-LM _undo_attention_load_balancing
(megatron/core/ssm/mamba_context_parallel.py:360-373).
"""
names: tuple[Optional[str], ...] = get_dim_names(tensor)
stripped: torch.Tensor = without_dim_names(tensor)
num_chunks: int = cp_size * 2
chunks: tuple[torch.Tensor, ...] = stripped.chunk(num_chunks, dim=dim)
order: list[int] = [2 * i for i in range(cp_size)] + [
num_chunks - 2 * i - 1 for i in range(cp_size)
]
result: torch.Tensor = torch.cat([chunks[i] for i in order], dim=dim)
if names[0] is not None:
result = apply_dim_names(result, list(names))
return result
@@ -0,0 +1,67 @@
from typing import Optional
from sglang.srt.debug_utils.comparator.aligner.reorderer.types import (
ReordererPlan,
ZigzagToNaturalParams,
ZigzagToNaturalThdParams,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import AxisInfo
from sglang.srt.debug_utils.comparator.dims_spec import (
SEQ_DIM_NAME,
TOKEN_DIM_NAME,
DimSpec,
Ordering,
ParallelAxis,
)
_ALLOWED_ZIGZAG_DIM_NAMES: set[str] = {SEQ_DIM_NAME, TOKEN_DIM_NAME}
def compute_reorderer_plans(
dim_specs: list[DimSpec],
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
*,
thd_global_seq_lens: Optional[list[int]] = None,
) -> list[ReordererPlan]:
plans: list[ReordererPlan] = []
for spec in dim_specs:
for modifier in spec.parallel_modifiers:
if modifier.ordering is None or modifier.ordering == Ordering.NATURAL:
continue
if spec.name not in _ALLOWED_ZIGZAG_DIM_NAMES:
raise ValueError(
f"Zigzag ordering is only supported on sequence dims "
f"(dim name must be one of "
f"{sorted(_ALLOWED_ZIGZAG_DIM_NAMES)}), "
f"but got dim name {spec.name!r} in {spec}"
)
if modifier.ordering != Ordering.ZIGZAG:
raise ValueError(
f"Unsupported ordering {modifier.ordering!r} for dim {spec.name!r}"
)
axis_size: int = parallel_infos[0][modifier.axis].axis_size
if spec.name == TOKEN_DIM_NAME:
if thd_global_seq_lens is None:
raise ValueError(
"thd_global_seq_lens is required for zigzag reorder on 't' dimension"
)
params = ZigzagToNaturalThdParams(
dim_name=spec.name,
cp_size=axis_size,
seq_lens=thd_global_seq_lens,
)
elif spec.name == SEQ_DIM_NAME:
params = ZigzagToNaturalParams(dim_name=spec.name, cp_size=axis_size)
else:
raise ValueError(
f"Unsupported zigzag dim name {spec.name!r}, "
f"expected one of {sorted(_ALLOWED_ZIGZAG_DIM_NAMES)}"
)
plans.append(ReordererPlan(params=params))
return plans
@@ -0,0 +1,29 @@
from typing import Annotated, Literal, Union
from pydantic import Field
from sglang.srt.debug_utils.comparator.utils import _FrozenBase
class ZigzagToNaturalParams(_FrozenBase):
op: Literal["zigzag_to_natural"] = "zigzag_to_natural"
dim_name: str
cp_size: int
class ZigzagToNaturalThdParams(_FrozenBase):
op: Literal["zigzag_to_natural_thd"] = "zigzag_to_natural_thd"
dim_name: str
cp_size: int
seq_lens: list[int] # unshard-ed per-seq token counts, e.g. [100, 64, 92]
ReordererParams = Annotated[
Union[ZigzagToNaturalParams, ZigzagToNaturalThdParams],
Field(discriminator="op"),
]
class ReordererPlan(_FrozenBase):
type: Literal["reorderer"] = "reorderer"
params: ReordererParams
@@ -0,0 +1,7 @@
from sglang.srt.debug_utils.comparator.aligner.token_aligner.concat_steps.executor import (
execute_token_aligner_concat_steps,
)
__all__ = [
"execute_token_aligner_concat_steps",
]
@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.dims_spec import (
SEQ_DIM_NAME,
TOKEN_DIM_NAME,
get_dim_names,
)
from sglang.srt.debug_utils.comparator.utils import Pair
_UNNAMED_TOKEN_DIM_FALLBACK: int = 0
def execute_token_aligner_concat_steps(
tensor_of_step_pair: Pair[dict[int, torch.Tensor]],
) -> Pair[torch.Tensor]:
"""Concat all steps in order, then truncate to min(total_x, total_y) tokens."""
some_tensor: torch.Tensor = next(iter(tensor_of_step_pair.x.values()))
token_dim: int = _resolve_token_dim(some_tensor)
concatenated: Pair[torch.Tensor] = tensor_of_step_pair.map(
lambda d: _concat_steps(d, dim=token_dim)
)
common: int = min(concatenated.x.shape[token_dim], concatenated.y.shape[token_dim])
return concatenated.map(lambda t: t.narrow(dim=token_dim, start=0, length=common))
def _resolve_token_dim(tensor: torch.Tensor) -> int:
"""Find the token/seq dim index. Falls back to dim 0 for unnamed tensors or
tensors without a recognised token/seq dim."""
names: tuple[Optional[str], ...] = get_dim_names(tensor)
if names[0] is None:
return _UNNAMED_TOKEN_DIM_FALLBACK
for candidate in (TOKEN_DIM_NAME, SEQ_DIM_NAME):
if candidate in names:
return list(names).index(candidate)
return _UNNAMED_TOKEN_DIM_FALLBACK
def _concat_steps(tensor_of_step: dict[int, torch.Tensor], *, dim: int) -> torch.Tensor:
return torch.cat([tensor_of_step[s] for s in sorted(tensor_of_step)], dim=dim)
@@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
import polars as pl
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.aux_loader import (
_detect_plugin,
_load_and_align_aux_tensor,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.aux_plugins import (
_AuxFrameworkPlugin,
)
def load_thd_seq_lens_only(
dump_path: Path, df: pl.DataFrame
) -> Optional[dict[int, list[int]]]:
plugin: Optional[_AuxFrameworkPlugin] = _detect_plugin(df, dump_path=dump_path)
if plugin is None or not plugin.cp_sharded_names:
return None
non_cp_tensor_names: set[str] = (
set(df["name"].unique().to_list()) & plugin.tensor_names
) - plugin.cp_sharded_names
steps: list[int] = sorted(df["step"].unique().to_list())
result: dict[int, list[int]] = {}
for step in steps:
step_data: dict[str, object] = {}
for name in non_cp_tensor_names:
tensor = _load_and_align_aux_tensor(
name=name, step=step, df=df, dump_path=dump_path, plugin=plugin
)
if tensor is not None:
step_data[name] = tensor
seq_lens: Optional[list[int]] = plugin.extract_global_seq_lens(step_data)
if seq_lens is not None:
result[step] = seq_lens
return result or None
@@ -0,0 +1,132 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, Optional
import polars as pl
from sglang.srt.debug_utils.comparator.aligner.token_aligner.concat_steps.thd_seq_lens_loader import (
load_thd_seq_lens_only,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.aux_loader import (
has_aux_tensors,
load_and_normalize_aux,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.planner import (
compute_token_aligner_plan,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.seq_info_builder import (
build_seqs_info,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerGlobalAux,
TokenAlignerPlan,
TokenAlignerSeqsInfo,
)
from sglang.srt.debug_utils.comparator.log_sink import log_sink
from sglang.srt.debug_utils.comparator.output_types import InfoLog
from sglang.srt.debug_utils.comparator.utils import Pair
_NONE_THD: Pair[Optional[dict[int, list[int]]]] = Pair(x=None, y=None)
TokenAlignerMode = Literal["concat_steps", "smart"]
@dataclass(frozen=True)
class TokenAlignerResult:
"""Result of token aligner computation, bundling mode + plan with THD metadata."""
mode: Optional[TokenAlignerMode]
plan: Optional[TokenAlignerPlan]
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]]
def compute_maybe_token_aligner_result(
*,
dir_pair: Pair[Path],
dfs: Pair[pl.DataFrame],
token_aligner_mode: Optional[TokenAlignerMode],
) -> TokenAlignerResult:
if token_aligner_mode is None:
return TokenAlignerResult(
mode=None, plan=None, thd_seq_lens_by_step_pair=_NONE_THD
)
if token_aligner_mode == "concat_steps":
thd_pair: Pair[Optional[dict[int, list[int]]]] = _load_thd_seq_lens_pair(
dir_pair=dir_pair, dfs=dfs
)
return TokenAlignerResult(
mode="concat_steps", plan=None, thd_seq_lens_by_step_pair=thd_pair
)
elif token_aligner_mode == "smart":
if not (has_aux_tensors(dfs.x) and has_aux_tensors(dfs.y)):
log_sink.add(
InfoLog(
category="aux_tensors_missing",
message="Aux tensors missing, skipping token alignment",
)
)
return TokenAlignerResult(
mode=None, plan=None, thd_seq_lens_by_step_pair=_NONE_THD
)
return _build_smart_result(dir_pair=dir_pair, dfs=dfs)
else:
raise NotImplementedError(f"Unknown {token_aligner_mode=}")
def _build_smart_result(
*,
dir_pair: Pair[Path],
dfs: Pair[pl.DataFrame],
) -> TokenAlignerResult:
"""Load aux tensors, build token indices, and compute the alignment plan."""
aux_pair: Pair[Optional[TokenAlignerGlobalAux]] = Pair(
x=load_and_normalize_aux(dump_path=dir_pair.x, df=dfs.x),
y=load_and_normalize_aux(dump_path=dir_pair.y, df=dfs.y),
)
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]] = aux_pair.map(
lambda aux: aux.thd_seq_lens_by_step if aux is not None else None
)
if aux_pair.x is None or aux_pair.y is None:
log_sink.add(
InfoLog(
category="framework_detection_failed",
message="Framework detection failed, skipping token alignment",
)
)
return TokenAlignerResult(
mode=None,
plan=None,
thd_seq_lens_by_step_pair=thd_seq_lens_by_step_pair,
)
global_aux: Pair[TokenAlignerGlobalAux] = Pair(x=aux_pair.x, y=aux_pair.y)
seqs_info: Pair[TokenAlignerSeqsInfo] = global_aux.map(build_seqs_info)
plan: Optional[TokenAlignerPlan] = compute_token_aligner_plan(
seqs_info_pair=seqs_info
)
return TokenAlignerResult(
mode="smart",
plan=plan,
thd_seq_lens_by_step_pair=thd_seq_lens_by_step_pair,
)
def _load_thd_seq_lens_pair(
*,
dir_pair: Pair[Path],
dfs: Pair[pl.DataFrame],
) -> Pair[Optional[dict[int, list[int]]]]:
"""Load only thd_seq_lens for each side (lightweight, no full aux loading)."""
return Pair(
x=load_thd_seq_lens_only(dump_path=dir_pair.x, df=dfs.x),
y=load_thd_seq_lens_only(dump_path=dir_pair.y, df=dfs.y),
)
@@ -0,0 +1,286 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
import polars as pl
import torch
from sglang.srt.debug_utils.comparator.aligner.entrypoint.executor import (
execute_sub_plans,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.planner import (
compute_per_step_sub_plans,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.aux_plugins import (
AUX_NAMES,
_AuxFrameworkPlugin,
_plugins,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerGlobalAux,
TokenAlignerStepAux,
)
from sglang.srt.debug_utils.comparator.aligner.unsharder.parallel_info import (
normalize_parallel_info,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
ParallelAxis,
TokenLayout,
apply_dim_names,
resolve_dim_names,
without_dim_names,
)
from sglang.srt.debug_utils.comparator.dp_utils import filter_to_non_empty_dp_rank
from sglang.srt.debug_utils.comparator.log_sink import log_sink
from sglang.srt.debug_utils.comparator.output_types import ErrorLog, InfoLog
from sglang.srt.debug_utils.dump_loader import ValueWithMeta, filter_rows
# re-export for existing callers
__all__ = [
"AUX_NAMES",
"has_aux_tensors",
"load_and_normalize_aux",
]
def load_and_normalize_aux(
dump_path: Path, df: pl.DataFrame
) -> Optional[TokenAlignerGlobalAux]:
"""Bootstrap: load, unshard, and normalize auxiliary tensors for one side."""
plugin: Optional[_AuxFrameworkPlugin] = _detect_plugin(df, dump_path=dump_path)
if plugin is None:
return None
available_names: set[str] = set(df["name"].unique().to_list()) & plugin.all_names
steps: list[int] = sorted(df["step"].unique().to_list())
tensor_names: set[str] = available_names & plugin.tensor_names
non_tensor_names: set[str] = available_names & plugin.non_tensor_names
steps_data: dict[int, dict[str, object]] = {}
thd_seq_lens_by_step: dict[int, list[int]] = {}
for step in steps:
step_data, thd_seq_lens = _load_step_data(
step=step,
tensor_names=tensor_names,
non_tensor_names=non_tensor_names,
df=df,
dump_path=dump_path,
plugin=plugin,
)
if step_data:
steps_data[step] = step_data
if thd_seq_lens is not None:
thd_seq_lens_by_step[step] = thd_seq_lens
layout: TokenLayout = plugin.detect_layout(steps_data)
step_auxs: dict[int, TokenAlignerStepAux] = {
step: plugin.compute_step_aux(step_data, layout=layout, step=step)
for step, step_data in steps_data.items()
}
return TokenAlignerGlobalAux(
step_auxs=step_auxs,
framework=plugin.name,
layout=layout,
thd_seq_lens_by_step=thd_seq_lens_by_step or None,
)
def has_aux_tensors(df: pl.DataFrame) -> bool:
"""Check if the DataFrame contains the minimum auxiliary tensors for alignment."""
names: set[str] = set(df["name"].unique().to_list())
return any(plugin.has_required_names(names) for plugin in _plugins)
def _detect_plugin(df: pl.DataFrame, dump_path: Path) -> Optional[_AuxFrameworkPlugin]:
names: set[str] = set(df["name"].unique().to_list())
for plugin in _plugins:
if names & plugin.discriminating_names:
return plugin
first_row: dict = df.row(0, named=True)
value: ValueWithMeta = ValueWithMeta.load(dump_path / first_row["filename"])
for plugin in _plugins:
if f"{plugin.name}_parallel_info" in value.meta:
return plugin
return None
def _load_step_data(
*,
step: int,
tensor_names: set[str],
non_tensor_names: set[str],
df: pl.DataFrame,
dump_path: Path,
plugin: _AuxFrameworkPlugin,
) -> tuple[dict[str, object], Optional[list[int]]]:
"""Load all tensor and non-tensor aux values for a single step.
Two-pass loading: non-CP-sharded tensors first (to obtain cu_seqlens_q
for seq_lens), then CP-sharded tensors with seq_lens for THD unshard/reorder.
Returns (step_data, thd_global_seq_lens).
"""
result: dict[str, object] = {}
# Pass 0: non-tensor values
for name in non_tensor_names:
value = _load_non_tensor_aux(name=name, step=step, df=df, dump_path=dump_path)
if value is not None:
result[name] = value
# Pass 1: non-CP-sharded tensors (e.g. cu_seqlens_q, seq_lens)
non_cp_tensor_names: set[str] = tensor_names - plugin.cp_sharded_names
cp_tensor_names: set[str] = tensor_names & plugin.cp_sharded_names
for name in non_cp_tensor_names:
tensor = _load_and_align_aux_tensor(
name=name, step=step, df=df, dump_path=dump_path, plugin=plugin
)
if tensor is not None:
result[name] = tensor
# Derive global seq_lens for THD unshard (framework-specific extraction)
thd_global_seq_lens: Optional[list[int]] = plugin.extract_global_seq_lens(result)
# Pass 2: CP-sharded tensors (input_ids, position_ids, etc.)
for name in cp_tensor_names:
tensor = _load_and_align_aux_tensor(
name=name,
step=step,
df=df,
dump_path=dump_path,
plugin=plugin,
thd_global_seq_lens=thd_global_seq_lens,
)
if tensor is not None:
result[name] = tensor
return result, thd_global_seq_lens
def _load_non_tensor_aux(
*, name: str, step: int, df: pl.DataFrame, dump_path: Path
) -> Optional[object]:
"""Load a non-tensor auxiliary value for a step, validating consistency across ranks."""
rows = filter_rows(df, conditions={"name": name, "step": step})
if not rows:
return None
loaded: list[ValueWithMeta] = [
ValueWithMeta.load(dump_path / r["filename"]) for r in rows
]
loaded = filter_to_non_empty_dp_rank(loaded, dp_axis=ParallelAxis.DP)
if len(loaded) > 1:
first_value = loaded[0].value
for i, item in enumerate(loaded[1:], start=1):
if item.value != first_value:
log_sink.add(
ErrorLog(
category=f"{name}_mismatch",
message=(
f"{name} mismatch across ranks: rank 0 has {first_value}, "
f"rank {i} has {item.value}"
),
)
)
break
return loaded[0].value
def _load_and_align_aux_tensor(
*,
name: str,
step: int,
df: pl.DataFrame,
dump_path: Path,
plugin: _AuxFrameworkPlugin,
thd_global_seq_lens: Optional[list[int]] = None,
) -> Optional[torch.Tensor]:
"""Load an auxiliary tensor for (name, step), align if needed."""
rows = filter_rows(df, conditions={"name": name, "step": step})
if not rows:
return None
loaded: list[ValueWithMeta] = [
ValueWithMeta.load(dump_path / r["filename"]) for r in rows
]
loaded = filter_to_non_empty_dp_rank(loaded, dp_axis=ParallelAxis.DP)
tensors: list[torch.Tensor] = [
item.value for item in loaded if isinstance(item.value, torch.Tensor)
]
if not tensors:
return None
if len(tensors) == 1:
return tensors[0]
metas: list[dict[str, Any]] = [item.meta for item in loaded]
metas = _ensure_dims_in_metas(
name=name, plugin=plugin, metas=metas, ndim=tensors[0].ndim
)
sub_plans = compute_per_step_sub_plans(
metas=metas,
thd_global_seq_lens=(
thd_global_seq_lens if name in plugin.cp_sharded_names else None
),
)
if sub_plans:
dims_str: Optional[str] = metas[0].get("dims")
if dims_str is not None:
dim_names: list[str] = resolve_dim_names(dims_str)
tensors = [apply_dim_names(t, dim_names) for t in tensors]
sub_result = execute_sub_plans(tensors=tensors, plans=sub_plans)
assert sub_result.tensor is not None
return without_dim_names(
sub_result.tensor
) # strip named dims before returning to plugin
log_sink.add(
InfoLog(
category="aux_no_dims",
message=(
f"aux tensor '{name}' has {len(tensors)} ranks "
f"but no dims metadata, using rank 0 only"
),
)
)
return tensors[0]
def _ensure_dims_in_metas(
*,
name: str,
plugin: _AuxFrameworkPlugin,
metas: list[dict[str, Any]],
ndim: int,
) -> list[dict[str, Any]]:
"""Inject inferred dims into metas if not already present.
Returns metas unchanged if dims is already set, or a new list with dims
injected if inference succeeds for CP-sharded tensors.
"""
if metas[0].get("dims") is not None:
return metas
parallel_infos = [normalize_parallel_info(m) for m in metas]
has_cp: bool = any(ParallelAxis.CP in info for info in parallel_infos)
if not has_cp:
return metas
if name in plugin.cp_sharded_names:
inferred_dims: str = plugin.infer_cp_sharded_dims(name=name, ndim=ndim)
return [{**m, "dims": inferred_dims} for m in metas]
return metas
@@ -0,0 +1,292 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
PositionalSeqId,
SeqId,
SGLangSeqId,
TokenAlignerStepAux,
)
from sglang.srt.debug_utils.comparator.dims_spec import TokenLayout
from sglang.srt.debug_utils.comparator.log_sink import log_sink
from sglang.srt.debug_utils.comparator.output_types import InfoLog
# ── plugin ABC ─────────────────────────────────────────────────────
class _AuxFrameworkPlugin(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@property
@abstractmethod
def tensor_names(self) -> frozenset[str]: ...
@property
@abstractmethod
def non_tensor_names(self) -> frozenset[str]: ...
@property
def cp_sharded_names(self) -> frozenset[str]:
return frozenset()
@property
def discriminating_names(self) -> frozenset[str]:
"""Field names unique to this framework (excluding shared names like input_ids)."""
return frozenset()
@abstractmethod
def detect_layout(self, raw: dict[int, dict[str, object]]) -> TokenLayout: ...
@abstractmethod
def compute_step_aux(
self, step_data: dict[str, object], *, layout: TokenLayout, step: int
) -> TokenAlignerStepAux: ...
@abstractmethod
def has_required_names(self, names: set[str]) -> bool:
"""Whether the minimum set of aux names needed for alignment is present."""
...
@property
def all_names(self) -> frozenset[str]:
return self.tensor_names | self.non_tensor_names
def extract_global_seq_lens(
self, step_data: dict[str, object]
) -> Optional[list[int]]:
"""Extract per-seq token counts from loaded step data.
Returns None if this framework doesn't support THD / no relevant data available.
"""
return None
def infer_cp_sharded_dims(self, name: str, ndim: int) -> str:
"""Infer dims string for a CP-sharded aux tensor based on its ndim."""
raise NotImplementedError(
f"infer_cp_sharded_dims not implemented for {type(self).__name__}"
)
# ── sglang plugin ─────────────────────────────────────────────────
class _SGLangPlugin(_AuxFrameworkPlugin):
@property
def name(self) -> str:
return "sglang"
@property
def tensor_names(self) -> frozenset[str]:
return frozenset({"input_ids", "positions", "seq_lens", "req_pool_indices"})
@property
def non_tensor_names(self) -> frozenset[str]:
return frozenset({"rids"})
@property
def cp_sharded_names(self) -> frozenset[str]:
return frozenset({"input_ids", "positions"})
@property
def discriminating_names(self) -> frozenset[str]:
return frozenset({"seq_lens", "positions", "req_pool_indices", "rids"})
def has_required_names(self, names: set[str]) -> bool:
return "input_ids" in names and "seq_lens" in names
def detect_layout(self, raw: dict[int, dict[str, object]]) -> TokenLayout:
return TokenLayout.T
def extract_global_seq_lens(
self, step_data: dict[str, object]
) -> Optional[list[int]]:
if not self.cp_sharded_names:
return None
seq_lens = step_data.get("seq_lens")
if not isinstance(seq_lens, torch.Tensor):
return None
return seq_lens.tolist()
def infer_cp_sharded_dims(self, name: str, ndim: int) -> str:
"""Infer dims for CP-sharded aux tensors.
NOTE: assumes zigzag ordering — natural-order CP without explicit dims
will be mishandled. Callers should set dims explicitly for non-zigzag CP.
"""
if ndim == 1:
return "t[cp:zigzag]"
raise ValueError(
f"SGLang: cannot infer dims for CP-sharded '{name}' with ndim={ndim}"
)
def compute_step_aux(
self, step_data: dict[str, object], *, layout: TokenLayout, step: int
) -> TokenAlignerStepAux:
input_ids = step_data["input_ids"]
positions = step_data["positions"]
seq_lens = step_data["seq_lens"]
rids_raw = step_data.get("rids")
assert isinstance(
input_ids, torch.Tensor
), f"input_ids: expected Tensor, got {type(input_ids)}"
assert isinstance(
positions, torch.Tensor
), f"positions: expected Tensor, got {type(positions)}"
assert isinstance(
seq_lens, torch.Tensor
), f"seq_lens: expected Tensor, got {type(seq_lens)}"
seq_lens_list: list[int] = seq_lens.tolist()
num_seqs: int = len(seq_lens_list)
seq_ids: list[SeqId]
if rids_raw is not None and isinstance(rids_raw, (list, tuple)):
seq_ids = [SGLangSeqId(rid=str(r)) for r in rids_raw]
else:
seq_ids = [PositionalSeqId(step=step, seq_index=i) for i in range(num_seqs)]
return TokenAlignerStepAux(
input_ids=input_ids.tolist(),
positions=positions.tolist(),
seq_lens=seq_lens_list,
seq_ids=seq_ids,
)
# ── megatron plugin ───────────────────────────────────────────────
class _MegatronPlugin(_AuxFrameworkPlugin):
@property
def name(self) -> str:
return "megatron"
@property
def tensor_names(self) -> frozenset[str]:
return frozenset({"input_ids", "position_ids", "cu_seqlens_q", "cu_seqlens_kv"})
@property
def non_tensor_names(self) -> frozenset[str]:
return frozenset({"qkv_format"})
@property
def cp_sharded_names(self) -> frozenset[str]:
return frozenset({"input_ids", "position_ids"})
@property
def discriminating_names(self) -> frozenset[str]:
return frozenset({"cu_seqlens_q", "cu_seqlens_kv", "qkv_format"})
def has_required_names(self, names: set[str]) -> bool:
return "input_ids" in names
def extract_global_seq_lens(
self, step_data: dict[str, object]
) -> Optional[list[int]]:
if not self.cp_sharded_names:
return None
cu_seqlens_q = step_data.get("cu_seqlens_q")
if not isinstance(cu_seqlens_q, torch.Tensor):
return None
return (cu_seqlens_q[1:] - cu_seqlens_q[:-1]).tolist()
def infer_cp_sharded_dims(self, name: str, ndim: int) -> str:
"""Infer dims for CP-sharded aux tensors.
NOTE: assumes zigzag ordering — natural-order CP without explicit dims
will be mishandled. Callers should set dims explicitly for non-zigzag CP.
"""
if ndim == 1:
return "t[cp:zigzag]"
if ndim == 2:
return "b s[cp:zigzag]"
raise ValueError(
f"Megatron: cannot infer dims for CP-sharded '{name}' with ndim={ndim}"
)
def detect_layout(self, raw: dict[int, dict[str, object]]) -> TokenLayout:
for step_data in raw.values():
if (qkv_format := step_data.get("qkv_format")) is not None:
fmt = qkv_format if isinstance(qkv_format, str) else str(qkv_format)
if "bshd" in fmt.lower():
return TokenLayout.BS
return TokenLayout.T
input_ids = step_data.get("input_ids")
if isinstance(input_ids, torch.Tensor) and input_ids.ndim == 2:
return TokenLayout.BS
log_sink.add(
InfoLog(
category="layout_detection_fallback",
message=(
"Megatron layout detection: no qkv_format or 2D input_ids found, "
"falling back to T"
),
)
)
return TokenLayout.T
def compute_step_aux(
self, step_data: dict[str, object], *, layout: TokenLayout, step: int
) -> TokenAlignerStepAux:
input_ids: torch.Tensor = step_data["input_ids"]
is_bshd: bool = layout == TokenLayout.BS
# BSHD [B, S] → flat [B*S]; THD [T] stays as-is
flat_ids: list[int] = input_ids.reshape(-1).tolist()
if (cu_seqlens_q := step_data.get("cu_seqlens_q")) is not None:
seq_lens_list: list[int] = (cu_seqlens_q[1:] - cu_seqlens_q[:-1]).tolist()
elif is_bshd:
seq_lens_list = [input_ids.shape[1]] * input_ids.shape[0]
else:
seq_lens_list = [input_ids.shape[0]]
if (position_ids := step_data.get("position_ids")) is not None:
flat_positions: list[int] = position_ids.reshape(-1).tolist()
elif is_bshd:
flat_positions = list(range(input_ids.shape[1])) * input_ids.shape[0]
else:
flat_positions = _infer_positions(
seq_lens=torch.tensor(seq_lens_list)
).tolist()
num_seqs: int = len(seq_lens_list)
seq_ids: list[SeqId] = [
PositionalSeqId(step=step, seq_index=seq_index)
for seq_index in range(num_seqs)
]
return TokenAlignerStepAux(
input_ids=flat_ids,
positions=flat_positions,
seq_lens=seq_lens_list,
seq_ids=seq_ids,
)
# ── plugin registry ───────────────────────────────────────────────
_plugins: list[_AuxFrameworkPlugin] = [_SGLangPlugin(), _MegatronPlugin()]
AUX_NAMES: frozenset[str] = frozenset().union(*(p.all_names for p in _plugins))
# ── helpers ────────────────────────────────────────────────────────
def _infer_positions(*, seq_lens: torch.Tensor) -> torch.Tensor:
"""Infer positions when position_ids is missing (THD only)."""
return torch.cat([torch.arange(int(slen.item())) for slen in seq_lens])
@@ -0,0 +1,150 @@
from __future__ import annotations
import torch
from einops import rearrange
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerPlan,
TokenLocator,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
BATCH_DIM_NAME,
SEQ_DIM_NAME,
TOKEN_DIM_NAME,
TokenLayout,
apply_dim_names,
get_dim_names,
resolve_dim_by_name,
)
from sglang.srt.debug_utils.comparator.utils import Pair
_UNNAMED_TOKEN_DIM_FALLBACK: int = 0
def execute_token_aligner(
plan: TokenAlignerPlan,
tensor_of_step_pair: Pair[dict[int, torch.Tensor]],
) -> Pair[torch.Tensor]:
flat_pair: Pair[dict[int, torch.Tensor]] = Pair(
x=_collapse_bs_to_t(
tensor_of_step=tensor_of_step_pair.x, layout=plan.layouts.x
),
y=_collapse_bs_to_t(
tensor_of_step=tensor_of_step_pair.y, layout=plan.layouts.y
),
)
if not plan.locators.x.steps:
return Pair(
x=_make_empty(tensor_of_step=flat_pair.x),
y=_make_empty(tensor_of_step=flat_pair.y),
)
return Pair(
x=_extract_and_stack_tokens(
tensor_of_step=flat_pair.x, locator=plan.locators.x
),
y=_extract_and_stack_tokens(
tensor_of_step=flat_pair.y, locator=plan.locators.y
),
)
# ── BS → T preprocessing ─────────────────────────────────────────
def _collapse_bs_to_t(
*,
tensor_of_step: dict[int, torch.Tensor],
layout: TokenLayout,
) -> dict[int, torch.Tensor]:
"""Collapse B and S dims into a single flat token dim (always batch-major).
Handles both ``b s`` and ``s b`` orderings correctly via einops rearrange.
Returns the original tensors unchanged if layout is T.
"""
if layout != TokenLayout.BS:
return tensor_of_step
some_tensor: torch.Tensor = next(iter(tensor_of_step.values()))
batch_dim: int = _resolve_dim_or_fallback(some_tensor, BATCH_DIM_NAME)
seq_dim: int = _resolve_dim_or_fallback(some_tensor, SEQ_DIM_NAME)
if abs(batch_dim - seq_dim) != 1:
raise ValueError(
f"BS dims must be adjacent: "
f"{BATCH_DIM_NAME}={batch_dim}, "
f"{SEQ_DIM_NAME}={seq_dim}"
)
lhs_pattern, rhs_pattern, new_names = _build_bs_collapse_pattern(
names=list(get_dim_names(some_tensor)),
batch_dim=batch_dim,
seq_dim=seq_dim,
)
result: dict[int, torch.Tensor] = {}
for step, tensor in tensor_of_step.items():
collapsed: torch.Tensor = rearrange(tensor, f"{lhs_pattern} -> {rhs_pattern}")
collapsed = apply_dim_names(collapsed, [n for n in new_names if n is not None])
result[step] = collapsed
return result
def _build_bs_collapse_pattern(
*,
names: list[str | None],
batch_dim: int,
seq_dim: int,
) -> tuple[str, str, list[str | None]]:
"""Build einops lhs/rhs patterns and output dim names for BS→T collapse.
Always produces batch-major order ``(b s)`` regardless of input ordering.
Uses the tensor's own dim names as einops axis names.
"""
lo: int = min(batch_dim, seq_dim)
hi: int = max(batch_dim, seq_dim)
lhs: str = " ".join(names) # type: ignore[arg-type]
rhs_names: list[str] = list(names[:lo]) + [f"({BATCH_DIM_NAME} {SEQ_DIM_NAME})"] + list(names[hi + 1 :]) # type: ignore[misc]
rhs: str = " ".join(rhs_names)
new_names: list[str | None] = (
list(names[:lo]) + [TOKEN_DIM_NAME] + list(names[hi + 1 :])
)
return lhs, rhs, new_names
# ── core logic (T layout only) ───────────────────────────────────
def _resolve_dim_or_fallback(tensor: torch.Tensor, name: str) -> int:
if get_dim_names(tensor)[0] is None:
return _UNNAMED_TOKEN_DIM_FALLBACK
return resolve_dim_by_name(tensor, name)
def _make_empty(*, tensor_of_step: dict[int, torch.Tensor]) -> torch.Tensor:
dummy: torch.Tensor = next(iter(tensor_of_step.values()))
token_dim: int = _resolve_dim_or_fallback(dummy, TOKEN_DIM_NAME)
shape: list[int] = list(dummy.shape)
shape[token_dim] = 0
return torch.empty(shape, dtype=dummy.dtype)
def _extract_and_stack_tokens(
*,
tensor_of_step: dict[int, torch.Tensor],
locator: TokenLocator,
) -> torch.Tensor:
some_tensor: torch.Tensor = next(iter(tensor_of_step.values()))
token_dim: int = _resolve_dim_or_fallback(some_tensor, TOKEN_DIM_NAME)
tokens: list[torch.Tensor] = [
tensor_of_step[s].select(dim=token_dim, index=i)
for s, i in zip(locator.steps, locator.token_index_in_step)
]
return torch.stack(tokens, dim=token_dim)
@@ -0,0 +1,135 @@
from __future__ import annotations
from collections import defaultdict
from typing import NamedTuple, Optional
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
SeqId,
TokenAlignerPlan,
TokenAlignerSeqInfo,
TokenAlignerSeqsInfo,
TokenLocator,
)
from sglang.srt.debug_utils.comparator.utils import Pair
def compute_token_aligner_plan(
seqs_info_pair: Pair[TokenAlignerSeqsInfo],
) -> TokenAlignerPlan:
"""Compute a token alignment plan from two side token seqs_info_pair."""
matched_pairs: list[tuple[SeqId, SeqId]] = _match_sequences(
seqs=Pair(x=seqs_info_pair.x.sequences, y=seqs_info_pair.y.sequences)
)
_empty = TokenLocator(steps=[], token_index_in_step=[])
locator_x: TokenLocator = _empty
locator_y: TokenLocator = _empty
for seq_id_x, seq_id_y in matched_pairs:
rec: Pair[TokenAlignerSeqInfo] = Pair(
x=seqs_info_pair.x.sequences[seq_id_x],
y=seqs_info_pair.y.sequences[seq_id_y],
)
# positions is validated to be [0, 1, ..., N-1], so position == index
# and the common range is simply [0, min(len_x, len_y)).
common_len: int = min(len(rec.x.positions), len(rec.y.positions))
x_ids = rec.x.input_ids[:common_len]
y_ids = rec.y.input_ids[:common_len]
assert x_ids == y_ids, f"{seq_id_x=} {seq_id_y=} {x_ids=} {y_ids=}"
locator_x = locator_x + TokenLocator(
steps=rec.x.locator.steps[:common_len],
token_index_in_step=rec.x.locator.token_index_in_step[:common_len],
)
locator_y = locator_y + TokenLocator(
steps=rec.y.locator.steps[:common_len],
token_index_in_step=rec.y.locator.token_index_in_step[:common_len],
)
return TokenAlignerPlan(
locators=Pair(x=locator_x, y=locator_y),
layouts=seqs_info_pair.map(lambda s: s.layout),
)
# -------------------- Sequence matcher --------------------
def _match_sequences(
seqs: Pair[dict[SeqId, TokenAlignerSeqInfo]],
) -> list[tuple[SeqId, SeqId]]:
"""For each y (target) sequence, find a matching x (baseline) sequence.
Two-pass: exact match first, then prefix match for remaining.
"""
x_lookup: dict[tuple[int, ...], list[SeqId]] = defaultdict(list)
for seq_id, rec in seqs.x.items():
x_lookup[tuple(rec.input_ids)].append(seq_id)
claimed_x_ids: set[SeqId] = set()
matched_seq_id_pairs: list[tuple[SeqId, SeqId]] = []
for seq_id_y in sorted(seqs.y.keys()):
seq_y: TokenAlignerSeqInfo = seqs.y[seq_id_y]
matched_x: Optional[SeqId] = _find_matching_x_exact(
seq_y=seq_y, x_lookup=x_lookup, claimed_x_ids=claimed_x_ids
)
if matched_x is None:
matched_x = _find_matching_x_prefix(
seq_y=seq_y, x_seqs=seqs.x, claimed_x_ids=claimed_x_ids
)
if matched_x is not None:
matched_seq_id_pairs.append((matched_x, seq_id_y))
claimed_x_ids.add(matched_x)
return matched_seq_id_pairs
def _find_matching_x_exact(
*,
seq_y: TokenAlignerSeqInfo,
x_lookup: dict[tuple[int, ...], list[SeqId]],
claimed_x_ids: set[SeqId],
) -> Optional[SeqId]:
"""Find an x sequence with identical input_ids."""
ids_y_key: tuple[int, ...] = tuple(seq_y.input_ids)
candidates: list[SeqId] = x_lookup.get(ids_y_key, [])
for candidate in candidates:
if candidate not in claimed_x_ids:
return candidate
return None
class _PrefixCandidate(NamedTuple):
seq_id_x: SeqId
overlap_len: int
def _find_matching_x_prefix(
*,
seq_y: TokenAlignerSeqInfo,
x_seqs: dict[SeqId, TokenAlignerSeqInfo],
claimed_x_ids: set[SeqId],
) -> Optional[SeqId]:
"""Find the x sequence with the longest prefix relationship to y."""
ids_y: list[int] = seq_y.input_ids
candidates: list[_PrefixCandidate] = [
_PrefixCandidate(
seq_id_x=seq_id_x, overlap_len=min(len(seq_x.input_ids), len(ids_y))
)
for seq_id_x, seq_x in x_seqs.items()
if seq_id_x not in claimed_x_ids and _is_prefix_pair(seq_x.input_ids, ids_y)
]
if not candidates:
return None
return max(candidates, key=lambda c: c.overlap_len).seq_id_x
def _is_prefix_pair(a: list[int], b: list[int]) -> bool:
"""True if a is a prefix of b, or b is a prefix of a."""
shorter_len: int = min(len(a), len(b))
return a[:shorter_len] == b[:shorter_len]
@@ -0,0 +1,81 @@
from __future__ import annotations
from dataclasses import dataclass, field
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
SeqId,
TokenAlignerGlobalAux,
TokenAlignerSeqInfo,
TokenAlignerSeqsInfo,
TokenAlignerStepAux,
TokenLocator,
)
@dataclass
class _SeqInfoAccumulator:
"""Mutable accumulator for building TokenAlignerSeqInfo without per-step validation."""
input_ids: list[int] = field(default_factory=list)
positions: list[int] = field(default_factory=list)
steps: list[int] = field(default_factory=list)
token_index_in_step: list[int] = field(default_factory=list)
def extend(
self,
*,
input_ids: list[int],
positions: list[int],
steps: list[int],
token_index_in_step: list[int],
) -> None:
self.input_ids.extend(input_ids)
self.positions.extend(positions)
self.steps.extend(steps)
self.token_index_in_step.extend(token_index_in_step)
def build(self) -> TokenAlignerSeqInfo:
return TokenAlignerSeqInfo(
input_ids=self.input_ids,
positions=self.positions,
locator=TokenLocator(
steps=self.steps,
token_index_in_step=self.token_index_in_step,
),
)
def build_seqs_info(global_aux: TokenAlignerGlobalAux) -> TokenAlignerSeqsInfo:
"""Build sequence info for one side from its auxiliary tensors."""
return TokenAlignerSeqsInfo(
sequences=_build_token_aligner_seq_infos(global_aux),
layout=global_aux.layout,
)
def _build_token_aligner_seq_infos(
global_aux: TokenAlignerGlobalAux,
) -> dict[SeqId, TokenAlignerSeqInfo]:
"""Build token index for any framework/layout using seq_ids for identity tracking."""
accum: dict[SeqId, _SeqInfoAccumulator] = {}
for step in sorted(global_aux.step_auxs.keys()):
aux: TokenAlignerStepAux = global_aux.step_auxs[step]
offset: int = 0
for seq_index, seq_len in enumerate(aux.seq_lens):
seq_id: SeqId = aux.seq_ids[seq_index]
if seq_id not in accum:
accum[seq_id] = _SeqInfoAccumulator()
accum[seq_id].extend(
input_ids=aux.input_ids[offset : offset + seq_len],
positions=aux.positions[offset : offset + seq_len],
steps=[step] * seq_len,
token_index_in_step=list(range(offset, offset + seq_len)),
)
offset += seq_len
return {seq_id: acc.build() for seq_id, acc in accum.items()}
@@ -0,0 +1,128 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import NamedTuple, Optional, Union
from pydantic import model_validator
from sglang.srt.debug_utils.comparator.dims_spec import TokenLayout
from sglang.srt.debug_utils.comparator.utils import (
Pair,
_check_equal_lengths,
_FrozenBase,
)
class SGLangSeqId(NamedTuple):
rid: str
class PositionalSeqId(NamedTuple):
step: int
seq_index: int
SeqId = Union[SGLangSeqId, PositionalSeqId]
@dataclass(frozen=True)
class TokenAlignerStepAux:
"""Normalized auxiliary tensors for a single step (framework-agnostic)."""
input_ids: list[int] # [num_tokens]
positions: list[int] # [num_tokens]
seq_lens: list[int] # [num_seqs]
seq_ids: list[SeqId] # [num_seqs] — sequence identity
def __post_init__(self) -> None:
_check_equal_lengths(input_ids=self.input_ids, positions=self.positions)
_check_equal_lengths(seq_lens=self.seq_lens, seq_ids=self.seq_ids)
token_count: int = sum(self.seq_lens)
if token_count != len(self.input_ids):
raise ValueError(
f"sum(seq_lens)={token_count} != len(input_ids)={len(self.input_ids)}"
)
@dataclass(frozen=True)
class TokenAlignerGlobalAux:
"""Auxiliary tensors for one side across all steps + side-level metadata."""
step_auxs: dict[int, TokenAlignerStepAux]
framework: str # "sglang" | "megatron"
layout: TokenLayout
thd_seq_lens_by_step: Optional[dict[int, list[int]]] = field(default=None)
class TokenLocator(_FrozenBase):
"""Locates tokens within a multi-step tensor store.
token i is at tensor_of_step[steps[i]][token_index_in_step[i]].
"""
steps: list[int]
token_index_in_step: list[int]
def __add__(self, other: TokenLocator) -> TokenLocator:
return TokenLocator(
steps=self.steps + other.steps,
token_index_in_step=self.token_index_in_step + other.token_index_in_step,
)
class TokenAlignerSeqInfo(_FrozenBase):
"""Information for a sequence, containing information to locate all the tokens inside the sequence."""
# All these fields are of shape (num_tokens_in_seq,)
input_ids: list[int]
positions: list[int]
locator: TokenLocator
@model_validator(mode="after")
def _validate_fields(self) -> TokenAlignerSeqInfo:
n: int = len(self.input_ids)
_check_equal_lengths(
input_ids=self.input_ids,
positions=self.positions,
locator_steps=self.locator.steps,
locator_token_index_in_step=self.locator.token_index_in_step,
)
if self.positions != list(range(n)):
raise ValueError(
f"positions must be [0, 1, ..., {n - 1}], got {self.positions}"
)
return self
def __add__(self, other: TokenAlignerSeqInfo) -> TokenAlignerSeqInfo:
return TokenAlignerSeqInfo(
input_ids=self.input_ids + other.input_ids,
positions=self.positions + other.positions,
locator=self.locator + other.locator,
)
class TokenAlignerSeqsInfo(_FrozenBase):
"""All sequences for one side across all steps."""
sequences: dict[SeqId, TokenAlignerSeqInfo]
layout: TokenLayout
class TokenAlignerPlan(_FrozenBase):
"""Token alignment plan. locators.x[i] and locators.y[i] correspond to the same logical token."""
locators: Pair[TokenLocator]
layouts: Pair[TokenLayout]
@model_validator(mode="after")
def _validate_fields(self) -> TokenAlignerPlan:
_check_equal_lengths(
locators_x_steps=self.locators.x.steps,
locators_x_token_index_in_step=self.locators.x.token_index_in_step,
locators_y_steps=self.locators.y.steps,
locators_y_token_index_in_step=self.locators.y.token_index_in_step,
)
return self
@@ -0,0 +1,190 @@
from dataclasses import dataclass, field
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import (
ConcatParams,
CpThdConcatParams,
PickParams,
ReduceSumParams,
UnsharderParams,
UnsharderPlan,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
ParallelAxis,
apply_dim_names,
get_dim_names,
resolve_dim_by_name,
without_dim_names,
)
from sglang.srt.debug_utils.comparator.output_types import ReplicatedCheckResult
from sglang.srt.debug_utils.comparator.tensor_comparator.comparator import compute_diff
_REPLICATED_ATOL: float = 1e-6
@dataclass(frozen=True)
class UnsharderResult:
tensors: list[torch.Tensor]
replicated_checks: list[ReplicatedCheckResult] = field(default_factory=list)
def execute_unsharder_plan(
plan: UnsharderPlan,
tensors: list[torch.Tensor],
) -> UnsharderResult:
result_tensors: list[torch.Tensor] = []
all_checks: list[ReplicatedCheckResult] = []
for group_idx, group in enumerate(plan.groups):
group_tensors = [tensors[i] for i in group]
tensor, checks = _apply_unshard(
plan.params,
group_tensors,
axis=plan.axis,
group_index=group_idx,
)
result_tensors.append(tensor)
all_checks.extend(checks)
return UnsharderResult(tensors=result_tensors, replicated_checks=all_checks)
def _apply_unshard(
params: UnsharderParams,
ordered_tensors: list[torch.Tensor],
*,
axis: ParallelAxis,
group_index: int,
) -> tuple[torch.Tensor, list[ReplicatedCheckResult]]:
if isinstance(params, PickParams):
checks: list[ReplicatedCheckResult] = _verify_replicated_group(
ordered_tensors,
axis=axis,
group_index=group_index,
)
return ordered_tensors[0], checks
if isinstance(params, ConcatParams):
dim: int = resolve_dim_by_name(ordered_tensors[0], params.dim_name)
names: tuple[Optional[str], ...] = get_dim_names(ordered_tensors[0])
result = torch.cat(ordered_tensors, dim=dim)
if names[0] is not None:
result = apply_dim_names(result, list(names))
return result, []
if isinstance(params, CpThdConcatParams):
thd_dim: int = resolve_dim_by_name(ordered_tensors[0], params.dim_name)
return (
_thd_concat(
ordered_tensors,
dim=thd_dim,
seq_lens_per_rank=params.seq_lens_per_rank,
),
[],
)
if isinstance(params, ReduceSumParams):
names: tuple[Optional[str], ...] = get_dim_names(ordered_tensors[0])
stripped: list[torch.Tensor] = [without_dim_names(t) for t in ordered_tensors]
result: torch.Tensor = torch.stack(stripped).sum(dim=0)
if names[0] is not None:
result = apply_dim_names(result, list(names))
return result, []
raise ValueError(f"Unsupported unshard operation: {type(params).__name__}")
def _verify_replicated_group(
ordered_tensors: list[torch.Tensor],
*,
axis: ParallelAxis,
group_index: int,
) -> list[ReplicatedCheckResult]:
baseline: torch.Tensor = ordered_tensors[0].float()
return [
_check_replicated_pair(
baseline=baseline,
other=ordered_tensors[i],
axis=axis,
group_index=group_index,
compared_index=i,
)
for i in range(1, len(ordered_tensors))
]
def _check_replicated_pair(
*,
baseline: torch.Tensor,
other: torch.Tensor,
axis: ParallelAxis,
group_index: int,
compared_index: int,
) -> ReplicatedCheckResult:
other_float: torch.Tensor = other.float()
if baseline.shape != other_float.shape:
passed = False
diff_info = None
else:
diff_info = compute_diff(
x_baseline=baseline,
x_target=other_float,
predicate=f"max_abs <= {_REPLICATED_ATOL}",
)
passed = diff_info.passed
return ReplicatedCheckResult(
axis=axis.value,
group_index=group_index,
compared_index=compared_index,
baseline_index=0,
passed=passed,
atol=_REPLICATED_ATOL,
diff=diff_info,
)
def _thd_concat(
ordered_tensors: list[torch.Tensor],
*,
dim: int,
seq_lens_per_rank: list[int],
) -> torch.Tensor:
"""Per-seq concat across ranks for THD format.
Each rank holds segments of each seq packed contiguously:
rank_data = [seq0_tokens | seq1_tokens | ... | pad_tokens]
This function splits each rank by seq_lens, then interleaves across ranks
per-seq: [seqA_r0 + seqA_r1 + ... | seqB_r0 + seqB_r1 + ... | tail_pad].
"""
names: tuple[Optional[str], ...] = get_dim_names(ordered_tensors[0])
stripped: list[torch.Tensor] = [without_dim_names(t) for t in ordered_tensors]
# Split each rank into [seq0, seq1, ..., tail_remainder]
split_sizes: list[int] = list(seq_lens_per_rank)
remainder: int = stripped[0].shape[dim] - sum(split_sizes)
if remainder < 0:
raise ValueError(
f"sum(seq_lens_per_rank)={sum(split_sizes)} exceeds tensor dim size "
f"{stripped[0].shape[dim]} along dim={dim}"
)
if remainder > 0:
split_sizes.append(remainder)
per_rank_splits: list[tuple[torch.Tensor, ...]] = [
t.split(split_sizes, dim=dim) for t in stripped
]
# Per-seq concat across ranks, then concatenate all seqs
result: torch.Tensor = torch.cat(
[torch.cat(rank_parts, dim=dim) for rank_parts in zip(*per_rank_splits)],
dim=dim,
)
if names[0] is not None:
result = apply_dim_names(result, list(names))
return result
@@ -0,0 +1,45 @@
from typing import Optional
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import AxisInfo
from sglang.srt.debug_utils.comparator.dims_spec import ParallelAxis
_PARALLEL_INFO_KEYS = ("sglang_parallel_info", "megatron_parallel_info")
def _is_error_sentinel(value: dict) -> bool:
"""Check if a parallel_info dict is an error sentinel (e.g. {'megatron_error': True})."""
return any(k.endswith("_error") for k in value)
def normalize_parallel_info(meta: dict) -> dict[ParallelAxis, AxisInfo]:
"""Extract unified parallel info from dump meta."""
info: Optional[dict] = None
for key in _PARALLEL_INFO_KEYS:
value = meta.get(key)
if isinstance(value, dict) and value and not _is_error_sentinel(value):
if info is not None:
raise ValueError(
f"Meta contains multiple parallel_info keys among {_PARALLEL_INFO_KEYS}"
)
info = value
if info is None:
info = {}
result: dict[ParallelAxis, AxisInfo] = {}
for axis in ParallelAxis:
axis_rank = info.get(f"{axis.value}_rank")
axis_size = info.get(f"{axis.value}_size")
# Recompute pseudo-axis lives at top-level meta, not inside parallel_info
if axis_rank is None:
axis_rank = meta.get(f"{axis.value}_rank")
axis_size = meta.get(f"{axis.value}_size")
if axis_rank is not None and axis_size is not None and axis_size > 1:
result[axis] = AxisInfo(
axis_rank=axis_rank,
axis_size=axis_size,
)
return result
@@ -0,0 +1,373 @@
from collections import defaultdict
from typing import NamedTuple, Optional
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import (
AxisInfo,
ConcatParams,
CpThdConcatParams,
PickParams,
ReduceSumParams,
UnsharderParams,
UnsharderPlan,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
TOKEN_DIM_NAME,
DimSpec,
ParallelAxis,
ParallelModifier,
)
# _CoordsList[tensor_index][axis] =
# the axis_rank (shard position) of the tensor_index-th tensor along `axis`
# (e.g. coords[2] = {TP: 3} means tensor 2 is the 3rd shard in TP axis)
_CoordsList = list[dict[ParallelAxis, int]]
class _GroupResult(NamedTuple):
groups: list[list[int]]
projected_coords: _CoordsList
def compute_unsharder_plan(
dim_specs: list[DimSpec],
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
*,
explicit_replicated_axes: frozenset[ParallelAxis] = frozenset(),
thd_global_seq_lens: Optional[list[int]] = None,
dp_filtered_axis: Optional[ParallelAxis] = None,
) -> list[UnsharderPlan]:
if not parallel_infos:
raise ValueError("parallel_infos must not be empty")
# Within each dim spec, reverse modifier order: innermost shard (rightmost) unshards first.
reversed_sharded_modifiers: list[tuple[str, ParallelModifier]] = [
(spec.sanitized_name, m)
for spec in dim_specs
for m in reversed(spec.parallel_modifiers)
]
sharded_axes_raw: set[ParallelAxis] = {
m.axis for _, m in reversed_sharded_modifiers
}
all_axes: set[ParallelAxis] = {axis for info in parallel_infos for axis in info}
# axis annotated in dims but absent from all parallel_infos -> axis_size=1, skip
sharded_axes: set[ParallelAxis] = sharded_axes_raw & all_axes
reversed_sharded_modifiers = [
(name, m) for name, m in reversed_sharded_modifiers if m.axis in sharded_axes
]
# RECOMPUTE_PSEUDO is always implicitly replicated (system-injected, not user-facing)
auto_replicated: frozenset[ParallelAxis] = frozenset(
{ParallelAxis.RECOMPUTE_PSEUDO} & all_axes
)
effective_replicated: frozenset[ParallelAxis] = (
explicit_replicated_axes | auto_replicated
)
_validate_explicit_replicated(
explicit_replicated_axes=effective_replicated,
sharded_axes=sharded_axes,
all_axes=all_axes,
parallel_infos=parallel_infos,
dp_filtered_axis=dp_filtered_axis,
)
replicated_axes: frozenset[ParallelAxis] = effective_replicated
if not sharded_axes and not replicated_axes:
return []
_validate(
axes_to_validate=sharded_axes | replicated_axes,
parallel_infos=parallel_infos,
)
current_coords: _CoordsList = [
{axis: info[axis].axis_rank for axis in sharded_axes | replicated_axes}
for info in parallel_infos
]
axis_and_params: list[tuple[ParallelAxis, UnsharderParams]] = [
(axis, PickParams()) for axis in sorted(replicated_axes, key=lambda a: a.value)
] + [
(
modifier.axis,
_resolve_unshard_params(
modifier=modifier,
dim_name=dim_name,
parallel_infos=parallel_infos,
thd_global_seq_lens=thd_global_seq_lens,
),
)
for dim_name, modifier in reversed_sharded_modifiers
]
plans: list[UnsharderPlan] = []
for axis, params in axis_and_params:
result = _group_and_project(
current_coords=current_coords,
target_axis=axis,
)
plans.append(UnsharderPlan(axis=axis, params=params, groups=result.groups))
current_coords = result.projected_coords
return plans
def _validate_explicit_replicated(
*,
explicit_replicated_axes: frozenset[ParallelAxis],
sharded_axes: set[ParallelAxis],
all_axes: set[ParallelAxis],
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
dp_filtered_axis: Optional[ParallelAxis] = None,
) -> None:
"""Validate explicit replicated declarations against sharded axes and parallel_infos."""
invalid: frozenset[ParallelAxis] = explicit_replicated_axes - all_axes
if invalid:
invalid_names: str = ", ".join(sorted(a.value for a in invalid))
raise ValueError(
f"Declared replicated axes {{{invalid_names}}} not found in parallel_infos "
f"(active axes: {{{', '.join(sorted(a.value for a in all_axes))}}})"
)
conflict: set[ParallelAxis] = explicit_replicated_axes & sharded_axes
if conflict:
conflict_names: str = ", ".join(sorted(a.value for a in conflict))
raise ValueError(
f"Axes {{{conflict_names}}} declared as both sharded and replicated"
)
_validate_replicated_axes_orthogonal(
explicit_replicated_axes=explicit_replicated_axes,
parallel_infos=parallel_infos,
)
candidate_axes: set[ParallelAxis] = (
all_axes - sharded_axes - explicit_replicated_axes
)
implicitly_replicated: frozenset[ParallelAxis] = _compute_dependent_axes(
parent_axes=explicit_replicated_axes,
candidate_axes=candidate_axes,
parallel_infos=parallel_infos,
)
implicitly_sharded: frozenset[ParallelAxis] = _compute_dependent_axes(
parent_axes=sharded_axes,
candidate_axes=candidate_axes - implicitly_replicated,
parallel_infos=parallel_infos,
)
declared_axes: frozenset[ParallelAxis] = frozenset(
sharded_axes
| explicit_replicated_axes
| implicitly_replicated
| implicitly_sharded
| ({dp_filtered_axis} if dp_filtered_axis is not None else set())
)
undeclared: set[ParallelAxis] = all_axes - declared_axes
jointly_determined: frozenset[ParallelAxis] = frozenset(
child
for child in undeclared
if _is_jointly_determined(
parallel_infos, parent_axes=declared_axes, child=child
)
)
undeclared -= jointly_determined
if undeclared:
undeclared_names: str = ", ".join(sorted(a.value for a in undeclared))
raise ValueError(
f"Axes {{{undeclared_names}}} are active (axis_size > 1) but not declared "
f"in dims. Annotate as sharded in dim spec or as '# axis:replicated'."
)
def _validate_replicated_axes_orthogonal(
*,
explicit_replicated_axes: frozenset[ParallelAxis],
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
) -> None:
"""Every pair of explicitly replicated axes must be fully orthogonal (no dependency)."""
axes: list[ParallelAxis] = sorted(explicit_replicated_axes, key=lambda a: a.value)
if len(axes) < 2:
return
violations: list[str] = []
for i, axis_a in enumerate(axes):
for axis_b in axes[i + 1 :]:
for parent, child in [(axis_a, axis_b), (axis_b, axis_a)]:
if _is_dependent_axis(parallel_infos, parent=parent, child=child):
violations.append(
f"'{parent.value}' determines '{child.value}'"
f"remove '{child.value}:replicated'"
)
if violations:
details = "; ".join(violations)
raise ValueError(
f"Explicitly-replicated axes overlap (not orthogonal): {details}"
)
def _validate(
*,
axes_to_validate: set[ParallelAxis],
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
) -> None:
"""Check that every rank has all axes, sizes are consistent, and ranks are complete."""
axis_sizes: dict[ParallelAxis, int] = {}
for world_rank, parallel_info in enumerate(parallel_infos):
for axis in axes_to_validate:
if axis not in parallel_info:
raise ValueError(
f"world_rank={world_rank} missing parallel_info for "
f"axis {axis.value!r}"
)
axis_info = parallel_info[axis]
if axis not in axis_sizes:
axis_sizes[axis] = axis_info.axis_size
elif axis_info.axis_size != axis_sizes[axis]:
raise ValueError(
f"Inconsistent axis_size for {axis.value}: "
f"expected {axis_sizes[axis]}, got {axis_info.axis_size} "
f"at world_rank={world_rank}"
)
for axis, expected_size in axis_sizes.items():
seen_ranks = {info[axis].axis_rank for info in parallel_infos}
if seen_ranks != set(range(expected_size)):
raise ValueError(
f"axis_rank coverage for {axis.value} is incomplete: "
f"got {sorted(seen_ranks)}, expected 0..{expected_size - 1}"
)
def _compute_dependent_axes(
parent_axes: set[ParallelAxis] | frozenset[ParallelAxis],
candidate_axes: set[ParallelAxis],
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
) -> frozenset[ParallelAxis]:
"""Return candidate axes whose rank is uniquely determined by some parent axis."""
return frozenset(
child
for child in candidate_axes
if any(
_is_dependent_axis(parallel_infos, parent=parent, child=child)
for parent in parent_axes
)
)
def _is_jointly_determined(
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
*,
parent_axes: frozenset[ParallelAxis],
child: ParallelAxis,
) -> bool:
"""True if child's rank is uniquely determined by the joint tuple of parent ranks.
Unlike ``_is_dependent_axis`` which checks single-parent dependency, this
checks whether the *combination* of all parent axes jointly determines the
child. For example, ``edp_rank`` may not be a function of ``tp_rank`` alone
or ``cp_rank`` alone, but it *is* a function of ``(tp_rank, cp_rank)``.
Parent axes that are absent from *every* info are ignored (they carry no
information — e.g. DP with size 1 filtered by ``normalize_parallel_info``).
However, a parent axis present in *some* infos but missing from an info
that contains the child makes the determination incomplete → ``False``.
"""
if not parent_axes:
return False
active_parents: frozenset[ParallelAxis] = frozenset(
ax for ax in parent_axes if any(ax in info for info in parallel_infos)
)
if not active_parents:
return False
mapping: dict[frozenset, int] = {}
for info in parallel_infos:
if child not in info:
continue
if not active_parents.issubset(info):
return False
parent_key = frozenset((ax, info[ax].axis_rank) for ax in active_parents)
child_rank: int = info[child].axis_rank
if mapping.setdefault(parent_key, child_rank) != child_rank:
return False
return bool(mapping)
def _is_dependent_axis(
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
*,
parent: ParallelAxis,
child: ParallelAxis,
) -> bool:
"""True if child's rank is uniquely determined by parent's rank."""
parent_rank_to_child_rank: dict[int, int] = {}
for info in parallel_infos:
if parent not in info or child not in info:
continue
parent_rank = info[parent].axis_rank
child_rank = info[child].axis_rank
if parent_rank_to_child_rank.setdefault(parent_rank, child_rank) != child_rank:
return False
return True
def _group_and_project(
*,
current_coords: _CoordsList,
target_axis: ParallelAxis,
) -> _GroupResult:
"""Group tensors by other-axes coords, sort within group by target_axis rank."""
# buckets[coords_excluding_target] = [(axis_rank, tensor_index), ...]
# e.g. when target_axis=CP: buckets[{(TP,0)}] = [(0, 1), (1, 3)]
# means tensor 1 (CP rank 0) and tensor 3 (CP rank 1) share TP rank 0
buckets: dict[frozenset, list[tuple[int, int]]] = defaultdict(list)
for idx, coords in enumerate(current_coords):
key = frozenset((k, v) for k, v in coords.items() if k != target_axis)
buckets[key].append((coords[target_axis], idx))
groups: list[list[int]] = []
projected: _CoordsList = []
for key in sorted(buckets, key=lambda k: sorted((a.value, v) for a, v in k)):
entries = sorted(buckets[key])
groups.append([idx for _, idx in entries])
projected.append(dict(key))
return _GroupResult(groups=groups, projected_coords=projected)
def _resolve_unshard_params(
*,
modifier: ParallelModifier,
dim_name: str,
parallel_infos: list[dict[ParallelAxis, AxisInfo]],
thd_global_seq_lens: Optional[list[int]] = None,
) -> UnsharderParams:
if modifier.reduction is not None:
return ReduceSumParams()
if (
dim_name == TOKEN_DIM_NAME
and modifier.axis == ParallelAxis.CP
and thd_global_seq_lens is not None
):
axis_size: int = parallel_infos[0][modifier.axis].axis_size
for s in thd_global_seq_lens:
if s % axis_size != 0:
raise ValueError(
f"THD seq_len {s} is not divisible by cp_size {axis_size}. "
f"Sequences must be padded to a multiple of cp_size for CP zigzag."
)
seq_lens_per_rank: list[int] = [s // axis_size for s in thd_global_seq_lens]
return CpThdConcatParams(dim_name=dim_name, seq_lens_per_rank=seq_lens_per_rank)
return ConcatParams(dim_name=dim_name)
@@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Annotated, Literal, Union
from pydantic import Field, model_validator
from sglang.srt.debug_utils.comparator.dims_spec import ParallelAxis
from sglang.srt.debug_utils.comparator.utils import _FrozenBase
class AxisInfo(_FrozenBase):
axis_rank: int
axis_size: int
@model_validator(mode="after")
def _validate_bounds(self) -> AxisInfo:
if self.axis_size <= 0:
raise ValueError(f"axis_size must be > 0, got {self.axis_size}")
if not (0 <= self.axis_rank < self.axis_size):
raise ValueError(
f"axis_rank must be in [0, {self.axis_size}), got {self.axis_rank}"
)
return self
class ConcatParams(_FrozenBase):
op: Literal["concat"] = "concat"
dim_name: str
class CpThdConcatParams(_FrozenBase):
op: Literal["cp_thd_concat"] = "cp_thd_concat"
dim_name: str
seq_lens_per_rank: list[int] # per-seq token count on each rank, e.g. [50, 32, 46]
class PickParams(_FrozenBase):
op: Literal["pick"] = "pick"
class ReduceSumParams(_FrozenBase):
op: Literal["reduce_sum"] = "reduce_sum"
UnsharderParams = Annotated[
Union[ConcatParams, CpThdConcatParams, PickParams, ReduceSumParams],
Field(discriminator="op"),
]
class UnsharderPlan(_FrozenBase):
type: Literal["unsharder"] = "unsharder"
axis: ParallelAxis
params: UnsharderParams
# groups[i] = indices in the input tensor list, which will be operated (e.g. concat) into i-th output tensor.
#
# Multistep example (CP=2, TP=2, 4 input tensors):
# plan[0] (CP): groups=[[0,2],[1,3]] — 4 tensors → 2 tensors
# plan[1] (TP): groups=[[0,1]] — 2 tensors → 1 tensor
groups: list[list[int]]
@@ -0,0 +1,436 @@
"""Compare two tensor bundles."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional, Union
import torch
from sglang.srt.debug_utils.comparator.aligner.entrypoint.executor import (
AlignerResult,
execute_aligner_plan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.planner import (
compute_aligner_plan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import AlignerPlan
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerPlan,
)
from sglang.srt.debug_utils.comparator.dims_spec import (
SEQ_DIM_NAME,
TOKEN_DIM_NAME,
ParallelAxis,
apply_dim_names,
get_dim_names,
parse_dims,
resolve_dim_names,
without_dim_names,
)
from sglang.srt.debug_utils.comparator.dp_utils import filter_to_non_empty_dp_rank
from sglang.srt.debug_utils.comparator.log_sink import log_sink
from sglang.srt.debug_utils.comparator.meta_overrider import MetaOverrider
from sglang.srt.debug_utils.comparator.output_types import (
BundleFileInfo,
BundleSideInfo,
ComparisonNonTensorRecord,
ComparisonSkipRecord,
ComparisonTensorRecord,
ErrorLog,
_split_logs,
)
from sglang.srt.debug_utils.comparator.tensor_comparator.comparator import (
FailureDisplayBudget,
compare_tensor_pair,
compute_tensor_info,
)
from sglang.srt.debug_utils.comparator.threshold_dsl import DiffThresholdRule
from sglang.srt.debug_utils.comparator.utils import Pair
from sglang.srt.debug_utils.dump_loader import LOAD_FAILED, ValueWithMeta
def _build_skip_from_one_empty_side(
*, name: str, pair: Pair[list[ValueWithMeta]]
) -> ComparisonSkipRecord:
"""Build a skip record when one side of *pair* is empty.
The non-empty side's tensor info is attached to the record.
"""
assert not pair.x or not pair.y
if not pair.x:
reason, available_side, available_items = (
"baseline_load_failed",
"target",
pair.y,
)
else:
reason, available_side, available_items = (
"target_load_failed",
"baseline",
pair.x,
)
tensor_items: list[ValueWithMeta] = [
it for it in available_items if isinstance(it.value, torch.Tensor)
]
if not tensor_items:
return ComparisonSkipRecord(name=name, reason=reason)
first_tensor: torch.Tensor = tensor_items[0].value
tensor_info = compute_tensor_info(first_tensor, include_sample=True)
metas: list[dict[str, Any]] = [it.meta for it in tensor_items]
bundle_info: BundleSideInfo = _collect_bundle_side_info(
items=tensor_items, metas=metas
)
return ComparisonSkipRecord(
name=name,
reason=reason,
available_side=available_side, # type: ignore[arg-type]
available_tensor_info=tensor_info,
available_bundle_info=bundle_info,
)
def _collect_bundle_side_info(
items: list[ValueWithMeta],
metas: list[dict[str, Any]],
) -> BundleSideInfo:
from sglang.srt.debug_utils.comparator.display import (
PARALLEL_INFO_KEYS,
_extract_parallel_info,
)
files: list[BundleFileInfo] = []
for item, meta in zip(items, metas):
assert isinstance(item.value, torch.Tensor)
tensor: torch.Tensor = item.value
parallel_info: dict[str, str] = {}
for key in PARALLEL_INFO_KEYS:
_extract_parallel_info(row_data=parallel_info, info=meta.get(key, {}))
files.append(
BundleFileInfo(
shape=list(tensor.shape),
dtype=str(tensor.dtype),
rank=meta.get("rank"),
parallel_info=parallel_info if parallel_info else None,
filename=meta.get("filename"),
)
)
dims: Optional[str] = metas[0].get("dims") if metas else None
return BundleSideInfo(num_files=len(files), files=files, dims=dims)
def compare_bundle_pair(
*,
name: str,
filenames_pair: Pair[list[str]],
dir_pair: Pair[Path],
token_aligner_mode: Optional[str],
token_aligner_plan: Optional[TokenAlignerPlan],
diff_threshold_rules: Optional[list[DiffThresholdRule]] = None,
failure_display_budget: Optional[FailureDisplayBudget] = None,
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]] = Pair(
x=None, y=None
),
viz_output_dir: Optional[Path] = None,
compute_per_token: bool = False,
meta_overrider: Optional[MetaOverrider] = None,
) -> Union[ComparisonTensorRecord, ComparisonSkipRecord, ComparisonNonTensorRecord]:
with log_sink.context() as collected_logs:
result = _compare_bundle_pair_inner(
name=name,
filenames_pair=filenames_pair,
dir_pair=dir_pair,
token_aligner_mode=token_aligner_mode,
token_aligner_plan=token_aligner_plan,
diff_threshold_rules=diff_threshold_rules,
failure_display_budget=failure_display_budget,
thd_seq_lens_by_step_pair=thd_seq_lens_by_step_pair,
viz_output_dir=viz_output_dir,
compute_per_token=compute_per_token,
meta_overrider=meta_overrider,
)
errors, infos = _split_logs(collected_logs)
return result.model_copy(update={"errors": errors, "infos": infos})
def _compare_bundle_pair_inner(
*,
name: str,
filenames_pair: Pair[list[str]],
dir_pair: Pair[Path],
token_aligner_mode: Optional[str],
token_aligner_plan: Optional[TokenAlignerPlan],
diff_threshold_rules: Optional[list[DiffThresholdRule]] = None,
failure_display_budget: Optional[FailureDisplayBudget] = None,
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]] = Pair(
x=None, y=None
),
viz_output_dir: Optional[Path] = None,
compute_per_token: bool = False,
meta_overrider: Optional[MetaOverrider] = None,
) -> Union[ComparisonTensorRecord, ComparisonSkipRecord, ComparisonNonTensorRecord]:
# 1. Load all successfully loaded values
all_pair: Pair[list[ValueWithMeta]] = Pair(
x=_load_all_values(filenames=filenames_pair.x, base_path=dir_pair.x),
y=_load_all_values(filenames=filenames_pair.y, base_path=dir_pair.y),
)
if not all_pair.x or not all_pair.y:
return _build_skip_from_one_empty_side(name=name, pair=all_pair)
# 1b. Dims override: patch meta["dims"] before DP filter reads it
# (--override-dims may add ``# dp:=moe_dp``, so it must run first)
if meta_overrider is not None and not meta_overrider.is_empty:
_apply = meta_overrider.apply_to_meta
all_pair = Pair(
x=[
ValueWithMeta(
value=v.value, meta=_apply(name=name, meta=v.meta, side="baseline")
)
for v in all_pair.x
],
y=[
ValueWithMeta(
value=v.value, meta=_apply(name=name, meta=v.meta, side="target")
)
for v in all_pair.y
],
)
# 1c. DP filter: keep only the non-empty dp_rank
all_pair = all_pair.map(
lambda items: filter_to_non_empty_dp_rank(
items, dp_axis=_extract_dp_axis_from_items(items)
)
)
# 2. Check if any side has non-tensor values → non-tensor display path
has_non_tensor: bool = any(
not isinstance(it.value, torch.Tensor) for it in [*all_pair.x, *all_pair.y]
)
if has_non_tensor:
return _compare_bundle_pair_non_tensor_type(name=name, value_pair=all_pair)
# 3. All values are tensors → tensor comparison path
return _compare_bundle_pair_tensor_type(
name=name,
valid_pair=all_pair,
token_aligner_mode=token_aligner_mode,
token_aligner_plan=token_aligner_plan,
diff_threshold_rules=diff_threshold_rules,
failure_display_budget=failure_display_budget,
thd_seq_lens_by_step_pair=thd_seq_lens_by_step_pair,
viz_output_dir=viz_output_dir,
compute_per_token=compute_per_token,
)
def _extract_dp_axis_from_items(items: list[ValueWithMeta]) -> ParallelAxis:
"""Extract dp axis from the first item's ``meta["dims"]``."""
if not items:
return ParallelAxis.DP
dims_str: Optional[str] = items[0].meta.get("dims")
if dims_str is None:
return ParallelAxis.DP
return parse_dims(dims_str).dp_axis
def _compare_bundle_pair_tensor_type(
*,
name: str,
valid_pair: Pair[list[ValueWithMeta]],
token_aligner_mode: Optional[str],
token_aligner_plan: Optional[TokenAlignerPlan],
diff_threshold_rules: Optional[list[DiffThresholdRule]] = None,
failure_display_budget: Optional[FailureDisplayBudget] = None,
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]] = Pair(
x=None, y=None
),
viz_output_dir: Optional[Path] = None,
compute_per_token: bool = False,
) -> Union[ComparisonTensorRecord, ComparisonSkipRecord]:
if not valid_pair.x or not valid_pair.y:
return _build_skip_from_one_empty_side(name=name, pair=valid_pair)
# Plan (meta only, no tensor)
metas_pair: Pair[list[dict[str, Any]]] = valid_pair.map(
lambda items: [it.meta for it in items]
)
plan: AlignerPlan = compute_aligner_plan(
metas_pair=metas_pair,
token_aligner_mode=token_aligner_mode,
token_aligner_plan=token_aligner_plan,
thd_seq_lens_by_step_pair=thd_seq_lens_by_step_pair,
)
# Collect raw bundle info before alignment
raw_bundle_info: Pair[BundleSideInfo] = Pair(
x=_collect_bundle_side_info(items=valid_pair.x, metas=metas_pair.x),
y=_collect_bundle_side_info(items=valid_pair.y, metas=metas_pair.y),
)
# Apply dim names to tensors, then execute
tensors_pair: Pair[list[torch.Tensor]] = Pair(
x=_apply_dim_names_from_meta(
tensors=[it.value for it in valid_pair.x],
metas=metas_pair.x,
),
y=_apply_dim_names_from_meta(
tensors=[it.value for it in valid_pair.y],
metas=metas_pair.y,
),
)
aligner_result: AlignerResult = execute_aligner_plan(
tensors_pair=tensors_pair, plan=plan
)
replicated_checks = aligner_result.replicated_checks
if aligner_result.tensors is None:
assert aligner_result.failed_side_xy is not None
failed_xy: str = aligner_result.failed_side_xy
pair_with_failed_emptied: Pair[list[ValueWithMeta]] = Pair(
x=[] if failed_xy == "x" else valid_pair.x,
y=[] if failed_xy == "y" else valid_pair.y,
)
return _build_skip_from_one_empty_side(name=name, pair=pair_with_failed_emptied)
# Resolve seq_dim for per-token computation
seq_dim: Optional[int] = (
_resolve_seq_dim(aligner_result.tensors.y) if compute_per_token else None
)
# Compare
aligned_baseline: torch.Tensor = without_dim_names(aligner_result.tensors.x)
aligned_target: torch.Tensor = without_dim_names(aligner_result.tensors.y)
info = compare_tensor_pair(
x_baseline=aligned_baseline,
x_target=aligned_target,
name=name,
diff_threshold_rules=diff_threshold_rules,
failure_display_budget=failure_display_budget,
seq_dim=seq_dim,
)
record = ComparisonTensorRecord(
**info.model_dump(),
traced_plan=aligner_result.traced_plan,
replicated_checks=replicated_checks,
raw_bundle_info=raw_bundle_info,
)
if viz_output_dir is not None:
_try_generate_viz(
baseline=aligned_baseline,
target=aligned_target,
name=name,
viz_output_dir=viz_output_dir,
)
return record
def _try_generate_viz(
*,
baseline: torch.Tensor,
target: torch.Tensor,
name: str,
viz_output_dir: Path,
) -> None:
from sglang.srt.debug_utils.comparator.visualizer import (
generate_comparison_figure,
)
from sglang.srt.debug_utils.comparator.visualizer.preprocessing import (
_sanitize_filename,
)
filename: str = _sanitize_filename(name) + ".png"
output_path: Path = viz_output_dir / filename
try:
generate_comparison_figure(
baseline=baseline,
target=target,
name=name,
output_path=output_path,
)
except Exception as exc:
log_sink.add(
ErrorLog(
category="visualizer",
message=f"Visualization failed for {name}: {exc}",
)
)
def _resolve_seq_dim(tensor: torch.Tensor) -> Optional[int]:
"""Find the token/seq dimension index from the tensor's named dims."""
names: tuple[Optional[str], ...] = get_dim_names(tensor)
if names[0] is None:
return None
for target_name in (TOKEN_DIM_NAME, SEQ_DIM_NAME):
if target_name in names:
return list(names).index(target_name)
return None
def _compare_bundle_pair_non_tensor_type(
*,
name: str,
value_pair: Pair[list[ValueWithMeta]],
) -> ComparisonNonTensorRecord:
baseline_value: Any = value_pair.x[0].value
target_value: Any = value_pair.y[0].value
try:
values_equal: bool = bool(baseline_value == target_value)
except Exception:
values_equal = False
return ComparisonNonTensorRecord(
name=name,
baseline_value=repr(baseline_value),
target_value=repr(target_value),
baseline_type=type(baseline_value).__name__,
target_type=type(target_value).__name__,
values_equal=values_equal,
)
def _apply_dim_names_from_meta(
*,
tensors: list[torch.Tensor],
metas: list[dict[str, Any]],
) -> list[torch.Tensor]:
if not metas:
return tensors
dims_str: Optional[str] = metas[0].get("dims")
if dims_str is None:
return tensors
dim_names: list[str] = resolve_dim_names(dims_str)
return [apply_dim_names(t, dim_names) for t in tensors]
def _load_all_values(filenames: list[str], base_path: Path) -> list[ValueWithMeta]:
result: list[ValueWithMeta] = []
for f in filenames:
item: ValueWithMeta = ValueWithMeta.load(base_path / f)
if item.value is LOAD_FAILED:
log_sink.add(
ErrorLog(
category="load_failed",
message=f"Failed to load tensor file: {f}",
)
)
continue
result.append(item)
return result
@@ -0,0 +1,46 @@
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from typing import Any
import polars as pl
from sglang.srt.debug_utils.comparator.utils import Pair
from sglang.srt.debug_utils.dump_loader import filter_rows
@dataclass(frozen=True)
class TensorFileInfo:
filename: str
name: str
step: int
TensorBundleInfo = list[TensorFileInfo]
def match_bundles(
*,
dfs: Pair[pl.DataFrame],
skip_keys: set[str],
) -> list[Pair[TensorBundleInfo]]:
match_key_cols: list[str] = [c for c in dfs.y.columns if c not in skip_keys]
unique_keys: pl.DataFrame = dfs.y.select(match_key_cols).unique(maintain_order=True)
results: list[Pair[TensorBundleInfo]] = []
for key_values in unique_keys.iter_rows(named=True):
result = dfs.map(
lambda df: _rows_to_tensor_infos(filter_rows(df, conditions=key_values))
)
results.append(result)
return results
def _rows_to_tensor_infos(rows: list[dict[str, Any]]) -> list[TensorFileInfo]:
tensor_info_fields: set[str] = {f.name for f in dataclasses.fields(TensorFileInfo)}
return [
TensorFileInfo(**{k: v for k, v in row.items() if k in tensor_info_fields})
for row in rows
]
@@ -0,0 +1,51 @@
from sglang.srt.debug_utils.comparator.dims_spec.dim_parser import parse_dim
from sglang.srt.debug_utils.comparator.dims_spec.dims_parser import (
_SingletonDimUtil,
parse_dims,
resolve_dim_names,
)
from sglang.srt.debug_utils.comparator.dims_spec.tensor_naming import (
apply_dim_names,
find_dim_index,
get_dim_names,
resolve_dim_by_name,
without_dim_names,
)
from sglang.srt.debug_utils.comparator.dims_spec.types import (
_FUSED_NAME_SEP,
BATCH_DIM_NAME,
SEQ_DIM_NAME,
SQUEEZE_DIM_NAME,
TOKEN_DIM_NAME,
DimSpec,
DimsSpec,
Ordering,
ParallelAxis,
ParallelModifier,
Reduction,
TokenLayout,
)
__all__ = [
"BATCH_DIM_NAME",
"SEQ_DIM_NAME",
"SQUEEZE_DIM_NAME",
"TOKEN_DIM_NAME",
"DimsSpec",
"DimSpec",
"Ordering",
"ParallelAxis",
"ParallelModifier",
"Reduction",
"TokenLayout",
"_FUSED_NAME_SEP",
"_SingletonDimUtil",
"apply_dim_names",
"find_dim_index",
"get_dim_names",
"parse_dim",
"parse_dims",
"resolve_dim_by_name",
"resolve_dim_names",
"without_dim_names",
]
@@ -0,0 +1,59 @@
from __future__ import annotations
import re
from typing import NamedTuple, Optional
from sglang.srt.debug_utils.comparator.dims_spec.types import (
_AXIS_LOOKUP,
ParallelAxis,
)
_DP_ALIAS_PATTERN = re.compile(r"^dp:=(\w+)$")
_REPLICATED_PATTERN = re.compile(r"^(\w+):replicated$")
class _CommentSuffix(NamedTuple):
dp_group_alias: Optional[str] = None
replicated_axes: frozenset[ParallelAxis] = frozenset()
def _parse_comment_suffix(declaration_part: str) -> _CommentSuffix:
"""Parse the ``#`` comment section for dp alias and replicated declarations."""
dp_group_alias: Optional[str] = None
replicated_axes: set[ParallelAxis] = set()
for token in declaration_part.strip().split():
dp_match = _DP_ALIAS_PATTERN.match(token)
if dp_match is not None:
if dp_group_alias is not None:
raise ValueError(
f"Duplicate dp alias declaration: already have {dp_group_alias!r}, "
f"got {dp_match.group(1)!r}"
)
dp_group_alias = dp_match.group(1)
continue
repl_match = _REPLICATED_PATTERN.match(token)
if repl_match is not None:
axis_str: str = repl_match.group(1)
axis: Optional[ParallelAxis] = _AXIS_LOOKUP.get(axis_str)
if axis is None:
raise ValueError(
f"Unknown axis {axis_str!r} in replicated declaration: {token!r}"
)
if axis in replicated_axes:
raise ValueError(
f"Duplicate replicated declaration for axis {axis_str!r}"
)
replicated_axes.add(axis)
continue
raise ValueError(
f"Unrecognized token {token!r} in # comment section. "
f"Expected 'dp:=<group>' or '<axis>:replicated'."
)
return _CommentSuffix(
dp_group_alias=dp_group_alias,
replicated_axes=frozenset(replicated_axes),
)
@@ -0,0 +1,68 @@
from __future__ import annotations
import re
from typing import Optional
from sglang.srt.debug_utils.comparator.dims_spec.modifier_parser import (
_parse_modifiers,
)
from sglang.srt.debug_utils.comparator.dims_spec.types import (
SQUEEZE_DIM_NAME,
DimSpec,
ParallelModifier,
)
_DIM_PATTERN = re.compile(r"^(?P<name>[a-zA-Z_]\w*)(?:\[(?P<modifiers>[^\]]+)\])?$")
_FUSED_DIM_PATTERN = re.compile(r"^\((?P<inner>[^)]+)\)(?:\[(?P<modifiers>[^\]]+)\])?$")
_SUB_DIM_NAME_PATTERN = re.compile(r"^[a-zA-Z_]\w*$")
def parse_dim(token: str) -> DimSpec:
if token == SQUEEZE_DIM_NAME:
return DimSpec(name=SQUEEZE_DIM_NAME)
fused_match = _FUSED_DIM_PATTERN.match(token)
if fused_match is not None:
return _parse_fused_dim(token=token, fused_match=fused_match)
return _parse_single_dim(token)
def _parse_single_dim(token: str) -> DimSpec:
match = _DIM_PATTERN.match(token)
if match is None:
raise ValueError(f"Invalid dim token: {token!r}")
name: str = match.group("name")
modifiers: list[ParallelModifier] = _parse_modifiers(
modifiers_str=match.group("modifiers"), dim_token=token
)
return DimSpec(name=name, parallel_modifiers=modifiers)
def _parse_fused_dim(*, token: str, fused_match: re.Match[str]) -> DimSpec:
inner: str = fused_match.group("inner")
modifiers_str: Optional[str] = fused_match.group("modifiers")
sub_names: list[str] = [s.strip() for s in inner.split("*")]
for sub_name in sub_names:
if not _SUB_DIM_NAME_PATTERN.match(sub_name):
raise ValueError(
f"Invalid sub-dim {sub_name!r} in fused dim token: {token!r}"
)
if len(sub_names) != len(set(sub_names)):
raise ValueError(f"Duplicate sub-dim names in fused dim token: {token!r}")
if len(sub_names) < 2:
raise ValueError(
f"Fused dim must have at least 2 sub-dims, got {len(sub_names)} in: {token!r}"
)
fused_name: str = "*".join(sub_names)
modifiers: list[ParallelModifier] = _parse_modifiers(
modifiers_str=modifiers_str, dim_token=token
)
return DimSpec(name=fused_name, parallel_modifiers=modifiers)
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Optional
from sglang.srt.debug_utils.comparator.dims_spec.comment_parser import (
_CommentSuffix,
_parse_comment_suffix,
)
from sglang.srt.debug_utils.comparator.dims_spec.dim_parser import parse_dim
from sglang.srt.debug_utils.comparator.dims_spec.types import (
SQUEEZE_DIM_NAME,
DimSpec,
DimsSpec,
ParallelAxis,
)
class _SingletonDimUtil:
"""Utilities for squeeze dims (name="1") and their singleton tensor-name mapping."""
PREFIX: str = "singleton"
@staticmethod
def is_squeeze(spec: DimSpec) -> bool:
return spec.name == SQUEEZE_DIM_NAME
@staticmethod
def filter_out(dim_specs: list[DimSpec]) -> list[DimSpec]:
return [s for s in dim_specs if not _SingletonDimUtil.is_squeeze(s)]
@staticmethod
def make_name(index: int) -> str:
return f"{_SingletonDimUtil.PREFIX}{index}"
@staticmethod
def is_singleton_name(name: str) -> bool:
return (
name.startswith(_SingletonDimUtil.PREFIX)
and name[len(_SingletonDimUtil.PREFIX) :].isdigit()
)
@staticmethod
def sanitize_names(names: list[str]) -> list[str]:
"""Replace '1' with 'singleton0', 'singleton1', ... for named tensor compatibility."""
result: list[str] = []
sq_idx: int = 0
for name in names:
if name == SQUEEZE_DIM_NAME:
result.append(_SingletonDimUtil.make_name(sq_idx))
sq_idx += 1
else:
result.append(name)
return result
def parse_dims(dims_str: str) -> DimsSpec:
"""Parse ``"b s[cp:zigzag] h[tp] d # dp:=moe_dp ep:replicated"`` → :class:`DimsSpec`.
The shape part (before ``#``) produces :pyattr:`DimsSpec.dims`.
The declaration part (after ``#``) is scanned for:
- ``dp:=<group>`` → :pyattr:`DimsSpec.dp_group_alias`
- ``axis:replicated`` → :pyattr:`DimsSpec.replicated_axes`
"""
parts: list[str] = dims_str.split("#", maxsplit=1)
raw: str = parts[0]
if not raw.strip():
raise ValueError("dims string must not be empty")
dims: list[DimSpec] = [parse_dim(token) for token in raw.strip().split()]
# Collect all semantic names (expanding fused sub-dims) for duplicate detection
semantic_names: list[str] = []
for spec in dims:
if _SingletonDimUtil.is_squeeze(spec):
continue
semantic_names.extend(spec.sub_dims)
if len(semantic_names) != len(set(semantic_names)):
duplicates = sorted({n for n in semantic_names if semantic_names.count(n) > 1})
raise ValueError(f"Duplicate dim names: {duplicates}")
comment_suffix: _CommentSuffix = (
_parse_comment_suffix(parts[1]) if len(parts) > 1 else _CommentSuffix()
)
dp_group_alias: Optional[str] = comment_suffix.dp_group_alias
replicated_axes: frozenset[ParallelAxis] = comment_suffix.replicated_axes
sharded_axes: set[ParallelAxis] = {
m.axis for spec in dims for m in spec.parallel_modifiers
}
conflict: frozenset[ParallelAxis] = replicated_axes & sharded_axes
if conflict:
conflict_names: str = ", ".join(sorted(a.value for a in conflict))
raise ValueError(
f"Axes declared as both sharded (in dim spec) and replicated "
f"(in # declaration): {conflict_names}"
)
return DimsSpec(
dims=dims,
dp_group_alias=dp_group_alias,
replicated_axes=replicated_axes,
)
def resolve_dim_names(dims_str: str) -> list[str]:
"""Parse dims string and return tensor-compatible names ('1''singleton0', ...)."""
specs: list[DimSpec] = parse_dims(dims_str).dims
names: list[str] = [spec.sanitized_name for spec in specs]
return _SingletonDimUtil.sanitize_names(names)
@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Optional
from sglang.srt.debug_utils.comparator.dims_spec.types import (
_AXIS_LOOKUP,
_QUALIFIER_LOOKUP,
Ordering,
ParallelAxis,
ParallelModifier,
Reduction,
)
def _parse_modifier_token(modifier_token: str, dim_token: str) -> ParallelModifier:
"""Parse 'sp', 'cp:zigzag', 'tp:partial', or 'cp:zigzag+partial' → ParallelModifier.
Format: ``axis`` or ``axis:qual`` or ``axis:qual+qual``.
Colon separates axis from qualifiers; ``+`` separates multiple qualifiers.
"""
axis_str: str
qualifiers_str: str
if ":" in modifier_token:
axis_str, qualifiers_str = modifier_token.split(":", maxsplit=1)
else:
axis_str, qualifiers_str = modifier_token, ""
axis_str = axis_str.strip()
axis: Optional[ParallelAxis] = _AXIS_LOOKUP.get(axis_str)
if axis is None:
raise ValueError(
f"Unknown axis {axis_str!r} in modifier {modifier_token!r} "
f"of dim spec: {dim_token!r}"
)
ordering: Optional[Ordering] = None
reduction: Optional[Reduction] = None
for q_str in (q.strip() for q in qualifiers_str.split("+") if q.strip()):
if q_str == "sharded":
continue
qualifier: Optional[Ordering | Reduction] = _QUALIFIER_LOOKUP.get(q_str)
if qualifier is None:
raise ValueError(
f"Unknown qualifier {q_str!r} in modifier "
f"{modifier_token!r} of dim spec: {dim_token!r}"
)
if isinstance(qualifier, Ordering):
if ordering is not None:
raise ValueError(
f"Multiple ordering values in modifier "
f"{modifier_token!r} of dim spec: {dim_token!r}"
)
ordering = qualifier
else:
if reduction is not None:
raise ValueError(
f"Multiple reduction values in modifier "
f"{modifier_token!r} of dim spec: {dim_token!r}"
)
reduction = qualifier
return ParallelModifier(axis=axis, ordering=ordering, reduction=reduction)
def _parse_modifiers(
*, modifiers_str: Optional[str], dim_token: str
) -> list[ParallelModifier]:
if modifiers_str is None:
return []
modifiers: list[ParallelModifier] = []
seen_axes: set[ParallelAxis] = set()
for modifier_token in (p.strip() for p in modifiers_str.split(",")):
modifier: ParallelModifier = _parse_modifier_token(modifier_token, dim_token)
if modifier.axis in seen_axes:
raise ValueError(
f"Duplicate axis {modifier.axis.value!r} in dim spec: {dim_token!r}"
)
seen_axes.add(modifier.axis)
modifiers.append(modifier)
return modifiers
@@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.dims_spec.types import DimSpec
_DIM_NAMES_ATTR = "_dim_names"
def find_dim_index(dim_specs: list[DimSpec], name: str) -> Optional[int]:
"""Find index by name. Accepts both ``*``-form and ``___``-form for fused dims."""
for i, spec in enumerate(dim_specs):
if spec.name == name or spec.sanitized_name == name:
return i
return None
def get_dim_names(tensor: torch.Tensor) -> tuple[Optional[str], ...]:
"""Get dimension names attached to a tensor.
Returns a tuple of ``None`` values if no names are attached.
"""
names = getattr(tensor, _DIM_NAMES_ATTR, None)
if names is not None:
return names
return (None,) * tensor.ndim
def resolve_dim_by_name(tensor: torch.Tensor, name: str) -> int:
names = get_dim_names(tensor)
if names[0] is None:
raise ValueError(f"Tensor has no names, cannot resolve {name!r}")
try:
return list(names).index(name)
except ValueError:
raise ValueError(f"Dim name {name!r} not in tensor names {names}")
def apply_dim_names(tensor: torch.Tensor, dim_names: list[str]) -> torch.Tensor:
if tensor.ndim != len(dim_names):
raise ValueError(
f"dims metadata mismatch: tensor has {tensor.ndim} dims (shape {list(tensor.shape)}) "
f"but dims string specifies {len(dim_names)} names {dim_names}. "
f"Please fix the dims string in the dumper.dump() call to match the actual tensor shape."
)
view = torch.ops.aten.alias(tensor)
view._dim_names = tuple(dim_names)
return view
def without_dim_names(tensor: torch.Tensor) -> torch.Tensor:
# Returns a new view without _dim_names; the original tensor is not modified.
return torch.ops.aten.alias(tensor)
@@ -0,0 +1,94 @@
from __future__ import annotations
from enum import Enum
from typing import Optional
from sglang.srt.debug_utils.comparator.utils import _FrozenBase
TOKEN_DIM_NAME: str = "t"
BATCH_DIM_NAME: str = "b"
SEQ_DIM_NAME: str = "s"
SQUEEZE_DIM_NAME: str = "1"
class TokenLayout(Enum):
T = "t" # single flat token dim
BS = "bs" # separate batch + seq dims, need collapse
# TODO: allow arbitrary string
class ParallelAxis(Enum):
TP = "tp"
CP = "cp"
EP = "ep"
SP = "sp"
DP = "dp"
ETP = "etp"
EDP = "edp"
ATTN_TP = "attn_tp"
ATTN_DP = "attn_dp"
MOE_EP = "moe_ep"
MOE_TP = "moe_tp"
MOE_DP = "moe_dp"
RECOMPUTE_PSEUDO = "recompute_pseudo"
class Ordering(Enum):
ZIGZAG = "zigzag"
NATURAL = "natural"
class Reduction(Enum):
PARTIAL = "partial"
class ParallelModifier(_FrozenBase):
axis: ParallelAxis
ordering: Optional[Ordering] = None
reduction: Optional[Reduction] = None
_AXIS_LOOKUP: dict[str, ParallelAxis] = {m.value: m for m in ParallelAxis}
_QUALIFIER_LOOKUP: dict[str, Ordering | Reduction] = {
**{m.value: m for m in Ordering},
**{m.value: m for m in Reduction},
}
_FUSED_NAME_SEP: str = "___"
class DimSpec(_FrozenBase):
name: str
parallel_modifiers: list[ParallelModifier] = []
@property
def sub_dims(self) -> list[str]:
"""Sub-dim names. Fused: ``["num_heads", "head_dim"]``; plain: ``["h"]``."""
return self.name.split("*")
@property
def is_fused(self) -> bool:
return len(self.sub_dims) > 1
@property
def sanitized_name(self) -> str:
"""Name safe for PyTorch named tensors (``*`` → ``___``)."""
if self.is_fused:
return _FUSED_NAME_SEP.join(self.sub_dims)
return self.name
class DimsSpec(_FrozenBase):
"""Parsed result of a full dims string like ``"b s h[tp] # dp:=moe_dp"``."""
dims: list[DimSpec]
dp_group_alias: Optional[str] = None
replicated_axes: frozenset[ParallelAxis] = frozenset()
@property
def dp_axis(self) -> ParallelAxis:
return (
ParallelAxis(self.dp_group_alias)
if self.dp_group_alias
else ParallelAxis.DP
)
@@ -0,0 +1,144 @@
from __future__ import annotations
from collections import defaultdict
from io import StringIO
from pathlib import Path
from typing import Any, Optional
import polars as pl
from sglang.srt.debug_utils.comparator.output_types import (
InputIdsRecord,
RankInfoRecord,
)
from sglang.srt.debug_utils.comparator.report_sink import report_sink
from sglang.srt.debug_utils.dump_loader import LOAD_FAILED, ValueWithMeta
PARALLEL_INFO_KEYS: list[str] = ["sglang_parallel_info", "megatron_parallel_info"]
def emit_display_records(
*,
df: pl.DataFrame,
dump_dir: Path,
label: str,
tokenizer: Any,
) -> None:
rank_rows: Optional[list[dict[str, Any]]] = _collect_rank_info(
df, dump_dir=dump_dir
)
if rank_rows is not None:
report_sink.add(RankInfoRecord(label=label, rows=rank_rows))
input_ids_rows: Optional[list[dict[str, Any]]] = _collect_input_ids_and_positions(
df, dump_dir=dump_dir, tokenizer=tokenizer
)
if input_ids_rows is not None:
report_sink.add(InputIdsRecord(label=label, rows=input_ids_rows))
def _render_polars_as_text(df: pl.DataFrame, *, title: Optional[str] = None) -> str:
from rich.console import Console
from rich.table import Table
table = Table(title=title)
for col in df.columns:
table.add_column(col)
for row in df.iter_rows():
table.add_row(*[str(v) for v in row])
buf = StringIO()
Console(file=buf, force_terminal=False, width=200).print(table)
return buf.getvalue().rstrip("\n")
def _render_polars_as_rich_table(
df: pl.DataFrame, *, title: Optional[str] = None
) -> Any:
from rich.table import Table
table = Table(title=title)
for col in df.columns:
table.add_column(col)
for row in df.iter_rows():
table.add_row(*[str(v) for v in row])
return table
def _collect_rank_info(
df: pl.DataFrame, dump_dir: Path
) -> Optional[list[dict[str, Any]]]:
unique_rows: pl.DataFrame = (
df.filter(pl.col("name") == "input_ids")
.sort("rank")
.unique(subset=["rank"], keep="first")
)
if unique_rows.is_empty():
return None
table_rows: list[dict[str, Any]] = []
for row in unique_rows.to_dicts():
meta: dict[str, Any] = ValueWithMeta.load(dump_dir / row["filename"]).meta
row_data: dict[str, Any] = {"rank": row["rank"]}
for key in PARALLEL_INFO_KEYS:
_extract_parallel_info(row_data=row_data, info=meta.get(key, {}))
table_rows.append(row_data)
return table_rows or None
def _collect_input_ids_and_positions(
df: pl.DataFrame,
dump_dir: Path,
*,
tokenizer: Any = None,
) -> Optional[list[dict[str, Any]]]:
filtered: pl.DataFrame = df.filter(pl.col("name").is_in(["input_ids", "positions"]))
if filtered.is_empty():
return None
data_by_step_rank: dict[tuple[int, int], dict[str, Any]] = defaultdict(dict)
for row in filtered.to_dicts():
key: tuple[int, int] = (row["step"], row["rank"])
item: ValueWithMeta = ValueWithMeta.load(dump_dir / row["filename"])
if item.value is not LOAD_FAILED:
data_by_step_rank[key][row["name"]] = item.value
table_rows: list[dict[str, Any]] = []
for (step, rank), data in sorted(data_by_step_rank.items()):
ids = data.get("input_ids")
pos = data.get("positions")
ids_list: Optional[list[int]] = (
ids.flatten().tolist() if ids is not None else None
)
row_data: dict[str, Any] = {
"step": step,
"rank": rank,
"num_tokens": len(ids_list) if ids_list is not None else None,
"input_ids": str(ids_list) if ids_list is not None else "N/A",
"positions": str(pos.flatten().tolist()) if pos is not None else "N/A",
}
if tokenizer is not None and ids_list is not None:
row_data["decoded_text"] = repr(
tokenizer.decode(ids_list, skip_special_tokens=False)
)
table_rows.append(row_data)
return table_rows or None
def _extract_parallel_info(row_data: dict[str, Any], info: dict[str, Any]) -> None:
if not info or info.get("error"):
return
for key in sorted(info.keys()):
if key.endswith("_rank"):
base: str = key[:-5]
size_key: str = f"{base}_size"
if size_key in info:
row_data[base] = f"{info[key]}/{info[size_key]}"
@@ -0,0 +1,100 @@
"""DP filtering: keep only the non-empty dp_rank items."""
from __future__ import annotations
from collections import defaultdict
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.dims_spec import ParallelAxis
from sglang.srt.debug_utils.dump_loader import ValueWithMeta
_PARALLEL_INFO_KEYS = ("sglang_parallel_info", "megatron_parallel_info")
def filter_to_non_empty_dp_rank(
items: list[ValueWithMeta],
*,
dp_axis: ParallelAxis,
) -> list[ValueWithMeta]:
"""Filter items to the single non-empty dp_rank.
- dp_size <= 1: return items unchanged.
- dp_size > 1: group by dp_rank, assert exactly one group has non-empty
tensors, return that group.
*dp_axis* determines which rank/size fields to look up (e.g.
``ParallelAxis.MOE_DP`` → ``moe_dp_rank`` / ``moe_dp_size``).
If the fields are absent the filter is a noop (items returned unchanged).
"""
if not items:
return items
dp_info: Optional[tuple[int, int]] = _extract_dp_info(
items[0].meta, dp_axis=dp_axis
)
if dp_info is None:
return items
_dp_rank, dp_size = dp_info
if dp_size <= 1:
return items
has_any_tensor: bool = any(isinstance(item.value, torch.Tensor) for item in items)
if not has_any_tensor:
return items
groups: dict[int, list[ValueWithMeta]] = defaultdict(list)
for item in items:
item_dp: Optional[tuple[int, int]] = _extract_dp_info(
item.meta, dp_axis=dp_axis
)
rank: int = item_dp[0] if item_dp is not None else 0
groups[rank].append(item)
non_empty_ranks: list[int] = [
rank for rank, group in groups.items() if _group_has_data(group)
]
assert len(non_empty_ranks) == 1, (
f"Expected exactly 1 non-empty dp_rank, got {len(non_empty_ranks)}: "
f"ranks={non_empty_ranks}"
)
return groups[non_empty_ranks[0]]
def _extract_dp_info(
meta: dict,
*,
dp_axis: ParallelAxis,
) -> Optional[tuple[int, int]]:
"""Extract (dp_rank, dp_size) from meta's parallel_info block.
*dp_axis* determines which fields to look up: e.g.
``ParallelAxis.DP`` → ``dp_rank``/``dp_size``,
``ParallelAxis.MOE_DP`` → ``moe_dp_rank``/``moe_dp_size``.
"""
rank_field: str = f"{dp_axis.value}_rank"
size_field: str = f"{dp_axis.value}_size"
for key in _PARALLEL_INFO_KEYS:
info = meta.get(key)
if not isinstance(info, dict) or not info:
continue
dp_rank = info.get(rank_field)
dp_size = info.get(size_field)
if dp_rank is not None and dp_size is not None:
return (int(dp_rank), int(dp_size))
return None
def _group_has_data(group: list[ValueWithMeta]) -> bool:
"""Check if any tensor in the group is non-empty (numel > 0)."""
return any(
isinstance(item.value, torch.Tensor) and item.value.numel() > 0
for item in group
)
@@ -0,0 +1,475 @@
from __future__ import annotations
import argparse
import sys
import traceback as _traceback_module
from pathlib import Path
from typing import Any, Iterator, Optional, Union
import polars as pl
from sglang.srt.debug_utils.comparator.aligner.token_aligner.entrypoint import (
TokenAlignerResult,
compute_maybe_token_aligner_result,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.aux_loader import (
AUX_NAMES,
)
from sglang.srt.debug_utils.comparator.aligner.token_aligner.smart.types import (
TokenAlignerPlan,
)
from sglang.srt.debug_utils.comparator.bundle_comparator import compare_bundle_pair
from sglang.srt.debug_utils.comparator.bundle_matcher import (
TensorBundleInfo,
match_bundles,
)
from sglang.srt.debug_utils.comparator.display import emit_display_records
from sglang.srt.debug_utils.comparator.meta_overrider import MetaOverrider
from sglang.srt.debug_utils.comparator.output_types import (
ComparisonErrorRecord,
ComparisonNonTensorRecord,
ComparisonSkipRecord,
ComparisonTensorRecord,
ConfigRecord,
RecordLocation,
SummaryRecord,
)
from sglang.srt.debug_utils.comparator.per_token_visualizer import (
generate_per_token_heatmap,
)
from sglang.srt.debug_utils.comparator.preset import PRESETS, expand_preset
from sglang.srt.debug_utils.comparator.report_sink import report_sink
from sglang.srt.debug_utils.comparator.tensor_comparator.comparator import (
DEFAULT_PREDICATE,
FailureDisplayBudget,
)
from sglang.srt.debug_utils.comparator.threshold_dsl import (
DiffThresholdRule,
parse_diff_threshold_rules,
)
from sglang.srt.debug_utils.comparator.utils import (
Pair,
auto_descend_dir,
compute_exit_code,
)
from sglang.srt.debug_utils.dump_loader import read_meta, read_tokenizer_path
_DEFAULT_SKIP_KEYS: set[str] = {"dump_index", "filename"}
_DIMS_DEBUG_HINT: str = (
"\nHint: If this is a dims annotation issue, do NOT re-run expensive dumps.\n"
"Use --override-dims at comparison time, e.g.:\n"
' python -m sglang.srt.debug_utils.comparator --override-dims "tensor_name:b s h[tp] d"\n'
"(Use --override-baseline-dims / --override-target-dims for per-side overrides.\n"
" Use --override-config for bulk overrides via YAML file.)"
)
def main() -> None:
args = parse_args(sys.argv[1:])
sys.exit(run(args))
def run(args: argparse.Namespace) -> int:
report_sink.configure(
output_format=args.output_format,
report_path=None,
verbosity=args.verbosity,
)
dir_pair: Pair[Path] = Pair(
x=auto_descend_dir(Path(args.baseline_path), label="baseline_path"),
y=auto_descend_dir(Path(args.target_path), label="target_path"),
)
viz_output_dir: Optional[Path] = (
Path(args.viz_output_dir) if args.viz_bundle_details else None
)
visualize_per_token: Optional[Path] = (
Path(args.visualize_per_token) if args.visualize_per_token else None
)
override_config: Optional[Path] = (
Path(args.override_config) if args.override_config else None
)
report_path: Optional[Path] = _resolve_report_path(
target_path=dir_pair.y,
report_path_arg=args.report_path,
)
report_sink.configure(
output_format=args.output_format,
report_path=report_path,
verbosity=args.verbosity,
)
try:
report_sink.add(ConfigRecord(config=vars(args)))
dfs: Pair[pl.DataFrame] = _read_df(
dir_pair=dir_pair,
start_step=args.start_step,
end_step=args.end_step,
filter_pattern=args.filter,
)
tokenizer: Any = _maybe_load_tokenizer(
tokenizer_arg=args.tokenizer, dir_pair=dir_pair
)
for label, df, dump_dir in [
("baseline", dfs.x, dir_pair.x),
("target", dfs.y, dir_pair.y),
]:
emit_display_records(
df=df, dump_dir=dump_dir, label=label, tokenizer=tokenizer
)
ta_result: TokenAlignerResult = compute_maybe_token_aligner_result(
dir_pair=dir_pair,
dfs=dfs,
token_aligner_mode=args.token_aligner,
)
if ta_result.mode == "smart":
dfs = dfs.map(lambda df: df.filter(~pl.col("name").is_in(AUX_NAMES)))
skip_keys: set[str] = _DEFAULT_SKIP_KEYS | set(args.grouping_skip_keys or [])
bundle_info_pairs: list[Pair[TensorBundleInfo]] = match_bundles(
dfs=dfs, skip_keys=skip_keys
)
meta_overrider: MetaOverrider = MetaOverrider.from_args_and_config(
override_dims=args.override_dims,
override_baseline_dims=args.override_baseline_dims,
override_target_dims=args.override_target_dims,
override_config=override_config,
)
comparison_records = _compare_bundle_pairs(
bundle_info_pairs=bundle_info_pairs,
dir_pair=dir_pair,
token_aligner_mode=ta_result.mode,
token_aligner_plan=ta_result.plan,
diff_threshold_rules=parse_diff_threshold_rules(
args.diff_threshold, default_predicate=DEFAULT_PREDICATE
),
failure_display_budget=FailureDisplayBudget(),
thd_seq_lens_by_step_pair=ta_result.thd_seq_lens_by_step_pair,
viz_output_dir=viz_output_dir,
compute_per_token=visualize_per_token is not None,
meta_overrider=meta_overrider,
)
summary, skipped_names, failed_names, errored_names = (
_consume_comparison_records(
comparison_records=comparison_records,
visualize_per_token=visualize_per_token,
)
)
return compute_exit_code(
summary,
allow_skipped_pattern=args.allow_skipped_pattern,
skipped_names=skipped_names,
allow_failed_pattern=args.allow_failed_pattern,
failed_names=failed_names,
errored_names=errored_names,
)
finally:
report_sink.close()
if report_path is not None:
print(f"Report: {report_path}", file=sys.stderr)
def _resolve_report_path(
*, target_path: Path, report_path_arg: Optional[str]
) -> Optional[Path]:
if report_path_arg is not None:
return Path(report_path_arg) if report_path_arg else None
return target_path / "comparator_report.jsonl"
def _maybe_load_tokenizer(*, tokenizer_arg: Optional[str], dir_pair: Pair[Path]) -> Any:
tokenizer_path: Optional[str] = tokenizer_arg
if tokenizer_path is None:
for directory in [dir_pair.x, dir_pair.y]:
tokenizer_path = read_tokenizer_path(directory)
if tokenizer_path is not None:
break
if tokenizer_path is None:
return None
try:
from transformers import AutoTokenizer
return AutoTokenizer.from_pretrained(tokenizer_path)
except Exception:
return None
def _read_df(
*,
dir_pair: Pair[Path],
start_step: int,
end_step: int,
filter_pattern: Optional[str],
) -> Pair[pl.DataFrame]:
df_baseline = read_meta(dir_pair.x)
df_target = read_meta(dir_pair.y)
df_target = df_target.filter(
(pl.col("step") >= start_step) & (pl.col("step") <= end_step)
)
if filter_pattern:
df_target = df_target.filter(pl.col("filename").str.contains(filter_pattern))
assert all(c in df_target.columns for c in ["rank", "step", "dump_index", "name"])
return Pair(x=df_baseline, y=df_target)
def _compare_bundle_pairs(
*,
bundle_info_pairs: list[Pair[TensorBundleInfo]],
dir_pair: Pair[Path],
token_aligner_mode: Optional[str],
token_aligner_plan: Optional[TokenAlignerPlan],
diff_threshold_rules: Optional[list[DiffThresholdRule]] = None,
failure_display_budget: Optional[FailureDisplayBudget] = None,
thd_seq_lens_by_step_pair: Pair[Optional[dict[int, list[int]]]],
viz_output_dir: Optional[Path] = None,
compute_per_token: bool = False,
meta_overrider: Optional[MetaOverrider] = None,
) -> Iterator[
Union[
ComparisonTensorRecord,
ComparisonSkipRecord,
ComparisonNonTensorRecord,
ComparisonErrorRecord,
]
]:
for bundle_info_pair in bundle_info_pairs:
if not bundle_info_pair.y:
continue
name: str = bundle_info_pair.y[0].name
filenames_pair: Pair[list[str]] = bundle_info_pair.map(
lambda infos: [info.filename for info in infos]
)
record: Union[
ComparisonTensorRecord,
ComparisonSkipRecord,
ComparisonNonTensorRecord,
ComparisonErrorRecord,
]
try:
record = compare_bundle_pair(
name=name,
filenames_pair=filenames_pair,
dir_pair=dir_pair,
token_aligner_mode=token_aligner_mode,
token_aligner_plan=token_aligner_plan,
diff_threshold_rules=diff_threshold_rules,
failure_display_budget=failure_display_budget,
thd_seq_lens_by_step_pair=thd_seq_lens_by_step_pair,
viz_output_dir=viz_output_dir,
compute_per_token=compute_per_token,
meta_overrider=meta_overrider,
)
except Exception as exc:
tb = _traceback_module.format_exc()
record = ComparisonErrorRecord(
name=name,
exception_type=type(exc).__name__,
exception_message=str(exc),
traceback_str=f"{_DIMS_DEBUG_HINT}\n\n{tb}",
)
target_steps: set[int] = {info.step for info in bundle_info_pair.y}
step: Optional[int] = target_steps.pop() if len(target_steps) == 1 else None
if step is not None:
record = record.model_copy(update={"location": RecordLocation(step=step)})
yield record
def _consume_comparison_records(
*,
comparison_records: Iterator[
Union[
ComparisonTensorRecord,
ComparisonSkipRecord,
ComparisonNonTensorRecord,
ComparisonErrorRecord,
]
],
visualize_per_token: Optional[Path] = None,
) -> tuple[SummaryRecord, list[str], list[str], list[str]]:
counts: dict[str, int] = {"passed": 0, "failed": 0, "skipped": 0, "errored": 0}
collected_comparisons: list[ComparisonTensorRecord] = []
skipped_names: list[str] = []
failed_names: list[str] = []
errored_names: list[str] = []
for record in comparison_records:
counts[record.category] += 1
report_sink.add(record)
if isinstance(record, ComparisonSkipRecord) and record.category == "skipped":
skipped_names.append(record.name)
if record.category == "failed":
failed_names.append(record.name)
if isinstance(record, ComparisonErrorRecord):
errored_names.append(record.name)
if visualize_per_token is not None and isinstance(
record, ComparisonTensorRecord
):
collected_comparisons.append(record)
summary: SummaryRecord = SummaryRecord(total=sum(counts.values()), **counts)
report_sink.add(summary)
if visualize_per_token is not None and collected_comparisons:
generate_per_token_heatmap(
records=collected_comparisons,
output_path=visualize_per_token,
)
return summary, skipped_names, failed_names, errored_names
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse CLI arguments from an argv list. Applies preset expansion."""
argv = expand_preset(argv, presets=PRESETS)
parser = argparse.ArgumentParser()
parser.add_argument("--baseline-path", type=str)
parser.add_argument("--target-path", type=str)
parser.add_argument("--start-step", type=int, default=0)
parser.add_argument("--end-step", type=int, default=1000000)
parser.add_argument(
"--diff-threshold",
nargs="*",
default=None,
metavar="REGEX PREDICATE",
help="Per-tensor pass criterion. Either a single float shorthand "
"(0.0085 == '.*' 'rel <= 0.0085'), or (regex predicate) pairs, e.g. "
"--diff-threshold '.*expert.*' 'rel <= 0.0085 or max_abs <= 1e-3' '.*' 'rel <= 0.0085'. "
"A tensor uses the first fullmatching regex's predicate -- a boolean expression "
"over rel/max_abs/mean_abs with < <= > >= and and/or. A tensor matching no "
"pattern is an error. Default: 'rel <= 1e-3' for every tensor.",
)
parser.add_argument(
"--filter", type=str, default=None, help="Regex to filter filenames (include)"
)
parser.add_argument(
"--output-format",
type=str,
choices=["text", "json"],
default="text",
help="Output format: text (default) or json (JSONL, one JSON object per line)",
)
parser.add_argument(
"--verbosity",
type=str,
choices=["minimal", "normal", "verbose"],
default="normal",
help="Output verbosity: minimal (1 line per tensor), normal (compact lifecycle), "
"verbose (full detail). Default: normal",
)
parser.add_argument(
"--preset",
type=str,
choices=list(PRESETS.keys()),
default=None,
help="Preset configuration (expanded before parsing). "
f"Available: {list(PRESETS.keys())}",
)
parser.add_argument(
"--grouping-skip-keys",
nargs="*",
default=None,
help="Metadata keys to skip when grouping bundles (additive on top of "
"always-skipped dump_index and filename). "
"E.g. '--grouping-skip-keys rank step' skips rank and step.",
)
parser.add_argument(
"--token-aligner",
type=str,
choices=["smart", "concat_steps"],
default=None,
help="Token aligner mode: concat_steps (BS=1, no aux needed) or smart (BS>1, sequence matching). "
"Default None (per-step comparison).",
)
parser.add_argument(
"--tokenizer",
type=str,
default=None,
help="Tokenizer path for decoding input_ids (auto-discovered from dump metadata if not set)",
)
parser.add_argument(
"--viz-bundle-details",
action="store_true",
default=False,
help="Generate comparison heatmap/histogram PNG for each compared tensor",
)
parser.add_argument(
"--viz-output-dir",
type=str,
default="/tmp/comparator_viz/",
help="Output directory for visualization PNGs (default: /tmp/comparator_viz/)",
)
parser.add_argument(
"--visualize-per-token",
type=str,
default=None,
help="Output path for per-token relative difference heatmap PNG",
)
# Dims override
parser.add_argument(
"--override-dims",
action="append",
default=[],
help="Override dims for both sides: 'name:dims_string' (repeatable)",
)
parser.add_argument(
"--override-baseline-dims",
action="append",
default=[],
help="Override dims for baseline only: 'name:dims_string' (repeatable)",
)
parser.add_argument(
"--override-target-dims",
action="append",
default=[],
help="Override dims for target only: 'name:dims_string' (repeatable)",
)
parser.add_argument(
"--override-config",
type=str,
default=None,
help="Path to YAML override config file (dims overrides, etc.)",
)
parser.add_argument(
"--allow-skipped-pattern",
type=str,
default=".*",
help="Regex pattern for tensor names allowed to be skipped. "
"Default '.*' allows all skips. Use '^$' to forbid all skips.",
)
parser.add_argument(
"--allow-failed-pattern",
type=str,
default=None,
help="Regex pattern for tensor names allowed to fail without affecting exit code. "
"Default None (all failures affect exit code).",
)
# Report output
parser.add_argument(
"--report-path",
type=str,
default=None,
help="Path for JSONL report (default: <target-path>/comparator_report.jsonl). "
"Pass empty string '' to disable.",
)
return parser.parse_args(argv)
@@ -0,0 +1,37 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Generator
from sglang.srt.debug_utils.comparator.output_types import BaseLog
class LogSink:
def __init__(self) -> None:
self._stack: list[list[BaseLog]] = []
@contextmanager
def context(self) -> Generator[list[BaseLog], None, None]:
bucket: list[BaseLog] = []
self._stack.append(bucket)
try:
yield bucket
finally:
popped = self._stack.pop()
assert popped is bucket
def add(self, log: BaseLog) -> None:
if self._stack:
self._stack[-1].append(log)
else:
from sglang.srt.debug_utils.comparator.output_types import (
LogRecord,
_split_logs,
)
from sglang.srt.debug_utils.comparator.report_sink import report_sink
errors, infos = _split_logs([log])
report_sink.add(LogRecord(errors=errors, infos=infos))
log_sink = LogSink()
@@ -0,0 +1,107 @@
"""Meta overrider: replace metadata fields without re-running dumps.
Currently only overrides 'dims', but the design supports overriding
additional meta fields (e.g. parallel_info) in the future.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Literal, Optional
import yaml
from sglang.srt.debug_utils.comparator.utils import _StrictBase
class MetaOverrideRule(_StrictBase):
"""Single override rule: regex match on tensor name → replacement meta field(s).
Currently only 'dims' is supported; more fields may be added in the future.
"""
match: str
dims: str
side: Literal["both", "baseline", "target"] = "both"
class MetaOverrideConfig(_StrictBase):
"""YAML top-level config for overriding comparator behavior."""
overrides: list[MetaOverrideRule] = []
class MetaOverrider:
"""Holds override rules and applies first-match-wins replacement."""
def __init__(self, rules: list[MetaOverrideRule]) -> None:
self._rules: list[MetaOverrideRule] = rules
@property
def is_empty(self) -> bool:
return len(self._rules) == 0
@classmethod
def from_args_and_config(
cls,
*,
override_dims: list[str],
override_baseline_dims: list[str],
override_target_dims: list[str],
override_config: Optional[Path],
) -> MetaOverrider:
per_side_args: list[tuple[list[str], Literal["both", "baseline", "target"]]] = [
(override_dims, "both"),
(override_baseline_dims, "baseline"),
(override_target_dims, "target"),
]
cli_rules: list[MetaOverrideRule] = [
MetaOverrideRule(match=name, dims=dims_str, side=side)
for raw_args, side in per_side_args
for name, dims_str in [_parse_cli_override_arg(raw) for raw in raw_args]
]
yaml_rules: list[MetaOverrideRule] = (
_load_yaml_rules(override_config) if override_config is not None else []
)
return cls(rules=cli_rules + yaml_rules)
def apply_to_meta(
self,
*,
name: str,
meta: dict[str, Any],
side: Literal["baseline", "target"],
) -> dict[str, Any]:
"""First-match-wins: return meta with dims replaced by the first matching rule for this side."""
for rule in self._rules:
if rule.side not in ("both", side):
continue
if re.search(rule.match, name):
return {**meta, "dims": rule.dims}
return meta
def _parse_cli_override_arg(raw: str) -> tuple[str, str]:
"""Parse 'name:dims_string' from a CLI --override-* argument."""
parts: list[str] = raw.split(":", maxsplit=1)
if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
raise ValueError(
f"Invalid override format: {raw!r}; expected 'name:dims_string'"
)
return parts[0].strip(), parts[1].strip()
def _load_yaml_rules(path: Path) -> list[MetaOverrideRule]:
"""Load override rules from a YAML config file."""
with open(path) as f:
raw_data: Any = yaml.safe_load(f)
if raw_data is None:
return []
config: MetaOverrideConfig = MetaOverrideConfig.model_validate(raw_data)
return config.overrides
@@ -0,0 +1,398 @@
"""Formatting functions for comparator output records.
Extracted from output_types.py to separate data-structure definitions
from rendering / formatting logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from rich.console import Group
from rich.markup import escape
from rich.panel import Panel
from sglang.srt.debug_utils.comparator.tensor_comparator.formatter import (
format_comparison,
format_replicated_checks,
)
if TYPE_CHECKING:
from rich.console import RenderableType
from sglang.srt.debug_utils.comparator.aligner.entrypoint.traced_types import (
TracedAlignerPlan,
TracedSubPlan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import AlignerPlan
from sglang.srt.debug_utils.comparator.output_types import (
ComparisonErrorRecord,
ComparisonNonTensorRecord,
ComparisonSkipRecord,
ComparisonTensorRecord,
ConfigRecord,
ErrorLog,
InfoLog,
LogRecord,
SummaryRecord,
_OutputRecord,
_TableRecord,
)
Verbosity = Literal["minimal", "normal", "verbose"]
# ── Record-level rendering (body + logs) ─────────────────────────────
def _render_record_rich(
record: _OutputRecord, *, verbosity: Verbosity = "normal"
) -> RenderableType:
body: RenderableType = record._format_rich_body(verbosity=verbosity)
log_lines: list[str] = _format_log_lines_rich(
errors=record.errors, infos=record.infos
)
if not log_lines:
return body
log_block: str = "\n".join(log_lines)
if isinstance(body, str):
return body + "\n" + log_block
return Group(body, log_block)
def _render_record_text(record: _OutputRecord) -> str:
body: str = record._format_body()
log_suffix: str = _format_log_lines_text(errors=record.errors, infos=record.infos)
if log_suffix:
body += "\n" + log_suffix
return body
def _format_log_lines_rich(
*, errors: list[ErrorLog], infos: list[InfoLog]
) -> list[str]:
lines: list[str] = []
if errors:
lines.extend(f" [red]✗ {e.to_text()}[/]" for e in errors)
if infos:
lines.extend(f" [dim] {i.to_text()}[/]" for i in infos)
return lines
def _format_log_lines_text(*, errors: list[ErrorLog], infos: list[InfoLog]) -> str:
lines: list[str] = []
if errors:
lines.extend(f"{e.to_text()}" for e in errors)
if infos:
lines.extend(f" {i.to_text()}" for i in infos)
return "\n".join(lines)
# ── ConfigRecord ──────────────────────────────────────────────────────
def _format_config_body(record: ConfigRecord) -> str:
return f"Config: {record.config}"
def _format_config_rich_body(
record: ConfigRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
lines: list[str] = [f" [bold]{k}[/] : {v}" for k, v in record.config.items()]
return Panel("\n".join(lines), title="Comparator Config", border_style="cyan")
# ── ComparisonSkipRecord ─────────────────────────────────────────────
def _format_skip_body(record: ComparisonSkipRecord) -> str:
text: str = (
f"Skip: {record.name}{record._format_location_suffix()} ({record.reason})"
)
if record.available_side is not None and record.available_tensor_info is not None:
info = record.available_tensor_info
text += f"\n {record.available_side}: shape={info.shape} dtype={info.dtype}"
text += (
f" mean={info.stats.mean:.4f} std={info.stats.std:.4f}"
f" range=[{info.stats.min:.4f}, {info.stats.max:.4f}]"
)
if info.sample is not None:
text += f"\n sample: {info.sample}"
return text
def _format_skip_rich_body(
record: ComparisonSkipRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
suffix: str = record._format_location_suffix()
header: str = (
f"[dim]⊘ {escape(record.name)}{suffix} ── skipped ({escape(record.reason)})[/]"
)
if (
verbosity == "minimal"
or record.available_side is None
or record.available_tensor_info is None
):
return header
info = record.available_tensor_info
side: str = record.available_side
dtype_str: str = info.dtype.replace("torch.", "")
lines: list[str] = [header]
# Bundle info line
if record.available_bundle_info is not None:
bi = record.available_bundle_info
shapes: list[list[int]] = [f.shape for f in bi.files]
unique_shapes: set[str] = {str(s) for s in shapes}
shape_desc: str = (
escape(str(shapes[0])) if len(unique_shapes) == 1 else "mixed shapes"
)
dims_part: str = f" [dim]dims: {bi.dims}[/]" if bi.dims else ""
lines.append(
f" {side:8s} [cyan]{bi.num_files} files[/]"
f" × {shape_desc} {dtype_str}{dims_part}"
)
else:
lines.append(f" {side:8s} {escape(str(info.shape))} {dtype_str}")
# Stats line (compact single-side)
stats = info.stats
range_str: str = escape(f"[{stats.min:.4f}, {stats.max:.4f}]")
lines.append(
f" [dim]stats[/] mean={stats.mean:.4f} std={stats.std:.4f}"
f" range={range_str}"
)
# Sample
if info.sample is not None:
lines.append(f" [dim]sample[/] {escape(info.sample)}")
return "\n".join(lines)
# ── ComparisonErrorRecord ────────────────────────────────────────────
def _format_error_body(record: ComparisonErrorRecord) -> str:
prefix: str = record._format_location_prefix()
return (
f"{prefix}Error: {record.name} ({record.exception_type})\n"
f"{record.exception_message}\n"
f"{record.traceback_str}"
)
def _format_error_rich_body(
record: ComparisonErrorRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
prefix: str = record._format_location_prefix_rich()
name: str = escape(record.name)
header: str = (
f"{prefix}[bold red]{name} ── errored ({escape(record.exception_type)}): "
f"{escape(record.exception_message)}[/]"
)
if verbosity == "minimal":
return header
return header + f"\n[dim]{escape(record.traceback_str)}[/]"
# ── _TableRecord ─────────────────────────────────────────────────────
def _format_table_body(record: _TableRecord) -> str:
import polars as pl
from sglang.srt.debug_utils.comparator.display import _render_polars_as_text
return _render_polars_as_text(
pl.DataFrame(record.rows), title=record._table_title()
)
def _format_table_rich_body(
record: _TableRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
import polars as pl
from sglang.srt.debug_utils.comparator.display import (
_render_polars_as_rich_table,
)
return _render_polars_as_rich_table(
pl.DataFrame(record.rows), title=record._table_title()
)
# ── ComparisonTensorRecord ───────────────────────────────────────────
def _format_tensor_comparison_body(record: ComparisonTensorRecord) -> str:
body: str = record._format_location_prefix() + format_comparison(record)
if record.replicated_checks:
body += "\n" + format_replicated_checks(record.replicated_checks)
if record.traced_plan is not None:
body += "\n" + _format_aligner_plan(record.traced_plan)
return body
def _format_tensor_comparison_rich_body(
record: ComparisonTensorRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
from sglang.srt.debug_utils.comparator.tensor_comparator.formatter import (
format_comparison_rich,
)
return record._format_location_prefix_rich() + format_comparison_rich(
record=record, verbosity=verbosity
)
# ── ComparisonNonTensorRecord ────────────────────────────────────────
def _format_non_tensor_body(record: ComparisonNonTensorRecord) -> str:
suffix: str = record._format_location_suffix()
if record.values_equal:
return f"NonTensor: {record.name}{suffix} = {record.baseline_value} ({record.baseline_type}) [equal]"
return (
f"NonTensor: {record.name}{suffix}\n"
f" baseline = {record.baseline_value} ({record.baseline_type})\n"
f" target = {record.target_value} ({record.target_type})"
)
def _format_non_tensor_rich_body(
record: ComparisonNonTensorRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
suffix: str = record._format_location_suffix()
name: str = escape(record.name)
baseline_val: str = escape(record.baseline_value)
target_val: str = escape(record.target_value)
if record.values_equal:
return (
f"{name}{suffix} = {baseline_val} "
f"({record.baseline_type}) [green]✓[/]"
)
return (
f"═ [bold red]{name}{suffix}[/]\n"
f" baseline = {baseline_val} ({record.baseline_type})\n"
f" target = {target_val} ({record.target_type})"
)
# ── SummaryRecord ────────────────────────────────────────────────────
def _format_summary_body(record: SummaryRecord) -> str:
text: str = (
f"Summary: {record.passed} passed, {record.failed} failed, "
f"{record.skipped} skipped (total {record.total})"
)
if record.errored > 0:
text += f", {record.errored} errored"
return text
def _format_summary_rich_body(
record: SummaryRecord, verbosity: Verbosity = "normal"
) -> RenderableType:
text: str = (
f"[bold green]{record.passed} passed[/] │ "
f"[bold red]{record.failed} failed[/] │ "
f"[yellow]{record.skipped} skipped[/] │ "
f"{record.total} total"
)
if record.errored > 0:
text += f" │ [bold red]{record.errored} errored[/]"
return Panel(text, title="SUMMARY", border_style="bold")
# ── LogRecord ────────────────────────────────────────────────────────
def _format_log_body(record: LogRecord) -> str:
return ""
# ── Standalone helpers ───────────────────────────────────────────────
def _format_aligner_plan(traced_plan: TracedAlignerPlan) -> str:
lines: list[str] = ["Aligner Plan:"]
for side_label, traced_side in [
("baseline", traced_plan.per_side.x),
("target", traced_plan.per_side.y),
]:
if not traced_side.step_plans:
lines.append(f" {side_label}: (no steps)")
continue
step_summaries: list[str] = []
for traced_step in traced_side.step_plans:
sub_strs: list[str] = [
_format_sub_plan_text(traced_sub)
for traced_sub in traced_step.sub_plans
]
summary: str = ", ".join(sub_strs) if sub_strs else "passthrough"
step_summaries.append(f"step={traced_step.step}: {summary}")
lines.append(f" {side_label}: [{'; '.join(step_summaries)}]")
lines.extend(_format_cross_side_plan_text(traced_plan.plan))
return "\n".join(lines)
def _format_sub_plan_text(traced_sub: TracedSubPlan) -> str:
from sglang.srt.debug_utils.comparator.aligner.reorderer.types import ReordererPlan
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import UnsharderPlan
sub = traced_sub.plan
qualifier: str = ""
if isinstance(sub, UnsharderPlan):
qualifier = f"({sub.axis.value})"
elif isinstance(sub, ReordererPlan):
qualifier = f"({sub.params.op})"
sub_desc: str = f"{sub.type}{qualifier}"
if traced_sub.snapshot is not None:
snap = traced_sub.snapshot
in_count: int = len(snap.input_shapes)
out_count: int = len(snap.output_shapes)
in_shape: str = str(snap.input_shapes[0]) if snap.input_shapes else "?"
out_shape: str = str(snap.output_shapes[0]) if snap.output_shapes else "?"
sub_desc += f" {in_count}x{in_shape} -> {out_count}x{out_shape}"
return sub_desc
def _format_cross_side_plan_text(plan: AlignerPlan) -> list[str]:
lines: list[str] = []
if plan.token_aligner_plan is not None:
num_tokens: int = len(plan.token_aligner_plan.locators.x.steps)
lines.append(f" token_aligner: {num_tokens} tokens aligned")
if plan.axis_aligner_plan is not None:
parts: list[str] = []
if plan.axis_aligner_plan.pattern.x:
parts.append(f"x: {plan.axis_aligner_plan.pattern.x}")
if plan.axis_aligner_plan.pattern.y:
parts.append(f"y: {plan.axis_aligner_plan.pattern.y}")
lines.append(f" axis_aligner: {', '.join(parts)}")
return lines
@@ -0,0 +1,324 @@
from __future__ import annotations
from abc import abstractmethod
from typing import TYPE_CHECKING, Annotated, Any, Literal, Optional, Union
from pydantic import ConfigDict, Discriminator, Field, TypeAdapter, model_validator
from rich.console import Group, RenderableType
from rich.markup import escape
from sglang.srt.debug_utils.comparator.output_formatter import ( # noqa: F401 — re-export
_format_aligner_plan as _format_aligner_plan,
)
from sglang.srt.debug_utils.comparator.output_formatter import (
_format_config_body,
_format_config_rich_body,
_format_error_body,
_format_error_rich_body,
_format_log_body,
_format_non_tensor_body,
_format_non_tensor_rich_body,
_format_skip_body,
_format_skip_rich_body,
_format_summary_body,
_format_summary_rich_body,
_format_table_body,
_format_table_rich_body,
_format_tensor_comparison_body,
_format_tensor_comparison_rich_body,
_render_record_rich,
_render_record_text,
)
from sglang.srt.debug_utils.comparator.tensor_comparator.types import (
DiffInfo,
TensorComparisonInfo,
TensorInfo,
)
from sglang.srt.debug_utils.comparator.utils import Pair, _StrictBase
if TYPE_CHECKING:
from sglang.srt.debug_utils.comparator.aligner.entrypoint.traced_types import (
TracedAlignerPlan,
)
from sglang.srt.debug_utils.comparator.report_sink import Verbosity
class BaseLog(_StrictBase):
category: str
message: str
def to_text(self) -> str:
return self.message
class ErrorLog(BaseLog):
kind: Literal["error"] = "error"
class InfoLog(BaseLog):
kind: Literal["info"] = "info"
AnyLog = Annotated[Union[ErrorLog, InfoLog], Discriminator("kind")]
def _split_logs(logs: list[BaseLog]) -> tuple[list[ErrorLog], list[InfoLog]]:
errors: list[ErrorLog] = [log for log in logs if isinstance(log, ErrorLog)]
infos: list[InfoLog] = [log for log in logs if isinstance(log, InfoLog)]
return errors, infos
class ReplicatedCheckResult(_StrictBase):
axis: str
group_index: int
compared_index: int
baseline_index: int
passed: bool
atol: float
diff: Optional[DiffInfo] = None
class BundleFileInfo(_StrictBase):
"""Per-file info within a bundle (one rank's raw tensor)."""
shape: list[int]
dtype: str
rank: Optional[int] = None
parallel_info: Optional[dict[str, str]] = None # e.g. {"tp": "0/4", "ep": "1/2"}
filename: Optional[str] = None
class BundleSideInfo(_StrictBase):
num_files: int
files: list[BundleFileInfo]
dims: Optional[str] = None # e.g. "b s h(tp) d"
class ShapeSnapshot(_StrictBase):
input_shapes: list[list[int]]
output_shapes: list[list[int]]
class _OutputRecord(_StrictBase):
errors: list[ErrorLog] = Field(default_factory=list)
infos: list[InfoLog] = Field(default_factory=list)
@abstractmethod
def _format_body(self) -> str: ...
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return self._format_body()
def to_rich(self, verbosity: Verbosity = "normal") -> RenderableType:
return _render_record_rich(self, verbosity=verbosity)
def to_text(self) -> str:
return _render_record_text(self)
class RecordLocation(_StrictBase):
step: Optional[int] = None
class _BaseComparisonRecord(_OutputRecord):
location: RecordLocation = Field(default_factory=RecordLocation)
def to_rich(self, verbosity: Verbosity = "normal") -> RenderableType:
result = _render_record_rich(self, verbosity=verbosity)
if isinstance(result, str):
return result + "\n"
return Group(result, "")
def _format_location_prefix(self) -> str:
if self.location.step is not None:
return f"[step={self.location.step}] "
return ""
def _format_location_prefix_rich(self) -> str:
if self.location.step is not None:
return escape(f"[step={self.location.step}]") + " "
return ""
def _format_location_suffix(self) -> str:
if self.location.step is not None:
return f" (step={self.location.step})"
return ""
class ConfigRecord(_OutputRecord):
type: Literal["config"] = "config"
config: dict[str, Any]
def _format_body(self) -> str:
return _format_config_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_config_rich_body(self, verbosity=verbosity)
class ComparisonSkipRecord(_BaseComparisonRecord):
type: Literal["comparison_skip"] = "comparison_skip"
name: str
reason: str
available_side: Optional[Literal["baseline", "target"]] = None
available_tensor_info: Optional[TensorInfo] = None
available_bundle_info: Optional[BundleSideInfo] = None
@property
def category(self) -> str:
if self.errors:
return "failed"
return "skipped"
def _format_body(self) -> str:
return _format_skip_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_skip_rich_body(self, verbosity=verbosity)
class ComparisonErrorRecord(_BaseComparisonRecord):
type: Literal["comparison_error"] = "comparison_error"
name: str
exception_type: str
exception_message: str
traceback_str: str
@property
def category(self) -> str:
return "errored"
def _format_body(self) -> str:
return _format_error_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_error_rich_body(self, verbosity=verbosity)
class _TableRecord(_OutputRecord):
label: str
rows: list[dict[str, Any]]
@abstractmethod
def _table_title(self) -> str: ...
def _format_body(self) -> str:
return _format_table_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_table_rich_body(self, verbosity=verbosity)
class RankInfoRecord(_TableRecord):
type: Literal["rank_info"] = "rank_info"
def _table_title(self) -> str:
return f"{self.label} ranks"
class InputIdsRecord(_TableRecord):
type: Literal["input_ids"] = "input_ids"
def _table_title(self) -> str:
return f"{self.label} input_ids & positions"
class ComparisonTensorRecord(TensorComparisonInfo, _BaseComparisonRecord):
model_config = ConfigDict(extra="forbid", defer_build=True)
type: Literal["comparison_tensor"] = "comparison_tensor"
traced_plan: Optional[TracedAlignerPlan] = None
replicated_checks: list[ReplicatedCheckResult] = Field(default_factory=list)
raw_bundle_info: Optional[Pair[BundleSideInfo]] = None
@property
def category(self) -> str:
if self.errors:
return "failed"
if any(not check.passed for check in self.replicated_checks):
return "failed"
return "passed" if self.diff is not None and self.diff.passed else "failed"
def _format_body(self) -> str:
return _format_tensor_comparison_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_tensor_comparison_rich_body(self, verbosity=verbosity)
class ComparisonNonTensorRecord(_BaseComparisonRecord):
type: Literal["comparison_non_tensor"] = "comparison_non_tensor"
name: str
baseline_value: str
target_value: str
baseline_type: str
target_type: str
values_equal: bool
@property
def category(self) -> str:
if self.errors:
return "failed"
return "passed" if self.values_equal else "failed"
def _format_body(self) -> str:
return _format_non_tensor_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_non_tensor_rich_body(self, verbosity=verbosity)
class SummaryRecord(_OutputRecord):
type: Literal["summary"] = "summary"
total: int
passed: int
failed: int
skipped: int
errored: int = 0
@model_validator(mode="after")
def _validate_totals(self) -> SummaryRecord:
expected: int = self.passed + self.failed + self.skipped + self.errored
if self.total != expected:
raise ValueError(
f"total={self.total} != passed({self.passed}) + failed({self.failed}) "
f"+ skipped({self.skipped}) + errored({self.errored}) = {expected}"
)
return self
def _format_body(self) -> str:
return _format_summary_body(self)
def _format_rich_body(self, verbosity: Verbosity = "normal") -> RenderableType:
return _format_summary_rich_body(self, verbosity=verbosity)
class LogRecord(_OutputRecord):
type: Literal["log"] = "log"
def _format_body(self) -> str:
return _format_log_body(self)
AnyRecord = Annotated[
Union[
ConfigRecord,
RankInfoRecord,
InputIdsRecord,
ComparisonSkipRecord,
ComparisonErrorRecord,
ComparisonTensorRecord,
ComparisonNonTensorRecord,
SummaryRecord,
LogRecord,
],
Discriminator("type"),
]
def _get_any_record_adapter() -> TypeAdapter:
return TypeAdapter(AnyRecord)
def parse_record_json(json_str: str | bytes) -> AnyRecord:
return _get_any_record_adapter().validate_json(json_str)
@@ -0,0 +1,83 @@
"""Per-token relative difference heatmap generator.
Produces a single PNG with rows = tensor names, columns = token positions,
color = log10(rel_diff).
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from sglang.srt.debug_utils.comparator.output_types import ComparisonTensorRecord
def generate_per_token_heatmap(
*,
records: list[ComparisonTensorRecord],
output_path: Path,
) -> Optional[Path]:
"""Generate a per-token relative difference heatmap PNG.
Returns the output path if a file was written, or None if no data was available.
"""
rows_data: list[tuple[str, list[float]]] = _collect_per_token_data(records=records)
if not rows_data:
return None
_render_heatmap(rows_data=rows_data, output_path=output_path)
return output_path
def _collect_per_token_data(
*,
records: list[ComparisonTensorRecord],
) -> list[tuple[str, list[float]]]:
rows: list[tuple[str, list[float]]] = []
for record in records:
if record.diff is None or record.diff.per_token_rel_diff is None:
continue
rows.append((record.name, record.diff.per_token_rel_diff))
return rows
def _render_heatmap(
*,
rows_data: list[tuple[str, list[float]]],
output_path: Path,
) -> None:
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
max_len: int = max(len(vals) for _, vals in rows_data)
labels: list[str] = [label for label, _ in rows_data]
matrix: np.ndarray = np.full((len(rows_data), max_len), np.nan, dtype=np.float64)
for i, (_, vals) in enumerate(rows_data):
matrix[i, : len(vals)] = vals
fig_width: float = max(12.0, max_len * 0.15)
fig_height: float = max(6.0, len(rows_data) * 0.3)
fig, ax = plt.subplots(figsize=(fig_width, fig_height))
im = ax.imshow(
np.log10(matrix + 1e-10), aspect="auto", cmap="hot", interpolation="nearest"
)
ax.set_xlabel("Token Position")
ax.set_ylabel("Tensor")
ax.set_yticks(range(len(labels)))
ax.set_yticklabels(labels, fontsize=8)
colorbar = fig.colorbar(im, ax=ax)
colorbar.set_label("log10(rel_diff)")
ax.set_title("Per-Token Relative Difference Heatmap")
fig.tight_layout()
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(str(output_path), dpi=150)
plt.close(fig)
@@ -0,0 +1,52 @@
from __future__ import annotations
PRESETS: dict[str, list[str]] = {
"raw": [
"--grouping-skip-keys",
],
"sglang_dev": [
"--grouping-skip-keys",
"rank",
],
"sglang_megatron": [
"--grouping-skip-keys",
"rank",
"step",
"--token-aligner",
"concat_steps",
],
}
DEFAULT_PRESET: str = "sglang_dev"
def expand_preset(argv: list[str], presets: dict[str, list[str]]) -> list[str]:
"""Expand ``--preset <name>`` into the corresponding argv fragment.
If ``--preset`` is absent **and** ``--grouping-skip-keys`` is also absent,
the DEFAULT_PRESET is applied automatically.
"""
if (expanded := _expand_flag(argv, "--preset", presets)) is not None:
return expanded
if "--grouping-skip-keys" not in argv:
return presets[DEFAULT_PRESET] + argv
return argv
def _expand_flag(
argv: list[str], flag: str, mapping: dict[str, list[str]]
) -> list[str] | None:
"""Replace ``flag <name>`` in *argv* with the corresponding argv fragment from *mapping*."""
if flag not in argv:
return None
idx: int = argv.index(flag)
name: str = argv[idx + 1]
if name not in mapping:
raise ValueError(
f"Unknown value for {flag}: {name}. Available: {list(mapping.keys())}"
)
return argv[:idx] + mapping[name] + argv[idx + 2 :]
@@ -0,0 +1,91 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import IO, Literal, Optional
from rich.console import Console
from sglang.srt.debug_utils.comparator.output_types import _OutputRecord
Verbosity = Literal["minimal", "normal", "verbose"]
class ReportSink:
"""Unified entry point for all record output."""
def __init__(self) -> None:
self._output_format: str = "text"
self._verbosity: Verbosity = "normal"
self._report_file: Optional[IO[str]] = None
self._report_path: Optional[Path] = None
self._console: Optional[Console] = None
@property
def verbosity(self) -> Verbosity:
return self._verbosity
def configure(
self,
*,
output_format: str = "text",
report_path: Optional[Path] = None,
verbosity: Verbosity = "normal",
) -> None:
self._output_format = output_format
self._verbosity = verbosity
if report_path is not None:
try:
report_path.parent.mkdir(parents=True, exist_ok=True)
self._report_file = open(report_path, "w", encoding="utf-8")
self._report_path = report_path
except OSError as exc:
print(
f"Warning: cannot open report file {report_path}: {exc}",
file=sys.stderr,
)
def add(self, record: _OutputRecord) -> None:
self._print_to_stdout(record)
if self._report_file is not None:
self._report_file.write(record.model_dump_json())
self._report_file.write("\n")
self._report_file.flush()
def close(self) -> None:
if self._report_file is not None:
self._report_file.close()
self._report_file = None
@property
def report_path(self) -> Optional[Path]:
return self._report_path
def _reset(self) -> None:
self.close()
self._output_format = "text"
self._verbosity = "normal"
self._report_path = None
self._console = None
def _get_console(self) -> Console:
if self._console is None:
try:
width = os.get_terminal_size().columns
except OSError:
width = 200
self._console = Console(force_terminal=True, width=width)
return self._console
def _print_to_stdout(self, record: _OutputRecord) -> None:
if self._output_format == "json":
print(record.model_dump_json())
else:
console: Console = self._get_console()
console.print(record.to_rich(verbosity=self._verbosity))
report_sink = ReportSink()
@@ -0,0 +1,3 @@
from sglang.srt.debug_utils.comparator.tensor_comparator.comparator import (
compare_tensor_pair,
)
@@ -0,0 +1,250 @@
from dataclasses import dataclass
from typing import Optional
import torch
from sglang.srt.debug_utils.comparator.tensor_comparator.types import (
DEFAULT_PERCENTILES,
DiffInfo,
TensorComparisonInfo,
TensorInfo,
TensorStats,
)
from sglang.srt.debug_utils.comparator.threshold_dsl import (
DiffThresholdRule,
evaluate_predicate,
parse_predicate,
resolve_predicate,
)
from sglang.srt.debug_utils.comparator.utils import (
Pair,
argmax_coord,
calc_per_token_rel_diff,
calc_rel_diff,
compute_smaller_dtype,
try_unify_shape,
)
from sglang.srt.debug_utils.dumper import get_truncated_value
QUANTILE_NUMEL_THRESHOLD = 10_000_000
SAMPLE_DIFF_THRESHOLD = 1e-3
DEFAULT_PREDICATE: str = "rel <= 0.001"
@dataclass
class FailureDisplayBudget:
max_detail: int = 50
num_emitted: int = 0
def take(self) -> bool:
if self.max_detail < 0:
return True
if self.num_emitted >= self.max_detail:
return False
self.num_emitted += 1
return True
def compute_tensor_info(
tensor: torch.Tensor,
*,
include_sample: bool = False,
include_percentiles: bool = True,
) -> TensorInfo:
"""Compute TensorInfo (shape, dtype, stats, optional sample) for a single tensor."""
stats: TensorStats = _compute_tensor_stats(
tensor.float(), include_percentiles=include_percentiles
)
sample: Optional[str] = (
str(get_truncated_value(tensor.float())) if include_sample else None
)
return TensorInfo(
shape=list(tensor.shape),
dtype=str(tensor.dtype),
stats=stats,
sample=sample,
)
def compare_tensor_pair(
x_baseline: torch.Tensor,
x_target: torch.Tensor,
name: str = "",
diff_threshold_rules: Optional[list[DiffThresholdRule]] = None,
seq_dim: Optional[int] = None,
failure_display_budget: Optional[FailureDisplayBudget] = None,
) -> TensorComparisonInfo:
predicate = resolve_predicate(
name, diff_threshold_rules, default_predicate=DEFAULT_PREDICATE
)
x_baseline_original = x_baseline
x_baseline = try_unify_shape(x_baseline, target_shape=x_target.shape)
unified_shape = list(x_baseline.shape)
baseline_original_dtype = x_baseline.dtype
target_original_dtype = x_target.dtype
x_baseline_f = x_baseline.float()
x_target_f = x_target.float()
shape_mismatch = x_baseline_f.shape != x_target_f.shape
diff: Optional[DiffInfo] = None
diff_downcast: Optional[DiffInfo] = None
downcast_dtype: Optional[torch.dtype] = None
if not shape_mismatch:
diff = compute_diff(
x_baseline=x_baseline_f,
x_target=x_target_f,
predicate=predicate,
seq_dim=seq_dim,
include_percentiles=False,
)
is_failure = shape_mismatch or (diff is not None and not diff.passed)
needs_detail = is_failure and (
failure_display_budget is None or failure_display_budget.take()
)
baseline_info: TensorInfo = compute_tensor_info(
x_baseline_original, include_percentiles=needs_detail
)
target_info: TensorInfo = compute_tensor_info(
x_target, include_percentiles=needs_detail
)
if not shape_mismatch and needs_detail:
diff = compute_diff(
x_baseline=x_baseline_f,
x_target=x_target_f,
predicate=predicate,
seq_dim=seq_dim,
include_percentiles=True,
)
if diff is not None:
needs_sample = diff.max_abs_diff > SAMPLE_DIFF_THRESHOLD
if needs_sample:
baseline_info.sample = str(get_truncated_value(x_baseline_f))
target_info.sample = str(get_truncated_value(x_target_f))
if baseline_original_dtype != target_original_dtype:
downcast_dtype = compute_smaller_dtype(
Pair(x=baseline_original_dtype, y=target_original_dtype)
)
if downcast_dtype is not None:
diff_downcast = compute_diff(
x_baseline=x_baseline_f.to(downcast_dtype),
x_target=x_target_f.to(downcast_dtype),
predicate=predicate,
include_percentiles=needs_detail,
)
return TensorComparisonInfo(
name=name,
baseline=baseline_info,
target=target_info,
unified_shape=unified_shape,
shape_mismatch=shape_mismatch,
diff=diff,
diff_downcast=diff_downcast,
downcast_dtype=str(downcast_dtype) if downcast_dtype is not None else None,
)
def _compute_tensor_stats(
x: torch.Tensor, *, include_percentiles: bool = True
) -> TensorStats:
if x.numel() == 0:
return TensorStats(
mean=0.0,
abs_mean=0.0,
std=0.0,
min=0.0,
max=0.0,
percentiles={},
)
include_quantiles: bool = (
include_percentiles and x.numel() < QUANTILE_NUMEL_THRESHOLD
)
return TensorStats(
mean=torch.mean(x).item(),
abs_mean=torch.mean(x.abs()).item(),
std=torch.std(x).item(),
min=torch.min(x).item(),
max=torch.max(x).item(),
percentiles=_compute_percentiles(x, include=include_quantiles),
)
def _compute_percentiles(x: torch.Tensor, *, include: bool) -> dict[int, float]:
if not include:
return {}
import numpy as np
arr = x.detach().float().numpy().ravel()
values = np.percentile(arr, list(DEFAULT_PERCENTILES))
return {p: float(v) for p, v in zip(DEFAULT_PERCENTILES, values)}
def compute_diff(
x_baseline: torch.Tensor,
x_target: torch.Tensor,
predicate: str = DEFAULT_PREDICATE,
seq_dim: Optional[int] = None,
include_percentiles: bool = True,
) -> DiffInfo:
if x_baseline.numel() == 0:
return DiffInfo(
rel_diff=0.0,
max_abs_diff=0.0,
mean_abs_diff=0.0,
abs_diff_percentiles={},
max_diff_coord=[],
baseline_at_max=0.0,
target_at_max=0.0,
predicate=predicate,
passed=True,
)
raw_abs_diff = (x_target - x_baseline).abs()
max_diff_coord = argmax_coord(raw_abs_diff)
max_abs_diff = raw_abs_diff.max().item()
rel_diff = (
0.0 if max_abs_diff == 0.0 else calc_rel_diff(x_target, x_baseline).item()
)
mean_abs_diff = raw_abs_diff.mean().item()
include_quantiles: bool = (
include_percentiles and raw_abs_diff.numel() < QUANTILE_NUMEL_THRESHOLD
)
per_token_rel_diff: Optional[list[float]] = None
if seq_dim is not None and x_baseline.dim() > seq_dim:
per_token_rel_diff = calc_per_token_rel_diff(
x_baseline, x_target, seq_dim=seq_dim
).tolist()
return DiffInfo(
rel_diff=rel_diff,
max_abs_diff=max_abs_diff,
mean_abs_diff=mean_abs_diff,
abs_diff_percentiles=_compute_percentiles(
raw_abs_diff, include=include_quantiles
),
max_diff_coord=list(max_diff_coord),
baseline_at_max=x_baseline[max_diff_coord].item(),
target_at_max=x_target[max_diff_coord].item(),
predicate=predicate,
passed=evaluate_predicate(
parse_predicate(predicate),
rel=rel_diff,
max_abs=max_abs_diff,
mean_abs=mean_abs_diff,
),
per_token_rel_diff=per_token_rel_diff,
)
@@ -0,0 +1,522 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
from rich.markup import escape
from sglang.srt.debug_utils.comparator.aligner.reorderer.types import ReordererPlan
from sglang.srt.debug_utils.comparator.aligner.unsharder.types import UnsharderPlan
from sglang.srt.debug_utils.comparator.tensor_comparator.types import (
DiffInfo,
TensorComparisonInfo,
TensorInfo,
TensorStats,
)
if TYPE_CHECKING:
from sglang.srt.debug_utils.comparator.aligner.entrypoint.traced_types import (
TracedAlignerPlan,
TracedSubPlan,
)
from sglang.srt.debug_utils.comparator.aligner.entrypoint.types import AlignerPlan
from sglang.srt.debug_utils.comparator.output_types import (
BundleSideInfo,
ComparisonTensorRecord,
ReplicatedCheckResult,
ShapeSnapshot,
)
from sglang.srt.debug_utils.comparator.utils import Pair
Verbosity = Literal["minimal", "normal", "verbose"]
def _esc_shape(shape: Optional[list[int]]) -> str:
return escape(str(shape))
def _strip_torch_prefix(dtype: str) -> str:
return dtype.replace("torch.", "")
# ---------------------------------------------------------------------------
# Number formatting
# ---------------------------------------------------------------------------
def _fmt_val(value: float) -> str:
return f"{value:.2e}"
def _fmt_diff_colored(diff: float, *, threshold: float = 1e-2) -> str:
formatted: str = f"{diff:+.2e}"
if abs(diff) >= threshold:
return f"[yellow]{formatted}[/]"
return f"[dim]{formatted}[/]"
# ---------------------------------------------------------------------------
# Passed / color / marker helper
# ---------------------------------------------------------------------------
def _category_marker(category: str) -> tuple[bool, str, str]:
passed: bool = category == "passed"
color: str = "green" if passed else "red"
marker: str = f"[{color}]✅[/]" if passed else f"[{color}]❌[/]"
return passed, color, marker
# ---------------------------------------------------------------------------
# Stats formatting helpers (shared between compact / verbose)
# ---------------------------------------------------------------------------
_STAT_HEADER = (
f" [dim]{'':10s} {'baseline':>10s} {'target':>10s} {'Δ':s}[/]"
)
def _format_stat_line(stat_name: str, val_b: float, val_t: float, diff: float) -> str:
return f" [blue]{stat_name:10s}[/] {val_b:>10.4f} {val_t:>10.4f} {_fmt_diff_colored(diff)}"
# ---------------------------------------------------------------------------
# Old text-only formatters (kept for to_text() backward compatibility)
# ---------------------------------------------------------------------------
def format_comparison(info: TensorComparisonInfo) -> str:
lines: list[str] = []
baseline = info.baseline
target = info.target
dtype_marker = "" if baseline.dtype == target.dtype else "🟠"
lines.append(
f"Raw "
f"[shape] {baseline.shape} vs {target.shape}\t"
f"[{dtype_marker}dtype] {baseline.dtype} vs {target.dtype}"
)
if info.unified_shape != baseline.shape:
lines.append(
f"Unify shape: {baseline.shape} -> {info.unified_shape} "
f"(to match {target.shape})"
)
lines.append(
f"After unify "
f"[shape] {info.unified_shape} vs {target.shape}\t"
f"[dtype] {baseline.dtype} vs {target.dtype}"
)
lines.extend(_format_stats_comparison(baseline=baseline.stats, target=target.stats))
if info.shape_mismatch:
lines.append("⚠️ Shape mismatch")
return "\n".join(lines)
if info.diff is not None:
lines.extend(_format_diff(diff=info.diff))
if info.diff_downcast is not None and info.downcast_dtype is not None:
lines.extend(
_format_diff(
diff=info.diff_downcast,
prefix_text=f"When downcast to {info.downcast_dtype}: ",
)
)
if baseline.sample is not None:
lines.append(f"x_baseline(sample)={baseline.sample}")
if target.sample is not None:
lines.append(f"x_target(sample)={target.sample}")
return "\n".join(lines)
def format_replicated_checks(checks: list[ReplicatedCheckResult]) -> str:
lines: list[str] = ["Replicated checks:"]
for check in checks:
marker: str = "" if check.passed else ""
if check.diff is not None:
detail: str = (
f"rel_diff={check.diff.rel_diff:.6e} "
f"max_abs_diff={check.diff.max_abs_diff:.6e} "
f"mean_abs_diff={check.diff.mean_abs_diff:.6e}"
)
else:
detail = "n/a diff"
lines.append(
f" {marker} axis={check.axis} group={check.group_index} "
f"idx={check.compared_index} vs {check.baseline_index}: "
f"{detail}"
)
return "\n".join(lines)
def _format_stats_comparison(baseline: TensorStats, target: TensorStats) -> list[str]:
lines: list[str] = []
for stat_name in TensorStats.model_fields:
if stat_name == "percentiles":
continue
value_baseline: float = getattr(baseline, stat_name)
value_target: float = getattr(target, stat_name)
lines.append(
f"[{stat_name}] {value_baseline:.4f} vs {value_target:.4f} "
f"(diff: {value_target - value_baseline:.4f})"
)
for p in sorted(set(baseline.percentiles) & set(target.percentiles)):
value_baseline = baseline.percentiles[p]
value_target = target.percentiles[p]
lines.append(
f"[p{p}] {value_baseline:.4f} vs {value_target:.4f} "
f"(diff: {value_target - value_baseline:.4f})"
)
return lines
def _format_diff(diff: DiffInfo, prefix_text: str = "") -> list[str]:
marker: str = "" if diff.passed else ""
lines: list[str] = [
prefix_text
+ f"{marker} rel_diff={diff.rel_diff}\t"
+ f"max_abs_diff={diff.max_abs_diff}\t"
+ f"mean_abs_diff={diff.mean_abs_diff}",
f"max_abs_diff happens at coord={diff.max_diff_coord} with "
f"baseline={diff.baseline_at_max} "
f"target={diff.target_at_max}",
]
if diff.abs_diff_percentiles:
quantile_parts: list[str] = [
f"p{p}={value:.4f}"
for p, value in sorted(diff.abs_diff_percentiles.items())
]
lines.append("[abs_diff] " + " ".join(quantile_parts))
return lines
# ---------------------------------------------------------------------------
# New Rich markup formatters
# ---------------------------------------------------------------------------
def format_comparison_rich(
record: ComparisonTensorRecord,
verbosity: Verbosity = "normal",
) -> str:
if verbosity == "minimal":
return _format_comparison_minimal(record)
return _format_comparison_normal_or_verbose(
record=record,
verbose=(verbosity == "verbose"),
)
def _format_comparison_minimal(record: ComparisonTensorRecord) -> str:
passed, color, marker = _category_marker(record.category)
name_part: str = f"[bold {color}]{escape(record.name):30s}[/]"
if record.diff is not None:
return f"{marker} {name_part} rel_diff={_fmt_val(record.diff.rel_diff)}"
elif record.shape_mismatch:
return f"{marker} {name_part} [yellow]shape mismatch[/]"
else:
return f"{marker} {name_part}"
def _format_comparison_normal_or_verbose(
*,
record: ComparisonTensorRecord,
verbose: bool,
) -> str:
passed, color, marker = _category_marker(record.category)
baseline: TensorInfo = record.baseline
target: TensorInfo = record.target
aligned_shape: str = _esc_shape(record.unified_shape)
dtype_str: str = _strip_torch_prefix(baseline.dtype)
lines: list[str] = []
# L0: Header
lines.append(
f"{marker} [bold {color}]{escape(record.name)}[/] "
f"[dim cyan]── {dtype_str} {aligned_shape}[/]"
)
# L1: Key metrics
if record.diff is not None:
diff: DiffInfo = record.diff
rel_style: str = f"bold {color}" if not passed else color
lines.append(
f" [{rel_style}]rel_diff={_fmt_val(diff.rel_diff)}[/]"
f" max_abs={_fmt_val(diff.max_abs_diff)}"
f" mean_abs={_fmt_val(diff.mean_abs_diff)}"
)
if not passed:
lines.append(
f" max_abs @ {_esc_shape(diff.max_diff_coord)}: "
f"baseline={diff.baseline_at_max} target={diff.target_at_max}"
)
elif record.shape_mismatch:
lines.append(" [yellow]⚠ Shape mismatch[/]")
# Downcast info
if record.diff_downcast is not None and record.downcast_dtype is not None:
dc: DiffInfo = record.diff_downcast
dc_marker: str = "[green]✅[/]" if dc.passed else "[red]❌[/]"
lines.append(
f" {dc_marker} downcast to {record.downcast_dtype}: "
f"rel_diff={_fmt_val(dc.rel_diff)}"
)
# Bundle section
if record.raw_bundle_info is not None:
lines.append(" [dim]Bundle[/]")
lines.extend(
_format_bundle_section(bundle_info=record.raw_bundle_info, verbose=verbose)
)
# Plan section
if record.traced_plan is not None:
lines.append(" [dim]Plan[/]")
lines.extend(
_format_plan_section_rich(
traced_plan=record.traced_plan,
verbose=verbose,
)
)
# Aligned section
lines.append(" [dim]Aligned[/]")
lines.append(
f" {_esc_shape(record.unified_shape)} vs {_esc_shape(target.shape)}"
f" {baseline.dtype} vs {target.dtype}"
)
# Stats section
lines.append(" [dim]Stats[/]")
lines.extend(
_format_stats_rich(
baseline=baseline.stats, target=target.stats, verbose=verbose
)
)
show_detail: bool = verbose or not passed
# Abs diff percentiles
if show_detail and record.diff is not None and record.diff.abs_diff_percentiles:
lines.append(" [dim]Abs Diff Percentiles[/]")
lines.append(" " + _format_abs_diff_percentiles_rich(record.diff))
# Samples
if show_detail and baseline.sample is not None:
lines.append(" [dim]Samples[/]")
lines.append(f" baseline {escape(baseline.sample)}")
if target.sample is not None:
lines.append(f" target {escape(target.sample)}")
# Replicated checks
if show_detail and record.replicated_checks:
lines.append(" [dim]Replicated Checks[/]")
for check in record.replicated_checks:
chk_marker: str = "[green]✅[/]" if check.passed else "[red]❌[/]"
if check.diff is not None:
lines.append(
f" {chk_marker} axis={check.axis} group={check.group_index}"
f" idx={check.compared_index} vs {check.baseline_index}"
f" rel_diff={_fmt_val(check.diff.rel_diff)}"
f" max_abs={_fmt_val(check.diff.max_abs_diff)}"
)
else:
lines.append(
f" {chk_marker} axis={check.axis} group={check.group_index}"
f" idx={check.compared_index} vs {check.baseline_index}: n/a"
)
return "\n".join(lines)
def _format_bundle_section(
bundle_info: Pair[BundleSideInfo], *, verbose: bool = False
) -> list[str]:
lines: list[str] = []
for label, side in [("baseline", bundle_info.x), ("target", bundle_info.y)]:
if not side.files:
lines.append(f" {label:8s} [dim](no files)[/]")
continue
dtype_desc: str = _strip_torch_prefix(side.files[0].dtype)
if verbose:
dims_part: str = f" dims: {side.dims}" if side.dims else ""
lines.append(
f" {label:8s} [cyan]{side.num_files} files[/]"
f" {dtype_desc}{dims_part}"
)
for idx, f in enumerate(side.files):
rank_part: str = f"rank={f.rank}" if f.rank is not None else ""
par_part: str = ""
if f.parallel_info:
par_part = " " + " ".join(
f"{k}={v}" for k, v in f.parallel_info.items()
)
file_part: str = f" [dim]{escape(f.filename)}[/]" if f.filename else ""
lines.append(
f" [{idx}] {_esc_shape(f.shape)} {rank_part}{par_part}{file_part}"
)
else:
shapes: list[list[int]] = [f.shape for f in side.files]
unique_shapes: set[str] = {str(s) for s in shapes}
shape_desc: str
if len(unique_shapes) == 1:
shape_desc = _esc_shape(shapes[0])
else:
shape_desc = "mixed shapes"
dims_part = f" [dim]dims: {side.dims}[/]" if side.dims else ""
lines.append(
f" {label:8s} [cyan]{side.num_files} files[/]"
f" × {shape_desc} {dtype_desc}{dims_part}"
)
return lines
def _format_plan_section_rich(
*,
traced_plan: TracedAlignerPlan,
verbose: bool = False,
) -> list[str]:
lines: list[str] = []
for side_label, traced_side in [
("baseline", traced_plan.per_side.x),
("target", traced_plan.per_side.y),
]:
if not traced_side.step_plans:
lines.append(f" {side_label:8s} [dim](passthrough)[/]")
continue
parts: list[str] = [
_format_sub_plan_rich(traced_sub)
for traced_step in traced_side.step_plans
for traced_sub in traced_step.sub_plans
]
lines.append(f" {side_label:8s} " + "".join(parts))
lines.extend(_format_cross_side_plan_rich(traced_plan.plan))
return lines
def _format_sub_plan_rich(traced_sub: TracedSubPlan) -> str:
sub = traced_sub.plan
snapshot: Optional[ShapeSnapshot] = traced_sub.snapshot
op_name: str = sub.type
qualifier: str = ""
if isinstance(sub, UnsharderPlan):
qualifier = f"({sub.axis.value})"
elif isinstance(sub, ReordererPlan):
qualifier = f"({sub.params.op})"
shape_change: str = ""
if snapshot:
in_count: int = len(snapshot.input_shapes)
out_count: int = len(snapshot.output_shapes)
in_shape: str = (
_esc_shape(snapshot.input_shapes[0]) if snapshot.input_shapes else "?"
)
out_shape: str = (
_esc_shape(snapshot.output_shapes[0]) if snapshot.output_shapes else "?"
)
shape_change = f" ({in_count}×{in_shape}{out_count}×{out_shape})"
return f"[magenta]{op_name}{qualifier}[/]{shape_change}"
def _format_cross_side_plan_rich(plan: AlignerPlan) -> list[str]:
lines: list[str] = []
if plan.token_aligner_plan is not None:
num_tokens: int = len(plan.token_aligner_plan.locators.x.steps)
lines.append(f" token_aligner [dim]{num_tokens} tokens[/]")
if plan.axis_aligner_plan is not None:
parts: list[str] = []
if plan.axis_aligner_plan.pattern.x:
parts.append(f"x={plan.axis_aligner_plan.pattern.x}")
if plan.axis_aligner_plan.pattern.y:
parts.append(f"y={plan.axis_aligner_plan.pattern.y}")
if parts:
lines.append(f" axis_aligner [dim]{', '.join(parts)}[/]")
else:
lines.append(" axis_aligner [dim](no-op)[/]")
return lines
def _format_stats_rich(
*,
baseline: TensorStats,
target: TensorStats,
verbose: bool = False,
) -> list[str]:
lines: list[str] = [_STAT_HEADER]
if verbose:
# All stat fields
for stat_name in TensorStats.model_fields:
if stat_name == "percentiles":
continue
val_b: float = getattr(baseline, stat_name)
val_t: float = getattr(target, stat_name)
lines.append(_format_stat_line(stat_name, val_b, val_t, val_t - val_b))
# Percentiles
for p in sorted(set(baseline.percentiles) & set(target.percentiles)):
val_b = baseline.percentiles[p]
val_t = target.percentiles[p]
lines.append(_format_stat_line(f"p{p}", val_b, val_t, val_t - val_b))
else:
# Compact: mean, std, range, then percentiles
for stat_name in ("mean", "std"):
val_b = getattr(baseline, stat_name)
val_t = getattr(target, stat_name)
lines.append(_format_stat_line(stat_name, val_b, val_t, val_t - val_b))
# Range line: combine min/max (escape brackets to avoid Rich markup)
range_baseline: str = escape(f"[{baseline.min:.4f}, {baseline.max:.4f}]")
range_target: str = escape(f"[{target.min:.4f}, {target.max:.4f}]")
lines.append(f" [blue]{'range':10s}[/] {range_baseline} {range_target}")
# Percentiles (compact: same as verbose)
for p in sorted(set(baseline.percentiles) & set(target.percentiles)):
val_b = baseline.percentiles[p]
val_t = target.percentiles[p]
lines.append(_format_stat_line(f"p{p}", val_b, val_t, val_t - val_b))
return lines
def _format_abs_diff_percentiles_rich(diff: DiffInfo) -> str:
parts: list[str] = []
for p, value in sorted(diff.abs_diff_percentiles.items()):
formatted: str = f"p{p}={_fmt_val(value)}"
if p >= 99 and value > 0.1:
formatted = f"[yellow]{formatted}[/]"
parts.append(formatted)
return " ".join(parts)
@@ -0,0 +1,45 @@
from typing import Optional
from sglang.srt.debug_utils.comparator.utils import _StrictBase
DEFAULT_PERCENTILES: tuple[int, ...] = (1, 5, 50, 95, 99)
class TensorStats(_StrictBase):
mean: float
abs_mean: float
std: float
min: float
max: float
percentiles: dict[int, float] = {}
class TensorInfo(_StrictBase):
shape: list[int]
dtype: str
stats: TensorStats
sample: Optional[str] = None
class DiffInfo(_StrictBase):
rel_diff: float
max_abs_diff: float
mean_abs_diff: float
abs_diff_percentiles: dict[int, float] = {}
max_diff_coord: list[int]
baseline_at_max: float
target_at_max: float
predicate: str = ""
passed: bool
per_token_rel_diff: Optional[list[float]] = None
class TensorComparisonInfo(_StrictBase):
name: str
baseline: TensorInfo
target: TensorInfo
unified_shape: Optional[list[int]]
shape_mismatch: bool
diff: Optional[DiffInfo] = None
diff_downcast: Optional[DiffInfo] = None
downcast_dtype: Optional[str] = None
@@ -0,0 +1,84 @@
import re
from dataclasses import dataclass
from functools import lru_cache
from types import CodeType
from typing import Optional
ALLOWED_NAMES: tuple[str, ...] = ("rel", "max_abs", "mean_abs")
_EVAL_GLOBALS: dict = {"__builtins__": {}}
_DUMMY_ENV: dict[str, float] = {name: 1.0 for name in ALLOWED_NAMES}
@dataclass(frozen=True)
class DiffThresholdRule:
pattern: str
predicate: str
def parse_diff_threshold_rules(
raw: Optional[list[str]], *, default_predicate: str
) -> list[DiffThresholdRule]:
if not raw:
return [DiffThresholdRule(".*", default_predicate)]
if len(raw) == 1:
try:
value = float(raw[0])
except ValueError as e:
raise ValueError(
f"--diff-threshold with a single argument must be a float shorthand "
f"(e.g. 0.0085); got {raw[0]!r}. For per-regex predicates pass "
f"(regex predicate) pairs."
) from e
return [DiffThresholdRule(".*", f"rel <= {value}")]
if len(raw) % 2 != 0:
raise ValueError(
f"--diff-threshold expects a single float shorthand or (regex predicate) "
f"pairs; got an odd number of arguments: {raw}"
)
rules = [DiffThresholdRule(raw[i], raw[i + 1]) for i in range(0, len(raw), 2)]
for rule in rules:
parse_predicate(rule.predicate)
return rules
def resolve_predicate(
name: str,
diff_threshold_rules: Optional[list[DiffThresholdRule]],
*,
default_predicate: str,
) -> str:
if not diff_threshold_rules:
return default_predicate
for rule in diff_threshold_rules:
if re.fullmatch(rule.pattern, name):
return rule.predicate
raise ValueError(
f"tensor {name!r} matched no --diff-threshold pattern "
f"({[rule.pattern for rule in diff_threshold_rules]}); add a catch-all '.*' rule or a matching pattern."
)
@lru_cache(maxsize=None)
def parse_predicate(expr: str) -> CodeType:
try:
code = compile(expr, "<predicate>", "eval")
except SyntaxError as e:
raise ValueError(f"invalid predicate {expr!r}: {e}") from e
try:
eval(code, _EVAL_GLOBALS, dict(_DUMMY_ENV))
except Exception as e:
raise ValueError(
f"invalid predicate {expr!r}: {e}; allowed names are {ALLOWED_NAMES}."
) from e
return code
def evaluate_predicate(
code: CodeType, *, rel: float, max_abs: float, mean_abs: float
) -> bool:
return bool(
eval(
code, _EVAL_GLOBALS, {"rel": rel, "max_abs": max_abs, "mean_abs": mean_abs}
)
)
@@ -0,0 +1,165 @@
from __future__ import annotations
import functools
import re
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Generic, Optional, Tuple, TypeVar
import torch
from pydantic import BaseModel, ConfigDict
_T = TypeVar("_T")
_U = TypeVar("_U")
def _check_equal_lengths(**named_lists: list) -> None:
lengths: dict[str, int] = {name: len(lst) for name, lst in named_lists.items()}
unique: set[int] = set(lengths.values())
if len(unique) > 1:
details: str = ", ".join(f"{name}={length}" for name, length in lengths.items())
raise ValueError(f"Length mismatch: {details}")
def auto_descend_dir(directory: Path, label: str) -> Path:
"""If directory has no .pt files but exactly one subdirectory does, descend into it.
Raises ValueError when the layout is ambiguous (>=2 subdirs with .pt)
or when no .pt data is found at all.
"""
if any(directory.glob("*.pt")):
return directory
candidates: list[Path] = [
sub for sub in directory.iterdir() if sub.is_dir() and any(sub.glob("*.pt"))
]
if len(candidates) >= 2:
names: str = ", ".join(sorted(c.name for c in candidates))
raise ValueError(
f"{label}: directory {directory} has no .pt files at top level "
f"and multiple subdirectories contain data ({names}). "
f"Please specify the exact subdirectory."
)
if len(candidates) == 0:
raise ValueError(
f"{label}: no .pt files found in {directory} or any of its subdirectories."
)
resolved: Path = candidates[0]
from sglang.srt.debug_utils.comparator.log_sink import log_sink
from sglang.srt.debug_utils.comparator.output_types import InfoLog
log_sink.add(
InfoLog(
category="auto_descend",
message=f"auto-descend {label}: {directory} -> {resolved}",
)
)
return resolved
class _StrictBase(BaseModel):
model_config = ConfigDict(extra="forbid")
class _FrozenBase(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class Pair(_FrozenBase, Generic[_T]):
x: _T
y: _T
def map(self, fn: Callable[[_T], _U]) -> Pair[_U]:
return Pair(x=fn(self.x), y=fn(self.y))
def argmax_coord(x: torch.Tensor) -> Tuple[int, ...]:
flat_idx = x.argmax()
return tuple(idx.item() for idx in torch.unravel_index(flat_idx, x.shape))
def compute_smaller_dtype(
dtypes: Pair[torch.dtype],
) -> Optional[torch.dtype]:
info_dict = {
(torch.float32, torch.bfloat16): torch.bfloat16,
# ... add more ...
}
return info_dict.get((dtypes.x, dtypes.y)) or info_dict.get((dtypes.y, dtypes.x))
def try_unify_shape(x: torch.Tensor, target_shape: torch.Size) -> torch.Tensor:
x_shape = x.shape
num_dim_to_remove = len(x_shape) - len(target_shape)
if (x_shape[num_dim_to_remove:] == target_shape) and all(
val == 1 for val in x_shape[:num_dim_to_remove]
):
return functools.reduce(lambda a, _: a.squeeze(0), range(num_dim_to_remove), x)
return x
# Copied from DeepGEMM
def calc_rel_diff(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
x, y = x.double(), y.double()
denominator = (x * x + y * y).sum()
sim = 2 * (x * y).sum() / denominator
return 1 - sim
def calc_per_token_rel_diff(
x: torch.Tensor, y: torch.Tensor, *, seq_dim: int
) -> torch.Tensor:
"""Cosine-distance-like metric per token position.
Sums over all dims except seq_dim.
"""
x, y = x.double(), y.double()
other_dims: list[int] = [d for d in range(x.dim()) if d != seq_dim]
if other_dims:
denominator: torch.Tensor = (x * x + y * y).sum(dim=other_dims)
sim: torch.Tensor = 2 * (x * y).sum(dim=other_dims) / (denominator + 1e-10)
else:
denominator = x * x + y * y
sim = 2 * (x * y) / (denominator + 1e-10)
return (1 - sim).float()
if TYPE_CHECKING:
from sglang.srt.debug_utils.comparator.output_types import SummaryRecord
def compute_exit_code(
summary: SummaryRecord,
*,
allow_skipped_pattern: str,
skipped_names: list[str],
allow_failed_pattern: Optional[str],
failed_names: list[str],
errored_names: Optional[list[str]] = None,
) -> int:
if summary.passed == 0:
return 1
if errored_names:
return 1
if not _is_all_match_pattern(pattern=allow_failed_pattern, strings=failed_names):
return 1
if not _is_all_match_pattern(pattern=allow_skipped_pattern, strings=skipped_names):
return 1
return 0
def _is_all_match_pattern(*, pattern: Optional[str], strings: list[str]) -> bool:
if pattern is None:
return len(strings) == 0
compiled: re.Pattern[str] = re.compile(pattern)
return all(compiled.fullmatch(s) for s in strings)
@@ -0,0 +1,3 @@
from sglang.srt.debug_utils.comparator.visualizer.figure import ( # noqa: F401
generate_comparison_figure,
)
@@ -0,0 +1,116 @@
"""Main orchestration logic for comparison figure generation."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import numpy as np
import torch
from sglang.srt.debug_utils.comparator.visualizer.preprocessing import (
_preprocess_tensor,
)
@dataclass(frozen=True)
class _PanelContext:
baseline_2d: torch.Tensor
target_2d: torch.Tensor
diff: Optional[torch.Tensor] # None when shapes differ
name: str
@dataclass(frozen=True)
class _Panel:
label: str
requires_diff: bool
draw: Callable[[np.ndarray, int, _PanelContext], Optional[str]]
def _build_panels() -> list[_Panel]:
from sglang.srt.debug_utils.comparator.visualizer.panels import (
_draw_baseline_heatmap,
_draw_diff_heatmap,
_draw_diff_histogram,
_draw_hist2d,
_draw_sampled,
_draw_target_heatmap,
)
return [
_Panel(
label="Baseline Heatmap", requires_diff=False, draw=_draw_baseline_heatmap
),
_Panel(label="Target Heatmap", requires_diff=False, draw=_draw_target_heatmap),
_Panel(label="Abs Diff Heatmap", requires_diff=True, draw=_draw_diff_heatmap),
_Panel(label="Abs Diff Hist", requires_diff=True, draw=_draw_diff_histogram),
_Panel(label="Hist2D", requires_diff=True, draw=_draw_hist2d),
_Panel(label="Sampled", requires_diff=True, draw=_draw_sampled),
]
def generate_comparison_figure(
*,
baseline: torch.Tensor,
target: torch.Tensor,
name: str,
output_path: Path,
) -> None:
"""Generate a multi-panel comparison PNG for a baseline/target tensor pair.
Panels (6 rows x 2 cols, left=normal, right=log10):
Row 0: Baseline heatmap
Row 1: Target heatmap
Row 2: Abs Diff heatmap
Row 3: Abs Diff histogram
Row 4: Hist2D scatter (baseline vs target density)
Row 5: Sampled scatter (10k sampled mini-heatmap)
"""
import matplotlib.pyplot as plt
baseline_f: torch.Tensor = baseline.detach().cpu().float()
target_f: torch.Tensor = target.detach().cpu().float()
can_diff: bool = baseline_f.shape == target_f.shape
baseline_2d: torch.Tensor = _preprocess_tensor(baseline_f)
target_2d: torch.Tensor = _preprocess_tensor(target_f)
diff: Optional[torch.Tensor] = (baseline_2d - target_2d).abs() if can_diff else None
ctx = _PanelContext(
baseline_2d=baseline_2d,
target_2d=target_2d,
diff=diff,
name=name,
)
panels: list[_Panel] = _build_panels()
active: list[_Panel] = [p for p in panels if not p.requires_diff or can_diff]
nrows: int = len(active)
ncols: int = 2
fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 3.5 * nrows))
if nrows == 1:
axes = axes.reshape(1, -1)
stats_lines: list[str] = []
for i, panel in enumerate(active):
stats_line: Optional[str] = panel.draw(axes, i, ctx)
if stats_line is not None:
stats_lines.append(stats_line)
num_stats: int = len(stats_lines)
title_height: float = 0.015 * num_stats + 0.015
fig.suptitle(
"\n".join(stats_lines),
fontsize=9,
family="monospace",
y=1 - title_height / 2,
)
plt.tight_layout(rect=[0, 0, 1, 1 - title_height])
output_path.parent.mkdir(parents=True, exist_ok=True)
plt.savefig(str(output_path), dpi=150, bbox_inches="tight")
plt.close(fig)
@@ -0,0 +1,226 @@
"""Panel draw functions for tensor comparison visualization."""
from __future__ import annotations
from typing import Optional
import numpy as np
import torch
from sglang.srt.debug_utils.comparator.visualizer.figure import _PanelContext
from sglang.srt.debug_utils.comparator.visualizer.preprocessing import (
_SCATTER_SAMPLE_SIZE,
_format_log_ticks,
_format_stats,
_maybe_downsample_numpy,
_safe_hist,
_to_log10,
)
def _draw_baseline_heatmap(
axes: np.ndarray, row_idx: int, ctx: _PanelContext
) -> Optional[str]:
_draw_heatmap_pair(
axes, row_idx=row_idx, t=ctx.baseline_2d, title=f"{ctx.name} Baseline"
)
return _format_stats("Baseline", ctx.baseline_2d)
def _draw_target_heatmap(
axes: np.ndarray, row_idx: int, ctx: _PanelContext
) -> Optional[str]:
_draw_heatmap_pair(
axes, row_idx=row_idx, t=ctx.target_2d, title=f"{ctx.name} Target"
)
return _format_stats("Target", ctx.target_2d)
def _draw_diff_heatmap(
axes: np.ndarray, row_idx: int, ctx: _PanelContext
) -> Optional[str]:
assert ctx.diff is not None
_draw_heatmap_pair(axes, row_idx=row_idx, t=ctx.diff, title=f"{ctx.name} Abs Diff")
return _format_stats("Abs Diff", ctx.diff)
def _draw_diff_histogram(
axes: np.ndarray, row_idx: int, ctx: _PanelContext
) -> Optional[str]:
assert ctx.diff is not None
_draw_histogram_pair(
axes, row_idx=row_idx, diff=ctx.diff, label=f"{ctx.name} Abs Diff"
)
return None
def _draw_hist2d(axes: np.ndarray, row_idx: int, ctx: _PanelContext) -> Optional[str]:
_draw_scatter_hist2d(
axes,
row_idx=row_idx,
baseline=ctx.baseline_2d,
target=ctx.target_2d,
label=ctx.name,
)
return None
def _draw_sampled(axes: np.ndarray, row_idx: int, ctx: _PanelContext) -> Optional[str]:
_draw_scatter_sampled(
axes,
row_idx=row_idx,
baseline=ctx.baseline_2d,
target=ctx.target_2d,
label=ctx.name,
)
return None
# ────────────────────── internal drawing helpers ──────────────────────
def _draw_heatmap_pair(
axes: np.ndarray,
*,
row_idx: int,
t: torch.Tensor,
title: str,
) -> None:
import matplotlib.pyplot as plt
ax_normal = axes[row_idx, 0]
ax_log = axes[row_idx, 1]
im = ax_normal.imshow(t.numpy(), aspect="auto", cmap="viridis")
ax_normal.set_title(title)
plt.colorbar(im, ax=ax_normal)
im_log = ax_log.imshow(_to_log10(t).numpy(), aspect="auto", cmap="viridis")
ax_log.set_title(f"{title} (Log10)")
cbar = plt.colorbar(im_log, ax=ax_log)
_format_log_ticks(cbar.ax, axis="y")
def _draw_histogram_pair(
axes: np.ndarray,
*,
row_idx: int,
diff: torch.Tensor,
label: str,
) -> None:
ax_normal = axes[row_idx, 0]
ax_log = axes[row_idx, 1]
diff_flat: np.ndarray = _maybe_downsample_numpy(diff.flatten())
_safe_hist(ax_normal, diff_flat, bins=100, edgecolor="none")
ax_normal.set_title(f"{label} Histogram")
ax_normal.set_xlabel("Abs Diff")
ax_normal.set_ylabel("Count")
log_flat: np.ndarray = np.log10(np.abs(diff_flat) + 1e-10)
_safe_hist(ax_log, log_flat, bins=100, edgecolor="none")
ax_log.set_title(f"{label} Histogram (Log10)")
ax_log.set_xlabel("Abs Diff")
ax_log.set_ylabel("Count")
_format_log_ticks(ax_log, axis="x")
def _draw_scatter_hist2d(
axes: np.ndarray,
*,
row_idx: int,
baseline: torch.Tensor,
target: torch.Tensor,
label: str,
) -> None:
import matplotlib.pyplot as plt
ax_normal = axes[row_idx, 0]
ax_log = axes[row_idx, 1]
b_flat: np.ndarray = _maybe_downsample_numpy(baseline.flatten())
t_flat: np.ndarray = _maybe_downsample_numpy(target.flatten())
min_len: int = min(len(b_flat), len(t_flat))
b_flat = b_flat[:min_len]
t_flat = t_flat[:min_len]
# Normal scale
lim: float = float(max(np.abs(b_flat).max(), np.abs(t_flat).max())) * 1.05
if lim == 0:
lim = 1.0
_h, _xe, _ye, im = ax_normal.hist2d(
b_flat,
t_flat,
bins=200,
range=[[-lim, lim], [-lim, lim]],
cmap="viridis",
norm="log",
)
ax_normal.plot([-lim, lim], [-lim, lim], "r--", linewidth=0.5)
ax_normal.set_title(f"{label} Hist2D")
ax_normal.set_xlabel("Baseline")
ax_normal.set_ylabel("Target")
ax_normal.set_aspect("equal")
plt.colorbar(im, ax=ax_normal)
# Log scale
b_log: np.ndarray = np.log10(np.abs(b_flat) + 1e-10)
t_log: np.ndarray = np.log10(np.abs(t_flat) + 1e-10)
vmin: float = float(min(b_log.min(), t_log.min())) - 0.5
vmax: float = float(max(b_log.max(), t_log.max())) + 0.5
_h2, _xe2, _ye2, im2 = ax_log.hist2d(
b_log,
t_log,
bins=200,
range=[[vmin, vmax], [vmin, vmax]],
cmap="viridis",
norm="log",
)
ax_log.plot([vmin, vmax], [vmin, vmax], "r--", linewidth=0.5)
ax_log.set_title(f"{label} Hist2D (Log10 Abs)")
ax_log.set_xlabel("Baseline")
ax_log.set_ylabel("Target")
ax_log.set_aspect("equal")
plt.colorbar(im2, ax=ax_log)
_format_log_ticks(ax_log, axis="both")
def _draw_scatter_sampled(
axes: np.ndarray,
*,
row_idx: int,
baseline: torch.Tensor,
target: torch.Tensor,
label: str,
) -> None:
import matplotlib.pyplot as plt
ax_baseline = axes[row_idx, 0]
ax_target = axes[row_idx, 1]
b_flat: np.ndarray = baseline.flatten().numpy()
t_flat: np.ndarray = target.flatten().numpy()
n_samples: int = min(_SCATTER_SAMPLE_SIZE, len(b_flat))
rng: np.random.Generator = np.random.default_rng(seed=42)
indices: np.ndarray = np.sort(rng.choice(len(b_flat), n_samples, replace=False))
b_sampled: np.ndarray = b_flat[indices]
t_sampled: np.ndarray = t_flat[indices]
side: int = int(np.sqrt(n_samples))
n_use: int = side * side
b_2d: np.ndarray = b_sampled[:n_use].reshape(side, side)
t_2d: np.ndarray = t_sampled[:n_use].reshape(side, side)
vmin: float = float(min(b_2d.min(), t_2d.min()))
vmax: float = float(max(b_2d.max(), t_2d.max()))
im_b = ax_baseline.imshow(b_2d, aspect="auto", cmap="viridis", vmin=vmin, vmax=vmax)
ax_baseline.set_title(f"{label} Baseline (10k sampled)")
plt.colorbar(im_b, ax=ax_baseline)
im_t = ax_target.imshow(t_2d, aspect="auto", cmap="viridis", vmin=vmin, vmax=vmax)
ax_target.set_title(f"{label} Target (10k sampled)")
plt.colorbar(im_t, ax=ax_target)
@@ -0,0 +1,101 @@
"""Tensor preprocessing and utility functions for visualization."""
from __future__ import annotations
import math
import re
import numpy as np
import torch
_DOWNSAMPLE_THRESHOLD: int = 10_000_000
_SCATTER_SAMPLE_SIZE: int = 10_000
def _preprocess_tensor(tensor: torch.Tensor) -> torch.Tensor:
t: torch.Tensor = tensor.squeeze()
while t.ndim < 2:
t = t.unsqueeze(0)
if t.ndim > 2:
t = t.reshape(-1, t.shape[-1])
t = _reshape_to_balanced_aspect(t)
return t
def _reshape_to_balanced_aspect(
t: torch.Tensor, max_ratio: float = 5.0
) -> torch.Tensor:
assert t.ndim == 2
h, w = t.shape
ratio: float = h / w if w > 0 else float("inf")
if 1 / max_ratio <= ratio <= max_ratio:
return t
total: int = h * w
target_side: int = int(math.sqrt(total))
for new_h in range(target_side, 0, -1):
if total % new_h == 0:
new_w: int = total // new_h
new_ratio: float = new_h / new_w
if 1 / max_ratio <= new_ratio <= max_ratio:
return t.reshape(new_h, new_w)
return t.reshape(1, -1)
# ────────────────────── utility ──────────────────────
def _to_log10(t: torch.Tensor) -> torch.Tensor:
return t.abs().clamp(min=1e-10).log10()
def _format_log_ticks(ax: object, axis: str = "both") -> None:
from matplotlib.ticker import FuncFormatter
formatter = FuncFormatter(
lambda x, _: f"1e{int(x)}" if x == int(x) else f"1e{x:.1f}"
)
if axis in ("x", "both"):
ax.xaxis.set_major_formatter(formatter)
if axis in ("y", "both"):
ax.yaxis.set_major_formatter(formatter)
def _format_stats(name: str, t: torch.Tensor) -> str:
return (
f"{name}: shape={tuple(t.shape)}, "
f"min={t.min().item():.4g}, max={t.max().item():.4g}, "
f"mean={t.mean().item():.4g}, std={t.std().item():.4g}"
)
def _safe_hist(
ax: object, data: np.ndarray, *, bins: int = 100, **kwargs: object
) -> None:
data_f64: np.ndarray = data.astype(np.float64)
try:
ax.hist(data_f64, bins=bins, **kwargs)
except ValueError:
ax.hist(data_f64, bins=max(1, len(np.unique(data_f64[:1000]))), **kwargs)
def _maybe_downsample_numpy(
t: torch.Tensor,
max_elements: int = _DOWNSAMPLE_THRESHOLD,
) -> np.ndarray:
if t.numel() <= max_elements:
return t.numpy()
rng: np.random.Generator = np.random.default_rng(seed=0)
indices: np.ndarray = rng.choice(t.numel(), max_elements, replace=False)
return t.numpy()[indices]
def _sanitize_filename(name: str) -> str:
return re.sub(r"[/\.\s]+", "_", name).strip("_")
@@ -0,0 +1,112 @@
"""CUDA coredump helpers.
When SGLANG_CUDA_COREDUMP=1, this module injects CUDA coredump environment
variables into the current process so that GPU exceptions (e.g. illegal
memory access) produce lightweight coredump files for post-mortem analysis
with cuda-gdb.
The injection happens at module import time via _inject_env() on a
best-effort basis. If any CUDA_* variable is already present in the
environment (e.g. set by the user in the shell), injection is skipped for
that variable and a warning is printed. For strict guarantees, set the
CUDA_* env vars in the shell before launching Python.
"""
import glob
import os
import warnings
from sglang.srt.environ import envs
_CUDA_COREDUMP_FLAGS = (
"skip_nonrelocated_elf_images,skip_global_memory,"
"skip_shared_memory,skip_local_memory,skip_constbank_memory"
)
def is_enabled() -> bool:
return envs.SGLANG_CUDA_COREDUMP.get()
def get_dump_dir() -> str:
# Resolve the base dir the same way as the uploader
# (.github/actions/upload-cuda-coredumps/action.yml) so they agree; an empty
# SGLANG_CUDA_COREDUMP_DIR counts as unset, like the action's `[ -n ... ]`.
explicit = envs.SGLANG_CUDA_COREDUMP_DIR.get()
runner_temp = os.getenv("RUNNER_TEMP")
if explicit:
base = explicit
elif runner_temp:
base = os.path.join(runner_temp, "sglang_cuda_coredumps")
else:
base = "/tmp/sglang_cuda_coredumps"
# Isolate dumps per (run, attempt): on a shared self-hosted runner a leftover
# dump from one job must not be picked up and mis-attributed by a later one.
run_id = os.getenv("GITHUB_RUN_ID")
if run_id:
attempt = os.getenv("GITHUB_RUN_ATTEMPT", "1")
return os.path.join(base, f"{run_id}-{attempt}")
return base
def _inject_env():
"""Inject CUDA coredump environment variables into the current process.
If a CUDA_* variable is already present, skip it and log a warning."""
dump_dir = get_dump_dir()
os.makedirs(dump_dir, exist_ok=True)
env_vars = {
"CUDA_ENABLE_COREDUMP_ON_EXCEPTION": "1",
"CUDA_COREDUMP_SHOW_PROGRESS": "1",
"CUDA_COREDUMP_GENERATION_FLAGS": _CUDA_COREDUMP_FLAGS,
"CUDA_COREDUMP_FILE": f"{dump_dir}/cuda_coredump_%h.%p.%t",
}
for key, value in env_vars.items():
if key in os.environ:
warnings.warn(
f"CUDA coredump env var {key} is already set to "
f"'{os.environ[key]}', skipping injection of '{value}'.",
stacklevel=2,
)
else:
os.environ[key] = value
def cleanup_dump_dir():
"""Remove stale coredump files from the dump directory."""
dump_dir = get_dump_dir()
for f in glob.glob(os.path.join(dump_dir, "cuda_coredump_*")):
os.remove(f)
def report():
"""Log any CUDA coredump files found after a test failure."""
dump_dir = get_dump_dir()
coredump_files = glob.glob(os.path.join(dump_dir, "cuda_coredump_*"))
if not coredump_files:
return
print(f"\n{'='*60}")
print(f"CUDA coredump(s) detected ({len(coredump_files)} file(s)):")
for f in coredump_files:
size_mb = os.path.getsize(f) / (1024 * 1024)
print(f" {f} ({size_mb:.1f} MB)")
print("Use cuda-gdb to analyze: cuda-gdb -c <coredump_file>")
run_id = os.environ.get("GITHUB_RUN_ID")
if run_id:
repo = os.environ.get("GITHUB_REPOSITORY", "sgl-project/sglang")
print(f"Download from CI: gh run download {run_id} --repo {repo}")
print(f"{'='*60}\n")
# Auto-inject CUDA coredump env vars at import time.
# The sentinel env var is inherited by child processes, so injection only
# happens once in the top-level process.
_SENTINEL = "_SGLANG_CUDA_COREDUMP_INJECTED"
if is_enabled() and _SENTINEL not in os.environ:
os.environ[_SENTINEL] = "1"
print(f"Injecting CUDA coredump env vars (pid={os.getpid()})")
_inject_env()
@@ -0,0 +1,296 @@
"""Simplified dump comparator — a self-contained single-file script for comparing
two dump directories tensor-by-tensor.
For advanced features (unshard, token alignment, per-dimension annotations), see the
full ``comparator/`` package: ``python -m sglang.srt.debug_utils.comparator``.
"""
import argparse
import functools
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional
import torch
from sglang.srt.debug_utils.dumper import get_truncated_value
def main(args):
import polars as pl
from sglang.srt.debug_utils.dump_loader import find_row, read_meta
df_target = read_meta(args.target_path)
df_target = df_target.filter(
(pl.col("step") >= args.start_step) & (pl.col("step") <= args.end_step)
)
if args.filter:
df_target = df_target.filter(pl.col("filename").str.contains(args.filter))
assert all(c in df_target.columns for c in ["rank", "step", "dump_index", "name"])
df_baseline = read_meta(args.baseline_path)
print("df_target", df_target)
print("df_baseline", df_baseline)
tensor_dim_descs: List[TensorDimDesc] = _get_tensor_dim_descs()
for row in df_target.iter_rows(named=True):
path_target = Path(args.target_path) / row["filename"]
tensor_dim_desc: Optional[TensorDimDesc] = None
if tensor_dim_descs:
matched: list[TensorDimDesc] = [
desc
for desc in tensor_dim_descs
if re.search(desc.pattern, row["filename"]) is not None
]
if matched:
tensor_dim_desc = matched[0]
row_baseline = find_row(
df_baseline,
conditions=dict(
step=row["step"],
**{
k: v
for k, v in row.items()
if k not in ["step", "dump_index", "filename"]
},
),
)
if row_baseline is None:
print(f"Skip: target={str(path_target)} since no baseline")
x_target = _load_object(path_target)
if x_target is not None:
print(f"x_target(sample)={get_truncated_value(x_target)}")
continue
path_baseline = Path(args.baseline_path) / row_baseline["filename"]
print(
f"Check:\n"
f"target={str(path_target)} (duplicate_index={row['duplicate_index']})\n"
f"baseline={str(path_baseline)} (duplicate_index={row_baseline['duplicate_index']})"
)
check_tensor_pair(
path_baseline=path_baseline,
path_target=path_target,
diff_threshold=args.diff_threshold,
name=row["name"],
tensor_dim_desc=tensor_dim_desc,
)
print()
def check_tensor_pair(
path_baseline,
path_target,
diff_threshold: float = 1e-3,
name="",
tensor_dim_desc: Optional["TensorDimDesc"] = None,
):
x_baseline = _load_object(path_baseline)
x_target = _load_object(path_target)
if x_baseline is None or x_target is None:
print(
f"Skip comparison because of None: x_baseline={x_baseline}, x_target={x_target}"
)
return
print(
f"Raw "
f"[shape] {x_baseline.shape} vs {x_target.shape}\t"
f"[{'' if x_baseline.dtype == x_target.dtype else '🟠'}dtype] {x_baseline.dtype} vs {x_target.dtype}"
)
if tensor_dim_desc is not None:
import einops
x_baseline = einops.rearrange(
x_baseline,
tensor_dim_desc.baseline_desc + " -> " + tensor_dim_desc.target_desc,
)
if tensor_dim_desc.baseline_cropper is not None:
print("Apply baseline_cropper")
x_baseline = tensor_dim_desc.baseline_cropper(x_baseline)
x_baseline, x_target = _comparison_preprocessor(x_baseline, x_target, name=name)
x_baseline = _try_unify_shape(x_baseline, target_shape=x_target.shape)
print(
f"After preprocessor "
f"[shape] {x_baseline.shape} vs {x_target.shape}\t"
f"[dtype] {x_baseline.dtype} vs {x_target.dtype}"
)
x_baseline_original_dtype = x_baseline.dtype
x_target_original_dtype = x_target.dtype
x_target = x_target.float()
x_baseline = x_baseline.float()
for name, fn in [
("mean", torch.mean),
("std", torch.std),
("min", torch.min),
("max", torch.max),
*(
[
("p1", functools.partial(torch.quantile, q=0.01)),
("p5", functools.partial(torch.quantile, q=0.05)),
("p95", functools.partial(torch.quantile, q=0.95)),
("p99", functools.partial(torch.quantile, q=0.99)),
]
if x_baseline.numel() < 10_000_000
else []
),
]:
value_baseline = fn(x_baseline).item()
value_target = fn(x_target).item()
print(
f"[{name}] {value_baseline :.4f} vs {value_target:.4f} (diff: {value_target - value_baseline:.4f})"
)
if x_baseline.shape != x_target.shape:
print(f"⚠️ Shape mismatch")
return
diff_info = _compute_and_print_diff(
x_baseline=x_baseline,
x_target=x_target,
diff_threshold=diff_threshold,
)
needs_print = diff_info["max_abs_diff"] > 1e-3
if (x_baseline_original_dtype != x_target_original_dtype) and (
(
downcast_dtype := _compute_smaller_dtype(
x_baseline_original_dtype, x_target_original_dtype
)
)
is not None
):
_compute_and_print_diff(
x_baseline=x_baseline.to(downcast_dtype),
x_target=x_target.to(downcast_dtype),
diff_threshold=diff_threshold,
prefix_text=f"When downcast to {downcast_dtype}: ",
)
if needs_print:
print(f"x_baseline(sample)={get_truncated_value(x_baseline)}")
print(f"x_target(sample)={get_truncated_value(x_target)}")
def _compute_and_print_diff(
x_baseline, x_target, diff_threshold: float, prefix_text=""
):
raw_abs_diff = (x_target - x_baseline).abs()
max_abs_diff = raw_abs_diff.max().item()
mean_abs_diff = raw_abs_diff.mean().item()
rel_diff = _calc_rel_diff(x_target, x_baseline)
rel_diff_marker: str = "" if rel_diff > diff_threshold else ""
print(
prefix_text
+ f"{rel_diff_marker} rel_diff={rel_diff}\t"
+ f"max_abs_diff={max_abs_diff}\t"
+ f"mean_abs_diff={mean_abs_diff}"
)
max_diff_coord = _argmax_coord(raw_abs_diff)
print(
f"max_abs_diff happens at coord={max_diff_coord} with "
f"baseline={x_baseline[max_diff_coord].item()} "
f"target={x_target[max_diff_coord].item()}"
)
return dict(max_abs_diff=max_abs_diff)
def _argmax_coord(x: torch.Tensor) -> tuple:
flat_idx = x.argmax()
return tuple(idx.item() for idx in torch.unravel_index(flat_idx, x.shape))
def _compute_smaller_dtype(dtype_a, dtype_b):
info_dict = {
(torch.float32, torch.bfloat16): torch.bfloat16,
# ... add more ...
}
return info_dict.get((dtype_a, dtype_b)) or info_dict.get((dtype_b, dtype_a))
def _try_unify_shape(x: torch.Tensor, target_shape):
x_shape = x.shape
num_dim_to_remove = len(x_shape) - len(target_shape)
if (x_shape[num_dim_to_remove:] == target_shape) and all(
val == 1 for val in x_shape[:num_dim_to_remove]
):
out = functools.reduce(lambda a, _: a.squeeze(0), range(num_dim_to_remove), x)
print(f"Unify shape: {x_shape} -> {out.shape} (to match {target_shape})")
return out
return x
# Copied from DeepGEMM
def _calc_rel_diff(x: torch.Tensor, y: torch.Tensor):
x, y = x.double(), y.double()
denominator = (x * x + y * y).sum()
sim = 2 * (x * y).sum() / denominator
return 1 - sim
def _load_object(path):
try:
x = torch.load(path, weights_only=False)
except Exception as e:
print(f"Skip load {path} since error {e}")
return None
if isinstance(x, dict) and "value" in x:
x = x["value"]
if not isinstance(x, torch.Tensor):
print(f"Skip load {path} since {type(x)=} is not a Tensor ({x=})")
return None
return x.cuda()
def _comparison_preprocessor(x_baseline, x_target, name):
"""Customization endpoint. Can insert arbitrary adhoc postprocessing logic here."""
return x_baseline, x_target
@dataclass
class TensorDimDesc:
pattern: str
baseline_desc: str
target_desc: str
baseline_cropper: Optional[Callable[[torch.Tensor], torch.Tensor]] = None
def _get_tensor_dim_descs() -> List[TensorDimDesc]:
"""Customization endpoint. Return a list of TensorDimDesc to rearrange baseline
dimensions to match target layout via einops before comparison."""
return []
if __name__ == "__main__":
# python -m sglang.srt.debug_utils.dump_comparator --baseline-path ... --target-path ...
parser = argparse.ArgumentParser()
parser.add_argument("--baseline-path", type=str)
parser.add_argument("--target-path", type=str)
parser.add_argument("--start-step", type=int, default=0)
parser.add_argument("--end-step", type=int, default=1000000)
parser.add_argument("--diff-threshold", type=float, default=1e-3)
parser.add_argument(
"--filter", type=str, default=None, help="Regex to filter filenames"
)
args = parser.parse_args()
main(args)
@@ -0,0 +1,183 @@
import functools
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
import polars as pl
import torch
LOAD_FAILED: object = object()
def parse_meta_from_filename(path: Path) -> Dict[str, Any]:
stem = Path(path).stem
result: Dict[str, Any] = {}
for kv in stem.split("___"):
if "=" in kv:
k, v = kv.split("=", 1)
result[k] = v
for field_name, converter in _TYPED_FIELDS:
if field_name in result:
result[field_name] = converter(result[field_name])
return result
@dataclass
class ValueWithMeta:
value: Any
meta: Dict[str, Any]
@staticmethod
def load(path: Path) -> "ValueWithMeta":
path = Path(path)
meta_from_filename = parse_meta_from_filename(path)
try:
raw = torch.load(path, weights_only=False, map_location="cpu")
except Exception as e:
print(f"Skip load {path} since error {e}")
return ValueWithMeta(
value=LOAD_FAILED, meta={**meta_from_filename, "filename": path.name}
)
value, meta_from_embedded = _unwrap_dict_format(raw)
return ValueWithMeta(
value=value,
meta={**meta_from_filename, **meta_from_embedded, "filename": path.name},
)
def _unwrap_dict_format(obj: Any) -> Tuple[Any, Dict[str, Any]]:
if isinstance(obj, dict) and "value" in obj:
meta = obj.get("meta", {})
assert isinstance(meta, dict), f"Expected meta to be dict, got {type(meta)}"
return obj["value"], meta
return obj, {}
class DumpLoader:
def __init__(self):
directory = os.environ.get("SGLANG_DUMP_LOADER_DIR")
self._enable = directory is not None
if self._enable:
self._directory = Path(directory)
self._df = read_meta(directory)
@property
def enable(self):
return self._enable
def load(self, name, **kwargs):
assert self._enable, "Please call DumpLoader.load only when it is enabled"
from sglang.srt.debug_utils.dumper import dumper
step = dumper._state.step
conditions = dict(name=name, step=step, **kwargs)
row = find_row(self._df, conditions=conditions)
assert (
row is not None
), f"DumpLoader cannot find row given query {name=} {kwargs=} {self._directory=}"
path = self._directory / row["filename"]
output = torch.load(path, weights_only=False)
if isinstance(output, dict) and "value" in output:
output = output["value"]
print(
f"[DumpLoader] load from {path=} (query: {name=} {kwargs=}, output: {type(output)})"
)
return output
def read_meta(directory):
directory = Path(directory)
assert directory.is_dir(), f"{directory=} should be a directory"
rows = []
for p in directory.glob("*.pt"):
try:
full_kwargs = parse_meta_from_filename(p)
rows.append(
{
"filename": str(p.name),
**full_kwargs,
}
)
except Exception as e:
print(f"[DumpLoader] skip loading {p} due to error {e}")
df = pl.DataFrame(rows)
df = df.with_columns(
pl.col("step").cast(int),
pl.col("rank").cast(int),
pl.col("dump_index").cast(int),
)
df = _add_duplicate_index(df)
df = df.sort("rank", "dump_index")
return df
def _add_duplicate_index(df: pl.DataFrame) -> pl.DataFrame:
group_cols = [c for c in df.columns if c not in ["filename", "dump_index"]]
df = df.sort(group_cols + ["dump_index"])
df = df.with_columns(
pl.cum_count("dump_index").over(group_cols).sub(1).alias("duplicate_index")
)
return df
def filter_rows(df: pl.DataFrame, conditions: Dict[str, Any]) -> list[dict]:
filter_exprs = [
(
pl.col(col) == _cast_to_polars_dtype(conditions[col], df.schema[col])
if conditions[col] is not None
else pl.col(col).is_null()
)
for col in conditions
if col in df.columns
]
if not filter_exprs:
return []
return df.filter(functools.reduce(lambda a, b: a & b, filter_exprs)).to_dicts()
def find_row(df: pl.DataFrame, conditions: Dict[str, Any]):
rows = filter_rows(df, conditions)
if len(rows) > 1:
print(f"find_row find ambiguous results: {rows=}")
return None
return rows[0] if rows else None
def _cast_to_polars_dtype(value, target_dtype):
if target_dtype in (pl.Int64, pl.Int32, pl.UInt64, pl.UInt32):
return int(value)
elif target_dtype in (pl.Float64, pl.Float32):
return float(value)
elif target_dtype == pl.Boolean:
return bool(value)
elif target_dtype == pl.String:
return str(value)
else:
return value
def read_tokenizer_path(directory: Path) -> Optional[str]:
"""Read tokenizer_path from any .pt file's embedded metadata in a dump directory."""
for p in directory.glob("*.pt"):
item: ValueWithMeta = ValueWithMeta.load(p)
tokenizer_path: Optional[str] = item.meta.get("tokenizer_path")
if tokenizer_path is not None:
return str(tokenizer_path)
return None
_TYPED_FIELDS: list[tuple[str, Callable[[str], Any]]] = [
("rank", int),
]
dump_loader = DumpLoader()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
_PATTERN_DECODE = (
r"(\(\w+ pid=(?P<pid>\d+)(?:,\s*ip=(?P<ip>[\d\.]+))?\))?\s*"
r"\[(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r"(?:\s+DP(?P<dp_rank>\d+))?"
r"(?:\s+TP(?P<tp_rank>\d+))?"
r"(?:\s+EP(?P<ep_rank>\d+))?"
r"(?:\s+PP(?P<pp_rank>\d+))?"
r"\]\s+"
r"Decode batch( \[\d+\])?,\s+"
r"#running-req:\s*(?P<num_running_req>\d+),\s+"
r"#token:\s*(?P<num_token>\d+),\s+"
r"token usage:\s*(?P<token_usage>[0-9.]+),\s+"
r".*?"
r"gen throughput \(token/s\):\s*(?P<gen_throughput>[0-9.]+),\s+"
r"#queue-req:\s*(?P<queue_req>\d+),"
)
def parse(lines):
import polars as pl
df = pl.DataFrame(dict(line=lines.splitlines()))
df = df.with_columns(info=pl.col("line").str.extract_groups(_PATTERN_DECODE))
df = df.unnest("info")
df = df.filter(pl.col("gen_throughput").is_not_null())
df = df.with_columns(
pl.col("time").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S"),
*[
pl.col(col).cast(dtype)
for col, dtype in [
("pid", pl.Int64),
("dp_rank", pl.Int64),
("tp_rank", pl.Int64),
("ep_rank", pl.Int64),
("pp_rank", pl.Int64),
("num_running_req", pl.Int64),
("num_token", pl.Int64),
("token_usage", pl.Float64),
("gen_throughput", pl.Float64),
("queue_req", pl.Int64),
]
if col in df.columns
],
)
return df
@@ -0,0 +1,112 @@
# This file also references Slime :: fp8_cast_bf16.py
import json
import os
import re
from argparse import ArgumentParser
from pathlib import Path
from typing import Dict
import torch
from huggingface_hub import snapshot_download
from safetensors.torch import load_file, save_file
def main(args):
dir_input = Path(_maybe_snapshot_download(args.input))
dir_output = Path(args.output)
print(f"{dir_input=} {dir_output=}")
dir_output.mkdir(parents=True, exist_ok=True)
for pattern in ["generation_config.json", "*.py", "tokenizer*"]:
os.system(f"cp -rf {dir_input}/{pattern} {dir_output}")
_transform_json(
dir_input,
dir_output,
"config.json",
lambda data: _transform_config(args, data),
)
safetensors_index = _transform_json(
dir_input,
dir_output,
"model.safetensors.index.json",
lambda data: _transform_safetensors_index(args, data),
)
for path_input_safetensors in sorted(list(dir_input.glob("*.safetensors"))):
path_output_safetensors = dir_output / path_input_safetensors.relative_to(
dir_input
)
state_dict = load_file(path_input_safetensors)
_transform_safetensors_file(
state_dict, safetensors_index, debug_name=str(path_output_safetensors)
)
if len(state_dict) > 0:
print(f"Save {len(state_dict)} tensors to {path_output_safetensors}")
save_file(state_dict, path_output_safetensors)
else:
print(f"Skip saving {path_output_safetensors} since it is empty")
def _maybe_snapshot_download(path):
if Path(path).exists():
return path
return snapshot_download(path)
def _transform_json(dir_input, dir_output, filename, fn):
data = json.loads((dir_input / filename).read_text())
fn(data)
(dir_output / filename).write_text(json.dumps(data, indent=4))
return data
def _transform_config(args, config_json):
config_json["num_hidden_layers"] = args.keep_num_layers
def _transform_safetensors_index(args, safetensors_index):
weight_map = safetensors_index["weight_map"]
weight_map = {
name: loc for name, loc in weight_map.items() if _filter_tensor_name(args, name)
}
safetensors_index["weight_map"] = weight_map
def _transform_safetensors_file(
state_dict: Dict[str, torch.Tensor], safetensors_index, debug_name: str
):
names_to_remove = set(state_dict) - set(safetensors_index["weight_map"])
print(f"Remove {list(names_to_remove)} in {debug_name}")
for name in names_to_remove:
del state_dict[name]
def _filter_tensor_name(args, tensor_name: str):
# We focus on DeepSeek-like names currently, but can be easily extended to more kinds of models
m = re.match(r"^model.layers.(\d+).*", tensor_name)
if m is None:
return True
layer_id = int(m.group(1))
return layer_id < args.keep_num_layers
if __name__ == "__main__":
"""
Example:
python -m sglang.srt.debug_utils.model_truncator --input deepseek-ai/DeepSeek-V3-0324 --output /tmp/DeepSeek-V3-0324-5layer
hf upload my_name/DeepSeek-V3-0324-5layer /tmp/DeepSeek-V3-0324-5layer
Alternatively, the following may be used on-the-fly.
But this may not be useful to test RL frameworks, and sometimes it may have issues.
--json-model-override-args '{"num_hidden_layers": 5}'
"""
parser = ArgumentParser(description="Create truncated model for fast debugging.")
parser.add_argument("--input", type=str, required=True)
parser.add_argument("--output", type=str, required=True)
parser.add_argument("--keep-num-layers", type=int, default=5)
main(parser.parse_args())
@@ -0,0 +1,118 @@
"""Reverse-apply historical PR fixes for regression-style tests."""
from __future__ import annotations
from typing import Dict
from sglang.srt.debug_utils.source_patcher import apply_patches_from_config
from sglang.srt.environ import envs
_PR_REVERT_YAML_25015 = """
patches:
- target: sglang.srt.speculative.eagle_worker_v2.EagleDraftWorker.draft_forward
edits:
- match: |
forward_batch.out_cache_loc = out_cache_loc[i]
spec_info.hidden_states = hidden_states
replacement: |
forward_batch.out_cache_loc = out_cache_loc[i]
forward_batch.positions.add_(1)
spec_info.hidden_states = hidden_states
- match: |
hidden_states = logits_output.hidden_states
forward_batch.positions.add_(1)
replacement: |
hidden_states = logits_output.hidden_states
- target: sglang.srt.speculative.eagle_draft_cuda_graph_runner.EAGLEDraftCudaGraphRunner.capture_one_shape
edits:
- match: |
forward_batch.spec_info.hidden_states = hidden_states_backup
forward_batch.positions.sub_(self.eagle_worker.speculative_num_steps - 1)
return ret
replacement: |
forward_batch.spec_info.hidden_states = hidden_states_backup
return ret
"""
_PR_REVERT_YAML_26329 = """
patches:
- target: sglang.srt.speculative.eagle_utils._eagle_prefill_tail_tokens
edits:
- match: |
tail_tokens = next_token_ids.to(batch.input_ids.dtype)
prepend: |
return next_token_ids.to(batch.input_ids.dtype)
"""
_PR_REVERT_YAML_27338 = """
patches:
- target: sglang.srt.layers.attention.flashinfer_backend.FlashInferMultiStepDraftBackend.init_cuda_graph_state
edits:
- match: |
(self.speculative_num_steps, max_bs * self.topk * self.max_context_len),
replacement: |
(self.speculative_num_steps, max_bs * self.max_context_len),
"""
_PR_REVERT_YAML_27360 = """
patches:
- target: sglang.srt.layers.attention.flashattention_backend.FlashAttentionBackend._apply_cuda_graph_metadata
edits:
- match: |
cache_loc = cache_loc[:, :decode_length]
replacement: ""
"""
_PR_REVERT_YAML_26972 = """
patches:
- target: sglang.srt.mem_cache.common.get_req_to_token_extra_context_len
edits:
- match: |
if (
server_args.speculative_algorithm is not None
and server_args.page_size > 1
and (server_args.speculative_eagle_topk or 1) > 1
):
extra = max(extra, get_alloc_reserve_per_decode(server_args))
replacement: ""
"""
_PR_REVERT_YAML_27460 = """
patches:
- target: sglang.srt.layers.attention.flashinfer_mla_backend.FlashInferMLAMultiStepDraftBackend.init_cuda_graph_state
edits:
- match: |
(self.speculative_num_steps, max_bs * self.topk * self.max_context_len),
replacement: |
(self.speculative_num_steps, max_bs * self.max_context_len),
"""
_PR_FIX_REVERT_YAML: Dict[int, str] = {
25015: _PR_REVERT_YAML_25015,
26329: _PR_REVERT_YAML_26329,
27338: _PR_REVERT_YAML_27338,
27360: _PR_REVERT_YAML_27360,
26972: _PR_REVERT_YAML_26972,
27460: _PR_REVERT_YAML_27460,
}
def maybe_revert_pr_fix() -> None:
if pr_num := envs.SGLANG_DEBUG_REVERT_PR.get():
_revert_pr_fix(pr_num)
def _revert_pr_fix(pr_num: int) -> None:
if pr_num not in _PR_FIX_REVERT_YAML:
raise NotImplementedError(
f"PR #{pr_num} revert is not registered; "
f"available: {sorted(_PR_FIX_REVERT_YAML.keys())}"
)
apply_patches_from_config(_PR_FIX_REVERT_YAML[pr_num])
@@ -0,0 +1,51 @@
from sglang.srt.debug_utils.schedule_simulator.data_source import (
generate_gsp_requests,
generate_random_requests,
load_from_request_logger,
)
from sglang.srt.debug_utils.schedule_simulator.entrypoint import create_arg_parser, main
from sglang.srt.debug_utils.schedule_simulator.gpu_state import GPUState, StepRecord
from sglang.srt.debug_utils.schedule_simulator.metrics import (
AttentionComputeBalancednessRecorder,
AvgBatchSizeRecorder,
BatchSizeBalancednessRecorder,
MetricRecorder,
)
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
from sglang.srt.debug_utils.schedule_simulator.routers import (
RandomRouter,
RoundRobinRouter,
RouterPolicy,
StickyRouter,
)
from sglang.srt.debug_utils.schedule_simulator.schedulers import (
FIFOScheduler,
SchedulerPolicy,
)
from sglang.srt.debug_utils.schedule_simulator.simulator import (
SimulationResult,
Simulator,
)
__all__ = [
"SimRequest",
"GPUState",
"Simulator",
"SimulationResult",
"StepRecord",
"RouterPolicy",
"RandomRouter",
"RoundRobinRouter",
"StickyRouter",
"SchedulerPolicy",
"FIFOScheduler",
"MetricRecorder",
"BatchSizeBalancednessRecorder",
"AttentionComputeBalancednessRecorder",
"AvgBatchSizeRecorder",
"load_from_request_logger",
"generate_random_requests",
"generate_gsp_requests",
"create_arg_parser",
"main",
]
@@ -0,0 +1,6 @@
from sglang.srt.debug_utils.schedule_simulator.entrypoint import create_arg_parser, main
if __name__ == "__main__":
parser = create_arg_parser()
args = parser.parse_args()
main(args)
@@ -0,0 +1,13 @@
from sglang.srt.debug_utils.schedule_simulator.data_source.data_loader import (
load_from_request_logger,
)
from sglang.srt.debug_utils.schedule_simulator.data_source.data_synthesis import (
generate_gsp_requests,
generate_random_requests,
)
__all__ = [
"load_from_request_logger",
"generate_random_requests",
"generate_gsp_requests",
]
@@ -0,0 +1,34 @@
import json
from pathlib import Path
from typing import List, Union
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
def load_from_request_logger(file_path: Union[str, Path]) -> List[SimRequest]:
requests = []
file_path = Path(file_path)
with file_path.open(encoding="utf-8") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line or not line.startswith("{"):
continue
data = json.loads(line)
if data.get("event") != "request.finished":
continue
rid = data.get("rid", f"req_{line_num}")
meta_info = data["out"]["meta_info"]
requests.append(
SimRequest(
request_id=rid,
input_len=meta_info["prompt_tokens"],
output_len=meta_info["completion_tokens"],
)
)
return requests
@@ -0,0 +1,79 @@
import random
from typing import List, Optional
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
def generate_random_requests(
num_requests: int,
input_len: int,
output_len: int,
range_ratio: float = 1.0,
seed: Optional[int] = None,
) -> List[SimRequest]:
if seed is not None:
random.seed(seed)
requests = []
for i in range(num_requests):
isl = _random_len(input_len, range_ratio)
osl = _random_len(output_len, range_ratio)
requests.append(
SimRequest(
request_id=f"syn{i}",
input_len=isl,
output_len=osl,
)
)
print(
f"Generated {len(requests)} random requests "
f"(input_len={input_len}, output_len={output_len}, range_ratio={range_ratio})"
)
return requests
def generate_gsp_requests(
num_groups: int,
prompts_per_group: int,
system_prompt_len: int,
question_len: int,
output_len: int,
range_ratio: float = 1.0,
seed: Optional[int] = None,
) -> List[SimRequest]:
if seed is not None:
random.seed(seed)
requests = []
idx = 0
for group_idx in range(num_groups):
group_id = f"g{group_idx}"
prefix_len = _random_len(system_prompt_len, range_ratio)
for _ in range(prompts_per_group):
q_len = _random_len(question_len, range_ratio)
osl = _random_len(output_len, range_ratio)
requests.append(
SimRequest(
request_id=f"gsp{idx}",
input_len=prefix_len + q_len,
output_len=osl,
group_id=group_id,
prefix_len=prefix_len,
)
)
idx += 1
random.shuffle(requests)
print(
f"Generated {len(requests)} GSP requests "
f"({num_groups} groups x {prompts_per_group} prompts, "
f"system_prompt_len={system_prompt_len}, question_len={question_len}, "
f"output_len={output_len})"
)
return requests
def _random_len(full_len: int, range_ratio: float) -> int:
min_len = max(int(full_len * range_ratio), 1)
return random.randint(min_len, full_len)
@@ -0,0 +1,168 @@
import argparse
import json
import random
from typing import List
from sglang.srt.debug_utils.schedule_simulator.data_source.data_loader import (
load_from_request_logger,
)
from sglang.srt.debug_utils.schedule_simulator.data_source.data_synthesis import (
generate_gsp_requests,
generate_random_requests,
)
from sglang.srt.debug_utils.schedule_simulator.metrics import (
AttentionComputeBalancednessRecorder,
AvgBatchSizeRecorder,
BatchSizeBalancednessRecorder,
)
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
from sglang.srt.debug_utils.schedule_simulator.routers import (
RandomRouter,
RoundRobinRouter,
StickyRouter,
)
from sglang.srt.debug_utils.schedule_simulator.schedulers import FIFOScheduler
from sglang.srt.debug_utils.schedule_simulator.simulator import (
SimulationResult,
Simulator,
)
def create_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Schedule Simulator for analyzing request scheduling across GPUs"
)
data_group = parser.add_mutually_exclusive_group(required=True)
data_group.add_argument(
"--input", type=str, help="Path to request_logger JSON file"
)
data_group.add_argument(
"--synthetic", action="store_true", help="Use synthetic data generation"
)
data_group.add_argument(
"--synth-gsp",
action="store_true",
help="Use generated-shared-prefix (GSP) data generation",
)
# Shared synthetic arguments
parser.add_argument("--synth-seed", type=int, default=None)
# Random dataset arguments (aligned with bench_serving.py --random-* options)
parser.add_argument("--synth-random-num-requests", type=int, default=1000)
parser.add_argument("--synth-random-input-len", type=int, default=1024)
parser.add_argument("--synth-random-output-len", type=int, default=256)
parser.add_argument("--synth-random-range-ratio", type=float, default=0.0)
# GSP dataset arguments (aligned with bench_serving.py --gsp-* options)
parser.add_argument("--synth-gsp-num-groups", type=int, default=64)
parser.add_argument("--synth-gsp-prompts-per-group", type=int, default=16)
parser.add_argument("--synth-gsp-system-prompt-len", type=int, default=2048)
parser.add_argument("--synth-gsp-question-len", type=int, default=128)
parser.add_argument("--synth-gsp-output-len", type=int, default=256)
parser.add_argument("--synth-gsp-range-ratio", type=float, default=1.0)
parser.add_argument("--num-gpus-per-engine", type=int, default=8)
parser.add_argument("--num-engines", type=int, default=1)
parser.add_argument(
"--router",
type=str,
choices=["random", "round_robin", "sticky"],
default="round_robin",
)
parser.add_argument("--scheduler", type=str, choices=["fifo"], default="fifo")
parser.add_argument("--max-total-tokens", type=int, default=100000)
parser.add_argument(
"--stop-criteria",
type=str,
choices=["all_done", "exist_no_pending"],
default="all_done",
help="all_done: run until all requests complete; exist_no_pending: stop when any GPU has no pending requests",
)
parser.add_argument("--max-steps", type=int, default=None)
parser.add_argument("--output", type=str, default=None)
parser.add_argument("--log-level", type=int, choices=[0, 1, 2], default=0)
return parser
def _load_requests(args: argparse.Namespace) -> List[SimRequest]:
if args.input:
requests = load_from_request_logger(args.input)
print(f"Loaded {len(requests)} requests from {args.input}")
elif args.synth_gsp:
requests = generate_gsp_requests(
num_groups=args.synth_gsp_num_groups,
prompts_per_group=args.synth_gsp_prompts_per_group,
system_prompt_len=args.synth_gsp_system_prompt_len,
question_len=args.synth_gsp_question_len,
output_len=args.synth_gsp_output_len,
range_ratio=args.synth_gsp_range_ratio,
seed=args.synth_seed,
)
else:
requests = generate_random_requests(
num_requests=args.synth_random_num_requests,
input_len=args.synth_random_input_len,
output_len=args.synth_random_output_len,
range_ratio=args.synth_random_range_ratio,
seed=args.synth_seed,
)
return requests
def _create_router(name: str, total_gpus: int):
if name == "random":
return RandomRouter(total_gpus)
if name == "round_robin":
return RoundRobinRouter(total_gpus)
if name == "sticky":
return StickyRouter(total_gpus)
raise ValueError(f"Unknown router: {name}")
def _create_scheduler(name: str):
if name == "fifo":
return FIFOScheduler()
raise ValueError(f"Unknown scheduler: {name}")
def main(args: argparse.Namespace) -> SimulationResult:
if args.synth_seed is not None:
random.seed(args.synth_seed)
requests = _load_requests(args)
total_gpus = args.num_gpus_per_engine * args.num_engines
router = _create_router(args.router, total_gpus)
scheduler = _create_scheduler(args.scheduler)
sim = Simulator(
num_gpus_per_engine=args.num_gpus_per_engine,
router=router,
scheduler=scheduler,
recorders=[
BatchSizeBalancednessRecorder(),
AttentionComputeBalancednessRecorder(),
AvgBatchSizeRecorder(),
],
log_level=args.log_level,
max_total_tokens=args.max_total_tokens,
stop_criteria=args.stop_criteria,
max_steps=args.max_steps,
)
print(
f"Running simulation with {args.num_gpus_per_engine} GPUs/engine x {args.num_engines} engines, router={args.router}, scheduler={args.scheduler}"
)
result = sim.run(requests)
print("\n=== Summary ===")
for key, value in result.summary.items():
print(f"{key}: {value:.4f}" if isinstance(value, float) else f"{key}: {value}")
if args.output:
with open(args.output, "w") as f:
json.dump(result.summary, f, indent=2)
print(f"\nSummary saved to {args.output}")
return result
@@ -0,0 +1,70 @@
from dataclasses import dataclass, field
from typing import List, Optional
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
@dataclass
class StepRecord:
step: int
gpu_id: int
running_count: int
pending_count: int
total_seq_len: int
running_req_ids: List[str] = field(default_factory=list)
pending_req_ids: List[str] = field(default_factory=list)
@dataclass
class GPUState:
gpu_id: int
max_total_tokens: int
pending_requests: List[SimRequest] = field(default_factory=list)
running_requests: List[SimRequest] = field(default_factory=list)
def batch_size(self) -> int:
return len(self.running_requests)
def total_attention_compute(self) -> int:
return sum(req.seq_len() for req in self.running_requests)
def total_seq_len(self, extra_reqs: Optional[List[SimRequest]] = None) -> int:
seen_groups = set()
total = 0
for req in self.running_requests + (extra_reqs or []):
is_shared = req.group_id is not None and req.group_id in seen_groups
total += req.seq_len() - (req.prefix_len if is_shared else 0)
if req.group_id is not None:
seen_groups.add(req.group_id)
return total
def is_valid(self) -> bool:
return self.total_seq_len() <= self.max_total_tokens
def start_request(self, req: SimRequest) -> None:
assert req in self.pending_requests
self.pending_requests.remove(req)
self.running_requests.append(req)
def evict_request(self, req: SimRequest) -> None:
assert req in self.running_requests
self.running_requests.remove(req)
self.pending_requests.insert(0, req)
def execute_step(self) -> None:
for req in self.running_requests:
req.decoded_tokens += 1
self.running_requests = [
r for r in self.running_requests if not r.is_finished()
]
def get_step_record(self, step: int) -> StepRecord:
return StepRecord(
step=step,
gpu_id=self.gpu_id,
running_count=len(self.running_requests),
pending_count=len(self.pending_requests),
total_seq_len=self.total_seq_len(),
running_req_ids=[r.request_id for r in self.running_requests],
pending_req_ids=[r.request_id for r in self.pending_requests],
)
@@ -0,0 +1,60 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List
from sglang.srt.debug_utils.schedule_simulator.gpu_state import GPUState
class MetricRecorder(ABC):
@abstractmethod
def on_step_end(self, step: int, gpu_states: List[GPUState]) -> None: ...
@abstractmethod
def get_summary(self) -> Dict[str, Any]: ...
class BalancednessRecorder(MetricRecorder):
def __init__(self, name: str, value_fn: Callable[[GPUState], float]):
self._name = name
self._value_fn = value_fn
self._history: List[float] = []
def on_step_end(self, step: int, gpu_states: List[GPUState]) -> None:
values = [self._value_fn(gpu) for gpu in gpu_states]
max_val = max(values) if values else 0
mean_val = sum(values) / len(values) if values else 0
balancedness = mean_val / max_val if max_val > 0 else 1.0
self._history.append(balancedness)
def get_summary(self) -> Dict[str, Any]:
if not self._history:
return {f"{self._name}_mean": 0.0}
return {
f"{self._name}_mean": sum(self._history) / len(self._history),
f"{self._name}_min": min(self._history),
f"{self._name}_max": max(self._history),
}
def BatchSizeBalancednessRecorder() -> BalancednessRecorder:
return BalancednessRecorder("batch_size_balancedness", lambda gpu: gpu.batch_size())
def AttentionComputeBalancednessRecorder() -> BalancednessRecorder:
return BalancednessRecorder(
"attention_compute_balancedness", lambda gpu: gpu.total_attention_compute()
)
class AvgBatchSizeRecorder(MetricRecorder):
def __init__(self):
self._total_running = 0
self._num_records = 0
def on_step_end(self, step: int, gpu_states: List[GPUState]) -> None:
for gpu in gpu_states:
self._total_running += gpu.batch_size()
self._num_records += 1
def get_summary(self) -> Dict[str, Any]:
avg = self._total_running / self._num_records if self._num_records else 0.0
return {"avg_batch_size": avg}
@@ -0,0 +1,18 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class SimRequest:
request_id: str
input_len: int
output_len: int
decoded_tokens: int = 0
group_id: Optional[str] = None
prefix_len: int = 0
def seq_len(self) -> int:
return self.input_len + self.decoded_tokens
def is_finished(self) -> bool:
return self.decoded_tokens >= self.output_len
@@ -0,0 +1,8 @@
from sglang.srt.debug_utils.schedule_simulator.routers.base import RouterPolicy
from sglang.srt.debug_utils.schedule_simulator.routers.random_router import RandomRouter
from sglang.srt.debug_utils.schedule_simulator.routers.round_robin_router import (
RoundRobinRouter,
)
from sglang.srt.debug_utils.schedule_simulator.routers.sticky_router import StickyRouter
__all__ = ["RouterPolicy", "RandomRouter", "RoundRobinRouter", "StickyRouter"]
@@ -0,0 +1,8 @@
from abc import ABC, abstractmethod
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
class RouterPolicy(ABC):
@abstractmethod
def route(self, incoming_request: SimRequest) -> int: ...
@@ -0,0 +1,12 @@
import random
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
from sglang.srt.debug_utils.schedule_simulator.routers.base import RouterPolicy
class RandomRouter(RouterPolicy):
def __init__(self, num_gpus: int):
self._num_gpus = num_gpus
def route(self, incoming_request: SimRequest) -> int:
return random.randint(0, self._num_gpus - 1)
@@ -0,0 +1,13 @@
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
from sglang.srt.debug_utils.schedule_simulator.routers.base import RouterPolicy
class RoundRobinRouter(RouterPolicy):
def __init__(self, num_gpus: int):
self._num_gpus = num_gpus
self._counter = 0
def route(self, incoming_request: SimRequest) -> int:
gpu_id = self._counter % self._num_gpus
self._counter += 1
return gpu_id
@@ -0,0 +1,20 @@
import random
from collections import defaultdict
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
from sglang.srt.debug_utils.schedule_simulator.routers.base import RouterPolicy
class StickyRouter(RouterPolicy):
def __init__(self, num_gpus: int):
self._num_gpus = num_gpus
self._group_to_gpu = defaultdict(self._assign_gpu)
def _assign_gpu(self) -> int:
return random.randint(0, self._num_gpus - 1)
def route(self, incoming_request: SimRequest) -> int:
group_id = incoming_request.group_id
if group_id is None:
return random.randint(0, self._num_gpus - 1)
return self._group_to_gpu[group_id]
@@ -0,0 +1,6 @@
from sglang.srt.debug_utils.schedule_simulator.schedulers.base import SchedulerPolicy
from sglang.srt.debug_utils.schedule_simulator.schedulers.fifo_scheduler import (
FIFOScheduler,
)
__all__ = ["SchedulerPolicy", "FIFOScheduler"]
@@ -0,0 +1,10 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sglang.srt.debug_utils.schedule_simulator.gpu_state import GPUState
class SchedulerPolicy(ABC):
@abstractmethod
def schedule(self, gpu_state: "GPUState") -> None: ...
@@ -0,0 +1,16 @@
from typing import TYPE_CHECKING
from sglang.srt.debug_utils.schedule_simulator.schedulers.base import SchedulerPolicy
if TYPE_CHECKING:
from sglang.srt.debug_utils.schedule_simulator.gpu_state import GPUState
class FIFOScheduler(SchedulerPolicy):
def schedule(self, gpu_state: "GPUState") -> None:
while not gpu_state.is_valid() and gpu_state.running_requests:
gpu_state.evict_request(gpu_state.running_requests[-1])
for req in list(gpu_state.pending_requests):
if gpu_state.total_seq_len(extra_reqs=[req]) <= gpu_state.max_total_tokens:
gpu_state.start_request(req)
@@ -0,0 +1,122 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from sglang.srt.debug_utils.schedule_simulator.gpu_state import GPUState, StepRecord
from sglang.srt.debug_utils.schedule_simulator.metrics import MetricRecorder
from sglang.srt.debug_utils.schedule_simulator.request import SimRequest
from sglang.srt.debug_utils.schedule_simulator.routers.base import RouterPolicy
from sglang.srt.debug_utils.schedule_simulator.schedulers.base import SchedulerPolicy
@dataclass
class SimulationResult:
step_records: List[StepRecord]
summary: Dict[str, Any]
class Simulator:
def __init__(
self,
num_gpus_per_engine: int,
router: RouterPolicy,
scheduler: SchedulerPolicy,
recorders: Optional[List[MetricRecorder]] = None,
log_level: int = 0,
max_total_tokens: int = 100000,
stop_criteria: str = "all_done",
max_steps: Optional[int] = None,
):
self.num_gpus_per_engine = num_gpus_per_engine
self.router = router
self.scheduler = scheduler
self.recorders = recorders or []
self.log_level = log_level
self.max_total_tokens = max_total_tokens
self.stop_criteria = stop_criteria
self.max_steps = max_steps
self.gpu_states: List[GPUState] = []
self.step = 0
def run(self, requests: List[SimRequest]) -> SimulationResult:
self.gpu_states = [
GPUState(gpu_id=i, max_total_tokens=self.max_total_tokens)
for i in range(self.num_gpus_per_engine)
]
self.step = 0
step_records: List[StepRecord] = []
incoming_requests = list(requests)
while True:
self._route_requests(incoming_requests)
incoming_requests.clear()
self._schedule_all_gpus()
if self._should_stop():
break
self._execute_step()
step_records.extend(
gpu.get_step_record(self.step) for gpu in self.gpu_states
)
self._log_step()
self._record_metrics()
self.step += 1
return SimulationResult(step_records=step_records, summary=self._get_summary())
def _should_stop(self) -> bool:
if self.max_steps is not None and self.step >= self.max_steps:
return True
if self.stop_criteria == "exist_no_pending":
return any(not gpu.pending_requests for gpu in self.gpu_states)
if self.stop_criteria == "all_done":
return not any(
gpu.pending_requests or gpu.running_requests for gpu in self.gpu_states
)
raise ValueError(f"Unknown stop criteria: {self.stop_criteria}")
def _route_requests(self, incoming_requests: List[SimRequest]) -> None:
for req in incoming_requests:
gpu_id = self.router.route(req)
if gpu_id < self.num_gpus_per_engine:
self.gpu_states[gpu_id].pending_requests.append(req)
def _schedule_all_gpus(self) -> None:
for gpu in self.gpu_states:
self.scheduler.schedule(gpu)
assert gpu.is_valid(), (
f"GPU{gpu.gpu_id} invalid after scheduling "
f"({gpu.total_seq_len()=}, {gpu.max_total_tokens=})"
)
def _execute_step(self) -> None:
for gpu in self.gpu_states:
gpu.execute_step()
def _log_step(self) -> None:
if self.log_level == 0 and self.step % 100 != 0:
return
parts = [f"step={self.step:<4}"]
for gpu in self.gpu_states:
r, q = len(gpu.running_requests), len(gpu.pending_requests)
if self.log_level <= 1:
parts.append(f"GPU{gpu.gpu_id}[R={r:<3} Q={q:<3}]")
else:
run_ids = _format_ids(gpu.running_requests)
queue_ids = _format_ids(gpu.pending_requests)
parts.append(f"GPU{gpu.gpu_id}[R={r}:{run_ids} Q={q}:{queue_ids}]")
print(" | ".join(parts))
def _record_metrics(self) -> None:
for recorder in self.recorders:
recorder.on_step_end(self.step, self.gpu_states)
def _get_summary(self) -> Dict[str, Any]:
return {k: v for r in self.recorders for k, v in r.get_summary().items()}
def _format_ids(requests: List[SimRequest], limit: int = 5) -> str:
if not requests:
return "-"
ids = ",".join(r.request_id for r in requests[:limit])
if len(requests) > limit:
ids += f"...+{len(requests) - limit}"
return ids
@@ -0,0 +1,12 @@
from sglang.srt.debug_utils.source_patcher.code_patcher import (
CodePatcher,
apply_patches_from_config,
patch_function,
)
from sglang.srt.debug_utils.source_patcher.types import (
EditSpec,
PatchApplicationError,
PatchConfig,
PatchSpec,
PatchState,
)
@@ -0,0 +1,195 @@
import __future__
import importlib
import inspect
import textwrap
import types
from collections.abc import Callable
from typing import Any, Optional
import yaml
from sglang.srt.debug_utils.source_patcher.source_editor import apply_edits
from sglang.srt.debug_utils.source_patcher.types import (
EditSpec,
PatchConfig,
PatchSpec,
PatchState,
)
def apply_patches_from_config(
yaml_content: str,
*,
extra_imports: Optional[list[str]] = None,
) -> list[PatchState]:
"""Parse a YAML config string and apply all patches.
Args:
yaml_content: YAML string with patch specifications.
extra_imports: Import lines inserted once at the top of each patched
function body (e.g. ["from pkg import foo"]). The caller (dumper)
uses this so users don't have to write boilerplate in YAML.
"""
raw: dict[str, Any] = yaml.safe_load(yaml_content)
config: PatchConfig = PatchConfig(**raw)
if extra_imports:
config = _inject_preamble(config=config, extra_imports=extra_imports)
return _apply_specs(config.patches)
class CodePatcher:
"""Context manager that patches functions on enter and restores on exit."""
def __init__(self, *, patches: list[PatchSpec]) -> None:
self._patches = patches
self._states: list[PatchState] = []
def __enter__(self) -> "CodePatcher":
self._states = _apply_specs(self._patches)
return self
def __exit__(
self,
exc_type: Optional[type],
exc_val: Optional[BaseException],
exc_tb: Optional[Any],
) -> None:
for state in reversed(self._states):
state.restore()
self._states.clear()
def patch_function(
*,
target: Callable[..., Any],
edits: list[EditSpec],
preamble: str = "",
) -> PatchState:
"""Patch a function by modifying its source and replacing __code__.
1. inspect.getsource -> get original source
2. apply_edits -> modify source text
3. optionally prepend preamble (e.g. import lines) inside the function body
4. compile + exec -> get new code object
5. replace target.__code__
Returns PatchState that can restore the original code.
"""
original_code: types.CodeType = target.__code__
source: str = inspect.getsource(target)
modified_source: str = apply_edits(source=source, edits=edits)
modified_source = textwrap.dedent(modified_source)
if preamble.strip():
modified_source = _insert_preamble(source=modified_source, preamble=preamble)
code: types.CodeType = compile(
modified_source,
inspect.getfile(target),
"exec",
flags=__future__.annotations.compiler_flag,
)
temp_namespace: dict[str, Any] = {}
exec(code, target.__globals__, temp_namespace)
new_fn: Any = temp_namespace[target.__name__]
target.__code__ = new_fn.__code__
return PatchState(target_fn=target, original_code=original_code)
# --------------------------------- private ---------------------------------
def _apply_specs(specs: list[PatchSpec]) -> list[PatchState]:
states: list[PatchState] = []
for spec in specs:
target_fn: Callable[..., Any] = _resolve_target(spec.target)
print(f"[source_patcher] patching {spec.target}")
state: PatchState = patch_function(
target=target_fn, edits=spec.edits, preamble=spec.preamble
)
states.append(state)
return states
def _inject_preamble(*, config: PatchConfig, extra_imports: list[str]) -> PatchConfig:
"""Set preamble on every PatchSpec so imports are inserted once at function top."""
import_block: str = "\n".join(extra_imports)
new_patches: list[PatchSpec] = []
for spec in config.patches:
existing: str = spec.preamble
combined: str = (
import_block + "\n" + existing if existing.strip() else import_block
)
new_patches.append(
PatchSpec(target=spec.target, edits=spec.edits, preamble=combined)
)
return PatchConfig(patches=new_patches)
def _insert_preamble(*, source: str, preamble: str) -> str:
"""Insert preamble lines right after the function signature (and optional docstring)."""
lines: list[str] = source.splitlines()
signature_end: int = _find_signature_end(lines)
body_start: int = signature_end + 1
body_indent: str = ""
for i in range(body_start, len(lines)):
if lines[i].strip():
body_indent = " " * (len(lines[i]) - len(lines[i].lstrip()))
body_start = i
break
preamble_lines: list[str] = [
body_indent + pl for pl in preamble.strip().splitlines()
]
return "\n".join(lines[:body_start] + preamble_lines + lines[body_start:])
def _find_signature_end(lines: list[str]) -> int:
"""Find the line index where the function signature ends (the line with trailing colon)."""
for i, line in enumerate(lines):
if line.rstrip().endswith(":"):
return i
return 0
def _resolve_target(qualified_name: str) -> Callable[..., Any]:
"""Resolve 'pkg.mod.Class.method' to the actual function object.
Tries progressively shorter module paths from right to left,
then uses getattr for the remaining attribute chain.
"""
parts: list[str] = qualified_name.split(".")
target: Any = None
for split_idx in range(len(parts), 0, -1):
module_path: str = ".".join(parts[:split_idx])
try:
target = importlib.import_module(module_path)
attr_parts: list[str] = parts[split_idx:]
break
except ImportError:
continue
else:
raise ImportError(f"could not import any module prefix of '{qualified_name}'")
for attr_name in attr_parts:
target = getattr(target, attr_name)
if isinstance(target, classmethod):
target = target.__func__
if not callable(target):
raise TypeError(
f"resolved target '{qualified_name}' is not callable: {type(target)}"
)
return target
@@ -0,0 +1,144 @@
from sglang.srt.debug_utils.source_patcher.types import EditSpec, PatchApplicationError
def apply_edits(*, source: str, edits: list[EditSpec]) -> str:
"""Apply a sequence of match/replacement edits to source text.
Each edit is applied sequentially so later edits see the result of earlier ones.
"""
result: str = source
for edit in edits:
result = _apply_single_edit(source=result, edit=edit)
return result
def _apply_single_edit(*, source: str, edit: EditSpec) -> str:
"""Apply a single match/replacement edit to the source text."""
match_text: str = edit.match.strip()
if not match_text:
raise PatchApplicationError("empty match text")
source_lines: list[str] = source.splitlines()
match_lines: list[str] = match_text.splitlines()
start_idx: int = _find_match(source_lines=source_lines, match_lines=match_lines)
match_len: int = len(match_lines)
original_indent: int = _leading_spaces(source_lines[start_idx])
effective_replacement: str = _resolve_replacement(edit=edit, match_text=match_text)
replacement_lines: list[str] = (
effective_replacement.splitlines() if effective_replacement else []
)
aligned: list[str] = _realign_replacement(
replacement_lines=replacement_lines, original_indent=original_indent
)
new_lines: list[str] = (
source_lines[:start_idx] + aligned + source_lines[start_idx + match_len :]
)
trailing_newline: str = "\n" if source.endswith("\n") else ""
return "\n".join(new_lines) + trailing_newline
def _resolve_replacement(*, edit: EditSpec, match_text: str) -> str:
"""Return the effective replacement text, handling replacement, prepend, and append modes."""
if edit.prepend.strip():
return edit.prepend.strip() + "\n" + match_text
if edit.append.strip():
return match_text + "\n" + edit.append.strip()
return edit.replacement.strip()
def _find_match(*, source_lines: list[str], match_lines: list[str]) -> int:
"""Find the start index of match_lines in source_lines (strip-compared).
Returns the index of the first matching line.
Raises PatchApplicationError if not found or found multiple times.
"""
stripped_source: list[str] = [line.strip() for line in source_lines]
stripped_match: list[str] = [line.strip() for line in match_lines]
match_len: int = len(stripped_match)
found_indices: list[int] = [
i
for i in range(len(stripped_source) - match_len + 1)
if stripped_source[i : i + match_len] == stripped_match
]
if len(found_indices) == 0:
raise PatchApplicationError(
_not_found_diagnostic(stripped_source, stripped_match)
)
if len(found_indices) > 1:
preview = "\n".join(match_lines)
raise PatchApplicationError(
f"match text found multiple times ({len(found_indices)} occurrences) in source:\n{preview}"
)
return found_indices[0]
def _not_found_diagnostic(stripped_source: list[str], stripped_match: list[str]) -> str:
preview = "\n".join(stripped_match)
lines = [
f"match text not found in source:\n{preview}",
"",
f"source_len={len(stripped_source)} lines",
]
if not stripped_match:
return "\n".join(lines)
first_match_line = stripped_match[0]
hits = [i for i, line in enumerate(stripped_source) if line == first_match_line]
if not hits:
lines.append(
f"first match line {first_match_line!r} does NOT appear anywhere in source"
)
return "\n".join(lines)
lines.append(
f"first match line {first_match_line!r} appears {len(hits)} time(s); showing up to 8 windows with context:"
)
for i in hits[:8]:
lo = max(0, i - 2)
hi = min(len(stripped_source), i + len(stripped_match) + 2)
block: list[str] = []
for j in range(lo, hi):
marker = (
">" if lo + (j - lo) >= i and (j - i) < len(stripped_match) else " "
)
block.append(f"{marker} {j:4d}: {stripped_source[j]}")
lines.append("--")
lines.extend(block)
return "\n".join(lines)
def _realign_replacement(
*, replacement_lines: list[str], original_indent: int
) -> list[str]:
"""Realign replacement lines to the original indentation level.
Strategy:
- Take the leading spaces of the first non-empty replacement line as base_indent
- For each replacement line: remove base_indent, add original_indent
"""
non_empty: list[str] = [line for line in replacement_lines if line.strip()]
if not non_empty:
return []
base_indent: int = _leading_spaces(non_empty[0])
result: list[str] = []
for line in replacement_lines:
if not line.strip():
result.append("")
else:
stripped = line[min(base_indent, len(line) - len(line.lstrip())) :]
result.append(" " * original_indent + stripped)
return result
def _leading_spaces(line: str) -> int:
return len(line) - len(line.lstrip(" "))
@@ -0,0 +1,63 @@
import types
from collections.abc import Callable
from typing import Any
from pydantic import BaseModel, ConfigDict, model_validator
class PatchApplicationError(Exception):
"""match text not found or not unique in source."""
class _StrictBase(BaseModel):
model_config = ConfigDict(extra="forbid")
class EditSpec(_StrictBase):
"""Specify one edit: replace, prepend before, or append after the matched text.
Use ``replacement`` to substitute the matched text (empty string = delete).
Use ``prepend`` to keep the matched text and add lines before it.
Use ``append`` to keep the matched text and add lines after it.
Only one of ``replacement``, ``prepend``, and ``append`` may be set.
"""
match: str
replacement: str = ""
prepend: str = ""
append: str = ""
@model_validator(mode="after")
def _check_modes_mutually_exclusive(self) -> "EditSpec":
active: list[str] = [
name
for name in ("replacement", "prepend", "append")
if getattr(self, name).strip()
]
if len(active) > 1:
raise ValueError(
f"only one of 'replacement', 'prepend', 'append' may be set, "
f"got: {', '.join(active)}"
)
return self
class PatchSpec(_StrictBase):
target: str
edits: list[EditSpec]
preamble: str = ""
class PatchConfig(_StrictBase):
patches: list[PatchSpec]
class PatchState:
def __init__(
self, *, target_fn: Callable[..., Any], original_code: types.CodeType
) -> None:
self.target_fn = target_fn
self.original_code = original_code
def restore(self) -> None:
self.target_fn.__code__ = self.original_code
@@ -0,0 +1,164 @@
"""
This file provides a function `register_forward_hook_for_model` that registers a forward hook on every operator of the model.
After registration, during model inference, all tensors generated throughout the forward pass will be recorded.
Usage:
Specify the output directory for dumping tensors using the argument `--debug-tensor-dump-output-folder`.
A separate directory will be created for each GPU rank, named in the format `f"TP{tp_rank}_PP{pp_rank}_Rank{rank}_pid{pid}"`.
Each complete forward pass of the model generates a `.pt` file named `f"Pass{pass_num}.pt"`, which can be loaded using `torch.load`.
The file contains a series of key-value pairs, where the keys correspond to operator names in the model
(similar to those in model.safetensors.index.json), and the values are the outputs produced by the respective operators.
"""
import logging
import os
from pathlib import Path
from typing import List, Optional
import torch
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, PPProxyTensors
logger = logging.getLogger(__name__)
class TensorDumper:
def __init__(
self,
dump_dir: str,
dump_layers: Optional[List[int]],
tp_size: int,
tp_rank: int,
pp_rank: int,
):
self._dump_layers = dump_layers
self._forward_pass_id = 0
self._pid = os.getpid()
self._current_tensors = {}
self._base_dir = Path(dump_dir)
rank = tp_size * pp_rank + tp_rank
self._process_dir = (
self._base_dir / f"TP{tp_rank}_PP{pp_rank}_Rank{rank}_pid{self._pid}"
)
self._process_dir.mkdir(parents=True, exist_ok=True)
def get_dump_dir(self):
return str(self._process_dir)
def add_tensor(self, name, tensor_item):
if isinstance(tensor_item, (tuple, list)):
tensors = [t.cpu() for t in tensor_item if t is not None]
if len(tensors) == 1:
self._current_tensors[name] = tensors[0]
else:
self._current_tensors[name] = tensors
elif isinstance(tensor_item, torch.Tensor):
self._current_tensors[name] = tensor_item.cpu()
elif isinstance(tensor_item, LogitsProcessorOutput):
self._current_tensors[name] = tensor_item.next_token_logits.cpu()
elif isinstance(tensor_item, ForwardBatch):
self._current_tensors[name + ".forward_batch_info.input_ids"] = (
tensor_item.input_ids.cpu()
)
self._current_tensors[name + ".forward_batch_info.seq_lens"] = (
tensor_item.seq_lens.cpu()
)
self._current_tensors[name + ".forward_batch_info.positions"] = (
tensor_item.positions.cpu()
)
elif isinstance(tensor_item, PPProxyTensors):
for tensor_name in tensor_item.tensors.keys():
self._current_tensors[name + ".pp_proxy_tensors." + tensor_name] = (
tensor_item.tensors[tensor_name].cpu()
)
else:
logger.warning(f"Unsupported type: {type(tensor_item)}: {tensor_item}")
def dump_current_tensors(self):
if len(self._current_tensors) == 0:
return
tensor_file_for_pass = self._process_dir / f"Pass{self._forward_pass_id:05d}.pt"
logger.info(
f"Dump {self._forward_pass_id:05d}th pass to {tensor_file_for_pass}"
)
torch.save(self._current_tensors, str(tensor_file_for_pass))
self._current_tensors = {}
self._forward_pass_id += 1
def _add_hook_recursive(
self, model, prefix, top_level_module_name, layers_module_name
):
model_top_level_module_matched = False
layers_prefix = top_level_module_name + "." + layers_module_name
for name, module in model._modules.items():
top_level_model = False
if len(prefix) == 0:
cur_name = name
if cur_name == top_level_module_name:
model_top_level_module_matched = True
top_level_model = True
else:
cur_name = prefix + "." + name
if (
self._dump_layers is not None
and name.isdigit()
and prefix == layers_prefix
):
# If we only need n layers, skip the reset layers.
# Most models' layout is like model.layers.0.
cur_layer = int(name)
if cur_layer not in self._dump_layers:
continue
if module is not None:
_, sub_count = self._add_hook_recursive(
module, cur_name, top_level_module_name, layers_module_name
)
if sub_count == 0 or top_level_model:
# Avoid duplicated output hooks, e.g. self_attn may contain:
# self_attn.qkv_proj, self_attn.attn & self_attn.o_proj.
# Therefore, we do not need to add output hooks for self_attn,
# since the output of self_attn should be the same to self_attn.o_proj.
module.register_forward_hook(
self._dump_hook(cur_name, top_level_model)
)
return model_top_level_module_matched, len(model._modules.items())
def _dump_hook(self, tensor_name, do_dump):
def inner_dump_hook(module, input, output):
if do_dump:
# This is the top-level model, so we will record the input for it.
for item in input:
if isinstance(item, ForwardBatch):
self.add_tensor(tensor_name, item)
self.dump_current_tensors()
if output is not None:
self.add_tensor(tensor_name, output)
return inner_dump_hook
def register_forward_hook_for_model(
model,
dump_dir: str,
dump_layers: Optional[List[int]],
tp_size: int,
tp_rank: int,
pp_rank: int,
):
tensor_dumper = TensorDumper(dump_dir, dump_layers, tp_size, tp_rank, pp_rank)
# Most models have the layerout like:
# XxxxForCausalLM
# (model): XxxxModel
# (layers): ModuleList
# If the model is not constructed with this layout,
# environment variable can be used to specify the module names.
top_level_module_name = os.getenv("TENSOR_DUMP_TOP_LEVEL_MODULE_NAME", "model")
layers_module_name = os.getenv("TENSOR_DUMP_LAYERS_MODULE_NAME", "layers")
model_top_level_module_matched, _ = tensor_dumper._add_hook_recursive(
model, "", top_level_module_name, layers_module_name
)
assert (
model_top_level_module_matched
), f"model should have a module named {top_level_module_name}"
return tensor_dumper
@@ -0,0 +1,234 @@
import argparse
import hashlib
import json
from pathlib import Path
import polars as pl
_DESCRIPTION = """Compare and find differences to benchmark outputs.
Supported inputs:
* The samples jsonl from `lm_eval --log_samples --output_path FOLDER_NAME`
* The output from `gsm8k/bench_sglang.py --raw-result-file FILE_NAME` (or mmlu)
"""
def main(args):
if args.data_type == "simple_evals":
df_input = _compute_df_input_mode_simple_evals(args)
else:
df_input = _transform_df_input(_compute_df_raw(args))
assert all(
c in df_input.columns
for c in ["category", "trial_index", "prompt_id", "prompt", "output", "correct"]
)
df_meta = _compute_df_meta(df_input)
df_correctness_per_trial = df_input.group_by(
"category", "trial_index", maintain_order=True
).agg(pl.col("correct").mean())
df_correctness_delta = (
df_meta.group_by("correctness_delta").len().sort("correctness_delta")
)
df_good_to_bad = df_meta.filter(pl.col("correctness_delta") < 0)
df_bad_to_good = df_meta.filter(pl.col("correctness_delta") > 0)
print(f"Dump output to {args.output_path}")
Path(args.output_path).write_text(
json.dumps(
dict(
df_meta=df_meta.to_dicts(),
df_good_to_bad=df_good_to_bad.to_dicts(),
df_bad_to_good=df_bad_to_good.to_dicts(),
),
indent=4,
),
)
if not args.disable_print_details:
with pl.Config(
fmt_str_lengths=10000,
tbl_cols=-1,
tbl_rows=-1,
tbl_width_chars=-1,
tbl_formatting="UTF8_FULL",
):
print("====== Correctness per trial ======")
print(df_correctness_per_trial)
print(
"====== Correctness Delta (-1.0 means all-right becomes all-wrong) ======"
)
print(df_correctness_delta)
for name, df in [
("Good->Bad", df_good_to_bad),
("Bad->Good", df_bad_to_good),
]:
print(f"====== Concrete Examples: {name} ======")
print(df)
def _compute_df_input_mode_simple_evals(args):
return pl.concat(
[
_compute_df_input_one_mode_simple_evals(**info)
for info in _get_file_infos(args=args)
]
)
def _compute_df_input_one_mode_simple_evals(path, category, trial_index):
data = json.loads(Path(path).read_text())
rows = []
for single_eval_result in data["metadata"]["single_eval_results"]:
prompt = single_eval_result["example_level_metadata"][
"actual_queried_prompt_messages"
]
score = single_eval_result["score"]
assert score in {0.0, 1.0}, f"{score=}"
row = dict(
category=category,
trial_index=trial_index,
prompt_id=_compute_id_from_object(prompt),
prompt=json.dumps(prompt),
output=single_eval_result["example_level_metadata"]["response_text"],
correct=score == 1.0,
)
rows.append(row)
return pl.DataFrame(rows)
def _compute_id_from_object(obj):
if isinstance(obj, pl.Series):
obj = obj.to_list()
json_str = json.dumps(obj, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(json_str.encode("utf-8")).hexdigest()
def _compute_df_raw(args):
return pl.concat(
[
_read_df_raw(
path=info["path"],
category=info["category"],
trial_index=info["trial_index"],
)
for info in _get_file_infos(args=args)
]
)
def _get_file_infos(args):
return [
dict(path=path, category=category, trial_index=trial_index)
for category, paths in [
("baseline", args.baseline_path),
("target", args.target_path),
]
for trial_index, path in enumerate(paths)
]
def _read_df_raw(path: str, category: str, trial_index: int):
return pl.read_ndjson(path).with_columns(
category=pl.lit(category), trial_index=trial_index
)
def _transform_df_input(df: pl.DataFrame):
if "doc_id" in df.columns:
print("Transform mode: lm_eval")
filter_names = df["filter"].unique(maintain_order=True).to_list()
if len(filter_names) > 1:
filter_name = filter_names[0]
print(f"Choose {filter_name=} among {filter_names}")
df = df.filter(pl.col("filter") == filter_name)
df = df.select(
pl.col("category"),
pl.col("trial_index"),
prompt_id=pl.col("doc_id"),
prompt=pl.col("arguments").struct.field("gen_args_0").struct.field("arg_0"),
output=pl.col("resps").list.get(0).list.get(0),
correct=pl.col("exact_match").cast(bool),
)
return df
elif "prompt_id" in df.columns:
print("Transform mode: SGLang bench")
return df
else:
raise Exception(
f"Unknown data: {df.columns}. You may need to set `--data-type` if using e.g. simple_evals."
)
def _compute_df_meta(df_input: pl.DataFrame):
df_input = df_input.sort("prompt_id", "category", "trial_index")
df_meta = pl.DataFrame(
[
_handle_one_prompt(df_one_prompt)
for df_one_prompt in df_input.partition_by("prompt_id", maintain_order=True)
]
)
df_meta = df_meta.with_columns(
correctness_delta=pl.col("correctness_target") - pl.col("correctness_baseline"),
)
df_meta = df_meta.sort("correctness_delta", "output_same_prefix_len")
return df_meta
def _handle_one_prompt(df_one_prompt: pl.DataFrame):
assert (
len(set(_compute_id_from_object(obj) for obj in df_one_prompt["prompt"])) == 1
)
df_baseline = df_one_prompt.filter(pl.col("category") == "baseline")
df_target = df_one_prompt.filter(pl.col("category") == "target")
outputs_baseline = df_baseline["output"].to_list()
outputs_target = df_target["output"].to_list()
output_same_prefix_len = max(
_compute_str_prefix_len(output_baseline, output_target)
for output_baseline in outputs_baseline
for output_target in outputs_target
)
return dict(
prompt_id=df_one_prompt[0, "prompt_id"],
correctness_baseline=df_baseline["correct"].mean(),
correctness_target=df_target["correct"].mean(),
output_same_prefix_len=output_same_prefix_len,
prompt=df_one_prompt[0, "prompt"],
outputs_baseline=outputs_baseline,
outputs_target=outputs_target,
)
def _compute_str_prefix_len(a: str, b: str) -> int:
min_len = min(len(a), len(b))
for i in range(min_len):
if a[i] != b[i]:
return i
return min_len
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=_DESCRIPTION)
parser.add_argument("--data-type", type=str, default="auto")
parser.add_argument("--baseline-path", type=str, nargs="+")
parser.add_argument("--target-path", type=str, nargs="+")
parser.add_argument(
"--output-path", type=str, default="/tmp/text_comparator_output.json"
)
parser.add_argument("--disable-print-details", action="store_true")
args = parser.parse_args()
main(args)