Skip to content

[Test] model-check: device-less Ascend/CUDA launchers and connector-independent byte checks - #1579

Open
ljccsu wants to merge 17 commits into
ModelEngine-Group:dev_connectorfrom
ljccsu:opt/model-check-native-oracle
Open

ljccsu wants to merge 17 commits into
ModelEngine-Group:dev_connectorfrom
ljccsu:opt/model-check-native-oracle

Conversation

@ljccsu

@ljccsu ljccsu commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

背景

model-check 验证昇腾 KV 布局只能靠 ascend.py,必须有 NPU 卡;验证官方布局要么用 cuda.py 上 GPU,要么用 cpu.py 在 CPU 版 vLLM 上手写后端约束来模拟,后者跑的不是真实的 CUDA 注意力后端。

另外,v2 connector 的比对有盲区。原来写入、比对、投毒用的地址都来自被测 connector 自己的 transfer_builder。connector 在 dump 和 load 时犯同一个错误(偏移算错,或者漏传某一层),写进去和读回来的位置是一致的,比对仍然通过。

原有段校验的盲区

本 PR 做三件事:

  1. 新增两个无卡启动器 ascend_sim.py 和 cuda_sim.py:没有 NPU / GPU 时,仍用 vllm-ascend / vLLM 原生代码生成各自平台的 KV 布局,KV 张量放在主机内存,照常跑 dump / load。
  2. verify() 新增几项只依赖 vLLM 原生张量和 block 表的校验,不使用 connector 算出的地址。
  3. 新增 VERIFICATION.md,写清楚无卡 PASS 断言了什么、没断言什么,以及一次运行必须留下哪些证据。

改动只在 toolkit/ 下的 model-check 和对应测试(另有 cli.py 补了 __main__ 入口),vLLM、vllm-ascend、UCM connector 都没有改。

model-check 在 vLLM 架构上的位置

model-check 在 vLLM 架构上的位置

绿色部分直接调用 vLLM / vllm-ascend 原函数,工具只决定传入的参数和运行设备:

组件 调用的原函数 工具控制的部分
配置 EngineArgs(...).create_engine_config() load_format="dummy"、enforce_eager、max_num_seqs=2,挂上 UCMConnector
KV 规格 平台 ModelRunner 的 get_kv_cache_spec() 模型用原生 initialize_model() 在 meta 设备上构造
分组 kv_cache_utils.get_kv_cache_configs() num_gpu_blocks_override 设为最少可用 block 数的 3 倍
分配 runner.initialize_kv_cache() 无卡时设备为 CPU,外层截获返回的张量
调度 get_scheduler_cls() → add_request() / schedule() 请求是合成 token;目标请求前调原生 evict_blocks(),让它走 UCM 外部命中
Connector KVConnectorFactory.create_connector() / register_kv_caches() 无

上面任何一步报错,工具直接抛 UnsupportedEnvironment 退出,不会改用自己的实现。

橙色部分由工具负责:不启动 EngineCore / Worker,由 common.py 直接创建 Runner 和 Scheduler;不跑前向,call_worker_layer_hooks() 按注意力层执行时的顺序手工调用 connector 钩子(基线已有);无卡时的替换见下文。

无卡启动器

原则:执行顺序、权重、计算 kernel、硬件探测可以替换;KV 规格、分组、形状、步长、block 编号、命中结果不动。两个启动器共用的部分放在 sim_runtime.py,避免两边各写一份。

类别 ascend_sim cuda_sim
执行顺序 流、事件、synchronize、set_device 改为空操作 同左
平台识别 — 用声明替代 NVML 设备数探测;vLLM 0.30 在 import vllm 时就已解析平台,先清掉缓存结果再声明 CUDA
硬件身份 --ascend-soc 必须给出,并和安装包 _build_info.__device_type__ 核对 --cuda-capability 必须给出,不猜
硬件探测 triton 的 get_arch / get_aicore_num 按声明作答;自定义算子报告不可用;锁页内存报告不可用 kernel 可用性探针按声明作答,vLLM 自己的后端选择规则照常运行
权重 Engram 码表 HostUvaBuffer 换成 meta 替身(原需 91.5 GiB) —
UCM 设备 create_device 改为 CpuDevice,UCM 以 simu 方式编译 同左

vLLM 会在新解释器里检查模型结构(python -m vllm.model_executor.models.registry),猴子补丁传不过去。sim_bootstrap.py 生成一个 sitecustomize.py,让每个子解释器启动时带上同样的声明;它不依赖 torch / vLLM / UCM,并且会接上原有的 sitecustomize。

