DeepSeek V4 Flash 双机部署:2 台 DGX Spark,上下文一长吞吐只剩 9%

本文部分内容由 AI 辅助整理,所有配置、命令、数据均标注了出处;脚本为本人在本机真实运行并贴出原始输出。

上周刷到一条帖子,说在 2 台 DGX Spark 上把 DeepSeek V4 Flash 0731 跑起来了,单流 82 tok/s,三路并发 135 tok/s,用的是官方权重。

我第一反应是:82 tok/s,对一个 300B 量级的 MoE 来说,两台桌面机能到这个数确实挺好看。

第二反应是:这个数是在多长的 prompt 下测的?

我去把那套配方的公开仓库和它 docs 里的完整 sweep 数据翻了一遍,然后写了个脚本,把自己手头要塞进上下文的东西数了数。结论有点扎心:

同样单流、同一台集群、同一个模型,prompt 从 256 token 涨到 12.5 万,解码速度只掉了 14%,但端到端吞吐掉了 91%。首字延迟从 0.63 秒变成 1 分 16 秒。

那条 82 tok/s 没有骗人。它只是在回答另一个问题。


一、先把这个数放回它的语境里

先说清楚我在讲哪台机器、哪个模型、哪份数据,免得后面的数字被断章取义。

模型是 deepseek-ai/DeepSeek-V4-Flash-0731。Hugging Face 模型卡上标的是 304B 总参数,MIT 协议,纯文本,模型卡给的 Terminal Bench 2.1 是 82.7、DeepSWE 是 54.4。0731 是它的日期戳,也是官方正式版,替代了此前的 preview。

配方来自 MiaAI-Lab/DeepSeek-v4-Flash-DSpark-2x-DGX-Spark 这个公开仓库(我抓的时候是 423 star / 50 fork)。它把两台 DGX Spark 用 vLLM 组成 TP=2,跑的镜像是 ghcr.io/anemll/dspark-vllm-gx10:0.1.1,钉的 revision 是 9e165c30e2704aec5d9d593cce3eebd58bbef1cb

**本文里所有 tok/s、TTFT、KV 池的数字,都来自这个仓库公开的测量记录,不是我测的。**我手上没有 DGX Spark。我做的是三件事:把它们的完整 sweep 表整理出来、从配置里反算出几处它们没明说的因果、写一个能在你自己机器上跑的预算脚本。哪句是引用、哪句是我的判断,我会逐处标出来。


二、这套配方长什么样

关键 flag 直接抄仓库的 compose:

/usr/local/bin/vllm serve \
  --tensor-parallel-size 2 \
  --distributed-executor-backend mp \
  --nnodes 2 \
  --kv-cache-dtype nvfp4_ds_mla \
  --block-size 256 \
  --max-model-len 1048576 \
  --max-num-seqs 6 \
  --max-num-batched-tokens 8192 \
  --gpu-memory-utilization 0.85 \
  --moe-backend flashinfer_b12x \
  --async-scheduling \
  --enable-chunked-prefill \
  --speculative-config '{"method":"dspark","num_speculative_tokens":5,"draft_sample_method":"probabilistic"}' \
  --generation-config vllm

几个值后面会反复用到,先记住:

参数 它到底是什么
max_model_len 1,048,576 单个请求的长度上限
max_num_seqs 6 调度器同时跑几路的并发上限
max_num_batched_tokens 8,192 一个批次最多喂进去多少 token
kv_cache_dtype nvfp4_ds_mla KV 的量化格式,直接决定池子多大

开机日志里会打出真正重要的那个数:

Available KV cache memory: 18.08 GiB
GPU KV cache size: 2,493,464 tokens
Maximum concurrency for 1,048,576 tokens per request: 2.38x
Application startup complete.

249 万 token 的 KV 池。这一行才是你的容量,不是那个 1M。第七节会讲为什么。

另外提醒一句:0731 这个 checkpoint 在 Hugging Face 上没有 Jinja chat_template,要靠 --tokenizer-mode deepseek_v4 去调 checkpoint 自带的 encoding/encoding_dsv4.py。仓库还提到,0731 之前的 tokenizer wrapper 会把 low 这一档推理强度错映射成 high,启动脚本里做了纠正。你要是自己拼装环境,这个坑值得先看一眼。


三、那张表:同一台机器,prompt 从 256 涨到 131,072

这是整篇文章的核心。全部单流(并发=1),只有 prompt 长度在变:

prompt tokens TTFT prefill tok/s decode tok/s 端到端 aggregate tok/s
256 0.63 s 447 75.4 69.1
2,048 0.81 s 2,563 68.8 62.0
8,192 4.80 s 1,713 73.9 43.7
32,768 22.96 s 1,428 64.0 16.6
131,072 78.75 s 1,665 65.2 5.9

盯着 decode 那一列看:75.4 → 68.8 → 73.9 → 64.0 → 65.2。

prompt 涨了 512 倍,这一列基本是平的。

再看 aggregate 那一列:69.1 → 62.0 → 43.7 → 16.6 → 5.9。

掉了 11.7 倍。

再看 TTFT:0.63 秒 → 78.75 秒,125 倍

三列,同一批测量,方向完全不一样。


四、为什么 decode 没怎么掉,体感却塌了

因为它们是两笔账。

prefill 是把你的 prompt 一次性读进去、把每一层的 KV 都算出来。这是一次计算密集的批处理,工作量正比于 prompt 长度。

decode 是逐个 token 往外吐。每吐一个都要把已有的 KV 读一遍,但每一步只算一个 token 的前向。它是带宽密集的,工作量跟 prompt 长度基本无关。

