Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/npu/NPU_UNIT_TEST_FIX_2026-08-12.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# NPU 单元测试修复报告(2026-08-12)

## 背景

vLLM 0.26 升级(c88babf)后,NPU 相关单元测试在真实 NPU 环境上大量失败(28 个),但 CPU-only CI 因 `pytest.importorskip("vllm"/"vllm_ascend")` 将这些模块整体跳过,失败长期不可见。本次在 afd-v026-1(Ascend A3)上将全量套件修到全绿。

## 最终结果

```
python3 -m pytest -q tests/unit -m "not gpu and not vllm_runtime"
→ 全绿(~450 passed, 2 skipped, rc=0)
```

修复前 28 个失败,分布于:

- `tests/unit/v1/worker/test_npu_runtime.py`(17)
- `tests/unit/v1/worker/test_ffn_model_runner.py`(10)
- `tests/unit/connectors/test_async_cam_connector.py`(1)

**结论:28 个失败全部是测试侧问题(陈旧夹具/测试污染/环境 import 顺序),未发现插件代码 bug。** 运行时正确性由双机 GSM8K 实证(token-split 全量 0.9522)。

## 三层问题与修复

### 第一层:vllm_ascend 固有 circular import(挡住测试收集)

vllm-ascend `80d8c194f`(v0.26 基线)存在固有循环导入:

```
device/device_op.py:32 → vllm_ascend.ops/__init__ → fused_moe/fused_moe.py
→ fused_moe/experts_selector.py:25 → from vllm_ascend.device.device_op import DeviceOperator
(此时 device_op 只初始化了一半)→ ImportError
```

真实 `vllm serve` 的 import 顺序先加载 `vllm_ascend.ops`,天然规避;pytest 收集顺序则会触发。

**修复**:`tests/conftest.py` 顶部预导入(CPU CI 无此包则静默跳过):

```python
try:
import vllm_ascend.ops # noqa: F401
except Exception:
pass
```

修复后不再需要任何手动 workaround,裸 pytest 直接可跑。

### 第二层:v0.26 升级后的陈旧测试夹具(26 个失败)

| 失败模式 | 根因 | 修复 |
|---|---|---|
| `SimpleNamespace has no attribute 'use_v2_model_runner'` ×10 | vllm 0.26 的 `VllmConfig` 新字段,vllm_ascend `platform.set_additional_forward_context` 必读 | 测试夹具的 vllm_config 补 `use_v2_model_runner=False` |
| `AFDNPUFFNModelRunner has no attribute 'afd_config'` ×9 | 测试用 `object.__new__` 绕过 `__init__`,缺 `afd_config`/`model_config`(`_ffn_layer_indices` 要读) | 共享 helper `_new_ffn_runner()` 补齐(生产配置 `compute_gate_on_attention=True` + 全 MoE hf_config) |
| `AFDNPUFFNWorker has no attribute '_ffn_loop_error'` ×2 | 同上,`__init__` 才初始化该属性 | `_new_ffn_worker()` 补 `_ffn_loop_error=None` |
| `'list' object has no attribute 'tolist'` | mock dp_metadata 用 plain list 冒充 tensor(代码 `_dp_metadata_debug_key` 调 `.tolist()`) | 改 `torch.tensor([...])`(注意这些测试的 torch 是测试函数内局部 `importorskip` 绑定,需补绑定) |
| `use_dcp property has no setter` | vllm-ascend 0.26 把 `use_dcp` 改成只读 property(`dcp_size > 1` 派生) | 测试改设底层 `runner.dcp_size = 1` |
| `initialize_attn_backend() missing 'kv_cache_config'` | 上游 0.26 签名必填该参数(插件 override 与上游一致) | 测试调用改为 `initialize_attn_backend(None)` |
| `assert None is True` | `_warmup_and_capture` 返回 None,测试断言了不存在的返回值 | 删除多余断言(后续行为断言已覆盖测试意图) |
| `Invalid device string: 'int64'` | connector 测试把 async_cam 模块的 torch 换成假 torch(`int64="int64"` 字符串),却又往 states 里塞真 tensor,代码 `.to(torch.int64)` 变成真 tensor `.to("int64")` | 新增 `_FakeIntVector`(支持 `.to().sum().item()` 链)替换真 tensor |

### 第三层:跨文件测试污染(1 个,最隐蔽)

**现象**:`test_npu_runtime.py::test_npu_create_ascend_forward_context_marks_current_ubatch` 单跑通过、单文件跑通过、全量跑失败(child metadata 变成 parent 原值,`build_ubatch_afd_metadata` 未生效)。

