Wrong FP16 results when a fused convolution, activation and residual add reads a strided concat view
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 13.4k
- Forks
- 2.4k
- Avg merge
- 5d 3h
- Merged PRs (30d)
- 2
Description
Description
On TensorRT 11.2.1.2 a fused FP16 region of the form convolution, activation, residual add returns wrong values when
the add operand is a non dense view of a later concatenation buffer. The fused layer reads that operand as if it were
dense, so it picks up the wrong elements.
The error is large and silent. The engine builds without a warning and inference returns plausible numbers. In our
YOLO26 model it turned a correct activation of -0.46 into 7.73 and dropped COCO mAP50 from 0.701 to 0.458.
Environment
| Item | Value |
|---|---|
| TensorRT | 11.2.1.2 |
| GPU | NVIDIA RTX PRO 6000 Blackwell Server Edition |
| Driver | 595.84 |
| CUDA | 13.2 |
| Torch | 2.11.0+cu128 |
| ONNX opset | 18 |
| OS | Ubuntu, container ultralytics/ultralytics:latest |
Reproducer
The script builds its own graph with random weights. It needs no model file and no framework beyond torch for the
export. The graph splits a tensor in two, runs a bottleneck with a shortcut on the second half, and later
concatenates three 256 channel tensors, one of which is that same shortcut.
import sys
import numpy as np
import onnxruntime as ort
import tensorrt as trt
import torch
import torch.nn as nn
LEVEL = int(sys.argv[1]) if len(sys.argv) > 1 else 3
C = 256
class Act(nn.Module):
def forward(self, x):
return x * (0.5 * torch.tanh(0.5 * x) + 0.5)
class Repro(nn.Module):
def __init__(self):
super().__init__()
self.cv1 = nn.Conv2d(C, 2 * C, 1)
self.b1 = nn.Conv2d(C, C // 2, 3, padding=1)
self.b2 = nn.Conv2d(C // 2, C, 3, padding=1)
self.cv2 = nn.Conv2d(3 * C, C, 1)
self.post = nn.Sequential(nn.Conv2d(C, C, 1), Act(), nn.Conv2d(C, C, 1))
self.act = Act()
def forward(self, x):
a, b = self.cv1(x).split((C, C), 1)
o = self.act(self.b2(self.act(self.b1(b)))) + b # b feeds both the add and the concat below
o = self.post(o) # keeps b alive so it is still needed at the concat
return self.cv2(torch.cat((a, b, o), 1))
torch.manual_seed(0)
model = Repro().eval().cuda().half()
x = (torch.randn(1, C, 20, 20) * 3).cuda().half()
torch.onnx.export(model, x, "repro.onnx", input_names=["i"], output_names=["o"], opset_version=18)
logger = trt.Logger(trt.Logger.ERROR)
builder = trt.Builder(logger)
cfg = builder.create_builder_config()
cfg.builder_optimization_level = LEVEL
cfg.profiling_verbosity = trt.ProfilingVerbosity.DETAILED
net = builder.create_network(0)
assert trt.OnnxParser(net, logger).parse_from_file("repro.onnx")
eng = trt.Runtime(logger).deserialize_cuda_engine(builder.build_serialized_network(net, cfg))
ctx = eng.create_execution_context()
bufs = []
for i in range(eng.num_io_tensors):
nm = eng.get_tensor_name(i)
dt = trt.nptype(eng.get_tensor_dtype(nm))
t = torch.zeros(tuple(ctx.get_tensor_shape(nm)), dtype=getattr(torch, np.dtype(dt).name), device="cuda")
ctx.set_tensor_address(nm, t.data_ptr())
bufs.append(t)
bufs[0].copy_(x)
ctx.execute_v2([b.data_ptr() for b in bufs])
torch.cuda.synchronize()
sess = ort.InferenceSession("repro.onnx", providers=["CPUExecutionProvider"])
ref = sess.run(None, {"i": x.cpu().numpy()})[0].astype("float32")
print(f"level {LEVEL}: max|TRT-ORT| = {np.abs(bufs[-1].float().cpu().numpy() - ref).max():.6f}")
import json
info = json.loads(eng.create_engine_inspector().get_engine_information(trt.LayerInformationFormat.JSON))
for layer in info["Layers"]:
if "Add" in layer.get("Name", "") and "Corr" in layer.get("Name", ""):
print("fused layer:", layer["Name"])
for t in layer.get("Inputs", []):
print(f" input shape={t['Dimensions']} strides={t['Strides']}")
Run it at two builder optimization levels:
python repro.py 1
python repro.py 3
Result
| Builder optimization level | Fused layer selected | max abs error vs ONNX Runtime |
|---|---|---|
| 0 | no | 0.007812 |
| 1 | no | 0.009766 |
| 2 | yes | 0.701416 |
| 3 | yes | 0.700928 |
| 4 | yes | 0.701416 |
| 5 | yes | 0.700928 |
Levels 2 and above select the fused layer and the error grows by about 70 times. The engine inspector shows the
operand layout:
fused layer: __myl_CorrMulTanhMulAddMulAdd_myl0_6
input shape=[1, 128, 20, 20] strides=[51200, 1, 2560, 128]
input shape=[1, 256, 20, 20] strides=[307200, 1, 15360, 768]
The second input is the residual operand. Its logical shape is 256 channels, but it aliases a 256 channel slice of
the 768 channel NHWC concatenation buffer that the graph builds later. Its strides are exactly three times dense in
N, H and W because 768 = 3 * 256. Dense strides for that shape are [102400, 1, 5120, 256].
Expected result
The fused layer honours the strides of its add operand, or the builder does not select that tactic when the operand
is not dense.
Further evidence from the original model
These come from the real YOLO26 layer this was reduced from, where the same fused layer appears with the tactic
sm80_xmma_fprop_implicit_gemm_f16f16_f16f16_f16_nhwckrsc_nhwc_tilesize64x32x64_stage5_warpsize2x2x1_g1_tensor16x8x16_t1r3s3_by_fusion_tactic.
Removing the shortcut, or making it dense, fixes the result while keeping the same convolution, weights, input and
precision.
| Variant | Implementation selected | max abs error |
|---|---|---|
| activation plus shortcut | CorrMulTanhMulAddMulAdd |
8.188080 |
| same graph, shortcut made dense | fused Corr... |
0.026419 |
| same graph, no shortcut | separate Conv and pointwise |
0.022666 |
| FP32 engine | convolution stays separate | 0.018910 |
The activation is not the cause. Replacing it with a plain tanh selects __myl_CorrTanhAdd and adds nearly the
same wrong offset to the same element.
| Activation before the shortcut | Reference | TensorRT | max abs error |
|---|---|---|---|
x * (0.5 * tanh(0.5 * x) + 0.5) |
-0.461518 | 7.726562 | 8.188080 |
tanh(x) |
-0.769696 | 7.421875 | 8.191571 |
Feeding eight identical images shows a period of three across the batch, matching the stride ratio. A correct kernel
must return eight identical outputs.
| Batch | max abs error | max difference from batch 0 |
|---|---|---|
| 0 | 7.341528 | 0.000000 |
| 1 | 8.095717 | 7.852905 |
| 2 | 8.188080 | 7.941528 |
| 3 | 7.341528 | 0.000000 |
| 4 | 8.095717 | 7.852905 |
| 5 | 8.188080 | 7.941528 |
| 6 | 7.341528 | 0.000000 |
| 7 | 8.095717 | 7.852905 |
Batch 0 is wrong as well, because the spatial strides carry the same factor of three.
Activation magnitude is not the trigger. The real preactivation range at that convolution is [-13.06, 9.79], and a
control with the wider range [-14.07, 15.08] on the same tactic is accurate when its residual is dense.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with repro.py and run it at builder optimization levels 1 and 3, then inspect the fused layer's input shapes and strides. Compare TensorRT's output with the ONNX Runtime reference and trace the selected fused tactic for the non-dense residual operand. Done means the fused layer honors the operand strides or is not selected, with the large error removed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- machine-learning, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100