所以 decode 那一列平,是符合预期的。

问题在于 aggregate 这个口径把两笔账加在一起了。当 prefill 从零点几秒涨到一分钟,而生成的 token 数没变,平均下来的 tok/s 当然会塌。

一句话:你看到的那个 82 tok/s 是 decode,你实际等的时间是 prefill 加 decode。prompt 越长,第二项越是被第一项主导。

拿 131,072 那行算一下:78.75 秒 prefill,之后按 65.2 tok/s 吐 2048 个 token 需要 31.4 秒。也就是说,你等第一个字的时间,是它把整段答案吐完所需时间的 2.5 倍。

这在 agent 场景里是常态,不是极端情况。把一个中等模块塞进上下文,轻松就十几万 token。我拿本机 Python 3.12 标准库的 asyncio 包量了一下(33 个 .py 文件),50.1 万字符,估算约 12.5 万 token。就一个模块。


五、加并发能救吗:prompt 越长越救不动

直觉上,单路慢就多开几路,总吞吐能补回来。短 prompt 下这招确实好使,长 prompt 下基本失效。

同一份 sweep,按 prompt 长度分组看 aggregate 随并发的变化:

prompt 并发 1 并发 2 并发 4 并发 6 6 路 / 1 路
256 69.1 104.9 164.5 191.2 2.77×
2,048 62.0 97.6 154.7 143.7 2.32×
8,192 43.7 56.2 72.3 73.1 1.67×
32,768 16.6 24.8 26.7 27.9 1.68×
131,072 5.9 6.6 1.12×(只到 2 路)

**并发的边际收益随上下文变长而衰减。**短 prompt 下 6 路能换来 2.77 倍,32K 下只剩 1.68 倍,128K 下 2 路只多了 12%。

原因不难理解(这一段是我的判断,不是仓库原话):GPU 已经被 prefill 占满了。再加一路,只是让几个长 prompt 互相排队分算力,总的计算量一点没少。

TTFT 那边更难看。131,072 那两行:单流 78.75 秒,两路 111.17 秒。加一路并发,每个人的首字都多等 41%。


六、2,048 × 6 那一行的跳变,能从配置里算出来

sweep 表里有一行看着很怪。prompt 2,048:

并发 TTFT prefill tok/s aggregate tok/s
1 0.81 s 2,563 62.0
2 1.11 s 1,911 97.6
4 1.38 s 1,505 154.7
6 6.06 s 342 143.7

从 4 路到 6 路,TTFT 跳了 4.4 倍,prefill 掉到原来的 1/4,aggregate 不升反降。前面三行都很平滑,就这一行断崖。

max_num_batched_tokens = 8192 拿出来算一下(这一节是我的推算,不是仓库明说的):

并发 4:4 × 2,048 = 8,192   恰好等于批次上限
并发 6:6 × 2,048 = 12,288  超了 50%

**边界正好卡在 4 和 6 之间。**超过之后 --enable-chunked-prefill 开始把 prefill 切块分批喂,首字延迟就不再是线性增长了。

我在脚本里把这条检查做进去了,第十一节有实际输出——不需要有机器,光靠配置就能提前算出你会不会撞上这一格。

再对照一下 256 那组:6 × 256 = 1,536,远在 8,192 以内,所以 256 那一行从 1 路到 6 路一路平滑涨到 191.2,没有任何断崖。同一个上限,一组撞上了一组没撞上,行为差异完全对得上。


七、1M 上下文是天花板,不是给你预留的

这是我看这份文档最大的收获,也是最容易想反的一处。

max_model_len=1048576max_num_seqs=6,很多人(包括一开始的我)会默认这意味着 6 × 1M 的 KV 被预留了。

不是。仓库里写得很直白:这两个都是上限(ceiling),不是预留(reservation)。PagedAttention 是按需分块发 KV、请求结束就回收。真实约束只有一条:

sum(所有活跃请求的 live tokens) <= KV 池

这台集群的池子是 2,493,464 token。于是:

6 路 × 5 万   =  30 万    轻松装下
6 路 × 20 万  = 120 万    装得下
3 路 × 100 万 = 300 万    超了,池子只有 249 万
6 路 × 100 万 = 600 万    不可能,多出来的请求会排队或被抢占

开机日志那行 Maximum concurrency for 1,048,576 tokens per request: 2.38x 说的就是这件事:同时跑满 1M 的请求,只能有两个多一点。

但这不代表 max_num_seqs=6 是虚标。它的实际含义是:**日常 agent 会话都远不到 1M,六路短会话共用一个池子完全没问题,同时把 1M 这个上限留给偶尔来的那个超长请求。**你买的不是 6 个 1M 的槽位,你买的是一个 249 万 token 的共享池子,外加一个 1M 的单请求天花板。

这两句话听起来像绕口令,但决定了你该怎么配。如果你的负载是「六个人同时喂 50 万 token」,这套配置扛不住,得往下调 max_model_len 或者认命降并发。


八、900K 那次验收:首字等了 17 分 09 秒

仓库还公开了一次极限验收:一个 899,994 token 的 prompt。

prompt tokens : 899,994
TTFT          : 1,028.85 秒
prefill       : 约 874.8 tok/s
结果          : 完成,角色边界干净,工具调用参数合法

1,028.85 秒 = 17 分 09 秒。

功能上它是通的:system/user 边界没串,推理内容和最终回答分开返回,deepseek_v4 tool parser 吐出了合法的 OpenAI function 参数。

