RotaryEmbed: support full-width cos/sin cache for 2D / vision RoPE - #6834
RotaryEmbed: support full-width cos/sin cache for 2D / vision RoPE#6834Acture wants to merge 2 commits into
Conversation
The non-interleaved path read only the first embed_dim/2 values of the cos/sin cache and reused them for both halves of each head vector. That is correct for standard 1-D RoPE (cos = concat(freqs, freqs), identical halves), but the pnnx fuse_convert_rotaryembed pass lowers the general expression x*cos + rotate_half(x)*sin — with a full-width cos — into this op without checking that the halves are equal. For 2-D / vision RoPE where the halves differ (cos = concat(cos_h, cos_w), e.g. the Penguin-VL vision encoder) the second half was rotated with the wrong frequencies, silently producing wrong output (max|diff| ~3.4 vs PyTorch on the Penguin-VL vision encoder). Read the second half's cos/sin for the second output half. Backward compatible via a cache-width guard: when the cache is full width (== embed_dim) the two halves are independent; when it is embed_dim/2 (the standard-rope form, as the unit test supplies) the second half reuses the first, so existing half-width callers stay bit-identical. Applied to every backend (naive / arm / x86 / loongarch / mips / vulkan) and its bf16/fp16 paths. Adds a full-width differing-halves test case; the previous test only exercised half-width caches, which are trivially identical-halves. After the fix the Penguin-VL vision encoder converts in-graph at max|diff| = 6.2e-4 vs PyTorch (fp32). Fixes Tencent#6833
Signed. |
|
@codex review |
|
@dosu any concerns with this fix? |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
The fix looks sound overall. The cache-width guard ( The mechanical consistency across all 11 backend files (naive, ARM NEON/fp16, x86 AVX/SSE/bf16, LoongArch, MIPS, Vulkan) is good — same pattern everywhere reduces divergence risk [1]. A few minor observations (none blocking):
None of these are blockers — the fix is correct, well-tested for its scope, and backward compatible. |
… fix tests/test_rotaryembed.cpp cannot detect this bug. ncnn's test_layer() compares the optimized backends against the naive layer, and this PR changes the naive layer too, so the test stays green whether the semantics are right or wrong — rebuilding the PR's test against master's src/layer still exits 0. tools/pnnx/tests/ncnn/ is the only harness in-tree that pins semantics to an external reference (torch.allclose against PyTorch), so the check belongs there. Every input is drawn with torch.rand in forward() parameter order, because the generated test_ncnn_fuse_rotaryembed_ncnn.py takes no arguments: it reseeds with manual_seed(0) and regenerates each input the same way. An input built any other way would leave the two sides comparing different tensors, and the test would fail unconditionally while appearing to test something. Random caches are also the stronger choice here: the defect is that the layer reads one cache row for both halves, so it only shows when the halves differ, which random values guarantee and a hand-built cat(f, f) cache would hide. A "standard RoPE stays correct" case is deliberately absent — the cache must stay a pnnx.Input for the fusion pattern to match, and the generator always randomizes inputs, so that case cannot be expressed in this harness. Also parameterizes the interleaved path in tests/test_rotaryembed.cpp. On a CPU-only build that is code-path coverage rather than a correctness check, for the reason above; its value is on the Vulkan backend. Not run here: building pnnx needs libtorch. Worth confirming under ctest that the generated .param really contains RotaryEmbed layers — if the fusion does not fire, the test passes without exercising anything.
What
RotaryEmbed's non-interleaved path read only the firstembed_dim/2values of the cos/sincache and reused them for both halves of each head vector. That is correct for standard 1-D
RoPE (
cos = concat(freqs, freqs), identical halves), butfuse_convert_rotaryembedlowers thegeneral expression
x*cos + rotate_half(x)*sin— with a full-width cos — into this op withoutchecking that the halves are equal. For 2-D / vision RoPE where the two halves differ
(
cos = concat(cos_h, cos_w), e.g. the Penguin-VL / Qwen-VL-style vision encoder) the second halfwas rotated with the wrong frequencies, silently producing wrong output.
Fixes #6833.
Fix
Read the second half's cos/sin for the second output half. Backward-compatible via a
cache-width guard:
cos_cache.w == embed_dim) → the two halves are independent (2-D rope);embed_dim/2, the standard-rope form the unit test supplies) → the secondhalf reuses the first, so existing half-width callers are bit-identical.
Applied to every backend (naive / arm / x86 / loongarch / mips / vulkan) and their bf16/fp16 paths.
The vulkan shaders take the cache row width as a new push constant.
Test
tests/test_rotaryembed.cpppreviously only used a half-width cache, which is triviallyidentical-halves and never exercised the differing-halves path. Added a full-width
differing-halves case for the non-interleaved op.
Verification
test_rotaryembedandtest_rotaryembed_oompass locally on the naive + ARM backends(macOS Apple Silicon), covering both the existing half-width and the new full-width cases.
pnnx now runs in-graph at max|diff| = 6.2e-4 vs the PyTorch reference (fp32); before the fix
the same fused graph gave max|diff| = 3.39.
fused graph fed an identical-halves cache.
(no toolchain on this host) — relying on CI to validate them.