All Products
Search
Document Center

Platform For AI:Optimize RetinaNet with Blade and custom C++ operators

Last Updated:Apr 01, 2026

RetinaNet's post-processing logic — bounding box decoding and Non-Maximum Suppression (NMS) — is often the inference bottleneck when implemented in Python. This tutorial shows how to replace that Python logic with high-performance CUDA C++ implementations using TorchScript Custom C++ Operators, export the resulting TorchScript model, and then optimize it with PAI-Blade to achieve a ~3.9x latency reduction on a T4 GPU.

Background

RetinaNet is a one-stage object detection network built on a backbone, a set of subnets, and an NMS post-processing stage. Detectron2 — the open source training framework from Facebook AI Research (FAIR) — is a common implementation. A companion tutorial covers the basic Blade optimization path for a Detectron2 RetinaNet model using scripting_with_instances. For details, see RetinaNet optimization case 1: Use Blade to optimize a RetinaNet (Detectron2) model.

This tutorial covers a more advanced scenario: replacing the Python post-processing logic with Custom C++ Operators backed by CUDA kernels, then optimizing the resulting model with Blade.

Prerequisites

Before you begin, make sure you have:

  • Linux with Python 3.6 or later, GNU Compiler Collection (GCC) 5.4 or later, an NVIDIA Tesla T4 GPU, CUDA 10.2, and cuDNN 8.0.5.39

  • PyTorch 1.8.1 or later and Detectron2 0.4.1 or later

  • Blade 3.16.0 or later

How it works

The workflow has five stages:

  1. Implement the post-processing logic as CUDA C++ kernels and compile them into a shared library (custom.so) using PyTorch's JIT extension mechanism.

  2. Load and register the compiled operators so that adapter_forward can call them in place of the Python post-processing code.

  3. Export the modified model as a TorchScript model using Detectron2's scripting_with_instances.

  4. Optimize the TorchScript model with blade.optimize (O1 level, FP16 on GPU).

  5. Deploy the optimized model — it remains a TorchScript model, so no environment changes are needed.

Important

Load the compiled .so before calling blade.optimize. Without it, Blade cannot trace through the custom operators, and optimization will either fail or produce incorrect results. Load the library in every process that loads or runs the model.

Step 1: Create a PyTorch model with custom C++ operators

This step uses PyTorch's TorchScript extension mechanism to replace RetinaNet's Python post-processing with CUDA kernels. The kernel implementations in this tutorial are adapted from the NVIDIA retinanet-examples open source project. For the full TorchScript Custom Operators reference, see Extending TorchScript with custom C++ operators.

1. Download the sample code

wget -nv https://pai-blade.oss-cn-zhangjiakou.aliyuncs.com/tutorials/retinanet_example/retinanet-examples.tar.gz -O retinanet-examples.tar.gz
tar xvfz retinanet-examples.tar.gz 1>/dev/null

2. Compile the custom C++ operators

PyTorch supports three build methods for custom operators: CMake, Just-in-Time (JIT) compilation, and Setuptools. The following table summarizes when to use each:

MethodBest forTrade-off
JIT compilationQuick iteration and developmentCompiles at import time; not suitable for pip distribution
CMakeProduction builds with strict dependency controlMore setup required; output is a standalone .so
Setuptoolspip-installable packagesRequires a setup.py; best for sharing operators as packages

This tutorial uses JIT compilation. The following code compiles decode.cu and nms.cu into custom.so inside the retinanet-examples folder:

import torch.utils.cpp_extension
import os

codebase = "retinanet-examples"
sources = [
    'csrc/extensions.cpp',
    'csrc/cuda/decode.cu',
    'csrc/cuda/nms.cu',
]
sources = [os.path.join(codebase, src) for src in sources]

torch.utils.cpp_extension.load(
    name="custom",
    sources=sources,
    build_directory=codebase,
    extra_include_paths=[
        '/usr/local/TensorRT/include/',
        '/usr/local/cuda/include/',
        '/usr/local/cuda/include/thrust/system/cuda/detail',
    ],
    extra_cflags=['-std=c++14', '-O2', '-Wall'],
    extra_cuda_cflags=[
        '-std=c++14', '--expt-extended-lambda',
        '--use_fast_math', '-Xcompiler', '-Wall,-fno-gnu-unique',
        '-gencode=arch=compute_75,code=sm_75',
    ],
    is_python_module=False,
    with_cuda=True,
    verbose=False,
)