但你得先等 17 分钟。

这个数我后面拿来当锚点用了,见第十一节。


九、报错 → 多半是什么 → 怎么确认

按这套配方走会遇到的坑,前四条来自仓库文档,后两条是我从上面几节的数里反推的:

现象 多半是什么 怎么确认
直连 API 正常,接上 agent 就输出乱码、循环、中文漂移、工具 XML 泄漏 运行时镜像不一致,或 agent 编排层在静默回退 docker image inspect $DSPARK_VLLM_IMAGE 核对两节点 tag,再清掉 agent 的 fallback 列表复测。别先怀疑权重
TP=2 起不来,worker 磁盘被塞满 权重在线重复下载 两节点各自补全 HF hub cache,确认 snapshot 里有 encoding/encoding_dsv4.py,然后 HF_HUB_OFFLINE=1
设了一堆 VLLM_DSPARK_*,日志刷 Unknown vLLM environment variable Anemll 镜像不注册 Stage-C 那套开关,这些设置是空操作 要么合并 docker-compose.stage-c.override.yml,要么干脆别设
单流 decode 比预期慢三成 没显式设 VLLM_USE_BREAKABLE_CUDAGRAPH,Anemll 会自动走较慢的 breakable 路径 显式设成 0。仓库对照数据:单流 74.55 → 95.9 tok/s(+28.6%),两路 aggregate 134.2 → 151.8(+13.1%)
首字等了几十秒到十几分钟,以为卡死了 没卡死,prefill 在算你那个长 prompt 按第三节的表估一下 TTFT,或者用第十一节的脚本算
max_num_seqs=6 但请求还在排队 池子被长请求占住了,ceiling 不等于 reservation 按第七节的式子算 sum(live tokens),跟 249 万比

十、两个节点各要一份权重,这份缓存放在哪儿

**这一节是判断,不是实测。**上面所有数字都跟它无关,你把这一节整段跳过,前面的结论一个字都不用改。

仓库里有个容易被划过去的细节:模型缓存必须在 head 和 worker 上各有一份完整的,而且推荐补全之后开 HF_HUB_OFFLINE=1。原文明说过,缓存不完整或者在线重复下载,把 worker 磁盘塞满过,导致 TP=2 起不来

这是多机部署里一个很少被算进预算的成本。你以为买的是算力,实际还得为「同一份权重存 N 遍、每次换模型再拉 N 遍」付账。两台还好,四台八台的时候,公网带宽和磁盘会先于 GPU 成为瓶颈——尤其是国内拉 Hugging Face 的那个速度。

这一层的解法是把权重的「源」放到本地:一台待在同一个机柜或者同一间办公室里的存储节点,权重从外网拉一次落在它那儿,推理节点各自从内网补全自己的 cache,然后一律离线加载。换模型、加节点、重装系统,都不用再出公网。这类设备市面上有做成一体化形态的,桌面尺寸、本地落盘、内网分发,不是什么新概念,只是很容易在做算力预算的时候被忘掉。

要不要为这一层单独花钱,取决于你换模型的频率和节点数。两台机器、一年换两次模型,手动 rsync 一次就完事,真没必要。


十一、脚本:把你自己的上下文落到这张表上

前面的表都是别人机器上的数。真正要回答的问题是:你的上下文有多长,落在哪一行。

这个脚本干四件事,前三件在你本机真算,第四件是查表:

  1. 数你要塞进去的东西有多少 token(可以直接扫一个目录)
  2. 落到公开 sweep 表上,估 TTFT 和端到端吞吐
  3. 算 KV 池装不装得下
  4. 检查有没有撞破 max_num_batched_tokens

零依赖,只用标准库。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ctx_budget_2spark.py —— 在你买机器之前,先算清「你那点上下文,能换回多少 tok/s」。

背景:双机 DGX Spark 跑 DeepSeek V4 Flash 0731 的公开配方里,
单流解码是 82 tok/s 量级。但那条数字是在**几百 token 的短提示**下测的。
真正决定你体感的不是解码速度,是你喂进去多长的 prompt。

脚本做四件事,前三件是**在你本机真算的**,第四件是**查表插值**(数据来源见 SOURCE):

  1. 数你要塞进上下文的东西到底有多少 token(可以直接扫一个目录)
  2. 把这个长度落到公开的 prefill/concurrency sweep 表上,估首字延迟与端到端吞吐
  3. 算 KV 池装不装得下:sum(live tokens) <= pool,不是 max_num_seqs × max_model_len
  4. 检查 concurrency × prompt 有没有撞破 max_num_batched_tokens 这道批次上限

零依赖,只用标准库。

  python3 ctx_budget_2spark.py --prompt-tokens 40000
  python3 ctx_budget_2spark.py --scan-dir ./src --ext .py,.md
  python3 ctx_budget_2spark.py --scan-dir ./src --concurrency 4
  python3 ctx_budget_2spark.py --table            # 只打公开 sweep 原表
  python3 ctx_budget_2spark.py --scan-dir . --json

⚠️ 分层说明(别搞混了):
   [本机] 标记的行是这台机器上真算出来的(字符数、token 估算、KV 池算术、批次边界)。
   [引用] 标记的行来自下面 SOURCE 里那份公开测量,**不是本脚本测的**,
          换一台机器、换一个 util、换一版镜像,这些数就会变。
