π Describe the bug
Summary
The Vulkan AOT fuses x * rsqrt(mean(xΒ²) + eps) into et_vk.rms_norm and folds
the multiply that follows the norm in as that norm's weight β without
checking that the multiplier is a constant it can prepack. When it is not, the
delegate aborts at the first inference:
prepack_standard at backends/vulkan/runtime/graph/ops/impl/Staging.cpp:229:
(graph.val_is_tref(tensor_data)) is false!
This makes every adaptive normalisation unlowerable, and also breaks Gemma's
ordinary RMSNorm, whose scale is written 1.0 + weight.
Reproduction
No model needed. Three cases; the only difference is what the norm is multiplied
by.
import torch
from executorch.exir import to_edge
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
from executorch.extension.pybindings.portable_lib import _load_for_executorch
D, EPS = 64, 1e-6
def norm(x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + EPS)
class TimesParam(torch.nn.Module): # OK
def __init__(self):
super().__init__()
self.w = torch.nn.Parameter(torch.ones(D))
def forward(self, x):
return norm(x) * self.w
class TimesComputedConst(torch.nn.Module): # ASSERTS (Gemma's RMSNorm)
def __init__(self):
super().__init__()
self.w = torch.nn.Parameter(torch.zeros(D))
def forward(self, x):
return norm(x) * (1.0 + self.w)
class TimesRuntime(torch.nn.Module): # ASSERTS (adaptive RMS norm)
def forward(self, x, scale):
return norm(x) * (1 + scale)
def run(mod, args, tag):
low = to_edge(torch.export.export(mod, args)).to_backend(VulkanPartitioner())
path = f"/tmp/{tag}.pte"
with open(path, "wb") as f:
low.to_executorch().write_to_file(f)
out = _load_for_executorch(path).forward(args)[0]
print(tag, "ok, max|d| =", (out - mod(*args)).abs().max().item())
x = torch.randn(1, 8, D)
run(TimesParam(), (x,), "times_param")
run(TimesComputedConst(), (x,), "times_computed_const")
run(TimesRuntime(), (x, torch.rand(1, 1, D) * 0.1), "times_runtime")
Result
| case |
multiplier |
outcome |
norm(x) * self.w |
a leaf parameter |
runs, max|d| 2.4e-07 |
norm(x) * (1.0 + w) |
constant, but an intermediate |
asserts in prepack_standard |
norm(x) * scale |
computed at inference |
asserts in prepack_standard |
So it is not "constant vs non-constant" that decides it β it is whether the
multiplier is a leaf the prepacker can see. 1.0 + w is constant-valued and
still fails.
Expected behaviour
The fusion should fold the trailing multiply into et_vk.rms_norm only when the
multiplier is prepackable, and otherwise leave it as a separate elementwise
multiply. Both graphs are legal; only one is fusable.
Suggested fix
In the rms-norm pattern (backends/vulkan/patterns/rms_norm.py), guard the fold
on the multiplier being a constant/leaf β the same val_is_tref-style
predicate the runtime later asserts on. Falling back to an unfused norm plus a
separate mul is correct and costs one extra dispatch.
Impact
Adaptive normalisation β a norm whose scale and shift are produced at inference
from a conditioning signal β appears in:
- Οβ.β
/ openpi (
PiGemmaRMSNorm, scale derived from the flow-matching
timestep)
- DiT and most diffusion transformers (AdaLN, AdaLN-Zero)
- any Gemma-family model, via the ordinary
1.0 + weight form
None of these can be lowered to the Vulkan delegate without rewriting the model.
Note on the obvious workaround
Rewriting the norm as F.rms_norm(x, ones) avoids the assert but stops the
fusion firing, and the + eps then survives as an aten.add.Scalar that the
partitioner leaves on the CPU β one graph break per norm, 35 of them in the
model that prompted this report. A workaround has to keep the fusion working,
not merely stop it crashing, which is why the guard belongs in the pattern.
Versions
collect_env.py output
Collecting environment information...
PyTorch version: 2.12.1+cpu
Is debug build: False
CUDA used to build PyTorch: None
ROCm SDK used to build PyTorch: N/A
HIP used to build PyTorch: N/A
OS: Ubuntu 24.04.4 LTS (x86_64)
GCC version: Could not collect
Clang version: Could not collect
CMake version: Could not collect
Libc version: glibc-2.39
Python version: 3.12.3 (main, Jul 15 2026, 23:46:41) [GCC 13.3.0] (64-bit runtime)
Python platform: Linux-7.0.0-30-generic-x86_64-with-glibc2.39
Is CUDA available: False
CUDA runtime version: No CUDA
CUDA_MODULE_LOADING set to: N/A
GPU models and configuration: No CUDA
Nvidia driver version: No CUDA
cuDNN version: No CUDA
Is XPU available: False
HIP runtime version: N/A
MIOpen runtime version: N/A
Is XNNPACK available: True
Caching allocator config: N/A
CPU: AMD RYZEN AI MAX+ 395 w/ Radeon 8060S, 16 cores / 32 threads (full lscpu elided)
Versions of relevant libraries:
[pip3] executorch==1.4.0a0+b20f16a
[pip3] numpy==2.4.6
[pip3] pytorch_tokenizers==1.4.1
[pip3] torch==2.12.1+cpu
[pip3] torchao==0.18.0.dev20260715+cpu
[conda] Could not collect
π Describe the bug
Summary
The Vulkan AOT fuses
x * rsqrt(mean(xΒ²) + eps)intoet_vk.rms_normand foldsthe multiply that follows the norm in as that norm's weight β without
checking that the multiplier is a constant it can prepack. When it is not, the
delegate aborts at the first inference:
This makes every adaptive normalisation unlowerable, and also breaks Gemma's
ordinary RMSNorm, whose scale is written
1.0 + weight.Reproduction
No model needed. Three cases; the only difference is what the norm is multiplied
by.
Result
norm(x) * self.wnorm(x) * (1.0 + w)prepack_standardnorm(x) * scaleprepack_standardSo it is not "constant vs non-constant" that decides it β it is whether the
multiplier is a leaf the prepacker can see.
1.0 + wis constant-valued andstill fails.
Expected behaviour
The fusion should fold the trailing multiply into
et_vk.rms_normonly when themultiplier is prepackable, and otherwise leave it as a separate elementwise
multiply. Both graphs are legal; only one is fusable.
Suggested fix
In the rms-norm pattern (
backends/vulkan/patterns/rms_norm.py), guard the foldon the multiplier being a constant/leaf β the same
val_is_tref-stylepredicate the runtime later asserts on. Falling back to an unfused norm plus a
separate
mulis correct and costs one extra dispatch.Impact
Adaptive normalisation β a norm whose scale and shift are produced at inference
from a conditioning signal β appears in:
PiGemmaRMSNorm, scale derived from the flow-matchingtimestep)
1.0 + weightformNone of these can be lowered to the Vulkan delegate without rewriting the model.
Note on the obvious workaround
Rewriting the norm as
F.rms_norm(x, ones)avoids the assert but stops thefusion firing, and the
+ epsthen survives as anaten.add.Scalarthat thepartitioner leaves on the CPU β one graph break per norm, 35 of them in the
model that prompted this report. A workaround has to keep the fusion working,
not merely stop it crashing, which is why the guard belongs in the pattern.
Versions
collect_env.py output