After this runs, custom.so is saved in retinanet-examples/.

3. Replace the Python post-processing with custom operators

The two compiled operators are:

  • torch.ops.retinanet.decode — decodes anchor boxes and class scores

  • torch.ops.retinanet.nms — applies NMS and returns final detections

The adapter_forward function below uses these operators in place of RetinaNet's original Python post-processing. It is then patched onto RetinaNet.forward:

import os
import torch
from typing import Tuple, Dict, List, Optional

codebase = "retinanet-examples"
torch.ops.load_library(os.path.join(codebase, 'custom.so'))

decode_cuda = torch.ops.retinanet.decode
nms_cuda = torch.ops.retinanet.nms

# Same as RetinaNet.forward, but uses decode_cuda and nms_cuda for post-processing.
def adapter_forward(self, batched_inputs: Tuple[Dict[str, torch.Tensor]]):
    images = self.preprocess_image(batched_inputs)
    features = self.backbone(images.tensor)
    features = [features[f] for f in self.head_in_features]
    cls_heads, box_heads = self.head(features)
    cls_heads = [cls.sigmoid() for cls in cls_heads]
    box_heads = [b.contiguous() for b in box_heads]

    # Decode anchor boxes and scores per feature level.
    strides = [images.tensor.shape[-1] // cls_head.shape[-1] for cls_head in cls_heads]
    decoded = [
        decode_cuda(
            cls_head,
            box_head,
            anchor.view(-1),
            stride,
            self.test_score_thresh,
            self.test_topk_candidates,
        )
        for stride, cls_head, box_head, anchor in zip(
            strides, cls_heads, box_heads, self.cell_anchors
        )
    ]

    # Concatenate across feature levels, then apply NMS.
    decoded = [torch.cat(tensors, 1) for tensors in zip(decoded[0], decoded[1], decoded[2])]
    return nms_cuda(decoded[0], decoded[1], decoded[2], self.test_nms_thresh, self.max_detections_per_image)

from detectron2.modeling.meta_arch import retinanet

# Patch the model class with the custom-operator-based forward function.
retinanet.RetinaNet.forward = adapter_forward

Step 2: Export a TorchScript model

Detectron2's flexible architecture makes standard TorchScript export methods unreliable. Detectron2 provides two supported export paths: TracingAdapter and scripting_with_instances. Blade supports TorchScript models from either method. For full details, see Detectron2 deployment usage.

The following example uses scripting_with_instances and verifies that outputs match before and after export:

import torch
import numpy as np

from torch import Tensor
from torch.testing import assert_allclose

from detectron2 import model_zoo
from detectron2.export import scripting_with_instances
from detectron2.structures import Boxes
from detectron2.data.detection_utils import read_image

def load_retinanet(config_path):
    model = model_zoo.get(config_path, trained=True).eval()
    # Set cell_anchors as a plain tensor attribute for TorchScript compatibility.
    model.cell_anchors = [c.contiguous() for c in model.anchor_generator.cell_anchors]
    fields = {
        "pred_boxes": Boxes,
        "scores": Tensor,
        "pred_classes": Tensor,
    }
    script_model = scripting_with_instances(model, fields)
    return model, script_model

# Download a sample image.
!wget http://images.cocodataset.org/val2017/000000439715.jpg -q -O input.jpg
img = read_image('./input.jpg')
img = torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1)))

# Export and verify outputs are equivalent.
pytorch_model, script_model = load_retinanet("COCO-Detection/retinanet_R_50_FPN_3x.yaml")
with torch.no_grad():
    batched_inputs = [{"image": img.float()}]
    pred1 = pytorch_model(batched_inputs)
    pred2 = script_model(batched_inputs)

assert_allclose(pred1[0], pred2[0])

Step 3: Optimize with Blade

1. Run the optimization

Call blade.optimize on the exported TorchScript model. Load the custom operator library first — Blade needs to trace through it during optimization.

import os
import blade
import torch

# Load the custom operator library before optimization.
codebase = "retinanet-examples"
torch.ops.load_library(os.path.join(codebase, 'custom.so'))