"""

import argparse
import json
import os
import sys

SOURCE = (
    'MiaAI-Lab/DeepSeek-v4-Flash-DSpark-2x-DGX-Spark · docs/DEEPSEEK_V4_FLASH_0731.md'
)

# ── 公开 sweep 表:{prompt_tokens: {concurrency: (TTFT秒, prefill tok/s, decode tok/s, aggregate tok/s)}}
# 两台 DGX Spark,TP=2,nvfp4_ds_mla KV,MTP5,max_model_len=1048576,max_num_seqs=6
SWEEP = {
    256: {1: (0.63, 447, 75.4, 69.1), 2: (0.81, 357, 58.3, 104.9),
          4: (1.26, 222, 46.8, 164.5), 6: (1.42, 197, 36.9, 191.2)},
    2048: {1: (0.81, 2563, 68.8, 62.0), 2: (1.11, 1911, 57.0, 97.6),
           4: (1.38, 1505, 44.0, 154.7), 6: (6.06, 342, 34.7, 143.7)},
    8192: {1: (4.80, 1713, 73.9, 43.7), 2: (7.51, 1176, 49.8, 56.2),
           4: (14.50, 578, 37.4, 72.3), 6: (18.38, 454, 23.6, 73.1)},
    32768: {1: (22.96, 1428, 64.0, 16.6), 2: (26.82, 1287, 41.5, 24.8),
            4: (44.85, 756, 17.4, 26.7), 6: (60.75, 550, 10.8, 27.9)},
    131072: {1: (78.75, 1665, 65.2, 5.9), 2: (111.17, 1306, 30.9, 6.6)},
}

# 同一份公开材料里的 900K 验收点,只有 C1,单独放
ACCEPT_900K = {'prompt_tokens': 899994, 'ttft_s': 1028.85, 'prefill_tps': 874.8}

# 配方默认值(来自 .env.dspark.example / docker-compose.dspark.yml)
DEFAULTS = {
    'kv_pool_tokens': 2493464,     # 该集群 boot 日志实测:GPU KV cache size
    'max_model_len': 1048576,      # 1M,是「天花板」不是「预留」
    'max_num_seqs': 6,
    'max_num_batched_tokens': 8192,
}

SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist',
             'build', '.next', 'target', '.idea', '.mypy_cache'}


def is_cjk(ch):
    o = ord(ch)
    return (0x3400 <= o <= 0x9FFF) or (0xF900 <= o <= 0xFAFF) or (0x3000 <= o <= 0x303F)


def est_tokens(text, chars_per_token=4.0):
    """粗估 token 数。CJK 按 1 字 1 token,其余按 chars_per_token 个字符 1 token。

    这是估算不是分词。真要精确就用模型自带的 tokenizer 跑一遍——
    脚本同时打印原始字符数,你可以拿自己的分词器重算,不用信我这个系数。
    """
    cjk = sum(1 for c in text if is_cjk(c))
    other = len(text) - cjk
    return int(cjk + other / chars_per_token), cjk, other


def scan_dir(root, exts, chars_per_token, max_mb=200):
    total = cjk_all = other_all = 0
    files = skipped = 0
    budget = max_mb * 1024 * 1024
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith('.')]
        for fn in filenames:
            if exts and not any(fn.endswith(e) for e in exts):
                continue
            p = os.path.join(dirpath, fn)
            try:
                if os.path.getsize(p) > budget:
                    skipped += 1
                    continue
                with open(p, encoding='utf-8', errors='strict') as f:
                    text = f.read()
            except (OSError, UnicodeDecodeError):
                skipped += 1                      # 二进制/无权限的直接跳过,别混进 token 数里
                continue
            t, c, o = est_tokens(text, chars_per_token)
            total += t
            cjk_all += c
            other_all += o
            files += 1
    return {'tokens': total, 'files': files, 'skipped': skipped,
            'cjk_chars': cjk_all, 'other_chars': other_all}


def loglerp(x, x0, y0, x1, y1):
    """双对数插值/外推。

    横轴用对数:sweep 的 prompt 长度是 4 倍一跳,线性插会严重低估中间段。
    纵轴也用对数:这几个量都是正的且近似幂律衰减,在纵轴上线性外推会算出负的
    tok/s(第一版就这么翻车过:2.6M prompt 外推出 -17.3 tok/s)。取几何插值
    之后结果恒为正,量级也更贴。
    """
    import math
    if x1 == x0:
        return y0
    f = (math.log(x) - math.log(x0)) / (math.log(x1) - math.log(x0))
    if y0 > 0 and y1 > 0:
        return math.exp(math.log(y0) + f * (math.log(y1) - math.log(y0)))
    return max(0.0, y0 + f * (y1 - y0))


def estimate(prompt_tokens, concurrency):
    """从公开 sweep 表插值。返回 ((ttft, prefill, decode, aggregate), 说明)。

    decode / aggregate 在表外一律返回 None —— 不编。
    第一版是直接按最后两行的斜率往外推的,拿公开那个 899,994 token 的验收点一对,
    TTFT 低估 58%、prefill 高估 136%。表外的曲线跟表内不是一回事,斜率不能直接用。
    现在 TTFT 和 prefill 改成锚到那个真实测过的 900K 点上插值,decode/aggregate
    因为 900K 那次没公布,就直接不给。
    """
    have = [L for L in sorted(SWEEP) if concurrency in SWEEP[L]]
    if not have:
        return None, '公开表里没有 concurrency=%d 这一列(表里有 1/2/4/6)' % concurrency

    if prompt_tokens <= have[0]:
        return SWEEP[have[0]][concurrency], '低于表中最短 prompt(%s),直接取该行,实际只会更快' % format(have[0], ',')

    if prompt_tokens > have[-1]:
        if concurrency != 1:
            return None, ('prompt 超出表中最长的 %s,而表外只有单流那个 900K 验收点可锚,'
                          '并发情况给不了数' % format(have[-1], ','))
        lo, hi = have[-1], ACCEPT_900K['prompt_tokens']
        a = SWEEP[lo][1]
        ttft = loglerp(prompt_tokens, lo, a[0], hi, ACCEPT_900K['ttft_s'])
        pre = loglerp(prompt_tokens, lo, a[1], hi, ACCEPT_900K['prefill_tps'])
        note = ('超出 sweep 表最长 prompt(%s),TTFT/prefill 锚到公开的 %s token 验收点插值;'
                'decode/aggregate 那次没公布,不给数' % (format(lo, ','), format(hi, ',')))
        if prompt_tokens > hi:
            note += ';且已超过 900K 锚点本身,只当量级'
        return (ttft, pre, None, None), note

    lo = max(L for L in have if L <= prompt_tokens)
    hi = min(L for L in have if L >= prompt_tokens)
    if lo == hi:
        return SWEEP[lo][concurrency], '正好命中表中一行,未插值'
    a, b = SWEEP[lo][concurrency], SWEEP[hi][concurrency]
    vals = tuple(loglerp(prompt_tokens, lo, a[i], hi, b[i]) for i in range(4))
    return vals, '在 %s 和 %s 两行之间,双对数插值' % (format(lo, ','), format(hi, ','))


def hms(s):
    if s < 60:
        return '%.1f 秒' % s
    m, sec = divmod(s, 60)
    if m < 60:
        return '%d 分 %02.0f 秒' % (m, sec)
    h, m = divmod(m, 60)
    return '%d 小时 %02d 分' % (h, m)


def width(s):
    return sum(2 if ord(c) > 0x2E80 else 1 for c in str(s))


def table(rows, headers):
    w = [max(width(h), max((width(r[i]) for r in rows), default=0)) for i, h in enumerate(headers)]
    out = ['  '.join(h + ' ' * (w[i] - width(h)) for i, h in enumerate(headers)),
           '  '.join('-' * x for x in w)]
    for r in rows:
        out.append('  '.join(str(c) + ' ' * (w[i] - width(c)) for i, c in enumerate(r)))
    return '\n'.join(out)


def print_sweep_table():
    print('\n公开 sweep 原表 [引用] 来源:%s' % SOURCE)
    print('两台 DGX Spark / TP=2 / nvfp4_ds_mla / MTP5 / max_model_len=1048576 / max_num_seqs=6\n')
    rows = []
    for L in sorted(SWEEP):
        for c in sorted(SWEEP[L]):
            ttft, pre, dec, agg = SWEEP[L][c]
            rows.append([format(L, ','), str(c), '%.2f' % ttft,
                         format(int(pre), ','), '%.1f' % dec, '%.1f' % agg])
    print(table(rows, ['prompt', '并发', 'TTFT(s)', 'prefill tok/s', 'decode tok/s', 'aggregate tok/s']))
    print('\n900K 验收点 [引用]:%s prompt tokens,TTFT %.2f 秒(%s),prefill ~%.1f tok/s'
          % (format(ACCEPT_900K['prompt_tokens'], ','), ACCEPT_900K['ttft_s'],
             hms(ACCEPT_900K['ttft_s']), ACCEPT_900K['prefill_tps']))


def main():
    ap = argparse.ArgumentParser(description='双机 Spark 跑长上下文的吞吐预算')
    g = ap.add_mutually_exclusive_group()
    g.add_argument('--prompt-tokens', type=int, help='直接给 prompt 长度')
    g.add_argument('--scan-dir', help='扫一个目录,估算全塞进上下文是多少 token')
    ap.add_argument('--ext', default='', help='只统计这些后缀,逗号分隔,如 .py,.md')
    ap.add_argument('--chars-per-token', type=float, default=4.0, help='非中文字符/token,默认 4.0')
    ap.add_argument('--concurrency', type=int, default=1, help='同时几路会话,默认 1')
    ap.add_argument('--output-tokens', type=int, default=2048, help='一次回答生成多少 token,默认 2048')
    ap.add_argument('--kv-pool', type=int, default=DEFAULTS['kv_pool_tokens'], help='KV 池 token 数')
    ap.add_argument('--max-model-len', type=int, default=DEFAULTS['max_model_len'])
    ap.add_argument('--max-batched', type=int, default=DEFAULTS['max_num_batched_tokens'])
    ap.add_argument('--table', action='store_true', help='只打印公开 sweep 原表')
    ap.add_argument('--json', action='store_true')
    a = ap.parse_args()

    if a.table:
        print_sweep_table()
        return

    scan = None
    if a.scan_dir:
        exts = [e.strip() for e in a.ext.split(',') if e.strip()]
        scan = scan_dir(a.scan_dir, exts, a.chars_per_token)
        prompt = scan['tokens']
    elif a.prompt_tokens:
        prompt = a.prompt_tokens
    else:
        ap.error('给 --prompt-tokens 或 --scan-dir 其中一个')

    est, note = estimate(prompt, a.concurrency)
    live = prompt + a.output_tokens
    fits = live * a.concurrency
    batched = prompt * a.concurrency
    over_ceiling = live > a.max_model_len

    if a.json:
        print(json.dumps({
            'measured_here': {'prompt_tokens': prompt, 'scan': scan,
                              'live_tokens_per_seq': live,
                              'live_tokens_total': fits,
                              'kv_pool_tokens': a.kv_pool,
                              'kv_pool_used_pct': round(fits * 100.0 / a.kv_pool, 2),
                              'batched_tokens_needed': batched,
                              'max_num_batched_tokens': a.max_batched},
            'interpolated_from_public_sweep': None if not est else {
                'source': SOURCE, 'note': note,
                'ttft_s': round(est[0], 2), 'prefill_tps': round(est[1], 1),
                'decode_tps': round(est[2], 1) if est[2] else None,
                'aggregate_tps': round(est[3], 1) if est[3] else None},
        }, ensure_ascii=False, indent=2))
        return

    print('\n' + '=' * 70)
    print('一、你的上下文有多长')
    print('=' * 70)
    if scan:
        print('  [本机] 扫描目录     : %s' % os.path.abspath(a.scan_dir))
        print('  [本机] 计入文件     : %d 个(跳过 %d 个:二进制/超大/读不了)' % (scan['files'], scan['skipped']))
        print('  [本机] 中文字符     : %s' % format(scan['cjk_chars'], ','))
        print('  [本机] 其余字符     : %s' % format(scan['other_chars'], ','))
        print('  [本机] token 估算   : %s(中文 1:1,其余 %.1f 字符 1 token)'
              % (format(prompt, ','), a.chars_per_token))
        print('         ↑ 这是估算。字符数是准的,换算系数不是——想精确就拿模型自带 tokenizer 重跑。')
    else:
        print('  [本机] prompt tokens: %s(你给的)' % format(prompt, ','))
    print('  [本机] 并发路数     : %d' % a.concurrency)
    print('  [本机] 每次生成     : %s token' % format(a.output_tokens, ','))

    if over_ceiling:
        print('\n  ⛔ [本机] prompt + output = %s,已经超过 max_model_len = %s。'
              % (format(live, ','), format(a.max_model_len, ',')))
        print('     这个请求会被服务端直接拒掉,下面几节的估算对它不成立。')
        print('     先把上下文砍到 %s token 以内,或者换个更长上下文的部署档位。'
              % format(a.max_model_len - a.output_tokens, ','))

    print('\n' + '=' * 70)
    print('二、落到公开 sweep 表上,大概是什么体感')
    print('=' * 70)
    if not est:
        print('  %s' % note)
    else:
        ttft, pre, dec, agg = est
        print('  [引用] 首字延迟 TTFT     : %s' % hms(ttft))
        print('  [引用] prefill           : %.0f tok/s' % pre)
        print('  [引用] 单流 decode       : %s' % ('%.1f tok/s' % dec if dec else '表外,不给数'))
        print('  [引用] 端到端 aggregate  : %s' % ('%.1f tok/s' % agg if agg else '表外,不给数'))
        if dec:
            print('  [本机] 一次回答总耗时估  : %s(首字 %s + 生成 %s token)'
                  % (hms(ttft + a.output_tokens / dec), hms(ttft), format(a.output_tokens, ',')))
        print('  插值说明:%s' % note)
        if dec and agg and a.concurrency in SWEEP[256]:
            base = SWEEP[256][a.concurrency]
            print('\n  同样 %d 路并发、prompt 从 256 涨到 %s,变化是:'
                  % (a.concurrency, format(prompt, ',')))
            print('    decode      %.1f → %.1f tok/s   (剩 %.0f%%)'
                  % (base[2], dec, dec * 100.0 / base[2]))
            print('    aggregate   %.1f → %.1f tok/s   (剩 %.0f%%)'
                  % (base[3], agg, agg * 100.0 / base[3]))
            print('    TTFT        %s → %s   (%.0f 倍)'
                  % (hms(base[0]), hms(ttft), ttft / base[0]))
            d_keep, a_keep = dec / base[2], agg / base[3]
            if a_keep < d_keep:
                print('  decode 只掉了 %.0f%%,aggregate 掉了 %.0f%% —— 差额全被 prefill 吃了。'
                      % (100 - d_keep * 100, 100 - a_keep * 100))
            else:
                print('  这一档 aggregate 还没被 prefill 压过去;真正的分水岭在更长的 prompt 上。')

    print('\n' + '=' * 70)
    print('三、KV 池装不装得下([本机] 算术)')
    print('=' * 70)
    print('  max_model_len 和 max_num_seqs 都是**天花板**,不是预留。')
    print('  真实约束只有一条:sum(所有活跃请求的 live tokens) <= KV 池\n')
    print('  [本机] 单路 live tokens   : %s (prompt %s + output %s)'
          % (format(live, ','), format(prompt, ','), format(a.output_tokens, ',')))
    print('  [本机] %d 路合计          : %s' % (a.concurrency, format(fits, ',')))
    print('  [引用] KV 池              : %s token' % format(a.kv_pool, ','))
    pct = fits * 100.0 / a.kv_pool
    print('  [本机] 占用               : %.1f%%  →  %s' % (pct, '装得下' if pct <= 100 else '装不下,多出来的会排队或被抢占'))
    room = a.kv_pool // live if live else 0
    print('  [本机] 这个长度最多能并发: %d 路(纯池子除法,没算调度开销与碎片)' % room)

    print('\n' + '=' * 70)
    print('四、批次上限撞没撞([本机] 算术)')
    print('=' * 70)
    print('  [本机] 并发 × prompt      : %s' % format(batched, ','))
    print('  [引用] max_num_batched_tokens : %s' % format(a.max_batched, ','))
    if prompt > a.max_batched:
        print('  ⚠️ 单条 prompt 自己就超了(%s > %s),chunked prefill 会把它切成 %d 批往里喂。'
              % (format(prompt, ','), format(a.max_batched, ','),
                 -(-prompt // a.max_batched)))
        print('     这是长上下文的常态,不是配置错误 —— 但首字延迟由此变成分钟级,降并发救不了。')
    elif batched > a.max_batched:
        print('  ⚠️ 单条装得下,但 %d 路一起就超了(%s > %s)。'
              % (a.concurrency, format(batched, ','), format(a.max_batched, ',')))
        print('     几路的 prefill 要排队分批,首字延迟会跳一档而不是线性变慢。')
        print('     公开 sweep 里 2,048×6=12,288 那一行就是这么跳的:TTFT 从 1.38 秒到 6.06 秒。')
        print('     解法二选一:降并发到 %d 路以内,或把 max_num_batched_tokens 调大(拿显存换延迟)。'
              % (a.max_batched // max(prompt, 1)))
    else:
        print('  ✅ %d 路的 prefill 一个批次就装得下,不会被切。' % a.concurrency)

    print('\n' + '=' * 70)
    print('五、这个脚本没算的东西')
    print('=' * 70)
    print('  · 你的真实分词结果 —— 上面是字符估算,不是 tokenizer 跑出来的')
    print('  · 前缀缓存 —— 多轮对话复用同一段前缀时,第二轮的 prefill 会便宜很多')
    print('  · 推测解码接受率 —— MTP 的接受率随内容而变,decode 会跟着上下浮动')
    print('  · 两个节点之间那条网 —— TP=2 每层都要过一次 all-reduce,网差了这些数全不作数')
    print('  · 权重加载与镜像拉取 —— 那是开机一次性成本,不在本文口径里')
    print()


if __name__ == '__main__':
    sys.exit(main())

真实输出一:扫一个模块

拿本机 Python 3.12 标准库的 asyncio 包当例子,你换成自己的项目目录就行:

$ python3 ctx_budget_2spark.py --scan-dir /usr/lib/python3.12/asyncio --ext .py

======================================================================
一、你的上下文有多长
======================================================================
  [本机] 扫描目录     : /usr/lib/python3.12/asyncio
  [本机] 计入文件     : 33 个(跳过 0 个:二进制/超大/读不了)
  [本机] 中文字符     : 0
  [本机] 其余字符     : 501,140
  [本机] token 估算   : 125,272(中文 1:1,其余 4.0 字符 1 token)
         ↑ 这是估算。字符数是准的,换算系数不是——想精确就拿模型自带 tokenizer 重跑。
  [本机] 并发路数     : 1
  [本机] 每次生成     : 2,048 token

======================================================================
二、落到公开 sweep 表上,大概是什么体感
======================================================================
  [引用] 首字延迟 TTFT     : 1 分 16 秒
  [引用] prefill           : 1657 tok/s
  [引用] 单流 decode       : 65.2 tok/s
  [引用] 端到端 aggregate  : 6.1 tok/s
  [本机] 一次回答总耗时估  : 1 分 47 秒(首字 1 分 16 秒 + 生成 2,048 token)
  插值说明:在 32,768 和 131,072 两行之间,双对数插值

  同样 1 路并发、prompt 从 256 涨到 125,272,变化是:
    decode      75.4 → 65.2 tok/s   (剩 86%)
    aggregate   69.1 → 6.1 tok/s   (剩 9%)
    TTFT        0.6 秒 → 1 分 16 秒   (120 倍)
  decode 只掉了 14%,aggregate 掉了 91% —— 差额全被 prefill 吃了。

======================================================================
三、KV 池装不装得下([本机] 算术)
======================================================================
  max_model_len 和 max_num_seqs 都是**天花板**,不是预留。
  真实约束只有一条:sum(所有活跃请求的 live tokens) <= KV 池

  [本机] 单路 live tokens   : 127,320 (prompt 125,272 + output 2,048)
  [本机] 1 路合计          : 127,320
  [引用] KV 池              : 2,493,464 token
  [本机] 占用               : 5.1%  →  装得下
  [本机] 这个长度最多能并发: 19 路(纯池子除法,没算调度开销与碎片)

======================================================================
四、批次上限撞没撞([本机] 算术)
======================================================================
  [本机] 并发 × prompt      : 125,272
  [引用] max_num_batched_tokens : 8,192
  ⚠️ 单条 prompt 自己就超了(125,272 > 8,192),chunked prefill 会把它切成 16 批往里喂。
     这是长上下文的常态,不是配置错误 —— 但首字延迟由此变成分钟级,降并发救不了。

一个标准库模块,一路会话,占 KV 池 5.1%,端到端只剩 6.1 tok/s。

第三节说 KV 池能装 19 路这个长度——但这只是池子的账,不是吞吐的账。19 路一起跑,每个人的首字延迟是另一回事。这两笔账千万别混。

真实输出二:不用有机器,也能提前算出第六节那个断崖

$ python3 ctx_budget_2spark.py --prompt-tokens 2048 --concurrency 6

  [引用] 首字延迟 TTFT     : 6.1 秒
  [引用] prefill           : 342 tok/s
  [引用] 单流 decode       : 34.7 tok/s
  [引用] 端到端 aggregate  : 143.7 tok/s
  插值说明:正好命中表中一行,未插值

======================================================================
四、批次上限撞没撞([本机] 算术)
======================================================================
  [本机] 并发 × prompt      : 12,288
  [引用] max_num_batched_tokens : 8,192
  ⚠️ 单条装得下,但 6 路一起就超了(12,288 > 8,192)。
     几路的 prefill 要排队分批,首字延迟会跳一档而不是线性变慢。
     公开 sweep 里 2,048×6=12,288 那一行就是这么跳的:TTFT 从 1.38 秒到 6.06 秒。
     解法二选一:降并发到 4 路以内,或把 max_num_batched_tokens 调大(拿显存换延迟)。

我在这个脚本上翻的一次车

第一版的表外外推是直接拿最后两行的斜率往下推的。我拿公开那个 899,994 token 的验收点做了个自检,结果是:

外推 TTFT     : 436.7 秒
公开实测 TTFT : 1028.85 秒
偏差          : -58%

外推 prefill  : 2061 tok/s
公开实测      : 874.8 tok/s
偏差          : +136%

**低估了一倍多。**更早一版还更离谱:纵轴上做线性外推,2.6M token 的 prompt 直接算出 -17.3 tok/s,负的吞吐。

两处都不是参数没调好,是方法本身站不住:表内四个点的斜率外推不到表外去,而线性外推一个恒正的衰减量必然会穿过零。

所以我改了两件事,改完之后 900K 那个点的偏差是 0.0%

  1. 纵轴也走对数,几何插值,结果恒为正
  2. 表外不再盲推,锚到那个真实测过的 900K 点上插值;而 decode 和 aggregate 因为那次验收没公布,干脆不给数,打印「表外,不给数」

第二条是我觉得更重要的那条。一个说不知道的估算器,比一个编数字的估算器有用。


十二、什么时候不该按这篇做

这篇的适用面比标题看起来窄,说清楚免得误导:

  1. 你的 prompt 一直很短(几百到几千 token)。那这篇讲的塌陷你根本碰不到,短 prompt 那几行是一路往上涨的,6 路并发能到 191.2 tok/s。照着推文那个数走就行。
  2. **你只有一台机器。**全文所有数字的前提是 TP=2 双节点,单机的账完全不一样,别套。
  3. **你要处理图片。**0731 是纯文本的,仓库明写了要图像输入得另配多模态 sidecar。
  4. **你在做采购决策。**这些数是别人那套集群上的,换网络、换 gpu_memory_utilization、换镜像版本,KV 池和吞吐都会变——仓库自己就列了三种不同镜像下 190 万到 320 万 token 的池子。拿这篇当采购依据是不合适的,它只能帮你把问题问对。
  5. **你追求的是峰值 tok/s。**那该看的是 CUDA graph 那组对照(单流 +28.6%),不是本文这条长上下文线。

十三、这篇不解决什么

  • 不告诉你这两台机器值不值。全文没有一个价格。
  • 没算前缀缓存。**多轮对话里第二轮往后 prefill 会便宜很多,sweep 是特意每条请求给了不同的首块来规避前缀复用的,所以它测的是最坏情况。
  • 没量两节点之间那条网。TP=2 每层都要过 all-reduce,配方要求 RoCE/IB 并且 NCCL_SOCKET_IFNAME / TP_SOCKET_IFNAME / GLOO_SOCKET_IFNAME 对齐到同一张网卡。网不对,上面所有数全部作废。
  • 没测推测解码的接受率。MTP5 的接受率随内容浮动,decode 会跟着晃。
  • 这条路本身有个前提。仓库自己在 Caveat 里写了:这是 Stage C padded NVFP4 路径,保留了 DeepSeek V4 已知可用的 584 字节 sparse-MLA cache 结构,不是那个尚未解决的 416 字节 true-layout NVFP4 kernel 修复;true-layout 的实验在大约 411 个真实 prompt token 之后就失败了,所以没被当成可复现配方放出来。

小结

  1. 单流 82 tok/s 是 decode 速度,测的时候 prompt 只有几百 token。
  2. prompt 从 256 涨到 131,072,decode 只从 75.4 掉到 65.2(14%),端到端 aggregate 从 69.1 掉到 5.9(11.7 倍),TTFT 涨 125 倍
  3. 差额全被 prefill 吃了。decode 跟上下文长度基本无关,prefill 跟它成正比。
  4. 加并发在短 prompt 下有用(6 路 2.77 倍),在长 prompt 下基本失效(128K 下 2 路只多 12%),因为 GPU 已经被 prefill 占满。
  5. max_num_batched_tokens = 8192 是一道能提前算出来的坎:2,048 × 6 = 12,288 超了,TTFT 就从 1.38 秒跳到 6.06 秒。
  6. max_model_lenmax_num_seqs上限不是预留。真实约束是 sum(live tokens) <= KV 池,这台集群的池子是 2,493,464 token,所以同时跑满 1M 的请求只能有 2.38 个。
  7. 900K 的极限验收能过,功能完整,但首字要等 17 分 09 秒
  8. 选机器之前,先量一下自己要塞多长的上下文。这个数比任何一条 tok/s 都更能决定你的体感。

面对「上下文一长就塌」,我看到的路有三条,各有各的代价,我没有标准答案:

  • 砍上下文。上检索,只把命中的片段喂进去,把 12 万压到几千。代价是检索错了模型就看不见,而且多了一套要维护的东西。
  • 吃住延迟。接受首字等一两分钟,让它一次读完整个仓库。适合批处理和夜间任务,不适合有人坐在屏幕前等。
  • 分层。短上下文走本地,超长的丢给外部的长上下文服务。代价是数据得出去一部分,而且要维护两套 prompt 逻辑。

你们那边是哪一种,为什么这么选?我尤其想知道第一条的检索命中率你们做到了多少——这是我目前最没底的一块。

更多推荐