PD 分离部署

0 阅读6分钟

# DeepSeek-V4-Flash 昇腾 910B PD 分离部署:8 卡全挂之后,我用“找 rank 0”定位了 6 个连环坑

> 两台 Atlas 910B-4、8 张卡、vLLM-Ascend v0.30.0rc2、1P1D 分离部署。

> 日志里 6 个 rank 齐声喊“连接已断开”,但**真正的原因只有一个 rank**。

> 这篇文章把 6 个坑和一套排查方法论一次讲清。

>

> 说明:文中 IP 地址、网卡名、模型路径均已脱敏处理,不影响复现与理解。

## 一、环境与目标

| 项 | 配置 |

|---|---|

| 硬件 | 2 × Atlas 910B-4(每台 8 卡),单机 8 卡全用 |

| 镜像 | `quay.io/ascend/vllm-ascend:v0.30.0rc2` |

| 并行 | DP4 × TP2(每节点 4 个 engine,端口 7100–7103) |

| 部署 | 1P1D:P 节点 `kv_producer:36000`,D 节点 `kv_consumer:36100` |

| KV 传输 | `MooncakeHybridConnector` + 负载均衡代理(1999) |

| 模型 | DeepSeek-V4-Flash-w8a8(量化版) |

目标很朴素:**让 PD 分离的两台机器把服务拉起来**。结果是连续 6 次“启动即全挂”。

## 二、先说结论:这套日志的“陪葬效应”

这是全文最值钱的一条经验。

8 个 rank 一起启动,最后挂在同一个地方时,日志是这样的:

```

(Worker_DP1_TP0_EP2) RuntimeError: [2] is setting up HCCL communicator and

retrieving hcclUniqueId from [0] via c10d key-value store by key '0',

but store->get('0') got error: Failed to recv, got 0 bytes.

Connection was likely closed. Did the remote server shutdown or crash?

(EngineCore_DP0) WARNING [shutdown] Executor: workers still running after

grace period; sending SIGTERM count=2

```

看到 6 个 rank 都在报 `store->get('0') failed`,第一反应容易去查网络、查 MoE、查存储。

**全错。** 这几行是“陪葬”:

- `dp_rank 0` 是 DP 组的 head,服务端的 TCPStore 就是它起的;

- 它**先**挂了 → Store 关闭 → 其余 6 个 rank 卡在 `store->get('0')` 上集体报“连接断开”;

- 所以报错数量最多的那个位置,往往**不是**根因所在。

**判据(记住这三条就能一眼看出是级联):**

1. 报错内容是 `Failed to recv, got 0 bytes / Connection was likely closed`——这是“对端没了”,不是“我错了”;

2. 出现 `[shutdown] workers still running after grace period` ——说明有进程在**主动**关闭;

3. 报错的是 `EP2~EP7`,而 `EP0 / EP1` **一行报错都没有**——不报错的那个才是凶手。

**对应动作**:日志永远不要只看尾部。取根因要用带上下文的 grep:

```bash

grep -n -m1 -B80 -A40 "Worker_DP0_TP0_EP0" logs/serve.log

```

> 我在这个问题上被坑了一整轮:同事发来的日志三次都是从“陪葬区”开始截的,

> 我两次基于残缺日志给出了错误结论。**完整日志 + rank 0 的 traceback,是这个场景下唯一有效的信息。**

## 三、6 个坑,逐个拆

### 坑 1:Windows 换行符 + `sh` 执行

**报错**

```

./start_serve.sh: 10: Syntax error: word unexpected (expecting "in")

: not found

```

**真相**:脚本是 Windows CRLF 换行。`\r` 被当成词的一部分,`in\r` 不再等于 `in`,`bash` 语法解析直接崩。

**修复**

```bash

sed -i 's/\r$//' *.sh

# 并且用 bash 而不是 sh:脚本里的 `==`、`kill -TERM -$PID` 都是 bash 专有

bash ./start_serve.sh

```

### 坑 2:代理的 hosts 数量必须等于 ports 数量

**报错**

```

ValueError: Number of prefiller hosts must match number of prefiller ports

```

**真相**:`load_balance_proxy_server_example.py` 强校验两者长度相等。1 个 host 配 4 个 port 是**不行**的。

**修复**(host 重复 4 次):

```bash

--prefiller-hosts 10.10.10.1 10.10.10.1 10.10.10.1 10.10.10.1 \

--prefiller-ports 7100 7101 7102 7103 \

--decoder-hosts 10.10.10.2 10.10.10.2 10.10.10.2 10.10.10.2 \

--decoder-ports 7100 7101 7102 7103

```

### 坑 3:一个布尔值写成字符串,8 张卡全挂