blade_config = blade.Config()
blade_config.gpu_config.disable_fp16_accuracy_check = True

test_data = [(batched_inputs,)]  # PyTorch input is a list of tuples.

with blade_config:
    optimized_model, opt_spec, report = blade.optimize(
        script_model,       # TorchScript model from Step 2.
        'o1',               # O1 optimization level.
        device_type='gpu',  # Target device.
        test_data=test_data,
    )

For a full description of blade.optimize parameters, see Optimize a PyTorch model.

2. Print the report and save the model

# Print the optimization report.
print("Report: {}".format(report))

# Save both models for later comparison.
torch.jit.save(script_model, 'script_model.pt')
torch.jit.save(optimized_model, 'optimized.pt')

The optimization report shows which passes were applied and their impact:

{
  "software_context": [
    {"software": "pytorch", "version": "1.8.1+cu102"},
    {"software": "cuda", "version": "10.2.0"}
  ],
  "hardware_context": {"device_type": "gpu", "microarchitecture": "T4"},
  "user_config": "",
  "diagnosis": {
    "model": "unnamed.pt",
    "test_data_source": "user provided",
    "shape_variation": "undefined",
    "message": "Unable to deduce model inputs information (data type, shape, value range, etc.)",
    "test_data_info": "0 shape: (3, 480, 640) data type: float32"
  },
  "optimizations": [
    {
      "name": "PtTrtPassFp16",
      "status": "effective",
      "speedup": "3.92",
      "pre_run": "40.72 ms",
      "post_run": "10.39 ms"
    }
  ],
  "overall": {
    "baseline": "40.64 ms",
    "optimized": "10.41 ms",
    "speedup": "3.90"
  },
  "model_info": {"input_format": "torch_script"},
  "compatibility_list": [{"device_type": "gpu", "microarchitecture": "T4"}],
  "model_sdk": {}
}

The PtTrtPassFp16 optimization — TensorRT FP16 conversion — produces the ~3.9x speedup. For a full field reference, see Optimization report.

3. Run a performance test

The following benchmark function runs 100 warmup iterations followed by 200 timed iterations:

import time

@torch.no_grad()
def benchmark(model, inp):
    # Warmup
    for i in range(100):
        model(inp)
    torch.cuda.synchronize()
    # Timed run
    start = time.time()
    for i in range(200):
        model(inp)
    torch.cuda.synchronize()
    elapsed_ms = (time.time() - start) * 1000
    print("Latency: {:.2f}".format(elapsed_ms / 200))

benchmark(script_model, batched_inputs)    # Before optimization
benchmark(optimized_model, batched_inputs) # After optimization

Expected output:

Latency: 40.65
Latency: 10.46

Step 4: Load and run the optimized model

The optimized model is still a TorchScript model. Load it with torch.jit.load — no environment changes required.

1. Authenticate

# Optional: prevent unexpected exits during the trial period.
export BLADE_AUTH_USE_COUNTING=1

# Required: set your region and authentication token.
export BLADE_REGION=<region>
export BLADE_TOKEN=<token>

Replace the placeholders:

PlaceholderDescription
<region>The region where you use PAI-Blade
<token>Your PAI-Blade authentication token

Obtain both values by joining the PAI-Blade DingTalk user group. For the QR code, see Install PAI-Blade.

2. Load and verify

import blade.runtime.torch
import detectron2
import torch
import numpy as np
import os
from detectron2.data.detection_utils import read_image
from torch.testing import assert_allclose

# Load the custom operator library before loading the model.
codebase = "retinanet-examples"
torch.ops.load_library(os.path.join(codebase, 'custom.so'))

script_model = torch.jit.load('script_model.pt')
optimized_model = torch.jit.load('optimized.pt')

img = read_image('./input.jpg')
img = torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1)))

# Verify that outputs are numerically equivalent.
with torch.no_grad():
    batched_inputs = [{"image": img.float()}]
    pred1 = script_model(batched_inputs)
    pred2 = optimized_model(batched_inputs)

assert_allclose(pred1[0], pred2[0], rtol=1e-3, atol=1e-2)

The tolerance values (rtol=1e-3, atol=1e-2) account for rounding differences introduced by FP16 computation.

What's next