Skip to content

Commit 6c42657

Browse files
ChaoWaoclaude
andcommitted
Fix: runtime isolation in pytest and unified DevicePool
CANN AICPU framework caches user .so per device context (firstCreatSo_ flag in BackendServerHandleManager). Switching runtimes on the same device within one process causes hangs. Changes: - conftest.py: pytest_runtestloop auto-detects multiple runtimes and spawns one subprocess per runtime for isolation. --runtime filter option for single-runtime runs. L3 tests ordered before L2. - conftest.py: unified DevicePool (no sim/onboard branching), --device is the single source of truth. CI passes --device 0-15 for sim, --device ${DEVICE_RANGE} for onboard. - host_build_graph paged_attention case2: mark manual (precision issue, was not run by ci.py DEFAULT_CASE either). - docs: document runtime isolation constraint and root cause in testing.md and distributed_level_runtime.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 00fc6a0 commit 6c42657

12 files changed

Lines changed: 175 additions & 69 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ jobs:
174174
run: python ci.py -p a2a3sim -c d96c8784 -t 600 --clone-protocol https
175175

176176
- name: Run pytest scene tests (a2a3sim)
177-
run: pytest examples tests/st --platform a2a3sim -v
177+
run: pytest examples tests/st --platform a2a3sim --device 0-15 -v
178178

179179
st-sim-a5:
180180
runs-on: ${{ matrix.os }}
@@ -228,7 +228,7 @@ jobs:
228228
run: python ci.py -p a5sim -c d96c8784 -t 600 --clone-protocol https
229229

230230
- name: Run pytest scene tests (a5sim)
231-
run: pytest examples tests/st --platform a5sim -v
231+
run: pytest examples tests/st --platform a5sim --device 0-15 -v
232232

233233
# ---------- Python unit tests (a2a3 hardware) ----------
234234
ut-py-a2a3:

conftest.py

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@
66
# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
77
# See LICENSE in the root of the software repository for the full text of the License.
88
# -----------------------------------------------------------------------------------------------------------
9-
"""Root conftest — CLI options, markers, ST platform filtering, and ST fixtures."""
9+
"""Root conftest — CLI options, markers, ST platform filtering, runtime isolation, and ST fixtures.
10+
11+
Runtime isolation: CANN's AICPU framework caches the user .so per device context.
12+
Switching runtimes on the same device within one process causes hangs. When multiple
13+
runtimes are collected and --runtime is not specified, pytest_runtestloop spawns a
14+
subprocess per runtime so each gets a clean CANN context. See docs/testing.md.
15+
"""
1016

1117
from __future__ import annotations
1218

19+
import subprocess
20+
import sys
21+
1322
import pytest
1423

1524

@@ -22,31 +31,24 @@ def _parse_device_range(s: str) -> list[int]:
2231

2332

