执行摘要
- 一句话:TRTLLM 解码按 KV 长度拆分,混合上下文迭代降 10%
- 推荐动作:值得精读。重点看
_run_fixed_q_len_decode 的排序-分组-拼回模式,理解其如何在 CUDA graph 捕获下保持输出顺序;同时关注 Fridge003 提出的默认值问题,后续可结合生产数据评估是否将默认 splits 提升到 2 甚至 4。风险控制上,建议补充 splits=3/4 及大 batch 的回归测试。
功能与动机
PR body 指出 TRTLLM-GEN decode attention 在 KV 序列长度差异大的 batch 中会因长请求形成 wave 尾部而付出 tail-latency 开销:TRTLLM-GEN decode attention can pay a large tail-latency tax when requests in the same batch have very different KV sequence lengths。GB300 上 profile 显示 8.377/9.822 ms 的 attention/step delta,其中 7.601 ms 来自 SM100 variable-sequence FMHA 主核;BS44/rank 下未拆分时每请求仅一个 KV CTA 并选择 persistent kernel,长请求成为共享 wave 尾部,拖累短请求,因此需要按 KV 长度拆分以提高 CTA 并行度。
实现拆解
- 环境变量接入(python/sglang/srt/environ.py):新增
SGLANG_TRTLLM_MHA_DECODE_SEQ_LEN_SPLITS = EnvInt(1),并注释说明取值大于 1 时按 KV 长度排序分组;默认 1 保证旧行为完全不变。
- 后端配置校验(python/sglang/srt/layers/attention/trtllm_mha_backend.py):在
__init__ 末尾读取环境变量并校验至少为 1,否则抛 ValueError;存为 self.decode_seq_len_splits。
- 核心分组函数
_run_fixed_q_len_decode:抽出原先 forward_decode 中的固定 query 长度 TRTLLM MHA 调用,包装为闭包 run_group,透传 bmm1_scale、bmm2_scale、window_left、sinks、kv_cache_sf、multi_ctas_kv_counter_buffer 等参数,兼容 NVFP4 KV-cache 缩放与 NEXTN/MTP 的 q_len_per_req;num_splits == 1 时保持单次调用。
- 排序、分组、拼回:
num_splits > 1 时先 torch.argsort(seq_lens) 按 KV 长度排序,再用 torch.tensor_split 均分索引;对每组通过 index_select 收集 query、block_tables、seq_lens,调用 run_group,最后 index_copy_ 按原索引写回 output_by_request,保证输出顺序与输入请求一致。
- 调用点替换与测试配套:
forward_decode 中普通 decode 路径改为调用 _run_fixed_q_len_decode,ragged-query 路径不变;frozen-KV MTP 目标验证也复用同一 helper。测试文件 test/registered/attention/unittests/dense/test_trtllm_mha.py 用 envs.SGLANG_TRTLLM_MHA_DECODE_SEQ_LEN_SPLITS.override(...) 覆盖 splits=2 的 eager decode(第一个 dense 用例)和 splits=2 的 frozen-KV MTP CUDA-graph 回放,未新增测试方法或模块。
关键文件:
python/sglang/srt/layers/attention/trtllm_mha_backend.py(模块 注意力后端;类别 source;类型 core-logic;符号 _run_fixed_q_len_decode, run_group): 核心实现文件:新增 _run_fixed_q_len_decode 按 KV 长度排序分组,并替换 forward_decode 中的固定 query 长度 decode 调用,是本 PR 的主体逻辑。
python/sglang/srt/environ.py(模块 环境配置;类别 source;类型 configuration): 新增开关环境变量 SGLANG_TRTLLM_MHA_DECODE_SEQ_LEN_SPLITS,控制解码拆分数,默认 1 保持旧行为。
test/registered/attention/unittests/dense/test_trtllm_mha.py(模块 单元测试;类别 test;类型 test-coverage): 测试配套:用 env override 将首个 dense decode 用例和 frozen-KV MTP CUDA-graph 用例切换为 splits=2,验证分组解码正确性。
关键符号:_run_fixed_q_len_decode, run_group, forward_decode
关键源码片段
python/sglang/srt/layers/attention/trtllm_mha_backend.py
核心实现文件:新增 _run_fixed_q_len_decode 按 KV 长度排序分组,并替换 forward_decode 中的固定 query 长度 decode 调用,是本 PR 的主体逻辑。
def _run_fixed_q_len_decode(
self,
query: torch.Tensor,
kv_cache,
block_tables: torch.Tensor,
seq_lens: torch.Tensor,
*,
bmm1_scale,
bmm2_scale,
window_left: int,
sinks: Optional[torch.Tensor],
q_len_per_req: int = 1,
kv_cache_sf=None,
) -> torch.Tensor:
'''运行固定 query 长度 decode,可按 KV 长度排序并分组。
分组后每组调用一次 `flashinfer.decode.trtllm_batch_decode_with_kv_cache`,
再把输出按照原始请求顺序拼回,避免长 KV 请求拖慢短 KV 请求的波前收尾。
'''
def run_group(group_query, group_block_tables, group_seq_lens):
# 单组请求的 TRTLLM MHA 调用;非 1 时透传每请求的 query 长度参数
kwargs = {}
if q_len_per_req != 1:
kwargs['q_len_per_req'] = q_len_per_req
return flashinfer.decode.trtllm_batch_decode_with_kv_cache(
query=group_query,
kv_cache=kv_cache,
workspace_buffer=self.workspace_buffer,
block_tables=group_block_tables,
seq_lens=group_seq_lens,
max_seq_len=self.max_context_len,
bmm1_scale=bmm1_scale,
bmm2_scale=bmm2_scale,
window_left=window_left,
sinks=sinks,
skip_softmax_threshold_scale_factor=envs.SGLANG_SKIP_SOFTMAX_DECODE_THRESHOLD_SCALE_FACTOR.get(),
out_dtype=self.q_data_type,
kv_cache_sf=kv_cache_sf,
multi_ctas_kv_counter_buffer=self._multi_ctas_kv_counter_buffer,
**kwargs,
)
num_requests = seq_lens.shape[0]
# 拆分数量上限为请求数,避免空组
num_splits = min(self.decode_seq_len_splits, num_requests)
if num_splits == 1:
# 默认路径:一次调用,保持与旧行为完全一致
return run_group(query, block_tables, seq_lens)
# 按 KV 序列长度升序排序,等分成 num_splits 个索引组
order = torch.argsort(seq_lens)
# query 先还原为按请求组织的形状,便于分组切片与回填
query_by_request = query.view(
num_requests, q_len_per_req, query.shape[-2], query.shape[-1]
)
output_by_request = torch.empty(
query_by_request.shape,
dtype=self.q_data_type,
device=query.device,
)
for indices in torch.tensor_split(order, num_splits):
group_output = run_group(
query_by_request.index_select(0, indices).reshape(
-1, query.shape[-2], query.shape[-1]
),
block_tables.index_select(0, indices),
seq_lens.index_select(0, indices),
)
# 按原始索引写回,保证输出顺序与输入请求一致
output_by_request.index_copy_(
0,
indices,
group_output.view(-1, q_len_per_req, query.shape[-2], query.shape[-1]),
)
return output_by_request.view(-1, query.shape[-2], query.shape[-1])
test/registered/attention/unittests/dense/test_trtllm_mha.py
测试配套:用 env override 将首个 dense decode 用例和 frozen-KV MTP CUDA-graph 用例切换为 splits=2,验证分组解码正确性。
# 在既有 dense decode 用例上叠加 env override,验证 splits=2 的 eager 路径
for case_index, case in enumerate(self.DECODE_CASES):
splits = 2 if case_index == 0 else 1
with self.subTest(
case=case.name, backend=case.backend
), envs.SGLANG_TRTLLM_MHA_DECODE_SEQ_LEN_SPLITS.override(splits):
run_dense_attention_case(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
)
# frozen-KV MTP 的 CUDA-graph 回放固定使用 splits=2,覆盖多 token 目标验证路径
for case in self.FROZEN_KV_MTP_RUNNER_CASES:
with self.subTest(
case=case.name, backend=case.backend
), envs.SGLANG_TRTLLM_MHA_DECODE_SEQ_LEN_SPLITS.override(2):
run_dense_frozen_kv_mtp_cuda_graph_runner_case(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
)
评论区精华
Fridge003 在 PR 评论中提出了唯一的设计问题:Is there any downside of enabling num_splits > 1, otherwise we can set default num_split to 2 or 4。该问题询问启用多个 splits 的潜在代价,并建议若代价不大可将默认值设为 2 或 4。作者没有在 PR 内给出文字回复,最终合入时仍保持默认 1(opt-in),说明该决策倾向保守,默认不改变已有行为。这为后续基于生产数据调整默认值保留了空间。其余评论为 CI 重跑命令(/tag-and-rerun-ci、/rerun-failed-ci),无实质技术讨论。
- num_splits 默认值与 downside (design): PR 合入时保留默认 1,未在 PR 内明确回复;合并者最终批准,说明接受 opt-in 式保守默认。
风险与影响
关联脉络
- PR #36219 [Performance] Tune FlashInfer EXTEND for DP prefill: 同属近期解码/注意力性能调优系列,关注混合上下文下的尾部开销;一个是 prefill EXTEND 预热,一个是 decode 拆批,可对照阅读批处理波前优化思路。
参与讨论