两台 DGX Spark 跑 Qwen3.6-35B-A3B:直连 vLLM vs 经过 Gateway 的吞吐对比
目录
TL;DR#
两台 DGX Spark 各自独立运行 vLLM 推理 Qwen3.6-35B-A3B-FP8,前置一层 FastAPI 写的 LLM Gateway 做路由/负载均衡,实测结果:
- 单请求解码吞吐:机器本地 curl 稳定在 ~50 tok/s,客户端经 Tailscale 访问约 ~45 tok/s(差的是网络往返时间)
- Gateway 代理开销:serial 场景下相对直连差异 <5%,几乎无额外开销
- 单机并发饱和点:N≈8 时单机聚合吞吐 ~300 tok/s(6× 单流)
- 双机聚合上限:经 Gateway round-robin 在 N=16 下 ~485 tok/s,接近这组测试里两台机器各自饱和时的合计
结论:在这组测试里,如果想更充分地利用两台机器,还是要走 Gateway,且客户端并发度也要跟上;低并发时 Gateway 与直连差异不大。
环境背景#
两台机器都是 NVIDIA DGX Spark,GB10 Grace Blackwell Superchip + 128GB LPDDR5X 统一内存。通过 Tailscale 组网,内部 200-subnet 做机器间低延迟直连:
┌──────────────────────────────────────────────────────────┐
│ DGX Spark 集群 │
│ │
│ Server 1 (100.97.87.120) Server 2 (100.67.164.92)│
│ 192.168.200.101 ◄── 200 Gbps ──► 192.168.200.102 │
│ vLLM :30000 vLLM :30000 │
│ + Gateway :8080 │
│ ↑ /v1/responses (sticky → server1) │
│ ↕ /v1/chat/completions (round-robin 两台) │
└──────────────────────────┬───────────────────────────────┘
│ Tailscale
┌───────▼────────┐
│ Mac client │
└────────────────┘
部署栈:
- 模型:
Qwen3.6-35B-A3B-FP8(MoE,ModelScope 缓存),max_model_len=262144 - vLLM 容器:
vllm-qwen36,端口 30000,--gpu-memory-utilization 0.70(这是我目前采用的保守值,再往上调容易 OOM,甚至把 sshd 一起拖死) - Gateway:FastAPI 反向代理,路由规则:
/v1/responses*→ server1(有状态 API,previous_response_id存在单节点内存里)/v1/chat/completions→ 两台轮询- 其它 → server1(catch-all)
Benchmark 1:机器本地直打 vLLM#
第一个 benchmark 想剔除网络影响,用 SSH 把 Python 脚本通过 stdin 送到两台机器上执行,直接 urllib 打 localhost:30000。
完整脚本见 benchmarks/qwen36-throughput.py,核心逻辑:
def one(prompt, max_tokens):
body = json.dumps({
"model": "Qwen3.6-35B-A3B", "prompt": prompt,
"max_tokens": max_tokens, "temperature": 0.0,
}).encode()
req = urllib.request.Request(
"http://localhost:30000/v1/completions",
data=body, headers={"Content-Type": "application/json"},
)
t0 = time.time()
with urllib.request.urlopen(req, timeout=300) as r:
d = json.loads(r.read())
return time.time() - t0, d["usage"]["completion_tokens"]
三种 prompt 串行 3 次 + 并发 N=4、N=8(medium prompt, 128 tok)。
结果#
单并发解码吞吐 (tok/s)
| host | short (32 tok) | medium (128 tok) | long (512 tok) |
|---|---|---|---|
| 100.97.87.120 (server 1) | 50.15 | 50.81 | 51.44 |
| 100.67.164.92 (server 2) | 49.24 | 49.87 | 50.66 |
两台机器单流解码稳定在 ~50 tok/s,差异 <2%,行为一致——说明部署和调度是对称的。
并发聚合吞吐(medium prompt, max_tokens=128)
| host | N=4 aggregate | N=8 aggregate | 单请求平均 (N=8) |
|---|---|---|---|
| server 1 | 167.7 tok/s | 306.3 tok/s | 38.4 tok/s |
| server 2 | 189.6 tok/s | 297.1 tok/s | 37.2 tok/s |
单台机器在 N=8 时聚合吞吐约 300 tok/s,是单流(~50)的 6 倍。MoE A3B(激活 3B 参数)的 continuous batching 在这组数据里扩展性不错——我倾向认为这和每个 token 实际计算的只是稀疏激活的那部分 expert 有关。
Benchmark 2:直连 vs 经 Gateway#
自然的下一个问题:经过 Gateway 代理一层,有没有明显性能损失?另外,Gateway 能不能真的把两台机器的算力聚合起来?
为此换了个脚本 benchmarks/qwen36-gateway-vs-direct.py,从本地 Mac 经 Tailscale 发起请求,对比 4 种路径:
- 直连
server1:30000/v1/completions(单后端基准) - 经
gateway:8080/v1/completions(catch-all → server1,测代理开销) - 直连
server1:30000/v1/chat/completions - 经
gateway:8080/v1/chat/completions(round-robin → 两台,测聚合能力)
Serial 结果(单请求,128 tok)#
| 路径 | tok/s |
|---|---|
直连 server1 /completions |
44.65 |
经 Gateway /completions |
46.60 |
| 直连 server1 chat | 45.55 |
| 经 Gateway chat (RR) | 43.55 |
四条路径都落在 43–47 tok/s,差异在抖动范围内。Gateway 是用 httpx.AsyncClient 做的异步透传,从这组结果看额外开销并不明显。
注意到客户端视角 ~45 tok/s 明显低于机器本地 ~50 tok/s:
- 客户端侧
elapsed = 请求发出到响应读完,包含了 Tailscale 往返(每个 HTTP 请求一次),长响应会因为响应体传输分多个 TCP 包 - 机器本地
elapsed只有 loopback,几乎就是纯解码时间
这个差额跟 Gateway 无关,是网络税。
并发聚合(medium prompt, max_tokens=128)#
| 场景 | 聚合 tok/s |
|---|---|
| 直连 server1 N=8 (1 台) | 270 |
| Gateway N=8 (RR → 2 台) | 293 |
| Gateway N=16 (RR → 2 台) | 485 |
关键观察:
- N=8 的时候 Gateway 只略强——单台机器在 N=8 接近解码饱和(上面 benchmark 1 里单机 N=8 已达 ~300),RR 分成 4+4 送给两台,两台都不在饱和区,但请求数被一分为二所以没体现出翻倍
- N=16 时效果才更明显——Gateway 把并发拉到 485 tok/s,对应两台机器各跑 N=8 的合计(300+300=600,实测 485 比理论低一些,RR 分布不完美 + 代理少量开销)
换句话说:
按这组实验结果看,双机并行的上限 大致接近各自饱和吞吐之和;前提是客户端并发要足够大,把两台都喂满。
几个要点#
1. 直连和 Gateway 的取舍#
- 单用户、单流:直连和 Gateway 体感一致,按需选择
- 多用户、多路并发:通常更适合走 Gateway,否则单台吃不下而另一台可能闲着
- 有状态 API(
/v1/responses):只能走 Gateway,因为 Gateway 专门把它 sticky 到 server1
codex 两个 profile 对应这两种场景:
codex --profile dgx # http://100.97.87.120:8080/v1 (经 Gateway)
codex --profile dgx-direct # http://100.97.87.120:30000/v1 (直连 server1)
2. 统一内存下的硬约束#
--gpu-memory-utilization 0.70 不是我想追求的极限值,而是当前这台机器上总结出的一个保守配置。GB10 的 128GB 是 CPU+GPU 共享的 LPDDR5X:
- 设到 0.9,内核没 buffer,vLLM 一旦触发内存碎片整理就会把整个系统(包括 sshd)挤到 OOM,导致 sshd 无响应
- 我这里直接把 swap 关掉(
swapoff -a),否则一旦压到 swap 整台机器会明显卡顿 nvidia-smi在 GB10 上的 per-process 内存,我这边一直看到都是[N/A],要监控就看free -h
3. MoE 并发扩展性好,但不是无限#
Qwen3.6-35B-A3B 是 MoE:名义 35B 总参数,实际每 token 激活 3B 左右。在 continuous batching 下:
- N=1 → 50 tok/s(memory bound,带宽占用主导)
- N=8 → 300 tok/s(6× 放大,decode 阶段 batched MoE 高效)
- 继续往上加并发单机也有极限,到某个点(受 KV cache 与计算资源限制)会从 memory bound 转到 compute bound,扩展停止
这也解释了为什么 N=16 要靠两台机器——单台已经不能线性往上走了。
脚本#
两个 benchmark 都放在 nv-dgx-spark 仓库下,可供复跑:
python3 benchmarks/qwen36-throughput.py # 机器本地直打两台 vLLM
python3 benchmarks/qwen36-gateway-vs-direct.py # 从客户端对比直连 vs Gateway
运行脚本后,输出的 summary 可直接粘贴为报告,便于每次改部署后快速回归测试。