**报错**

```

ValueError: enable_multithread_load must be a bool, got str

subprocess.CalledProcessError: Command '['bash','./run_dp_template.sh','0,1','7100',...]'

returned non-zero exit status 1

```

**真相**:JSON 里把布尔写成了字符串。

```bash

# 错

--model-loader-extra-config='{"enable_multithread_load": "true", "num_threads": 128}'

# 对

--model-loader-extra-config='{"enable_multithread_load": true, "num_threads": 128}'

```

**注意**:这个参数是“每个 engine 的 8 个 Worker 同时失败”,4 个 engine 全退。

`CalledProcessError ... exit status 1` 只是表象,**真正的类型错误在它上面 20 行**。

### 坑 4:多线程加载与 prefetch 策略互斥

**报错**

```

ValueError: enable_multithread_load does not support

safetensors_load_strategy='prefetch'; the multi-thread loader only

implements the default lazy strategy.

```

**真相**:这是坑 3 修完之后“浮出来”的第二个冲突。两个开关在同一个配置里互斥:

- `enable_multithread_load: true` → 只实现 lazy 策略

- `--safetensors-load-strategy 'prefetch'` → 预取策略

**修复**:二选一。推荐保留多线程加载(权重加载从“分钟级”降到“秒级/分片”),删掉 prefetch 那行。

### 坑 5(核心):HCCL 网络配置“三方打架”

**报错**

```

RuntimeError: createHCCLCommOrigin: ProcessGroupHCCL.cpp:3090

HCCL function error: hcclGetRootInfo(&hcclID), error code is 19

(call network api fail)

ERR02200 DIST call hccl api failed.

```

发生在 `w8a8_dynamic.py` 建 MoE 专家并行的 all-to-all 通信域时,**rank 0 / Device:0 直接失败**。

**真相**:HCCL 的网卡配置来自三个地方,而它们指的不是同一张网卡:

| 来源 | `HCCL_SOCKET_IFNAME` | `HCCL_COMM_DEFAULT_IF` | `HCCL_IF_IP` |

|---|---|---|---|

| 镜像/容器注入 | `lo` | `lo` | 未设 |

| 脚本 export | `bond1` | **未覆盖 → 仍是 lo** | `hostname -I` 的**第一个** IP |

三处不一致 → `hcclGetRootInfo` 失败。**这个 API 是纯本地的**(只枚举本机网卡/建 root),所以它失败跟跨机、跟 PD、跟 Mooncake 全无关系——别被“分布式”三个字带偏。

**另一个隐蔽陷阱**:

```bash

local_ip="`hostname -I | awk -F ' ' '{print$1}'`"

```

`hostname -I` 的**顺序不可控**。装有 docker0 的机器上,`172.17.0.1` 经常排在最前面 → `HCCL_IF_IP` 被设成 `172.17.0.1`,而它根本不在 `bond1` 上。

**修复原则(比具体命令重要)**:让 `HCCL_SOCKET_IFNAME`、`HCCL_COMM_DEFAULT_IF`、`HCCL_IF_IP` **严格指向同一张网卡的同一个 IP**,或者**全部不设**(交回镜像基线)。最忌讳“容器给 A、脚本给 B”。

**一个 5 秒钟定位 HCCL 配置是否可用的探针**(不用每次等 3 分钟起服务):

```bash

cat > /tmp/hccl_probe.py <<'PY'

import os, torch, torch_npu

import torch.distributed as dist

dist.init_process_group("hccl", rank=0, world_size=1)

x = torch.ones(8, dtype=torch.float16).npu()

dist.all_reduce(x)

print("HCCL OK | IFNAME=%s | IF_IP=%s" % (

os.environ.get("HCCL_SOCKET_IFNAME"), os.environ.get("HCCL_IF_IP")))

PY

export MASTER_ADDR=127.0.0.1 MASTER_PORT=29507

HCCL_SOCKET_IFNAME=lo python3 /tmp/hccl_probe.py

HCCL_SOCKET_IFNAME=bond1 HCCL_IF_IP=<该网卡上的真实IP> python3 /tmp/hccl_probe.py

```

它和报错点走**同一段 C++ 代码**(`HcclCommInitRootInfo`),通过/失败几秒就能看到,是二分定位 HCCL 配置最快的办法。

**顺带一个容器细节**:这个镜像里**没有 `ip` 命令**(没装 iproute2)。

```bash

ip a # bash: ip: command not found

```

所以排查网卡只能用:

```bash

ls /sys/class/net/ # 接口名

hostname -I # 所有 IP

cat /proc/net/dev

```

或者一段无依赖的 Python(取指定网卡的 IPv4):