run_model_check.sh 一条命令按 layout-only → bulk → layerwise 跑完一个模型。它以检查器自己打印的 PASS 行作为通过的依据,不看退出码:写这个脚本时发现 cli.py 缺 __main__ 入口,python -m ucm_toolkit.cli 什么都没跑就以 0 退出。

verify() 流程

verify() 的执行流程

每个请求只调度一次,不跑前向,也不向 Scheduler 回填模型输出。

顺序上有三点不能调换,代码里有注释:

  • 原生填充在 connector 填充之前,所以 connector 没覆盖到的字节保留原生模式,漏传的层在目标侧会一直是投毒值。
  • 源块快照在目标投毒之前。混合模型的多个组可能共用一块物理池,顺序反了会把投毒值当成源数据。
  • 地址合法性检查在按 connector 地址投毒之前。否则越界地址在被发现之前已经写坏了别的块。

新增的校验

黑盒三态:目标块先全部写成 0xff,load 之后逐字节判定。

黑盒三态判定

这项检查不依赖各组的存储策略,滑动窗口组、Mamba 组都能直接覆盖。它只证明"没有放错位置",不证明"该恢复的都恢复了":整组都停在 0xff 也能通过。所以每个组会单独标明有没有独立的必须恢复集合,没有的打印 PARTIALLY VERIFIED。

严格相等:对能从 vLLM spec 推出必须恢复哪些块的组(全注意力组的命中前缀、窗口恰好一块的窗口组的边界块),要求字节完全一致。其他几何结构在日志中标为 not natively assertable 并跳过。

地址合法性:允许写入的范围只从 vLLM 的 block 表和张量视图构建,再逐条核对 connector 报告的 load 地址;有任何一条越界就失败,并给出地址。

误写检查:load 前后对 KV 存储做快照,目标范围以外的字节不允许变化。

命中数:dump 的 chunk 数必须等于源请求调度范围按 chunk 粒度算出的值,load 的 chunk 数以目标请求长度为上界。两边各自独立计算,不从 connector 的 dump 计划里取期望值。

测试数据由 pattern_bytes() 生成:取值 0~250,按 (层, 分量, 块号) 派生,多个尺度叠加,周期约 15.8 MB,远大于一个 KV block,块内任何错位都能看出来。最早的单尺度版本周期只有 251 字节,相隔 251 字节的两段互换是看不出来的。

防止"跳过了检查却报 PASS"

这类问题比直接失败更危险,PR 里专门处理了几处:

  • v2 connector 内部对象:上游把 spec / layout / transfer_builder 移到了 connector_worker,tokens_per_chunk 改名 chunk_size,attention_type 改名 restore_policy。v2_worker_internals()、spec_chunk_tokens()、plan_restore_policy() 新旧名字都认,都找不到时直接报错,并说明该往哪个列表里加。
  • v2 还是 legacy:由启动配置里的 kv_connector_module_path 决定,不再从 metadata 形状推断。metadata 和配置不一致(比如字段又改名)时直接失败,并指出原因。
  • 地址核对被跳过:某层几何无法寻址时,PASS 行显示 transfer_ranges_checked=skipped (...),后面跟一行 PARTIALLY VERIFIED,不会显示成 0;检查真正跑了却得到 0 个范围则直接失败。
  • review 中发现的四个盲点(5f9861c7):误写检查的允许范围原来取自 connector 自己的地址;命中数的期望值原来从 connector 的 dump 计划推出;黑盒通过原来被当成"已恢复";cuda_sim 原来把 fp8 / FA 版本支持强制设成 True,改变了后端选择。四处都已修正,tests/test_model_check_blind_spots.py 有对应的回归测试。

验证

无卡与真卡交叉核对

判据见 VERIFICATION.md:同一模型分别在无卡启动器和真卡上运行,比较 ① sim.json(vLLM 的 KVCacheConfig)② 去掉时间戳等之后的日志 ③ 能从层数和块数推出来的计数 ④ 注入故障能否被发现并指出位置。①只在同一平台内有意义:Qwen3-0.6B 在 ascend_sim 和 cuda_sim 上的 sim.json 完全相同,但两个平台的布局实际不同,所以它不能单独作为结论。

Qwen3-0.6B(1024 tokens,block size 128):

启动器 对照真卡 结果
cuda_sim(capability 12.0) RTX 5090 ①②③④ 全部通过,日志包含张量签名 (30, 8, 128, 256) / (262144, 256, 2048, 1)
ascend_sim(A2) 910B3 ①③④ 通过,7 个计数全部一致;② 还没做日志比对
计数 cuda_sim ascend_sim 推算
hit check FA dump 8 / load 7 相同 1024 / 128 = 8;load 只能命中前 7 块
恢复字节 102760448 / 117440512(87.5%) 相同 7/8
compared_loaded_tensor_blocks 196 196 28 层 × 7 块
poisoned_blocks 224 448 28 × 8;昇腾每层 K、V 是两个张量,计数翻倍,字节总数相同
strict_oracle_blocks 196 392 同上
transfer_ranges_checked 196 196

