Prhub

#32223 [perf] Assemble flat prompt top logprobs scheduler-side as numpy arrays

原始 PR 作者 sshleifer 合并时间 2026-08-01 09:00 文件变更 9 提交数 1 评论 10 代码增减 +593 / -22

执行摘要

调度器侧扁平化 prompt top logprobs 数组,两跳 IPC 省约 30ms

PR body 明确指出:即便 #32078/#31960 已让 response 侧序列化变便宜,scheduler 仍把 prompt top logprobs 以嵌套的逐位置 Python 列表形式放在 BatchTokenIDOutput/BatchStrOutput 里跨两跳 ZMQ(scheduler → detokenizer → tokenizer manager)传输。实测数据:32768 位置、k=2 时嵌套形式 pickle.dumps 5.0ms + pickle.loads 10.3ms、1.12MB;扁平 f32/i32 数组仅 0.03ms + 0.03ms、0.53MB。作者估算每跳省约 15ms、两跳共约 30ms,且发生在服务器两个最忙的单例事件循环上。

值得精读。三个设计点尤其值得借鉴:(1) 把数据面“组装前移 + 可选字段 + 默认 None”的兼容策略——新字段只对 opt-in 请求非 None,未使用方 wire 不变;(2) 共享校验原语 build_flat_input_top_logprobs_arrays 在 scheduler 与 tokenizer 两个进程复用,保证 fallback 语义一致;(3) 提交了 env-gated 的可复现序列化 microbench。需要留意的是 ch-wan 提出的缓存遮蔽与 idx 校验两个未解决的健壮性问题,未来若把 flat 格式扩展到 MIS 或其他请求路径,应先补齐这些防御。

讨论亮点

review 中核心交锋如下:

chatgpt-codex-connector[bot](P2,正确性):--enable-mis 场景下 delimiter-only 的稀疏行若恰好是矩形,会通过 build_flat_input_top_logprobs_arrays 的校验,随后嵌套行被清空,响应暴露 null_prefix=0shape=[num_delimiters, k],客户端会把它们错误重建为位置 0..N-1。

sshleifer(作者回复):已在 #32078(db2f53b3a2)于请求校验层直接拒绝 MIS + flat,理由是 multi_item_delimiter_indicesGenerateReqInput 上可见,入口处快速失败能同时保护 scheduler 侧组装与 tokenizer 侧 builder,而不是依赖只能“偶尔”生效的内部 null 检查。

ch-wan(正确性建议):add_logprob_to_meta_info 的 scheduler-flat 分支只在 flat_fields is None 时构建,但如果早先的流式 chunk 走过嵌套路径并缓存了空字段(num_rows == 0),后来 chunk 再设置 input_top_logprobs_scheduler_flat 时缓存已非 None,真实数组永远不会被编码,形成静默空 payload。建议在 convert_logprob_style 赋值时清空缓存。

ch-wan(健壮性建议):build_flat_input_top_logprobs_arrays 只校验 val 行,idx 行直接 reshape,若形态不匹配会抛出非 ValueError 的异常,逃出 _flatten_input_top_logprobs 的 catch;最终代码未加镜像校验。

ch-wan(两个 nit):b64 文档声称 little-endian 但 tobytes() 是 native endian;三个平行列表(val / idx / null_prefix)缺少越界防御。

最终 ch-wan 以 APPROVED 合入,后四条建议未在最终 patch 中看到对应修改,属于“讨论后接受的已知边界”。

实现拆解

按以下 5 步拆解:

  1. 共享转换原语下沉到 io_structpython/sglang/srt/managers/io_struct.py 新增 build_flat_input_top_logprobs_arrays,把嵌套行统一转换为 (float32 [rows,k], int32 [rows,k], null_prefix) 三元组,并校验“仅前导 null、每行长度均为 k”的矩形约束,不满足时抛 ValueError。同时 TokenizedGenerateReqInput 增加 return_flat_raw_top_logprobs 布尔位,随 GenerateReqInput → TokenizedGenerateReqInput → Req 一路传递(scheduler.py 构造 Req 时透传,schedule_batch.pyReqLogprob 增加三个 flat 字段用于承载数组)。

  2. scheduler 侧组装python/sglang/srt/managers/scheduler_components/logprob_result_processor.pyadd_input_logprob_return_valuesrelevant_tokens_len 断言之后新增 _flatten_input_top_logprobs,对 opt-in 请求调用共享原语;成功则清空嵌套行(后续 batch 输出只带数组),失败则保留嵌套格式并打 WARNING,语义与 tokenizer manager 既有 fallback 完全对齐。

  3. IPC 线格式扩展BatchTokenIDOutput/BatchStrOutput 增加 input_top_logprobs_val_flat / idx_flat / null_prefix 三个可选字段;output_streamer.py_GenerationStreamAccumulator 中收集这三个字段,并仅在 batch 中存在 opt-in 请求时(has_input_top_logprobs_flat)才写入 payload,普通路径保持 None,从而对未使用方 wire 完全不变;multi_tokenizer_mixin.pydetokenizer_manager.py 分别按索引拆分 / 透传字段。

  4. tokenizer 侧消费tokenizer_manager.pyconvert_logprob_style 把数组中转到 ReqState.input_top_logprobs_scheduler_flat(三者成组赋值);add_logprob_to_meta_info 优先走新函数 _build_flat_input_top_logprobs_fields_from_arrays(b64 模式 tobytes() 后 base64,非 b64 模式 tolist()),并复用 input_top_logprobs_flat_fields 缓存;行版 _build_flat_input_top_logprobs_fields 改为复用同一原语做合法性检查,但非 b64 分支仍从原始行 flatten,以保持旧路径 JSON 数值的 float64 精度。

  5. 测试配套test/registered/unit/managers/test_flat_raw_top_logprobs.py 新增 28 个用例(+367 行),覆盖 flag on/off 行为差异、分块 prefill 与一次性 prefill 结果一致、参差行 fallback(assertLogs 验证告警)、from-arrays 与 from-rows 逐字段等价(flat / b64 / 全 null)、pickle 与 msgpack 双传输 roundtrip、默认 None wire 检查,并提交了一个环境变量门控的序列化 microbench。

文件 模块 状态 重要度
python/sglang/srt/managers/tokenizer_manager.py 分词管理 modified 7.32
python/sglang/srt/managers/scheduler_components/logprob_result_processor.py 概率输出 modified 6.95
python/sglang/srt/managers/io_struct.py 传输结构 modified 6.86
python/sglang/srt/managers/scheduler_components/output_streamer.py 输出流 modified 6.18
test/registered/unit/managers/test_flat_raw_top_logprobs.py 概率输出 modified 7.2
python/sglang/srt/managers/schedule_batch.py 请求批次 modified 4.87

关键符号

build_flat_input_top_logprobs_arrays _flatten_input_top_logprobs _build_flat_input_top_logprobs_fields_from_arrays add_logprob_to_meta_info convert_logprob_style

关键源码片段

python/sglang/srt/managers/tokenizer_manager.py core-logic

tokenizer 侧消费 scheduler 数组的核心:新增 `_build_flat_input_top_logprobs_fields_from_arrays`、`ReqState.input_top_logprobs_scheduler_flat` 状态,并在 `add_logprob_to_meta_info` / `convert_logprob_style` 中接入数组路径,同时保留嵌套回退。

def _build_flat_input_top_logprobs_fields_from_arrays(
    val_arr: np.ndarray,
    idx_arr: np.ndarray,
    null_prefix: int,
    return_b64: bool = False,
) -> Dict[str, Any]:
    """从 scheduler 侧组装好的 [rows, k] 数组构造 flat 响应字段。    与行版 `_build_flat_input_top_logprobs_fields` 共享同一套字段语义
    (shape / null_prefix / flat / b64),保证两条组装路径产出的 JSON
    结构逐字段一致。b64 模式用 dtype 标记字段, 便于以后调整位宽。
    """
    fields: Dict[str, Any] = {}
    if return_b64:
        # native-endian 连续二进制 + base64; dtype 标记由客户端解释
        fields["input_top_logprobs_val_flat_b64"] = pybase64.b64encode(
            val_arr.tobytes()
        ).decode("utf-8")
        fields["input_top_logprobs_idx_flat_b64"] = pybase64.b64encode(
            idx_arr.tobytes()
        ).decode("utf-8")
        fields["input_top_logprobs_val_flat_b64_dtype"] = "float32"
        fields["input_top_logprobs_idx_flat_b64_dtype"] = "int32"
    else:
        # 非 b64: 展平为普通列表 , 与旧行版 JSON 结构保持一致
        fields["input_top_logprobs_val_flat"] = val_arr.reshape(-1).tolist()
        fields["input_top_logprobs_idx_flat"] = idx_arr.reshape(-1).tolist()
    fields["input_top_logprobs_shape"] = [val_arr.shape[0], val_arr.shape[1]]
    fields["input_top_logprobs_null_prefix"] = null_prefix
    return fields