2433
class DevicePool:
25-
"""Simple device allocator for pytest fixtures.
34+
"""Device allocator for pytest fixtures.
2635
27-
On sim platforms, device IDs are virtual — allocate always succeeds.
28-
On real hardware, IDs are exclusive.
36+
Manages a fixed set of device IDs. Tests allocate IDs before use
37+
and release them after. Works identically for sim and onboard.
2938
"""
3039

31-
def __init__(self, device_ids: list[int], *, is_sim: bool = False):
40+
def __init__(self, device_ids: list[int]):
3241
self._available = list(device_ids)
33-
self._is_sim = is_sim
34-
self._sim_next = 0
3542

3643
def allocate(self, n: int = 1) -> list[int]:
37-
if self._is_sim:
38-
ids = list(range(self._sim_next, self._sim_next + n))
39-
self._sim_next += n
40-
return ids
4144
if n > len(self._available):
4245
return []
4346
allocated = self._available[:n]
4447
self._available = self._available[n:]
4548
return allocated
4649

4750
def release(self, ids: list[int]) -> None:
48-
if not self._is_sim:
49-
self._available.extend(ids)
51+
self._available.extend(ids)
5052

5153

5254
_device_pool: DevicePool | None = None
@@ -58,6 +60,7 @@ def pytest_addoption(parser):
5860
parser.addoption("--device", action="store", default="0", help="Device ID or range (e.g., 0, 4-7)")
5961
parser.addoption("--case", action="store", default=None, help="Run specific case name only")
6062
parser.addoption("--all-cases", action="store_true", default=False, help="Include manual cases")
63+
parser.addoption("--runtime", action="store", default=None, help="Only run tests for this runtime")
6164

6265

6366
def pytest_configure(config):
@@ -68,18 +71,31 @@ def pytest_configure(config):
6871

6972

7073
def pytest_collection_modifyitems(session, config, items):
71-
"""Skip ST tests based on --platform filter."""
74+
"""Skip ST tests based on --platform and --runtime filters, and order L3 before L2."""
7275
platform = config.getoption("--platform")
76+
runtime_filter = config.getoption("--runtime")
77+
78+
# Sort: L3 tests first (they fork child processes that inherit main process CANN state,
79+
# so they must run before L2 tests pollute the CANN context).
80+
def sort_key(item):
81+
cls = getattr(item, "cls", None)
82+
level = getattr(cls, "_st_level", 0) if cls else 0
83+
return (0 if level >= 3 else 1, item.nodeid)
84+
85+
items.sort(key=sort_key)
86+
7387
for item in items:
74-
# SceneTestCase subclass: skip if no case matches current platform
7588
cls = getattr(item, "cls", None)
7689
if cls and hasattr(cls, "CASES") and isinstance(cls.CASES, list):
7790
if not platform:
7891
item.add_marker(pytest.mark.skip(reason="--platform required"))
7992
elif not any(platform in c.get("platforms", []) for c in cls.CASES):
8093
item.add_marker(pytest.mark.skip(reason=f"No cases for {platform}"))
94+
elif runtime_filter and getattr(cls, "_st_runtime", None) != runtime_filter:
95+
item.add_marker(
96+
pytest.mark.skip(reason=f"Runtime {getattr(cls, '_st_runtime', '?')} != {runtime_filter}")
97+
)
8198
continue
82-
# Standalone function with @pytest.mark.platforms([...])
8399
platforms_marker = item.get_closest_marker("platforms")
84100
if platforms_marker:
85101
if not platform:
@@ -88,15 +104,77 @@ def pytest_collection_modifyitems(session, config, items):
88104
item.add_marker(pytest.mark.skip(reason=f"Not supported on {platform}"))
89105

90106

107+
# ---------------------------------------------------------------------------
108+
# Runtime isolation: spawn subprocess per runtime
109+
# ---------------------------------------------------------------------------
110+
111+
112+
def _collect_st_runtimes(items):
113+
"""Return sorted list of unique runtimes from collected SceneTestCase items."""
114+
runtimes = set()
115+
for item in items:
116+
cls = getattr(item, "cls", None)
117+
rt = getattr(cls, "_st_runtime", None) if cls else None
118+
if rt:
119+
runtimes.add(rt)
120+
return sorted(runtimes)
121+
122+
123+
def pytest_runtestloop(session):
124+
"""Override test execution to isolate runtimes in subprocesses.
125+
126+
If --runtime is specified (or only one runtime collected), run normally.
127+
Otherwise, spawn one subprocess per runtime and aggregate results.
128+
"""
129+
runtime_filter = session.config.getoption("--runtime")
130+
if runtime_filter:
131+
return # single runtime — let pytest run normally
132+
133+
runtimes = _collect_st_runtimes(session.items)
134+
if len(runtimes) <= 1:
135+
return # zero or one runtime — no isolation needed
136+
137+
# Multiple runtimes: spawn subprocess per runtime
138+
# Re-invoke pytest with the same args + --runtime <rt> for each runtime
139+
base_args = [sys.executable, "-m", "pytest"]
140+
for arg in session.config.invocation_params.args:
141+
base_args.append(str(arg))
142+
143+
failed = False
144+
for rt in runtimes:
145+
# Build subprocess command: inject --runtime <rt>
146+
cmd = base_args + ["--runtime", rt]
147+
header = f" Runtime: {rt}"
148+
print(f"\n{'=' * 60}\n{header}\n{'=' * 60}\n", flush=True)
149+
150+
result = subprocess.run(cmd, check=False, cwd=session.config.invocation_params.dir)
151+
if result.returncode != 0:
152+
failed = True
153+
print(f"\n*** Runtime {rt}: FAILED ***\n", flush=True)
154+
else:
155+
print(f"\n--- Runtime {rt}: PASSED ---\n", flush=True)
156+
157+
if failed:
158+
session.testsfailed = 1
159+
else:
160+
session.testscollected = sum(1 for _ in session.items)
161+
session.testsfailed = 0
162+
163+
return True # returning True prevents default runtestloop
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# Fixtures
168+
# ---------------------------------------------------------------------------
169+
170+
91171
@pytest.fixture(scope="session")
92172
def device_pool(request):
93173
"""Session-scoped device pool parsed from --device."""
94174
global _device_pool # noqa: PLW0603
95175
if _device_pool is None:
96176
raw = request.config.getoption("--device")
97-
platform = request.config.getoption("--platform")
98-
is_sim = platform is None or platform.endswith("sim")
99-
_device_pool = DevicePool(_parse_device_range(raw), is_sim=is_sim)
177+
_device_pool = DevicePool(_parse_device_range(raw))
100178
return _device_pool
101179

102180

docs/distributed_level_runtime.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,12 @@ Python Application
339339
└── forked child process ← mailbox state machine
340340
```
341341

342+
## Runtime Isolation (Onboard Hardware)
343+
344+
A single device can only run **one runtime** per CANN process context. CANN's AICPU framework (`libaicpu_extend_kernels.so`) caches the user AICPU .so on first load and skips reloading on subsequent launches. If a different runtime's AICPU .so is launched on the same device, the cached (stale) function pointers are used, causing hangs.
345+
346+
This means: **do not reuse a device across different runtimes within a single process.** Either use separate processes (one per runtime), or partition devices so each runtime gets exclusive devices. See [testing.md](testing.md#runtime-isolation-constraint-onboard) for details and the pytest device allocation algorithm.
347+
342348
## Files
343349

344350
| File | Purpose |

docs/testing.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,71 @@ The test will be automatically picked up by `ci.py`. New tests should prefer the
331331

332332
See [ci.md](ci.md) for the full CI pipeline documentation, including the job matrix, runner constraints, marker scheme, and `ci.sh` internals.
333333

334+
## Runtime Isolation Constraint (Onboard)
335+
336+
**One device can only run one runtime per process.** Switching runtimes on the same device within a single process causes AICPU kernel hangs.
337+
338+
### Root Cause
339+
340+
CANN's AICPU dispatch uses a framework SO (`libaicpu_extend_kernels.so`) with a global singleton `BackendServerHandleManager` that:
341+
342+
1. **`SaveSoFile`**: Writes the user AICPU .so to disk on first call, then sets `firstCreatSo_ = true` to skip all subsequent writes.
343+
2. **`SetTileFwkKernelMap`**: `dlopen`s the .so and caches function pointers on first call, then sets `firstLoadSo_ = true` to skip all subsequent loads.
344+
345+
When a second runtime launches on the same device (same CANN process context), the Init kernel call hits the cached flags — the new AICPU .so is never written or loaded. The Exec kernel then calls function pointers from the first runtime's .so, which operates on incompatible data structures and hangs.
346+
347+
### Impact
348+
349+
| Scenario | Result |
350+
| -------- | ------ |
351+
| Same runtime, same device, sequential | Works (same .so, cached pointers valid) |
352+
| Different runtime, same device, sequential | **Hangs** (stale .so, wrong function pointers) |
353+
| Different runtime, different device | Works (separate CANN context per device) |
354+
| Different runtime, different process, same device | Works (`rtDeviceReset` between processes clears context) |
355+
356+
### Mitigation in pytest
357+
358+
The `conftest.py` device allocator groups tests by runtime and assigns each runtime group to exclusive devices. See "Device Allocation Algorithm" below.
359+
360+
## Device Allocation Algorithm (Onboard pytest)
361+
362+
When running `pytest --platform a2a3 --device 8-11`, the fixture must allocate devices to tests such that:
363+
364+
1. **Runtime isolation**: A device used by runtime A must not be reused by runtime B in the same process.
365+
2. **L3 multi-device**: L3 tests may need 2+ contiguous devices.
366+
3. **Efficiency**: Devices freed by one test of the same runtime can be reused by the next.
367+
368+
### Algorithm
369+
370+
```text
371+
Phase 1: Group tests by runtime
372+
tensormap_and_ringbuffer: [TestVectorExample, TestScalarData, TestL3Dependency, ...]
373+
aicpu_build_graph: [TestPagedAttentionAicpuBuildGraph]
374+
host_build_graph: [TestPagedAttentionHostBuildGraph]
375+
376+
Phase 2: Partition devices across runtime groups
377+
Available: [8, 9, 10, 11]
378+
tensormap_and_ringbuffer (6 tests, needs max 2 for L3 group): devices [8, 9]
379+
aicpu_build_graph (1 test, needs 1): devices [10]
380+
host_build_graph (1 test, needs 1): devices [11]
381+
382+
Phase 3: Within each group, allocate from group's device pool
383+
TestVectorExample: dev 8 → run → release → dev 8 available again
384+
TestScalarData: dev 8 → run → release → OK (same runtime)
385+
TestL3Dependency: dev 8 → run → release
386+
TestL3Group: dev [8, 9] → run → release
387+
TestPagedAttentionAicpuBuildGraph: dev 10 → run → release
388+
TestPagedAttentionHostBuildGraph: dev 11 → run → release
389+
```
390+
391+
### Implementation
392+
393+
The `DevicePool` in `conftest.py` is extended with runtime-aware partitioning. The `st_worker` fixture checks the test class's `_st_runtime` and allocates from the corresponding partition.
394+
395+
### Sim platforms
396+
397+
On sim (`a2a3sim`, `a5sim`), device IDs are virtual — no hardware state, no isolation constraint. All tests share a single virtual pool with auto-incrementing IDs.
398+
334399
## Per-Case Device Filtering
335400

336401
The `@scene_test(platforms=[...])` decorator provides per-case platform filtering. A single test class declares which platforms it supports:

examples/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

examples/a2a3/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

examples/a2a3/aicpu_build_graph/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

examples/a2a3/aicpu_build_graph/paged_attention/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

examples/a2a3/host_build_graph/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

examples/a2a3/host_build_graph/paged_attention/__init__.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)