Environment
- TVM:
apache-tvm==0.25.0.post1 (pip wheel)
- OS: macOS 15.5, arm64 (Apple Silicon, 12-core Apple Silicon Mac)
- Python: 3.13
- Backends compiled into the wheel: LLVM (host), Metal. Vulkan and
CUDA codegen absent (verified — tvm.compile(mod, Target("vulkan"))
raises Cannot find global function target.build.vulkan).
Expected behaviour
Compiling the same Relax IR to Target("llvm", ...) and
Target("metal", host="llvm") and executing under the Relax VM with
identical inputs should produce numerically equivalent outputs within
float32 rounding tolerance.
Actual behaviour
LLVM produces the expected output. Metal produces output that is
uncorrelated with LLVM:
cosine(LLVM, Metal) = 0.755631
max_abs_delta = 4.0493e+00
RMS(Metal - LLVM) = 76% of RMS(LLVM)
The bug is deterministic across fresh Python subprocesses — a
single python3 repro.py invocation reproduces byte-exact-identical
wrong Metal output every time (verified across 8 consecutive fresh
subprocesses, all producing identical SHA-256 of the output tensor).
Reproducer
Repo: https://github.com/zacharywhitley/tvm-metal-codegen-bug-repro
Contents (all with published SHA-256 in the repo README):
minimum_repro_synthetic.onnx (~3.4 MB, 174 nodes, opset 17) —
a 174-node HiFi-GAN-style Relax subgraph. All float initializers
are i.i.d. N(0, 0.1) samples with a fixed seed. Graph topology,
node names, initializer names/dtypes/shapes, and value_info are
exactly those of a HiFi-GAN-style ONNX decoder subgraph extracted
with onnx.utils.extract_model; only float weight values are
synthetic.
expected_output_synthetic.f32 (589 824 bytes) — LLVM oracle output
for the deterministic input, raw fp32 little-endian, shape
[1, 144, 1024].
expected_output_synthetic.npy — same, NumPy format.
repro.py — self-contained driver.
To reproduce:
git clone https://github.com/zacharywhitley/tvm-metal-codegen-bug-repro
cd tvm-metal-codegen-bug-repro
pip install "apache-tvm==0.25.0.post1" "onnx>=1.16"
python3 repro.py
Op inventory (post-simplification, from onnx.load):
- 49× Slice
- 36× Mul
- 34× Conv (1-D)
- 19× Add
- 13× Tanh
- 13× Sigmoid
- 4× Split
- 3× Sub
- 3× Concat
- (174 total)
No ConvTranspose in this minimum subgraph — an important isolation
point since we previously suspected the Metal ConvTranspose lowering
(see "Isolation performed" below).
Reproduction
import numpy as np, onnx, tvm
from tvm import relax
from tvm.relax.frontend.onnx import from_onnx
model = onnx.load("minimum_repro_synthetic.onnx")
mod = from_onnx(model, keep_params_in_input=False)
# Deterministic inputs
ch = np.arange(128, dtype=np.float32).reshape(-1, 1)
f = np.arange(1024, dtype=np.float32).reshape(1, -1)
z_p = (np.sin(2 * np.pi * (ch * 3 + f) / 128) * 0.5).reshape(1, 128, 1024).astype("float32")
y_mask = np.ones((1, 1, 1024), dtype=np.float32)
def run(target, dev):
ex = tvm.compile(mod, target)
vm = relax.VirtualMachine(ex, dev)
out = vm["main"](tvm.runtime.tensor(z_p, dev),
tvm.runtime.tensor(y_mask, dev))
dev.sync()
return (out.numpy() if hasattr(out, "numpy") else out[0].numpy()).ravel()
wf_llvm = run(tvm.target.Target({"kind":"llvm","mtriple":"arm64-apple-darwin"}),
tvm.runtime.cpu())
wf_metal = run(tvm.target.Target({"kind":"metal"},
host={"kind":"llvm","mtriple":"arm64-apple-darwin"}),
tvm.metal(0))
def cos(a, b):
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
print("LLVM vs METAL cosine:", cos(wf_llvm, wf_metal))
# Expected: ~1.0. Observed: 0.756.
Recommend running LLVM and Metal in separate Python subprocesses —
see "Additional observations" for why. The attached repro.py
handles this automatically (default --fresh-subprocess mode) and
prints a correctness table.
Isolation performed
-
Not a single-op ConvTranspose1D bug. A hand-written Relax
module with a single nn.conv1d_transpose at the same shape the
parent decoder uses ([1,128,1024] × [128,64,16] stride=8 pad=8)
produces byte-exact LLVM ↔ Metal agreement (cosine=1.0000000,
max_abs_delta=0.0e+00).
-
Not the first three flow blocks. The exact same op pattern
(WaveNet-like Conv → gated (tanh × sigmoid) → Conv) is stacked
three times in the prefix of this graph without triggering the
divergence. Only when the fourth flow block's first in_layers.1
Conv is added does Metal diverge from LLVM. Bisection table:
| Anchor node index |
Nodes |
Fresh-proc cosine |
In-process cosine |
| flow.6/post/Conv (1 flow block) |
49 |
1.000000 |
1.000000 |
| flow.4/post/Conv (2 blocks) |
102 |
– |
1.000000 |
| flow.2/post/Conv (3 blocks) |
155 |
– |
1.000000 |
| flow.0/pre/Conv (4 blocks, pre only) |
162 |
– |
1.000000 |
| flow.0/enc/in_layers.0/Conv |
164 |
– |
1.000000 |
| flow.0/enc/res_skip_layers.0/Conv |
170 |
1.000000 |
0.905 (in-proc, run ≥ 2) |
| flow.0/enc/Slice_1_output_0 |
171 |
1.000000 |
0.914 |
| flow.0/enc/Add_output_0 |
172 |
1.000000 |
1.000000 |
| flow.0/enc/Mul_output_0 (mask × add) |
173 |
1.000000 |
0.832 |
| flow.0/enc/in_layers.1/Conv (this repro) |
174 |
~0.80 |
~0.80 |
| flow.0/enc/res_skip_layers.1/Conv |
180 |
0.794 |
0.706 |
| flow.0/post/Conv (4 blocks) |
208 |
0.645 |
0.645 |
| /dec/conv_pre/Conv |
215 |
0.648 |
0.648 |
| /dec/ups.0/ConvTranspose |
217 |
0.046 |
0.046 |
| waveform (full 410-node decoder) |
410 |
0.05 – 0.11 |
0.01 – 0.11 |
Cosine numbers in the bisection table are from the original trained
weights. With synthetic N(0, 0.1) weights the divergence pattern
is qualitatively the same (correct through node 170; wrong at node
174) with slightly different magnitudes.
Additional observations (in-process state carryover)
A related pattern surfaced during bisection that may narrow the
search space:
- On a strictly smaller subgraph (170 nodes; extracted at
/flow/flows.0/enc/res_skip_layers.0/Conv_output_0), a fresh
Python subprocess produces cosine=1.0000000 on the first Metal
compile — correct.
- Running Metal a second time on the same graph within the same
process produces cosine=0.905 — deterministically wrong, and
byte-exact-identical across further in-process runs.
- The 174-node graph in this reproducer produces
cosine=0.756
(synthetic weights) or cosine=0.802 (trained weights) on the
first Metal compile in a fresh process.
Reading: at least one component of the failure appears to be state
that persists across tvm.compile(mod, Target("metal")) invocations
within a single process (compilation cache, device kernel cache, or a
static-scoped IR mutation). A large-enough graph triggers the bug
cold; a smaller graph only triggers it once that state has been
perturbed by a prior Metal compile.
The attached repro.py --in-process toggles this mode for
comparison.
Ask
- Is this a known-fixed issue in
main / 0.26?
- Which Relax/TIR pass is the most likely culprit given the minimum
reproducer's op profile (Conv + Slice + gated activation chain)
and the in-process state-carryover observation? A pointer to
which pass to bisect against would let me narrow further.
- Is the compilation-cache / device-kernel-cache lifecycle expected
to be process-scoped and safely mutable across tvm.compile(...)
calls?
Happy to reduce further if pointed at a specific pass or op class,
share intermediate outputs at any bisection anchor, or run against a
tvm build with candidate patches.
Environment
apache-tvm==0.25.0.post1(pip wheel)CUDA codegen absent (verified —
tvm.compile(mod, Target("vulkan"))raises
Cannot find global function target.build.vulkan).Expected behaviour
Compiling the same Relax IR to
Target("llvm", ...)andTarget("metal", host="llvm")and executing under the Relax VM withidentical inputs should produce numerically equivalent outputs within
float32rounding tolerance.Actual behaviour
LLVM produces the expected output. Metal produces output that is
uncorrelated with LLVM:
cosine(LLVM, Metal) = 0.755631max_abs_delta = 4.0493e+00RMS(Metal - LLVM) = 76% of RMS(LLVM)The bug is deterministic across fresh Python subprocesses — a
single
python3 repro.pyinvocation reproduces byte-exact-identicalwrong Metal output every time (verified across 8 consecutive fresh
subprocesses, all producing identical SHA-256 of the output tensor).
Reproducer
Repo: https://github.com/zacharywhitley/tvm-metal-codegen-bug-repro
Contents (all with published SHA-256 in the repo README):
minimum_repro_synthetic.onnx(~3.4 MB, 174 nodes, opset 17) —a 174-node HiFi-GAN-style Relax subgraph. All float initializers
are i.i.d.
N(0, 0.1)samples with a fixed seed. Graph topology,node names, initializer names/dtypes/shapes, and value_info are
exactly those of a HiFi-GAN-style ONNX decoder subgraph extracted
with
onnx.utils.extract_model; only float weight values aresynthetic.
expected_output_synthetic.f32(589 824 bytes) — LLVM oracle outputfor the deterministic input, raw fp32 little-endian, shape
[1, 144, 1024].expected_output_synthetic.npy— same, NumPy format.repro.py— self-contained driver.To reproduce:
Op inventory (post-simplification, from
onnx.load):No
ConvTransposein this minimum subgraph — an important isolationpoint since we previously suspected the Metal ConvTranspose lowering
(see "Isolation performed" below).
Reproduction
Recommend running LLVM and Metal in separate Python subprocesses —
see "Additional observations" for why. The attached
repro.pyhandles this automatically (default
--fresh-subprocessmode) andprints a correctness table.
Isolation performed
Not a single-op ConvTranspose1D bug. A hand-written Relax
module with a single
nn.conv1d_transposeat the same shape theparent decoder uses (
[1,128,1024] × [128,64,16] stride=8 pad=8)produces byte-exact LLVM ↔ Metal agreement (
cosine=1.0000000,max_abs_delta=0.0e+00).Not the first three flow blocks. The exact same op pattern
(WaveNet-like
Conv → gated (tanh × sigmoid) → Conv) is stackedthree times in the prefix of this graph without triggering the
divergence. Only when the fourth flow block's first
in_layers.1Conv is added does Metal diverge from LLVM. Bisection table:
Cosine numbers in the bisection table are from the original trained
weights. With synthetic
N(0, 0.1)weights the divergence patternis qualitatively the same (correct through node 170; wrong at node
174) with slightly different magnitudes.
Additional observations (in-process state carryover)
A related pattern surfaced during bisection that may narrow the
search space:
/flow/flows.0/enc/res_skip_layers.0/Conv_output_0), a freshPython subprocess produces
cosine=1.0000000on the first Metalcompile — correct.
process produces
cosine=0.905— deterministically wrong, andbyte-exact-identical across further in-process runs.
cosine=0.756(synthetic weights) or
cosine=0.802(trained weights) on thefirst Metal compile in a fresh process.
Reading: at least one component of the failure appears to be state
that persists across
tvm.compile(mod, Target("metal"))invocationswithin a single process (compilation cache, device kernel cache, or a
static-scoped IR mutation). A large-enough graph triggers the bug
cold; a smaller graph only triggers it once that state has been
perturbed by a prior Metal compile.
The attached
repro.py --in-processtoggles this mode forcomparison.
Ask
main/ 0.26?reproducer's op profile (Conv + Slice + gated activation chain)
and the in-process state-carryover observation? A pointer to
which pass to bisect against would let me narrow further.
to be process-scoped and safely mutable across
tvm.compile(...)calls?
Happy to reduce further if pointed at a specific pass or op class,
share intermediate outputs at any bisection anchor, or run against a
tvmbuild with candidate patches.