feat: 昇腾 NPU 支持 — MossVL 推理(sglang serving + 独立推理脚本) - #15
Conversation
Minimal inference-only changeset — every divergence is NPU-gated and CUDA behavior is byte-identical to upstream. - device_utils.py + requirements-npu.txt: standalone dual-backend device abstraction (detect, device strings, sync, attn-impl resolution — CUDA and NPU both first-class). NPU installs: pip install -e sglang/python --no-deps && pip install -r requirements-npu.txt (upstream pyproject.toml is untouched — CUDA deps intact) - sglang server_args.py: ascend attention backend default for MossVL on NPU; flashinfer assert relaxed (device != 'npu'); radix cache auto-disabled on NPU (Ascend paged-attention KV layout sensitivity causes ~20% non-deterministic decode failure at temperature=0) - sglang models/moss_vl.py: skip FlashInfer packed cross-attention mask on non-flashinfer prefill backends (ascend falls back to standard full-visibility cross-attention — correct, not a degradation) - sglang ascend_backend.py: handle None k/v in decode cross-attention (NPU hardware backend path only — never loaded on CUDA) - sglang profiler patches: torch_npu._apply_all_patches() hasattr guard for torch-npu 2.10+ API rename - sglang schedule_batch.py: drop encoder-prefix cache matches that land inside the vision-token region (violates encoder-as-whole-unit under paged KV; dead code on CUDA where default page_size=1) - inference entry points (run_inference.py, run_online_inference.py): dual-backend device detection via device_utils; standalone scripts, no sglang dep
Minimal inference-only changeset — every divergence is NPU-gated and CUDA behavior is byte-identical to upstream. - device_utils.py + requirements-npu.txt: standalone dual-backend device abstraction (detect, device strings, sync, attn-impl resolution — CUDA and NPU both first-class). NPU installs: pip install -e sglang/python --no-deps && pip install -r requirements-npu.txt (upstream pyproject.toml is untouched — CUDA deps intact) - sglang server_args.py: ascend attention backend default for MossVL on NPU; flashinfer assert relaxed (device != 'npu'); radix cache auto-disabled on NPU (Ascend paged-attention KV layout sensitivity causes ~20% non-deterministic decode failure at temperature=0) - sglang models/moss_vl.py: skip FlashInfer packed cross-attention mask on non-flashinfer prefill backends (ascend falls back to standard full-visibility cross-attention — correct, not a degradation) - sglang models/qwen3.py + transcription_adapters: graceful ImportError fallback for sgl_kernel_npu imports (startup-blocking: sglang's model registry imports ALL model files at boot; upstream's hard import of sgl_kernel_npu.norm.split_qkv_rmsnorm_rope dies when triton-ascend lacks triton.language.extra.cann.extension — MossVL serving cannot start without this guard) - sglang ascend_backend.py: handle None k/v in decode cross-attention (NPU hardware backend path only — never loaded on CUDA) - sglang profiler patches: torch_npu._apply_all_patches() hasattr guard for torch-npu 2.10+ API rename - sglang schedule_batch.py: drop encoder-prefix cache matches that land inside the vision-token region (violates encoder-as-whole-unit under paged KV; dead code on CUDA where default page_size=1) - inference entry points (run_inference.py, run_online_inference.py): dual-backend device detection via device_utils; standalone scripts, no sglang dep
There was a problem hiding this comment.
直接这样跳过cross_attention_mask 在prefill阶段 还是会有训推不一致的问题吧 虽然也是能正常输出 不用flash_infer的话 完全没办法用custom_mask吗 如果可以的话 最好还是在prefill阶段用上cross_attention_mask吧
There was a problem hiding this comment.
感谢指出!已在 commit 163ec45 中修复:移除了 prepare_forward_batch 中对 non-flashinfer 后端的 skip,现在 Ascend NPU 后端也会构建并应用 cross_attention_custom_mask。
具体改动:
moss_vl.py:移除prefill_backend != "flashinfer"的 early returnascend_backend.py:在forward_extend中透传cross_attention_custom_mask到run_sdpa_forward_extendascend_torch_native_backend.py:在 sdpa 调用中解包 packed 1D mask 并作为attn_mask传入
实测多轮多图对话(3 轮不同图片),每轮正确描述当前图片而非历史图片。
Two fixes in the sglang fork's MOSS-VL support: 1. moss_vl.py _build_mm_items: HF AutoProcessor unconditionally produces placeholder pixel_values (shape [64,768], grid_thw [1,8,8]) even for text-only inputs. Without this guard, _get_encoder_len computes a non-zero encoder length and pad_input_ids prepends 17+ garbage pad tokens — corrupting every decode step's attention context and collapsing generation into repetition / early-EOS. Fix: skip image item creation when input_ids contain no image_token_id. 2. mrope.py forward_npu: torch_npu.npu_mrope was called with a hardcoded mrope_section=[0,0,0] and ignores mrope_interleaved — corrupting rotary embeddings for any request with per-axis positions (multimodal prompts). Fix: use the triton fused kernel (forward_triton) which correctly handles mrope_section and mrope_interleaved. ~12x faster than native fallback, verified against HF reference.
Previously prepare_forward_batch skipped the cross-attention custom mask on non-FlashInfer backends (ascend NPU), causing text tokens to see all vision frames instead of only frames at or before their position — a train-inference inconsistency for multi-frame video inputs. This patch: 1. Removes the flashinfer-only skip in prepare_forward_batch (moss_vl.py) — the mask is now always built 2. Passes cross_attention_custom_mask from the ascend backend (ascend_backend.py) to run_sdpa_forward_extend 3. Unpacks the packed 1D mask per-request and applies it as attn_mask in scaled_dot_product_attention (ascend_torch_native_backend.py) Single images: mask is all-visible → no behavioral change. Multi-frame video: frame-level causal visibility now matches training. Text-only: no vision input → mask not built → no behavioral change. Addresses reviewer feedback on PR OpenMOSS#15 (SSSSuperC).
| ].reshape(q_len_r, kv_len) | ||
| # Packed mask: 1=visible, 0=masked. | ||
| # sdpa attn_mask: True=masked (blocked), False=visible. | ||
| per_req_attn_mask = (mask_slice == 0).unsqueeze(0).unsqueeze(0) |
There was a problem hiding this comment.
这里的 mask 语义是不是反了,cross_attention_custom_mask 是 1 表示可见、0 表示不可见。
麻烦检查一下,并测试一下
There was a problem hiding this comment.
感谢检查!mask 语义是正确的,这里没有反:
cross_attention_custom_mask:1=可见,0=不可见(在moss_vl.py的_build_cross_attention_custom_mask中,mask = torch.zeros(...)初始全 0,然后mask[visible_rows, start:end] = 1设置可见位置)per_req_attn_mask = (mask_slice == 0):Truewheremask==0(即不可见的位置)- PyTorch
scaled_dot_product_attention的attn_mask:True=被屏蔽(blocked),False=可见
所以 (mask_slice == 0) → True where 不可见 → sdpa 屏蔽这些位置,逻辑正确。代码注释中也标注了:Packed mask: 1=visible, 0=masked. sdpa attn_mask: True=masked (blocked), False=visible.
There was a problem hiding this comment.
pytorch里scaled_dot_product_attention 的True是可见吧 这里能再去检查一下吗
There was a problem hiding this comment.
您是对的,非常感谢!PyTorch scaled_dot_product_attention 的 bool attn_mask 语义是 True=可见(should take part in attention),不是 True=masked。
之前的代码 (mask_slice == 0) 在 mask==0(不可见)时返回 True,导致 PyTorch 将不可见位置当作可见——mask 完全反了。
已在 commit ef29929 中修复:改为 mask_slice.bool(),True where mask==1(可见),与 PyTorch 语义一致。
| q_len_r = per_req_query_redudant.shape[1] | ||
| mask_slice = cross_attention_custom_mask[ | ||
| mask_offset : mask_offset + q_len_r * kv_len | ||
| ].reshape(q_len_r, kv_len) |
There was a problem hiding this comment.
这里读取 mask 的 query 长度,和之前生成 mask 时使用的长度似乎不一致,麻烦检查一下有缓存文本前缀的情况,是否应该让 cross-attention 直接使用本轮新增的 query,并按 extend_seq_len × encoder_len 读取 mask、推进 mask_offset,建议补一个带视觉输入、至少经过两轮 prefill 的测试,也检查多请求 batch 的 mask 偏移是否正确。
There was a problem hiding this comment.
感谢指出!确实是 bug,已在 commit f00242c 中修复。
问题根因:mask 构建时用 q_len = extend_seq_len(仅新 token),但消费时用 per_req_query_redudant.shape[1] = seq_len_kv(含缓存前缀的全长)。当 extend_prefix_len > 0 时两者不一致,导致读取过多 mask 数据并偏移 batch 中后续请求的 mask_offset。
修复方式:将 cross-attention 从 self-attention 路径中分离出来,不再复用 padded query(per_req_query_redudant),而是直接使用 per_req_query(extend_seq_len 个 token)+ per_req_key/per_req_value(encoder_len 个 token),mask 形状 [extend_seq_len, encoder_len] 与 query/key 1:1 匹配,无需 padding。
这与 FlashInfer 后端的 cross-attention 处理方式一致:query = 新 token,KV = encoder token,无前缀 padding。
…ch merge Two code quality fixes from review of PR OpenMOSS#15 commits: 1. mrope.py forward_npu: align with forward_cuda logic - Before: blindly calls forward_triton for ALL npu inputs - Problem: forward_triton asserts self.mrope_section, crashes for text-only requests (mrope_section is None) - Fix: if positions.ndim == 2 and self.mrope_section → triton, else → forward_native (mirrors forward_cuda exactly) 2. ascend_torch_native_backend.py: merge duplicated sdpa branches - Before: if/else with near-identical scaled_dot_product_attention calls (only difference: attn_mask param) - Fix: single call with attn_mask=None when no custom mask, is_causal=causal only when attn_mask is None
…q_len_kv Fix per SSSSuperC's review comment on PR OpenMOSS#15: The mask was built with q_len = extend_seq_len (new tokens only), but the consumption code used per_req_query_redudant.shape[1] which equals seq_len_kv (full length including cached prefix). When extend_prefix_len > 0 (multi-prefill-chunk or radix cache hit), this reads too much mask data and shifts the mask_offset for subsequent requests in the batch. Fix: - Read q_len_r from extend_seq_lens[seq_idx] (matches mask build) - Pad the attn_mask to seq_len_kv: prefix rows = all-visible (True), new-token rows = mask_slice. This aligns with per_req_query_redudant which places real query data at [prefill_seq_len_q:prefill_seq_len_q +extend_seq_len].
Separate cross-attention from self-attention in run_sdpa_forward_extend: Before: cross-attention reused the self-attention path which pads query to seq_len_kv (full length including cached prefix). This required padding the attn_mask to match, and was the root cause of SSSSuperC's review comment about query length mismatch. After: cross-attention uses per_req_query directly (extend_seq_len tokens) with per_req_key/value (encoder_len tokens), no padding. The mask [extend_seq_len × encoder_len] maps 1:1 to the query/key shapes. Self-attention path is unchanged. This aligns with the FlashInfer backend's cross-attention handling where query = new tokens, KV = encoder tokens, no prefix padding.
Fix per SSSSuperC's review on PR OpenMOSS#15: PyTorch scaled_dot_product_attention boolean attn_mask: True = visible (should take part in attention) False = masked (filled with -inf) Previous code: (mask_slice == 0) → True where mask==0 (masked) → PyTorch treated masked positions as VISIBLE (inverted!) Fixed code: mask_slice.bool() → True where mask==1 (visible) → Correct: only visible positions take part in attention This bug meant the cross-attention mask was completely ineffective — masked frames were visible and visible frames were masked.
概述
为 MOSS-VL 增加华为昇腾(Ascend)NPU 推理支持。包含两条路径:
所有改动均以 NPU 门控,非 NPU 环境走原有代码路径。
改动内容(15 文件,+310/-22)
新增文件
device_utils.pyrequirements-npu.txtpyproject.toml零改动(CUDA 依赖原样)独立推理脚本(上游入口的 NPU 适配,各 ~7 行)
inference/run_inference.py、realtime_inference/run_online_inference.py:attn_implementation从硬编码flash_attention_2(CUDA-only)改为经device_utils.resolve_attn_impl()解析(NPU →eager,CUDA → 原值不变)--attention-backend默认值flash_attention_2→autosglang 修复(9 文件)
server_args.pydevice != 'npu';NPU 上 radix cache 自动禁用(昇腾 paged-attention KV 布局敏感,temperature=0 下解码有 ~20% 非确定性失败)models/moss_vl.pymodels/qwen3.py+transcription_adapters/__init__.pyqwen3.py顶层硬 importsgl_kernel_npu.norm.split_qkv_rmsnorm_rope,当环境的 triton-ascend 缺triton.language.extra.cann.extension时整个 sglang 启动崩溃(跑 MossVL 也一样炸)。降级为 try/except graceful fallback——Qwen3 缺内核时退回慢路径,其它模型不再受牵连。任何 sgl_kernel_npu 安装不完整的环境都会撞上此问题ascend_backend.pyprofile_utils.pytorch_npu._apply_all_patches()加 hasattr 守卫(torch-npu 2.10+ API 改名兼容)schedule_batch.pyscheduler.py/scheduler_profiler_mixin.py/disaggregation/decode.py/dllm/mixin/scheduler.py使用方式
sglang serving(NPU)
独立推理(NPU,无 sglang 依赖)
验证
inference/run_inference.py)双后端加载/推理通过device_utils.is_npu()/device != 'npu'门控,非 NPU 环境走原有代码路径兼容性说明
pyproject.toml无改动,NPU 依赖独立在requirements-npu.txtrequirements-npu.txt,按需安装