真实服务验证(真引擎、真卡)

同一个 prompt 通过 LLM.generate 生成两次(temperature=0,固定 seed,enable_prefix_caching=False):Qwen3-0.6B 在 910B3(A2)和 RTX 5090 上都满足输出 token id 完全一致(64 个),第二次外部命中 1920 tokens(2008 token 的 prompt,2008 // 128 = 15 个整块)。命中数读自 vLLM 的 RequestOutput.num_cached_tokens。

故障注入

手工修改 UCMTransferBuilder._build,让 dump 和 load 都少传一层:原有段校验仍然通过,新校验报错,并给出组、层、块号和"该字节仍是投毒值"。故障注入曾暴露过一次真实缺口:当时严格校验只覆盖了 11 个持久化组中的 1 个,已修复。

DeepSeek-V4.1-Flash 在 vllm-ascend:v0.30.0rc1 上 layout-only / bulk / layerwise 都通过过(12 组:1 MLA + 1 瞬态 ring + 10 滑动窗口,124 段,与真卡记录一致),但那是在合并新版 dev_connector 之前的版本上跑的,当前版本还没重跑。

已知限制

  • Mamba state 组和窗口不是整块的滑动窗口组没有独立的必须恢复集合,结果标为 PARTIALLY VERIFIED。
  • 故障注入是手工的,没进 CI,而且只注入过整模型传输路径,从没单独对 layerwise 注入过。
  • worker 侧钩子按固定顺序手工调用(基线设计),真实引擎的调用循环靠服务验证覆盖。
  • 服务验证用了 VLLM_ENABLE_V1_MULTIPROCESSING=0,生产用的多进程路径没有覆盖到。
  • ascend_sim 还没做日志比对,它和真卡一致的结论依据是计数。
  • 端到端交叉核对只做了 Qwen3-0.6B;滑动窗口、Mamba、MLA 模型在两个平台上都还没和真卡比过。
  • --cuda-capability 对布局的影响没有被证实:Qwen3-0.6B 在 9.0 和 12.0 下布局相同,这个对照说明不了问题。
  • A5 未验证,当前只有 A2 构建的 vllm-ascend;vllm-ascend:v0.30.0rc2 的适配还没在服务器上重跑。
  • 不覆盖设备拷贝 kernel、ACL 事件时序、搬运与计算并发时的竞态、性能。

使用

# 昇腾布局,无 NPU:只读挂载 CANN 驱动,不映射 /dev/davinci*
./run_model_check.sh /path/to/model --platform ascend-sim --ascend-soc A2

# 官方布局,无 GPU(需要 CUDA 版 vLLM)
./run_model_check.sh /path/to/model --platform cuda-sim --cuda-capability 12.0

UCM 需以 PLATFORM 未设置的方式编译(simu 运行时),并安装 wrapt;编译方式、自检命令和每次运行要留存的证据目录见 VERIFICATION.md。

Test plan

  • 本机单元测试 87 项全部通过(7 个测试文件,不需要 vLLM),isort / black 通过
  • Qwen3-0.6B:cuda_sim 对照 RTX 5090,判据 ①②③④
  • Qwen3-0.6B:ascend_sim 对照 910B3,判据 ①③④
  • 服务验证:910B3 和 RTX 5090,输出一致,外部命中 1920 tokens
  • 故障注入:整模型传输路径漏传一层,新校验能发现并定位
  • 最新两个提交(v2 由配置决定、跳过时 PASS 行写明 skipped)在服务器上重跑
  • ascend_sim 补做日志比对(判据 ②)
  • DeepSeek-V4.1-Flash 在当前版本上重跑
  • layerwise 路径的故障注入

🤖 Generated with Claude Code

ljccsu-ops and others added 2 commits October 26, 2026 23:08
…dent byte checks

Two gaps in the UCM model compatibility checker:

1. The Ascend KV layout could only be checked on an NPU, while the CPU
   launcher reproduces the official (CUDA) layout, which is a different
   design. Add `ascend_sim.py`: vLLM-Ascend's own model construction,
   KVCacheSpec, grouping, KVCacheConfig and
   `NPUModelRunner.initialize_kv_cache()` run with the KV tensors in host
   memory, so the Ascend layout can be verified with the driver libraries
   present but no `/dev/davinci*`. Only execution-ordering APIs, weights and
   compute kernels are substituted; every substitution and its layout-neutral
   reason is listed in the README. The SoC family is reported from the
   installed wheel's build info rather than simulated, so `--ascend-soc` only
   asserts a match.

2. The connector-v2 verification derived every address from the connector's
   own TransferBuilder, so a symmetric addressing error cancelled out and a
   layer the connector never transferred was never inspected. Add checks that
   use only vLLM's tensors, specs and block tables:
   - native oracle: target block i equals source block i byte-for-byte, over
     persistent full-attention groups and whole-block sliding-window groups;
   - stray writes: the load changes no KV byte outside its target ranges;
   - hit length: loaded FA chunks are the reachable prefix of the dumped ones
     and agree with the scheduler's external hit.
   Fault injection confirms these are not vacuous: dropping one layer from
   every transfer keeps the connector-derived comparison green and fails the
   native oracle, for a full-attention and for a sliding-window layer.

Also: the legacy path's source fill becomes position-dependent bytes written
through a uint8 alias (a constant per block hid intra-block offset errors, and
distinct values could round together in fp8/bf16); comparisons are byte-wise,
so NaN payloads with equal bits compare equal; the FAWA path skips transient
groups instead of refusing to classify them; the local prefix-hash eviction
before the target lookup now also runs on the legacy path; and the fixed
10 s wait after dump is configurable and defaults to 0 for the synchronous
v2 proxy.

Verified on vLLM 0.30.0 / vLLM-Ascend 0.30.0rc1 and official vLLM 0.30.0+cpu,
UCM built with PLATFORM unset: DeepSeek-V4.1-Flash passes bulk, layerwise and
legacy-connector runs without a device, and Qwen-Flash-Next passes on the CPU
launcher. The segment counts match those recorded for the same models on real
hardware. README limitations are re-checked against 0.30: DeepSeek-V4.1 on the
CPU launcher now stops at the ScaledMM linear kernel, not the removed grouping
assertion, and CPU external load does work on 0.30 with connector v2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stent group

The previous checks could only assert bytes for groups whose storage policy
the checker can predict: full attention (the whole hit prefix) and a sliding
window that is exactly one block (its boundary block). Everything else was
reported as "not natively assertable" and skipped -- which left DeepSeek-V4.1's
window groups only partly covered and Qwen-Flash-Next's four MambaSpec state
groups not covered at all. Predicting where a state snapshot lands means
reimplementing the runner's load-slot rule, and the only source for that rule
is the connector under test.

Replace the question. Instead of asking which blocks must be restored, require
that no loaded byte holds a value UCM never stored: after the load, every byte
of a poisoned target block is either the source byte at the same position
(restored) or still 0xff (not restored). Patterns only use [0,250], so the two
cases cannot be confused, and a third value can only come from data placed at
the wrong offset, block, layer or group. That assertion contains no storage
policy, so it covers sliding-window, state-snapshot and future group kinds
without the checker having to model them. Restored/total bytes per group are
logged as a report rather than asserted, because a group legitimately restores
only part of its blocks.

The strict equality check stays for the groups it can cover; the two are
complementary (narrow and strong vs wide and weaker).

Also drop the one place that read connector internals: the hit-length check
used the lookup's private recompute window, and now bounds the restorable
prefix from the request length instead.

Verified without an accelerator (vLLM-Ascend 0.30.0rc1, driver libraries
mounted read-only, no /dev/davinci*): DeepSeek-V4.1-Flash passes with the
black-box check covering all 11 persistent groups (176 blocks, every group
restoring 100% of its poisoned bytes) next to 124 strict blocks. On the CPU
launcher Qwen-Flash-Next passes with 5 groups / 194 blocks covered, including
the four MambaSpec groups the strict oracle skips. Peak RSS of a full
DeepSeek-V4.1-Flash run is 2.8 GB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ljccsu-ops and others added 8 commits October 28, 2026 19:34
`ascend_sim.py` checks the Ascend layout without an NPU, but the official
(CUDA) layout had no device-less path: `cpu.py` reproduces it on a CPU build
through hand-declared backend constraints, which is a different thing from
running vLLM's real CUDA attention backends. Add `cuda_sim.py` so both
platforms can be checked the same way, and move what the two launchers share
into `sim_runtime.py` (host streams and events, pinned memory, UCM's host
device) so they cannot drift.

`cuda_sim.py` needs a CUDA build of vLLM: its attention backends import their
kernel entry points at module level behind `if current_platform.is_cuda()`, so
the real backend classes -- the ones that compute the KV shapes and layouts --
are only importable there. The launcher checks for `vllm.vllm_flash_attn` and
explains that, rather than failing deep inside vLLM.

Two substitutions are specific to CUDA. vLLM resolves its platform by asking
NVML for a device count, so with no GPU the CPU platform would win: the
launcher replaces that probe with a declaration, which answers "is this
hardware present" rather than "what does the layout look like", and refuses to
run if anything resolved the platform first. And the compute capability cannot
be read without a device while vLLM branches on it (FlashAttention version, fp8
support, MLA backend choice), so it is declared with `--cuda-capability` and
never guessed -- the same rule `ascend_sim.py` applies to the Ascend SoC
family, which it asserts against the installed wheel's build info. A PASS is
therefore a statement about the layout vLLM produces for that capability, and
the value is logged. Kernel-availability probes are answered from the same
declaration rather than skipped, so backend selection stays native and the
chosen backend appears in the layout log.

KV-cache specs, grouping, shapes, strides, dtypes and block IDs are not
touched, as in the other launchers.

Not yet run: the available host is aarch64 with no CUDA vLLM wheel installed,
so these substitutions are the set vLLM 0.30's source predicts is required,
not a verified one -- the README says so. Unit tests cover the capability
parsing (dotted and nvcc forms, refusal to invent one), the CPU-build refusal
and the late-platform-resolution refusal; the toolkit tests cover the new
`--platform cuda-sim` routing and that it asks for no visible device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review fixes on the device-less CUDA launcher:

* declare the platform by mutating `builtin_platform_plugins` in place.
  `vllm.platforms` defines its own `__setattr__`, so rebinding the name could
  have been intercepted;
* decide "did vLLM already resolve its platform" from `_init_trace`, the marker
  vLLM itself writes when it resolves, with the cached platform as a fallback.
  Reading `current_platform` instead would have done the resolving;
* report `cuda-sim` from `doctor()` beside `ascend-sim`;
* drop the `rebind` import `ascend_sim.py` no longer uses after the shared
  helpers moved to `sim_runtime.py`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of 199c4de found four places where a check could pass on code that is
wrong. All four are confirmed in the code, and the first three made the verdict
itself unreliable rather than merely narrow.

1. The stray-write check took its allowed set from the connector's own
   transfer addresses, so a connector writing an unrelated target block had
   that wrong address authorized before the check ran. The allowed set now
   comes only from vLLM's block tables and views, and the connector's ranges
   are verified against it (`check_transfers_inside_native_blocks`); a range
   leaving it fails with its address. `StorageSnapshot.contains()` makes a
   reported address checkable instead of trustworthy.

2. The hit-length check derived its expectation from the connector's own dump
   plan (`expected = min(len(dumped), reachable)`), so a truncated dump shrank
   its own expectation and a save and a load that were wrong together agreed.
   The dumped count is now required to equal what the source request's
   scheduled range and the chunk granularity imply, and the loaded count is
   bounded from the target request's length -- two independent assertions.

3. The black-box check accepts a byte that stayed `0xff`, which is what lets it
   cover groups whose storage policy the checker cannot derive, but it was
   reported as if it proved restoration. It is a placement check: a group whose
   bytes all stayed poison passes it. Each group now gets a verdict saying
   whether an independent required-restore set backed it, and the run reports
   the groups without one as PARTIALLY VERIFIED.

4. `cuda_sim.py` forced `flash_attn_supports_fp8` and `is_fa_version_supported`
   to True, which changes vLLM's backend support rules -- so the claim that
   backend selection stayed native was wrong. Those substitutions are gone:
   only the hardware identity is declared, and vLLM's own capability and
   availability rules keep running against it.

Also: a pattern was one arithmetic sequence modulo 251 and so repeated every
251 bytes, hiding a swap of equal-length pieces that far apart. Patterns now
sum one sequence per scale, which moves the repeat to 251**3 (about 15.8 MB,
far beyond one KV block) and makes every smaller shift visible. The README no
longer claims more than the checks deliver.

`tests/test_model_check_blind_spots.py` turns the review's reproductions into
10 regression tests: shifts that used to be invisible, an address outside the
allocated block, and a group with no required-restore set reading as partial.
34 tests pass locally; isort, black and pyflakes are clean. No model, device or
store run was performed for this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…with -m

One command per model, in the fixed case order the README calls the standard
flow: layout-only, then bulk, then layerwise. It resolves the repo root from
its own path, puts UCM's built `.so` directories on LD_LIBRARY_PATH, prints a
preflight showing which of torch / ucm / wrapt / vllm / vllm_ascend are
importable, writes one log and one store per case, and prints a summary whose
exit code is 0 only when every selected case passed.

A case counts as passed only when the checker itself printed its PASS verdict.
The exit code alone is not enough, and this turned up a real false pass while
testing the script: `python -m ucm_toolkit.cli` silently did nothing and
exited 0, because cli.py had no `__main__` guard, so a run that never reached
the checker was reported as a pass. The guard is added, and the script now
requires the verdict line as evidence.

The summary repeats what a PASS does and does not cover, and points at the
PARTIALLY VERIFIED lines for the groups that have no independent
required-restore set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ust the env

Measured on the Huawei host: dsv41-flash passes all three cases on
`vllm-ascend:v0.30.0rc1` (triton-ascend 3.2.2) and fails all three on
`v0.30.0rc2` (triton-ascend 3.6.0+dev) with

    Ascend environment variable 'TRITON_ASCEND_ARCH' is deprecated ...
    host environment overrides are ignored.
    cann_get_soc_name failed.
    SystemError: <built-in function get_arch> returned NULL

The three cases did not fail for three reasons: they all stop at the same
import-time query, inside vLLM-Ascend's patch chain pulling in vLLM's
flash-linear-attention ops, whose module body asks triton for the current
target. That is before any KV spec, grouping or dump/load work, so this was a
gap in the launcher's device-less adaptation, not a statement about whether
rc2 supports the model.

Setting `TRITON_ASCEND_ARCH` is no longer enough, so declare the arch where
the query actually happens: the driver's own `get_arch`, whichever class owns
it, which also covers instances already created. `get_aicore_num` on the same
object reaches the device too and is a kernel launch parameter, so it gets the
same placeholder as the compressor's core count. The environment variable is
still set, because older triton honours it. If a future triton moves the
lookup, the launcher now fails with the triton version and the expected error
text named instead of letting the device query through.

`tests/test_model_check_ascend_sim.py` covers both shapes with fake triton
modules: the rc2 path where only the lookup works, the rc1 path where the
environment variable is also set, a renamed lookup failing loudly, and a
missing Ascend backend staying non-fatal.

README: record that the toolkit needs `--no-build-isolation` on these images
(they ship setuptools but an isolated build tries to download it), that
`wrapt` must be installed, and the triton arch difference between the two
images.

Not verified: this fix has not been run on rc2. It is written from the
reported traceback and the 3.2.2 driver source; rc2 needs a rerun of all
three cases before anyone calls its device-less path adapted. Whether UCM
works with rc2 on a real card is a separate question that nothing here tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rpreters

Measured on rc2 with the previous commit: the arch declaration worked in the
main process -- the log shows `NPUUtils.get_arch` and `NPUUtils.get_aicore_num`
replaced, and the import chain that used to fail got past -- but all three
cases still failed. vLLM inspects a model architecture in a **fresh
interpreter** (`subprocess.run([sys.executable, "-m",
vllm.model_executor.models.registry])`), whose `_run()` calls
`load_general_plugins()` first. A monkey patch does not travel there, the
environment override is ignored by this triton, and the child repeated the
device query:

    registry.py:1570 in _run -> load_general_plugins() -> ...
    triton.runtime.driver.active.get_current_target().backend
    SystemError: <built-in function get_arch> returned NULL
    ValidationError: Model architectures ['DeepseekV41ForCausalLM']
    failed to be inspected.

Move the declarations into `sim_bootstrap.py`, a module that imports without
torch, vLLM or UCM, and reach every interpreter through a generated
`sitecustomize.py`: Python's `site` imports that name at startup, before the
`-m` module runs. The parent calls the same functions directly, so there is one
implementation instead of two that can drift. The generated file chains to any
`sitecustomize` it shadows, does nothing unless a simulated platform is
declared for the run, and warns on stderr instead of raising -- replacing a
clear error with an unrelated crash in an unrelated interpreter would be worse.

`tests/test_model_check_sim_bootstrap.py` really spawns interpreters with a
fake triton whose lookups fail the way the real ones do, so the child path is
covered rather than assumed: without the bootstrap a child reproduces
`get_arch returned NULL`, with it the child reports the declared arch, an
explicit arch reaches it too, a shadowed `sitecustomize` still runs, an
undeclarable arch warns without killing the child, and the generated file is
compiled so a name error in it cannot fail silently at startup. That last test
earned its place immediately: an earlier blind `sed` in this session deleted
`import sys` from the embedded template, and the only symptom was the
bootstrap quietly doing nothing.

55 tests pass locally; isort, black and pyflakes are clean.

Not verified: rc2 has not been rerun with this. Whether UCM works with rc2 on a
real card remains a separate, untested question -- nothing here speaks to it.
The rc2 failures so far are all the absence of a device, not a sign that the
image or the model is unsupported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated sitecustomize chained to the file it shadowed by popping itself
from sys.modules and re-importing the name. When the image has no sitecustomize
of its own -- the normal case -- that leaves site's own import with no module to
return, so every interpreter prints

    Error in sitecustomize; set PYTHONVERBOSE for traceback:
    KeyError: 'sitecustomize'

The declaration itself still works, so no existing test noticed: a real child
exits 0 with the declared arch, the only symptom being the stderr line that every
child of the run carries, and that vLLM pastes into its "Error raised in
subprocess" report whenever a later failure happens.

Load the shadowed file by path and execute it instead, keeping the bootstrap
directory out of sys.path afterwards as before. The new test asserts the child's
stderr is empty when nothing is shadowed; "a shadowed sitecustomize still runs"
keeps covering the other half.

Not verified: rc2 has not been rerun with this or the previous commit.
…ment

rc2 reported 54 passed / 1 failed where a clean checkout passes 55. The
failing test is the one that asserts the bootstrap warns when there is no arch
to declare: it removed `SOC_VERSION` from the dict it passes, but `_run_child`
started from `os.environ.copy()`, and the vLLM-Ascend images export
`SOC_VERSION` themselves, so the child could declare an arch after all and the
expected warning never appeared. The test was asserting a precondition it did
not actually establish.

Build the child environment by dropping every variable that changes what the
bootstrap declares, then applying only what the test states. A test now reads
the same way on a bare host and inside an image that exports these itself. The
one test that built its own environment inline does the same.

Verified both ways locally: with `SOC_VERSION` and `TRITON_ASCEND_ARCH`
exported, the suite failed exactly as reported before this change and passes
after it -- 56 tests, clean under `isort`, `black` and `pyflakes`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ljccsu-ops and others added 5 commits November 3, 2026 19:17
Three findings from re-reading the flow after the rc2 run.

The transfer-range check ran after the poisoning that uses the very addresses
it validates. A connector reporting an address outside the request's blocks
would already have had that memory poisoned -- possibly another group's source
data, which then fails the comparison and points at the wrong place. The check
moves ahead of the poisoning (`verify_transfer_ranges` builds its own allowed
set from vLLM's block tables), and the stray-write snapshot afterwards only
establishes the baseline.

The dumped-chunk expectation came from the source request's length. FA stores
every complete chunk of the *computed* range, which equals the prompt only
while the prompt fits one scheduling step; with chunked prefill a long prompt
is split and the expectation would ask for chunks the connector was never
told to store. It now reads the scheduler's own per-request token count, and
falls back to the request length where that is unavailable.

The generated bootstrap directory had a fixed name under the temp directory,
so two runs on one host shared it and one run's declarations could reach the
other's children. The name now carries the pid, with a test for it.

57 tests pass, including with SOC_VERSION exported; isort, black and pyflakes
clean. None of this has been rerun on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream refactored connector v2 while this branch sat on 5fafa6a: the v2
files were reorganised, `UCMConnector` became a facade over
`UCMConnectorWorker`, `tokens_per_chunk` became `chunk_size` and
`attention_type` became `restore_policy`. Upstream had already followed those
moves in `_v2_batch`; the functions added on this branch had not.

A static check of the symbols this checker reads showed ten of them absent at
the new upstream tip, so merging without adapting would have broken the v2
path -- and `check_v2_hit` would have *silently skipped* itself, which is worse
than a crash: the run would skip the hit check and still report PASS.

So the worker lookup is now explicit and fails loudly. `v2_worker_internals()`
finds the object carrying `spec` / `layout` / `transfer_builder`, trying the
object itself, `connector_worker` and `connector` (and one level deeper), and
raises with what it inspected and which list to extend when it cannot. The two
renamed fields go through `spec_chunk_tokens()` and `plan_restore_policy()`,
which read the current name, fall back to the previous one, and refuse to
guess. `describe_ucm_layout` asks the same lookup before falling back to the
legacy group report, so `--layout-only` keeps printing the classification.

Conflict resolved in `config.py`: upstream's `chunk_size` field name, keeping
this branch's `layout_only`, `ascend_soc` and `cuda_capability`.

`tests/test_model_check_v2_api.py` pins the behaviour across the move: the
internals are found in each shape they have lived in, a half-populated object
is rejected, the renamed fields read both ways and refuse to guess, and window
and state plans are never mistaken for full attention. 71 tests pass; isort,
black and pyflakes clean.

Not verified: nothing here has been run against the new upstream on a machine
with vLLM. The adaptation follows the new source, and the loud failures are
there so a remaining mismatch surfaces as an error rather than a pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Ascend and 5090 device-less runs both failed layout-only on
`UCMKVCacheSpec.scheduler_block_size`, which upstream removed on purpose
("Engine scheduler granularity is a parser diagnostic, not stored
metadata"). The line after it would have failed next:
`UCMKVCacheGroupSpec.tokens_per_block` became `block_size` plus a
`block_token_span` property that accounts for DCP sharding.

Report the scheduler block size only while a tree still exposes it,
rather than deriving a stand-in, and read the block span through an
accessor in the same shape as `spec_chunk_tokens` / `plan_restore_policy`.
Both failures were in the checker's own log path, so bulk and layerwise
passed on both platforms -- the device-less layers were never involved.

Also fold in the two substitutions the 5090 run needed on top of
b2de1f0. vLLM 0.30+ resolves its platform during `import vllm`, so the
launcher's refusal-when-already-resolved guard fired on every run.
Resolution order is a probe-timing question, not a layout one: clear the
cached answer, declare CUDA, and let the next read re-resolve. Children
need the same declaration, so `sim_bootstrap` grows a cuda branch beside
the Ascend one. Verified layout-neutral -- the normalized bulk and
layerwise logs are byte-identical to the pre-merge run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add VERIFICATION.md: what a device-less PASS does and does not assert, and
what a run has to produce before its PASS may be used as evidence.

The two claims a device-less run makes need separating, because they have
different evidence and a fixed order. Faithfulness (is this the layout a
real card produces?) comes from the real-card cross-check; correctness
(does dump/load have a bug?) comes from the three-state black-box check,
the stray-write check and fault injection. A device-less run can only
substitute for a card on the second once the first holds -- two runs that
both skipped the checks would agree perfectly.

Also record the acceptance rules the runs already rely on but that lived
only in commit messages: a zero exit status is not evidence (a missing
__main__ guard once produced exit 0 with an empty log), the three lines
whose presence means checks were skipped while still reporting PASS, why
the pattern is capped at 250 so the third state is distinguishable, and
why the transfer-range check has to precede poisoning.

README had two claims that the 5090 and 910B runs have overtaken:
cuda_sim is no longer unrun, and the platform-resolution change needs
recording next to the substitutions it belongs with. State plainly that
cuda_sim is verified for one model, and that the capability flag is
asserted but its layout effect is untested -- the control that was meant
to show it was inconclusive, since Qwen3-0.6B's layout does not branch
on capability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The A2 (910B3) and B2 (5090) service runs and both real-card
cross-checks are done, so the criteria they were judged against need to
match what the evidence actually supports.

sim.json was listed first among the faithfulness criteria, which
overstates it. It records vLLM's KVCacheConfig and nothing above it, so
it cannot see the K/V split -- that lives in how a platform hands tensors
to register_kv_caches. For Qwen3-0.6B the ascend_sim and cuda_sim
snapshots are byte identical, while the layouts demonstrably differ
(poisoned_blocks 448 vs 224). Within one platform the match is real
evidence; across platforms it proves nothing, and it must not be
reported as the headline. Say which criteria carry the platform-specific
claim instead: the normalized logs, which include the tensor signature
with shapes and strides, and the counters that differ by platform
precisely because of the split.

Record what each launcher has established on that basis: cuda_sim all
four criteria, ascend_sim 1, 3 and 4 with no log diff yet, so its
platform-specific claim rests on seven matching counters including the
two the K/V split produces.

Add the service criteria. Read the external hit from
RequestOutput.num_cached_tokens: the v2 scheduler logs no hit line at
all (get_num_new_matched_tokens returns without logging; "hit external"
exists only in the legacy connectors), so a wrapper around the lookup is
a logging convenience that proves nothing the field does not. It also
costs VLLM_ENABLE_V1_MULTIPROCESSING=0, which leaves the production
multiprocess path unexercised -- now listed as a gap, along with
layerwise fault injection never having been done and only one model
having been cross-checked end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ljccsu and others added 2 commits November 6, 2026 01:06
…ped range check as 0

The v2/legacy branch was chosen by sniffing the metadata for `requests`, so a
future rename could route a v2 run away from the v2 checks. It is now read
from kv_connector_module_path, and metadata of the other kind fails the run
with a message that names the cause.

transfer_ranges_checked could read 0 on a PASS when the check was skipped. It
now reads "skipped (...)" with a PARTIALLY VERIFIED line, and a check that ran
but found no ranges fails. _v2_batch reuses v2_worker_internals instead of its
own lookup.

Also applies isort/black to three files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ASS must not carry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ljccsu ljccsu changed the title [Test] model-check: device-less Ascend launcher (ascend_sim) and connector-independent byte checks [Test] model-check: device-less Ascend/CUDA launchers and connector-independent byte checks Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants