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 送到两台机器上执行,直接 urlliblocalhost: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 可直接粘贴为报告,便于每次改部署后快速回归测试。