```bash

python3 -c "

import socket,fcntl,struct,sys

n=sys.argv[1]; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)

a=fcntl.ioctl(s,0x8915,struct.pack('256s',n[:15].encode()))

print(n,'->',socket.inet_ntoa(a[20:24]))

" bond1

```

### 坑 6:MTP 草稿模型权重对不上,`assert` 失败

HCCL 打通之后,日志终于推进到了“加载中”:

```

(Worker_DP0_TP0_EP0) Multi-thread loading shards: 69% Completed | 52/75 [1.85it/s]

```

主模型建起来了、多线程加载正常跑,但 8 个 rank 又统一挂在一个新的 `AssertionError` 上:

```

File ".../vllm_ascend/models/deepseek_v4/mtp.py", line 335, in load_weights

assert "mtp.0." in name

AssertionError

```

**真相**:调用链是

```

model_runner_v1.py:4424 self.drafter.load_model(self.model)

→ llm_base_proposer.py:379 self._get_model()

→ default_loader.load_weights

→ deepseek_v4/mtp.py:335 assert "mtp.0." in name

```

即:**MTP(多 token 预测 / 投机解码)的草稿模型**在加载权重时,要求权重名必须含 `mtp.0.`,而当前 checkpoint 里取出来的名字不满足。

典型原因:**W8A8 量化权重在量化过程中丢掉了 MTP 层**(DeepSeek-V4 的 nextn 预测层)。

**先确认**:

```bash

python3 - <<'PY'

import json, os

d='/data/models/DeepSeek-V4-Flash-w8a8'

cfg=json.load(open(os.path.join(d,'config.json')))

print("num_nextn_predict_layers =", cfg.get('num_nextn_predict_layers'))

wm=json.load(open(os.path.join(d,'model.safetensors.index.json')))['weight_map']

mtp=[k for k in wm if 'mtp' in k.lower()]

print("含 mtp 的权重条数:", len(mtp))

for k in mtp[:5]: print(" ", k)

PY

```

**处置**:

- `mtp 条数 = 0` → 该权重没有 MTP 层,**删掉 `--speculative-config`** 先把服务跑起来;

- 有 MTP 权重但命名不是 `mtp.0.` → 需要换成与 vllm-ascend 该版本对齐的 checkpoint;

- 命名正常 → 问题在加载过滤逻辑,需要看具体权重名再深挖。

```bash

# 绕行:暂时关掉 MTP,先验证 PD 链路

sed -i "/--speculative-config/d" run_dp_template.sh

```

## 四、报错 → 根因 → 修复 总表

| # | 报错关键词 | 根因 | 修复 |

|---|---|---|---|

| 1 | `Syntax error: word unexpected` | CRLF 换行 + 用 `sh` 执行 | `sed -i 's/\r$//'`,改用 `bash` |

| 2 | `Number of prefiller hosts must match` | hosts 与 ports 数量不等 | host 按端口数重复 |

| 3 | `enable_multithread_load must be a bool` | JSON 里 `"true"` 是字符串 | 改成裸 `true` |

| 4 | `does not support safetensors_load_strategy='prefetch'` | 多线程加载与 prefetch 互斥 | 删 `--safetensors-load-strategy` |

| 5 | `hcclGetRootInfo ... error code is 19 (call network api fail)` | 容器/脚本 HCCL 网卡与 IP 三方不一致 | 三处统一到同一网卡同一 IP |

| 6 | `assert "mtp.0." in name` | 量化权重无 MTP 层 | 删 `--speculative-config` 或换权重 |

## 五、几条真正省时间的经验

1. **先找 rank 0**。`DP0 / Device:0` 的 traceback 才是根因,`EP2~EP7` 的“连接断开”都是陪葬。

2. **日志别只看尾部**。用 `grep -n -m1 -B80 -A40` 把上下文一起捞出来,否则会得出完全相反的结论。

3. **`hcclGetRootInfo` 是本地调用**。它失败就去查本机网卡/IP,别往跨机网络上想。

4. **配置只留一处姻源**。容器注入了就别在脚本里再覆盖一半——“半覆盖”是最难查的形态。

5. **别用 3 分钟的服务启动去验证一个 5 秒就能验证的配置**。HCCL 探针脚本能省掉大量等待。

6. **报错会“排队浮出”**。修掉一个(如 bool 类型)之后,下一个冲突才会暴露(如 prefetch 互斥)。一次只改一处、只重跑一轮,比一次改五处高效得多。

---

**下一篇预告**:PD 分离真正跑起来之后,还有更硬的三关——Mooncake KV 跨机传输的 RoCE 选卡、代理层的并发分发与压测、以及 MTP 在昇腾上的正确打开方式。感兴趣可以先关注。