[Feature] Optimize TP LMHead with All-to-All (#32313)

This commit is contained in:
Siyuan Chen
2026-08-17 19:55:27 -07:00
committed by GitHub
parent d6c837489a
commit fcdaaf8a5d
7 changed files with 226 additions and 11 deletions
+41 -1
View File
@@ -2404,14 +2404,54 @@ def _data_parallelism_defaults(view: Any) -> dict:
return {} return {}
@register_post_process
def _tp_lm_head_all_to_all_default(view: Any) -> dict:
"""Enable the TP LM-head all-to-all path only for pure-DP decode nodes.
Prefill-only and colocated nodes keep the feature disabled by default: the
LM-head weight layout is fixed at load time, so enabling the TP path would
also move their long prefills away from the communication-free DP LM head.
An explicit CLI value always wins.
"""
if view.enable_tp_lm_head_all_to_all is not None:
return {}
enable = (
view.disaggregation_mode == "decode"
and view.enable_dp_attention
and view.dp_size > 1
and view.tp_size == view.dp_size
and view.attn_cp_size == 1
and not view.enable_dp_lm_head
)
return {"enable_tp_lm_head_all_to_all": enable}
@register_post_process @register_post_process
def _dp_lm_head_validation(view: Any) -> dict: def _dp_lm_head_validation(view: Any) -> dict:
"""Read-only validation pass: dp-attention is a prerequisite for the """Read-only validation pass: dp-attention is a prerequisite for the
dp LM head. Reads the mid-resolution values through the view.""" dp LM head and the TP LM-head all-to-all path. Reads the mid-resolution
values through the view."""
if view.enable_dp_lm_head: if view.enable_dp_lm_head:
assert ( assert (
view.enable_dp_attention view.enable_dp_attention
), "Please enable dp attention when setting enable_dp_lm_head. " ), "Please enable dp attention when setting enable_dp_lm_head. "
if view.enable_tp_lm_head_all_to_all:
assert view.enable_dp_attention, (
"Please enable dp attention when setting " "enable_tp_lm_head_all_to_all."
)
assert not view.enable_dp_lm_head, (
"--enable-tp-lm-head-all-to-all uses a TP-sharded LM head and is "
"incompatible with --enable-dp-lm-head."
)
assert view.tp_size == view.dp_size, (
"--enable-tp-lm-head-all-to-all currently requires tp_size == "
f"dp_size, got tp_size={view.tp_size}, dp_size={view.dp_size}."
)
assert view.attn_cp_size == 1, (
"--enable-tp-lm-head-all-to-all currently requires "
f"attn_cp_size == 1, got {view.attn_cp_size}."
)
return {} return {}
@@ -45,6 +45,14 @@ logger = logging.getLogger(__name__)
_is_cpu_amx_available = cpu_has_amx_support() _is_cpu_amx_available = cpu_has_amx_support()
_is_cpu_arm64 = is_host_cpu_arm64() _is_cpu_arm64 = is_host_cpu_arm64()
# A representative per-peer payload for materializing the PyNCCL P2P
# connections used by TP LM-head all-to-all. The input/output tensors are
# temporary; NCCL owns the transport resources retained after the warmup.
# In dsv4-pro, assume bs per dp is 120 and the vocab_size is 129280.
# Therefore, the chunk size that each peer sends is 120*129280/8=1.849MB.
# The total warmup bytes per peer should be 1.849*2 = 4MB
_TP_ALL_TO_ALL_WARMUP_BYTES_PER_PEER = 4 << 20
class TorchDistributedResult(msgspec.Struct, frozen=True, kw_only=True): class TorchDistributedResult(msgspec.Struct, frozen=True, kw_only=True):
tp_group: object tp_group: object
@@ -110,6 +118,17 @@ def init_torch_distributed(
tp_size=ps.tp_size, pp_size=ps.pp_size, moe_ep_size=ps.moe_ep_size tp_size=ps.tp_size, pp_size=ps.pp_size, moe_ep_size=ps.moe_ep_size
) )
# CUDA graph capture enables the PyNCCL communicator for TP LM-head
# all-to-all. Exercise that exact send/recv path before measuring
# pre_model_load_memory so its persistent transport allocations are
# included in later KV-cache sizing instead of appearing during capture.
if (
device == "cuda"
and get_parallel().enable_tp_lm_head_all_to_all
and ps.tp_size > 1
):
_prewarm_tp_lm_head_all_to_all()
pre_model_load_memory = get_available_gpu_memory( pre_model_load_memory = get_available_gpu_memory(
device, device,
ps.gpu_id, ps.gpu_id,
@@ -271,6 +290,40 @@ def _prewarm_nccl(*, tp_size: int, pp_size: int, moe_ep_size: int) -> None:
) )
def _prewarm_tp_lm_head_all_to_all() -> None:
"""Materialize PyNCCL P2P resources before model-memory accounting."""
warmup_start = time.perf_counter()
tp_group = get_tp_group()
pynccl_comm = tp_group.pynccl_comm
if pynccl_comm is None or not pynccl_comm.available:
raise RuntimeError(
"--enable-tp-lm-head-all-to-all requires an available PyNCCL "
"communicator for CUDA graph capture."
)
numel = tp_group.world_size * _TP_ALL_TO_ALL_WARMUP_BYTES_PER_PEER
warmup_input = torch.empty(numel, dtype=torch.uint8, device=tp_group.device)
warmup_output = torch.empty_like(warmup_input)
# PyNCCL is disabled outside graph-capture contexts by default. Enable it
# explicitly so eager startup does not fall back to ProcessGroupNCCL and
# miss the P2P resources required by the captured all-to-all.
with pynccl_comm.change_state(enable=True):
pynccl_comm.all_to_all_single(warmup_output, warmup_input)
current_platform.synchronize()
del warmup_input, warmup_output
current_platform.empty_cache()
warmup_elapsed = time.perf_counter() - warmup_start
logger.info(
"TP LM-head PyNCCL all-to-all warmup completed in %.3fs "
"(tp_size=%d, bytes_per_peer=%d)",
warmup_elapsed,
tp_group.world_size,
_TP_ALL_TO_ALL_WARMUP_BYTES_PER_PEER,
)
def _check_tp_memory_balance( def _check_tp_memory_balance(
*, pre_model_load_memory: float, local_gpu_memory: float *, pre_model_load_memory: float, local_gpu_memory: float
) -> None: ) -> None:
@@ -176,7 +176,9 @@ def _compile_deep_gemm_one_type_all(
m_list = sorted(list(set(m for m in m_list if m % m_alignment == 0))) m_list = sorted(list(set(m for m in m_list if m % m_alignment == 0)))
# Here the precompilation is only run on the first rank, so gpu_id should be 0 # Here the precompilation is only run on the first rank, so gpu_id should be 0
memory_budget = get_available_gpu_memory(device="cuda", gpu_id=0) memory_budget = get_available_gpu_memory(
device="cuda", gpu_id=torch.cuda.current_device()
)
# If the memory budget is less memory requirement, we need to reduce max_m to avoid out of memory, which might further cause hanging during warmup # If the memory budget is less memory requirement, we need to reduce max_m to avoid out of memory, which might further cause hanging during warmup
max_m = max(m_list) max_m = max(m_list)
@@ -193,7 +195,7 @@ def _compile_deep_gemm_one_type_all(
kernel_type, max_m=max_m, n=n, k=k, num_groups=num_groups kernel_type, max_m=max_m, n=n, k=k, num_groups=num_groups
) )
> memory_budget > memory_budget
and max_m > 4096 and max_m > 2048
): ):
max_m = max_m // 2 max_m = max_m // 2
logger.warning( logger.warning(
@@ -24,6 +24,7 @@ from torch import nn
from sglang.kernels.ops.activation.softcap import ( from sglang.kernels.ops.activation.softcap import (
softcap_inplace_logits as fused_softcap, softcap_inplace_logits as fused_softcap,
) )
from sglang.srt.distributed import get_tp_group
from sglang.srt.distributed.device_communicators import triton_symm_mem_ag from sglang.srt.distributed.device_communicators import triton_symm_mem_ag
from sglang.srt.layers.aux_hidden_states import ( from sglang.srt.layers.aux_hidden_states import (
AuxHiddenStates, AuxHiddenStates,
@@ -292,6 +293,7 @@ class LogitsProcessor(nn.Module):
self.vocab_size = config.vocab_size self.vocab_size = config.vocab_size
self.logit_scale = logit_scale self.logit_scale = logit_scale
self.use_attn_tp_group = get_parallel().enable_dp_lm_head self.use_attn_tp_group = get_parallel().enable_dp_lm_head
self.use_tp_lm_head_all_to_all = get_parallel().enable_tp_lm_head_all_to_all
self.use_fp32_lm_head = get_exec().features.enable_fp32_lm_head self.use_fp32_lm_head = get_exec().features.enable_fp32_lm_head
if self.use_attn_tp_group: if self.use_attn_tp_group:
self.attn_tp_size = get_parallel().attn_tp_size self.attn_tp_size = get_parallel().attn_tp_size
@@ -666,12 +668,19 @@ class LogitsProcessor(nn.Module):
if self.logit_scale is not None: if self.logit_scale is not None:
logits.mul_(self.logit_scale) logits.mul_(self.logit_scale)
used_tp_lm_head_all_to_all = False
if self.do_tensor_parallel_all_gather: if self.do_tensor_parallel_all_gather:
if self.use_attn_tp_group: if self.use_attn_tp_group:
logits = self._gather_attn_tp_logits(logits) logits = self._gather_attn_tp_logits(logits)
elif self._can_use_tp_lm_head_all_to_all(
logits, local_hidden_states, lm_head, logits_metadata
):
logits = self._tp_lm_head_all_to_all(logits)
used_tp_lm_head_all_to_all = True
else: else:
logits = self._logits_gatherer(logits) logits = self._logits_gatherer(logits)
if not used_tp_lm_head_all_to_all:
logits = self._scatter_dp_attn_logits( logits = self._scatter_dp_attn_logits(
logits, local_hidden_states, logits_metadata logits, local_hidden_states, logits_metadata
) )
@@ -793,6 +802,54 @@ class LogitsProcessor(nn.Module):
) )
return global_logits return global_logits
def _can_use_tp_lm_head_all_to_all(
self,
logits: torch.Tensor,
local_hidden_states: torch.Tensor,
lm_head: VocabParallelEmbedding,
logits_metadata: LogitsMetadata,
) -> bool:
if not self.use_tp_lm_head_all_to_all:
return False
tp_size = get_parallel().tp_size
base_lm_head = getattr(lm_head, "base_layer", lm_head)
if getattr(base_lm_head, "tp_size", None) != tp_size:
# Tied embeddings may be replicated across DP ranks (tp_size=1),
# even though the logits processor runs in a larger global TP
# group. Such logits are full-vocabulary rather than TP shards and
# therefore do not satisfy the all-to-all layout contract.
return False
# Every participant must make the same collective choice. Decode CUDA
# graphs omit CPU counts and fill every GPU count with the same padded
# bucket size. Eager batches carry the same global CPU count list on
# every rank, so they are also safe when all entries are equal.
global_counts_cpu = logits_metadata.global_num_tokens_for_logprob_cpu
is_equal_padded_graph_layout = global_counts_cpu is None and (
logits_metadata.global_num_tokens_for_logprob_gpu is not None
)
is_equal_eager_layout = (
global_counts_cpu is not None
and len(global_counts_cpu) == tp_size
and len(global_counts_cpu) > 0
and all(count == global_counts_cpu[0] for count in global_counts_cpu)
)
if not (is_equal_padded_graph_layout or is_equal_eager_layout):
return False
local_rows = local_hidden_states.shape[0]
return local_rows > 0 and logits.shape[0] == local_rows * tp_size
def _tp_lm_head_all_to_all(self, logits: torch.Tensor) -> torch.Tensor:
"""Exchange only the row block owned by each destination DP rank."""
logits = logits.contiguous()
all_to_all_output = torch.empty_like(logits)
get_tp_group().all_to_all_single(all_to_all_output.view(-1), logits.view(-1))
return _reassemble_tp_lm_head_all_to_all_output(
all_to_all_output, get_parallel().tp_size
)
def _scatter_dp_attn_logits( def _scatter_dp_attn_logits(
self, self,
logits: torch.Tensor, logits: torch.Tensor,
@@ -951,6 +1008,26 @@ class LogitsProcessor(nn.Module):
) )
def _reassemble_tp_lm_head_all_to_all_output(
all_to_all_output: torch.Tensor, tp_size: int
) -> torch.Tensor:
"""Convert source-major all-to-all output to row-major full-vocab logits.
Each source TP rank contributes ``[local_rows, vocab_shard]`` for this
destination DP rank. ``all_to_all_single`` concatenates those contributions
along dim 0, while the sampler expects the vocab shards concatenated along
dim 1.
"""
assert all_to_all_output.shape[0] % tp_size == 0
local_rows = all_to_all_output.shape[0] // tp_size
vocab_shard = all_to_all_output.shape[1]
return (
all_to_all_output.view(tp_size, local_rows, vocab_shard)
.permute(1, 0, 2)
.reshape(local_rows, tp_size * vocab_shard)
)
def _has_lm_head_runtime_attrs(lm_head, attr_names: Tuple[str, ...]) -> bool: def _has_lm_head_runtime_attrs(lm_head, attr_names: Tuple[str, ...]) -> bool:
return all(hasattr(lm_head, attr_name) for attr_name in attr_names) return all(hasattr(lm_head, attr_name) for attr_name in attr_names)
+23 -4
View File
@@ -1166,6 +1166,22 @@ class ServerArgs:
), ),
NS("parallel"), NS("parallel"),
] = False ] = False
enable_tp_lm_head_all_to_all: A[
Optional[bool],
Arg(
help="Use all-to-all instead of TP all-gather followed by DP scatter "
"for the TP-sharded LM head under DP attention. By default this is "
"enabled only on decode-only PD nodes with pure DP attention "
"(tp_size == dp_size > 1 and attn_cp_size == 1), and disabled on "
"prefill-only and colocated nodes. Pass "
"--no-enable-tp-lm-head-all-to-all to opt out. The path is "
"incompatible with --enable-dp-lm-head; batches without an equal "
"padded row count fall back to the existing all-gather path.",
action=argparse.BooleanOptionalAction,
resolvable=True,
),
NS("parallel"),
] = None
enable_attn_tp_input_scattered: A[ enable_attn_tp_input_scattered: A[
bool, bool,
"Allow input of attention to be scattered when only using tensor parallelism, to reduce the computational load of operations such as qkv latent.", "Allow input of attention to be scattered when only using tensor parallelism, to reduce the computational load of operations such as qkv latent.",
@@ -6692,11 +6708,14 @@ class ServerArgs:
prefill_cfg.max_bs prefill_cfg.max_bs
) )
# The dp-lm-head validation moved to the resolution pipeline # Resolve the phase-aware TP LM-head default before validating the
# (arg_groups/overrides.py: _dp_lm_head_validation), invoked here at # resulting DP/TP LM-head configuration.
# its legacy slot. from sglang.srt.arg_groups.overrides import (
from sglang.srt.arg_groups.overrides import _dp_lm_head_validation _dp_lm_head_validation,
_tp_lm_head_all_to_all_default,
)
run_post_process_pass(self, _tp_lm_head_all_to_all_default)
run_post_process_pass(self, _dp_lm_head_validation) run_post_process_pass(self, _dp_lm_head_validation)
def _handle_moe_kernel_config(self): def _handle_moe_kernel_config(self):
+24 -1
View File
@@ -4,7 +4,7 @@ import unittest
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
from sglang.test.mock_model.utils import run_mock_model_bench_serving from sglang.test.mock_model.utils import run_mock_model_bench_serving
from sglang.test.test_utils import CustomTestCase from sglang.test.test_utils import CustomTestCase, is_in_amd_ci
register_cuda_ci(est_time=600, stage="extra-a", runner_config="2-gpu-large") register_cuda_ci(est_time=600, stage="extra-a", runner_config="2-gpu-large")
register_amd_ci(est_time=167, stage="extra-a", runner_config="2-gpu-large-amd") register_amd_ci(est_time=167, stage="extra-a", runner_config="2-gpu-large-amd")
@@ -16,6 +16,29 @@ class TestE2ETensorParallel(CustomTestCase):
extra_server_args=["--tp", "2", "--mem-fraction-static", "0.88"], extra_server_args=["--tp", "2", "--mem-fraction-static", "0.88"],
) )
@unittest.skipIf(is_in_amd_ci(), "PyNccl CUDA graph smoke test requires CUDA.")
def test_tp_lm_head_all_to_all_cuda_graph(self) -> None:
"""Smoke-test the PyNccl all-to-all path used during graph capture."""
run_mock_model_bench_serving(
extra_server_args=[
"--tp",
"2",
"--dp",
"2",
"--enable-dp-attention",
"--enable-tp-lm-head-all-to-all",
"--cuda-graph-max-bs-decode",
"4",
"--mem-fraction-static",
"0.88",
"--attention-backend",
"triton",
],
num_prompts=2,
random_input_len=32,
random_output_len=2,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -69,6 +69,7 @@ class TestModelOverridableWhitelist(CustomTestCase):
"quantization", "quantization",
"enable_dp_attention", "enable_dp_attention",
"enable_dp_lm_head", "enable_dp_lm_head",
"enable_tp_lm_head_all_to_all",
"moe_a2a_backend", "moe_a2a_backend",
"ep_size", "ep_size",
"moe_dense_tp_size", "moe_dense_tp_size",