python/sglang/srt/managers/scheduler_components/logprob_result_processor.py dependency-wiring

scheduler 侧组装入口:新增 `_flatten_input_top_logprobs`,在 prefill 长度断言后把嵌套行转数组并清空嵌套列表,是性能收益的起点。

def _flatten_input_top_logprobs(self, req: Req) -> None:
    """把嵌套的 prompt top logprob 行替换为 flat 数组。    仅对 opt-in 了 return_flat_raw_top_logprobs 的请求生效,使 batch
    输出只携带两个 ndarray,而不是 num_positions * k 个 Python 列表,
    从而压低 scheduler → detokenizer 这条 ZMQ 链路的序列化开销。
    """
    if req.logprob.top_logprobs_num <= 0:
        return
    try:
        (
            req.logprob.input_top_logprobs_val_flat,
            req.logprob.input_top_logprobs_idx_flat,
            req.logprob.input_top_logprobs_flat_null_prefix,
        ) = build_flat_input_top_logprobs_arrays(
            req.logprob.input_top_logprobs_val,
            req.logprob.input_top_logprobs_idx,
            req.logprob.top_logprobs_num,
        )
    except ValueError as e:
        # 无法用 (shape, null_prefix) 表达的行 ( 例如多条目打分 ):
        # 保留嵌套格式 , 与 tokenizer manager 侧的 fallback 语义一致。
        logger.warning(
            "Falling back to nested input top logprobs for rid=%s: %s",
            req.rid,
            e,
        )
        return
    # 组装成功后清空嵌套行 , 后续 batch 输出只携带数组 ; 该调用发生在
    # prefill 长度校验之后 , 因此不会破坏 relevant_tokens_len 断言。
    req.logprob.input_top_logprobs_val = []
    req.logprob.input_top_logprobs_idx = []
python/sglang/srt/managers/io_struct.py core-logic

共享转换原语 `build_flat_input_top_logprobs_arrays` 与两个 batch 输出结构的新增可选字段定义,是整个数据面契约所在,两个进程共用同一套矩形性校验。

def build_flat_input_top_logprobs_arrays(
    input_top_logprobs_val: List[Optional[List[float]]],
    input_top_logprobs_idx: List[Optional[List[int]]],
    top_logprobs_num: int,
) -> Tuple[np.ndarray, np.ndarray, int]:
    """把嵌套的逐位置 top logprob 行转换为 flat 数组格式。    返回 (float32 值数组 [rows, k], int32 token id 数组 [rows, k],
    null_prefix)。开头的 null 行计入 null_prefix 并从数组中剔除。
    行必须满足: 只有前导 null、且每行长度都为 k,否则抛 ValueError
    (例如 multi-item scoring 的稀疏 delimiter 行无法用 shape 表达)。
    """
    num_rows = len(input_top_logprobs_val)
    null_prefix = 0
    # 统计前导 null 行 , 这些位置没有 top logprobs, 由 null_prefix 承载
    while null_prefix < num_rows and not input_top_logprobs_val[null_prefix]:
        null_prefix += 1
    val_rows = input_top_logprobs_val[null_prefix:]
    idx_rows = input_top_logprobs_idx[null_prefix:]
    # 空行集合时以 top_logprobs_num 作为 k, 保证 shape 仍为 [0, k]
    k = len(val_rows[0]) if val_rows else top_logprobs_num
    for offset, row in enumerate(val_rows):
        if row is None or len(row) != k:
            # 中间空行或参差长度无法由 (shape, null_prefix) 表达 , 必须显式失败
            raise ValueError(
                "return_flat_raw_top_logprobs requires rectangular top logprob "
                f"rows with nulls only in the leading prefix; row {null_prefix + offset} "
                f"has {None if row is None else len(row)} entries (expected {k})."
            )
    val_arr = np.asarray(val_rows, dtype=np.float32).reshape(len(val_rows), k)
    idx_arr = np.asarray(idx_rows, dtype=np.int32).reshape(len(idx_rows), k)
    # 注意 : 目前只对 val 行做矩形性校验 , idx 行默认与 val 行形态一致 ;
    # review 已指出该假设未加固 , 参差 idx 可能抛出非 ValueError 异常。
    return val_arr, idx_arr, null_prefix