**根因**:`test_npu_mla_graph.py::_load_forward_context_module` 往 `sys.modules` 塞入假的 `afd_plugin.v1.worker.ubatch_wrapper`(identity 版 `build_ubatch_afd_metadata`),再 reload `forward_context`。当真实模块此前**从未被 import** 时,monkeypatch teardown 没有原始对象可恢复,假绑定的 forward_context 经**父包属性绑定**泄漏给后续测试。

**验证方法**:二分定位(逐文件→逐测试),再用探针确认污染后 `fm.build_ubatch_afd_metadata` 指向假 lambda;预 import 真模块后污染消失。

**修复**:新增 `_preimport_real_module()`——装假模块前先 import 真身,teardown 才有真身可恢复;三个 `_load_*_module` helper 全部加上,try/except 保护无 vllm 的 CI 环境。

```python
def _preimport_real_module(module_name: str) -> None:
try:
importlib.import_module(module_name)
except Exception:
pass
```

## 改动清单

| 文件 | 改动 |
|---|---|
| `tests/conftest.py` | +11:预导入 vllm_ascend.ops 绕 circular import |
| `tests/unit/v1/worker/test_npu_runtime.py` | 夹具修复(afd_config/model_config/_ffn_loop_error/torch tensor 化/dcp_size/kv_cache_config/删无效断言) |
| `tests/unit/v1/worker/test_ffn_model_runner.py` | +3:vllm_config 补 use_v2_model_runner |
| `tests/unit/v1/worker/test_npu_mla_graph.py` | +21:_preimport_real_module 防泄漏 |
| `tests/unit/connectors/test_async_cam_connector.py` | +20:_FakeIntVector |

另:recipe 启动脚本参数化(本次 GSM8K 双机部署使用):
- `GPU_MEM_UTIL`(默认 0.90)——0.9 留给 HCCL_BUFFSIZE=4096 的通信池(实测 8.6GB)不够,0.8 可行
- `ASYNC_MOE_UBATCHING`(默认 true)——跑 no-ubatch 对照用

## 经验教训

1. **CPU CI 的 importorskip 会让 NPU 单测静默腐烂**——涉及 vllm/vllm_ascend 的测试只有上 NPU pod 才真正执行,应定期在 pod 跑全量。
2. **monkeypatch + importlib.reload 组合对"未被 import 过的模块"清理不干净**(包属性绑定泄漏);sys.modules 换假模块的 helper 必须先 pre-import 真身。
3. **判读测试失败先孤立复现**:单跑挂 = 夹具/代码问题;单跑过、全量挂 = 测试污染。
4. mock 的字段类型要和真对象对齐(list vs tensor 的 `.tolist()`),0.26 升级把多个属性改成 property/必填参数,夹具要同步。
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
# Covers the roughly 64-second nested lm-eval/vLLM cleanup bound with buffer.
RUNNER_CLEANUP_TIMEOUT_S = 90

# vLLM-Ascend 80d8c194f (v0.26 baseline) has an inherent circular import:
# device/device_op.py -> ops/__init__ -> fused_moe -> experts_selector.py ->
# back to the partially initialized device_op. Real ``vllm serve`` imports
# ``vllm_ascend.ops`` first and never trips it, but pytest's import order does.
# Pre-importing ``vllm_ascend.ops`` here breaks the cycle for the test run.
# Environments without vllm_ascend (CPU-only CI) simply skip this.
try: # pragma: no cover - environment-dependent import ordering fix
import vllm_ascend.ops # noqa: F401
except Exception:
pass


