NVIDIA / NVIDIA/TensorRT

Wrong FP16 results when a fused convolution, activation and residual add reads a strided concat view

Open
#4,854 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Module:Accuracy
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.