评论区精华

MIS + flat 组合会丢失 delimiter 位置映射 正确性

codex bot 指出 `--enable-mis` 下 delimiter-only 的矩形行会通过校验并被清空嵌套,客户端会把结果重建为位置 0..N-1。

结论:sshleifer 在 #32078(db2f53b3a2)于请求校验层直接拒绝 MIS + flat,入口处快速失败同时保护 scheduler 与 tokenizer 两侧。 · 已解决

scheduler-flat 缓存可能被嵌套路径早先缓存遮蔽,导致静默空 payload 正确性

ch-wan 指出 `add_logprob_to_meta_info` 只在 `flat_fields is None` 时构建,若流式 chunk 先缓存空字段,后续 scheduler 数组到达后不会重新编码。

结论:建议在 `convert_logprob_style` 赋值 scheduler_flat 时清空缓存;最终 patch 未见修改,属讨论后接受的已知边界。 · 待处理

build_flat_input_top_logprobs_arrays 只校验 val 行,idx 行可抛非 ValueError 异常 正确性

ch-wan 建议镜像校验 idx 行并要求行数一致,否则异常可能逃出 `_flatten_input_top_logprobs` 的 catch 并中断批量结果循环。

结论:最终代码仍只校验 val 行,建议未被采纳。 · 待处理

b64 文档声称 little-endian 但编码为 native-endian documentation

ch-wan 指出 `tobytes()` 是 native endian,大端主机或按文档实现客户端会解码错误,建议强制 `<f4`/`<i4` 或改文档措辞。

结论:作为 nit 提出,未在最终 patch 中看到修改。 · 待处理

三个平行列表(val / idx / null_prefix)缺少越界防御 正确性

ch-wan 指出 val 非 None 时直接索引 idx 与 null_prefix,部分 payload 会 raise 而非降级。

结论:建议用断言或降级处理;当前 streamer 保证三者成组追加,风险被接受。 · 待处理

风险与影响

  • 静默空 payload 风险(tokenizer_manager.py add_logprob_to_meta_info:ch-wan 指出的缓存遮蔽问题未被修复——若流式时序出现“先走嵌套路径缓存空字段、后收到 scheduler 数组”的情况,input_top_logprobs_flat_fields 非 None 会跳过数组编码,响应缺 flat 字段且无异常。当前 streamer 通常 prefill 后首个流式 chunk 就发送 input logprobs,实际触发概率低,但属于静默失败。
  • idx 校验缺失(io_struct.py build_flat_input_top_logprobs_arrays:只对 input_top_logprobs_val 做矩形校验,idx 行未校验,np.asarray(...).reshape(...) 可能抛 TypeError 或非预期 ValueError,而 _flatten_input_top_logprobs 只捕获 ValueError,其他异常会逃出批量结果循环。
  • 浮点精度差异:非 b64 的 JSON 数值经 scheduler 数组路径会做 float32 舍入,与旧路径 float64 列表并非逐位一致;作者论证 logprobs 本就以 float32 计算,属 no-op,但严格讲输出字节可能不同。
  • 路径不完整覆盖:session / EPD / PP 请求不传递 flag,降级为旧嵌套路径,响应一致但拿不到 IPC 收益。
  • 传输安全:默认 pickle 走 protocol-5 buffer、msgpack 路径经 msgspec 的 ndarray 钩子编码解码(routed_experts 先例),roundtrip 测试已覆盖;big-endian 主机上 b64 二进制为 native-endian,与文档描述存在偏差。

对用户:return_flat_raw_top_logprobs / _b64 的响应字段结构不变,行为兼容;长 prompt + top logprobs 请求(如 32k 位置)可获得约 30ms 每请求的事件循环 CPU 节省与约减半的 IPC payload(1.12MB → 0.53MB)。对系统:收益集中在 detokenizer 与 tokenizer manager 两个最忙单例 loop,且只对 opt-in 请求生效;普通请求 wire 逐字节不变,回归面受控。对团队:组装与校验逻辑收敛到 build_flat_input_top_logprobs_arrays 单一原语,两个进程共享同一语义,后续调整 dtype 或扩展格式只需改一处;测试把两条组装路径的字段级等价性固化下来,降低未来改动风险。

IPC 数据面变更 缓存遮蔽致静默空 payload idx 校验缺失 异常类型逃逸风险 旧路径行为保持

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论