def run_runner(command: list[str], env: dict[str, str] | None = None) -> None:
"""Run an E2E runner command and forward cancellation to the process group."""
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ASYNC_CAM_ATTENTION_RANKS = 2
ASYNC_CAM_FFN_RANKS = 2
ASYNC_CAM_ATTENTION_TP_SIZE = 2
ASYNC_UBATCH_SCENARIO = "afd-async-ubatch"
PROCESS_TERMINATION_TIMEOUT_S = 20
PROCESS_POLL_INTERVAL_S = 0.2
PROCESS_REAP_TIMEOUT_S = 5
Expand Down Expand Up @@ -193,6 +194,7 @@ def parse_args() -> argparse.Namespace:
"afd-graph-dbo",
"afd-graph-dbo-2a2f",
ASYNC_CAM_SCENARIO,
ASYNC_UBATCH_SCENARIO,
],
required=True,
help="Fixed E2E scenario to run.",
Expand Down Expand Up @@ -294,6 +296,7 @@ def parse_args() -> argparse.Namespace:
def configure_scenario(args: argparse.Namespace) -> None:
"""Set topology and features for the selected fixed scenario."""
is_async_cam = args.scenario == ASYNC_CAM_SCENARIO
is_async_ubatch = args.scenario == ASYNC_UBATCH_SCENARIO
scenario_settings = {
"baseline-graph": (True, True, False, 1, 0),
"afd-eager": (False, False, False, 2, 1),
Expand All @@ -307,6 +310,7 @@ def configure_scenario(args: argparse.Namespace) -> None:
ASYNC_CAM_ATTENTION_RANKS,
ASYNC_CAM_FFN_RANKS,
),
ASYNC_UBATCH_SCENARIO: (False, False, False, 2, 1),
}
baseline, use_graph, enable_dbo, attention_ranks, ffn_ranks = scenario_settings[
args.scenario
Expand All @@ -332,6 +336,19 @@ def configure_scenario(args: argparse.Namespace) -> None:
args.afd_connector_extra_config = [
json.dumps(extra_config, separators=(",", ":")),
]
if is_async_ubatch:
args.afd_connector = ASYNC_AFD_CONNECTOR
args.afd_async = True
args.compute_gate_on_attention = True
# CAM dispatch allocates its HCCL communication pool (~8.6 GB) outside
# the torch memory pool; the default 0.92 GPU memory utilization leaves
# too little room and fails with EL0004. 0.8 leaves enough headroom.
if not any(
arg == "--gpu-memory-utilization"
or arg.startswith("--gpu-memory-utilization=")
for arg in args.common_vllm_arg
):
args.common_vllm_arg.extend(["--gpu-memory-utilization", "0.8"])
if use_graph:
args.cudagraph_capture_size = 8
if enable_dbo:
Expand Down
20 changes: 19 additions & 1 deletion tests/unit/connectors/test_async_cam_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ def item(self):
return self._value


class _FakeIntVector:
"""Stands in for a token-count tensor in fake-torch tests.

Supports the ``.to(dtype).sum().item()`` chain the connector applies to
``group_list``; ``.to()`` is a no-op so the fake torch's string dtypes
cannot leak into a real tensor API.
"""

def __init__(self, values):
self._values = [int(value) for value in values]

def to(self, _dtype):
return self

def sum(self):
return _FakeScalar(sum(self._values))


class _FakeTensorLike:
def __init__(self, name, *, shape=None, device="npu:0"):
self.name = name
Expand Down Expand Up @@ -585,7 +603,7 @@ def fake_recv_attn_output(*, stage_idx, layer_idx, batch_size, ubatch_idx):
_FakeScalar(7),
],
expert_token_nums_shared=[_FakeScalar(5)],
group_list=torch.zeros(8, dtype=torch.int64),
group_list=_FakeIntVector([0] * 8),
expand_x_shared=_FakeTensorLike("shared-hidden"),
dynamic_scales_shared=_FakeTensorLike("shared-scales"),
)
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/v1/worker/test_ffn_model_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def _payload(hidden_states, metadata):
def _runner_with_connector_and_model(model, *, num_layers=1):
runner = object.__new__(GPUFFNModelRunner)
runner.vllm_config = SimpleNamespace(
# vLLM 0.26 VllmConfig field read by the Ascend platform's
# set_additional_forward_context hook on NPU test environments.
use_v2_model_runner=False,
parallel_config=SimpleNamespace(
data_parallel_size=1,
is_moe_model=True,
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/v1/worker/test_npu_mla_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,25 @@ def _reload_module(
return importlib.import_module(module_name)


def _preimport_real_module(module_name: str) -> None:
"""Import the real module before the fake sys.modules swap.

``_reload_module`` only restores state monkeypatch recorded: when the real
module was never imported, teardown has no original sys.modules entry or
parent-package attribute to restore, and the fresh fake-bound module leaks
into later tests through the package attribute. Pre-importing guarantees a
genuine original exists to restore. On environments without the real
dependencies (CPU-only CI) the import fails and the fake path is used as
before.
"""
try:
importlib.import_module(module_name)
except Exception:
pass


def _load_mla_graph_module(monkeypatch):
_preimport_real_module("afd_plugin.v1.worker.npu.mla_graph")
fake_ascend = ModuleType("vllm_ascend")
fake_ascend.__path__ = []
fake_compilation = ModuleType("vllm_ascend.compilation")
Expand Down Expand Up @@ -69,6 +87,8 @@ def _graph_params(


def _load_forward_context_module(monkeypatch):
_preimport_real_module("afd_plugin.v1.worker.ubatch_wrapper")
_preimport_real_module("afd_plugin.v1.worker.npu.forward_context")
fake_vllm = ModuleType("vllm")
fake_vllm.__path__ = []
fake_config = ModuleType("vllm.config")
Expand Down Expand Up @@ -139,6 +159,7 @@ def __init__(self, **kwargs):


def _load_ubatch_wrapper_module(monkeypatch):
_preimport_real_module("afd_plugin.v1.worker.ubatch_wrapper")
class FakeStream:
def __init__(self, device=None):
self.device = device
Expand Down
30 changes: 23 additions & 7 deletions tests/unit/v1/worker/test_npu_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,14 +410,26 @@ def _new_ffn_runner():
runner.prof = None
runner.device = SimpleNamespace(type="npu")
runner._is_shutdown = False
# _ffn_layer_indices reads these; production runs with the gate on the
# Attention side, so every layer index is a MoE FFN layer here.
runner.afd_config = SimpleNamespace(compute_gate_on_attention=True)
runner.model_config = SimpleNamespace(
hf_config=SimpleNamespace(
n_routed_experts=8,
first_k_dense_replace=0,
moe_layer_freq=1,
),
)
return runner


def _new_ffn_worker():
_require_npu_runtime()
from afd_plugin.v1.worker.npu.ffn_worker import AFDNPUFFNWorker

return object.__new__(AFDNPUFFNWorker)
worker = object.__new__(AFDNPUFFNWorker)
worker._ffn_loop_error = None
return worker


@pytest.mark.parametrize(
Expand Down Expand Up @@ -517,16 +529,18 @@ def __init__(self, *args, **kwargs):


def test_npu_attention_runner_builds_and_sets_metadata():
torch = pytest.importorskip("torch")
runner = _new_attention_runner()
runner.vllm_config = _vllm_config(role="attention")
runner.connector = _RecordingConnector()
runner._is_warmup = False
runner._afd_is_graph_capturing = False
runner._afd_pending_metadata = None
runner._afd_transaction_counter = 0
runner._afd_suppress_metadata_send = False
forward_context = SimpleNamespace(
additional_kwargs={},
dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=[1]),
dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=torch.tensor([1])),
ubatch_slices=None,
batch_descriptor=SimpleNamespace(num_tokens=5),
)
Expand Down Expand Up @@ -583,6 +597,7 @@ def test_npu_attention_runner_builds_dp_fallback():


def test_npu_attention_runner_sends_graph_flags():
torch = pytest.importorskip("torch")
runner = _new_attention_runner()
runner.vllm_config = _vllm_config(role="attention")
runner.connector = _RecordingConnector()
Expand All @@ -591,7 +606,7 @@ def test_npu_attention_runner_sends_graph_flags():
runner._afd_transaction_counter = 0
runner._afd_pending_metadata = runner._build_afd_metadata(None, 3)

runner._send_dp_metadata(SimpleNamespace(num_tokens_across_dp_cpu=[3]), None)
runner._send_dp_metadata(SimpleNamespace(num_tokens_across_dp_cpu=torch.tensor([3])), None)

assert runner.connector.dp_metadata_updates[0][1:] == (True, True)
assert runner.connector.sent_dp_metadata_lists[0][1:] == (True, True)
Expand Down Expand Up @@ -683,13 +698,12 @@ def send_dp_metadata(dp_metadata, ubatch_slices):
runner._send_dp_metadata = send_dp_metadata
desc = SimpleNamespace(num_tokens=12, uniform=True, num_active_loras=0)

result = runner._warmup_and_capture(
runner._warmup_and_capture(
desc,
CUDAGraphMode.FULL,
allow_microbatching=True,
)

assert result is True
assert [call[1]["allow_microbatching"] for call in dummy_calls] == [
False,
False,
Expand Down Expand Up @@ -1053,7 +1067,9 @@ def make_attn_group(group_id):
runner.attn_groups = [[make_attn_group(0), make_attn_group(1)]]
runner.max_model_len = 105
runner.optimistic_seq_lens_cpu = torch.tensor([105], dtype=torch.int32)
runner.use_dcp = False
# vLLM-Ascend v0.26 exposes use_dcp as a read-only property derived from
# dcp_size, so the fixture sets the underlying size instead.
runner.dcp_size = 1
runner.use_async_spec_decode = False
runner.input_batch = SimpleNamespace(
block_table=[block_table],
Expand Down Expand Up @@ -1215,7 +1231,7 @@ def create_metadata_builders(
lambda self, *args, **kwargs: initialized,
)

assert runner.initialize_attn_backend() is initialized
runner.initialize_attn_backend(None)
assert create_calls == [3]
assert len(attn_group.metadata_builders) == 3

Expand Down
Loading