v1.2.1 · research release

Write Triton,
Ship C++

KernelLens compiles a PyTorch module with custom @triton.jit kernels into native ONNX Runtime and TensorRT C++ plugins. The engine folds your standard layers; your kernels stay kernels for the operations folding cannot express.

python ≥ 3.10 cuda ≥ 12.0 ONNX Runtime · TensorRT 10–11 MIT
1 · Trace PTX · shared memory · dynamic grid · arg taxonomy 2 · Rewrite the graph triton_custom nodes · CPU scalars · in-place aliasing 3 · Synthesize the plugins OrtCustomOp · IPluginV2DynamicExt · PTX ABI · grid 4 · Build & run nvcc / g++ → .so · zero-copy binding
One compile call, one run call per backend. Hover a stage.
What it is

One call, two artifacts, two engines.

Compile

kl.compile(model, sample_input) traces the module, rewrites it into an ONNX graph with injected custom nodes, synthesizes the C++ plugin classes and builds the shared libraries. You keep writing Triton in Python.

Run

compiled_model.run(inputs, backend="tensorrt") executes in the engine with zero-copy VRAM binding — PyTorch's data_ptr() handed straight to the engine, no host staging.

Fold + keep

The engine folds your standard layers; your kernels run as native plugins for the operations folding cannot express. You get both in one C++ graph.

The problem

Production runs in C++. Your Triton kernels do not.

Enterprise inference is served by pure C++ engines — ONNX Runtime and TensorRT — for latency, concurrency and whole-graph optimisation. But torch.onnx.export traces TorchScript or TorchDynamo IR that represents native PyTorch operators; an imperative Python call to a triton.JITFunction is opaque to it. The kernel is dropped or the export fails.

Doing it by hand means re-implementing the kernel in CUDA, writing the ONNX and TensorRT plugin classes, patching the PyTorch graph, and building and loading the result. A Triton kernel is not one operator — it presents five properties a C++ engine must recover.

PTX entry ABI

Triton's PTX re-orders, drops and re-bitwidths parameters; the host must rebuild the exact slot layout.

Dynamic launch grid

The grid is a Python expression over runtime shapes, e.g. (s0*s1+127)//128; it cannot be frozen at compile time.

Nested & in-place code

Stores delegated to helpers and buffers mutated in place produce spurious copies or corrupted graphs.

Dynamic host scalars

Per-call values (a soft-cap τ, a scale α) must be accepted without recompiling the engine.

Quantized layout

FP8 and sub-byte INT4 need 1-byte pointer mapping and dtype-accurate memory placement.

How it works

What you would build by hand — and what KernelLens does instead.

To put one Triton kernel into either engine manually, you port it to CUDA, implement the ONNX classes (Ort::CustomOp, Ort::CustomOpApi, domain registration), implement the TensorRT classes (nvinfer1::IPluginV2DynamicExt, nvinfer1::IPluginCreator), write a CMake build, patch the FX graph with a C++ extension and @torch.library.custom_op, export, build the engine, and then drive each engine yourself — creating the session, binding raw CUDA pointers, creating the execution context, calling setTensorAddress and enqueueing.

Manual pipeline versus KernelLens
Top: every box is a class, a build step or an engine call you write and maintain by hand — CUDA porting, Ort::CustomOp, IPluginV2DynamicExt, CMake, FX-graph patching, export, engine build, RunWithBindings and Enqueue Execution. Bottom: KernelLens is the three calls.

The compile pipeline

kl.compile(model, sample_input, backends=[...]) produces a validated manifest, an ONNX graph and the plugin libraries. Each arrow below is a concrete artifact.

The KernelLens compile pipeline
Trace → rewrite the graph → synthesize the plugins → build. The TensorRT engine is built lazily on the first run and cached.
Trace

Recover what the kernel really is

  • An eager pass intercepts the compiled PTX, shared memory, warps, the real grid and every argument's shape and strides.
  • A symbolic pass recovers the launch grid as an expression over runtime shapes.
  • A static AST scan of tl.load/tl.store classifies each argument as input, output or in-place.
Rewrite

Inject custom nodes

  • Inside torch.onnx.export, Triton calls become triton_custom::<kernel> nodes carrying the PTX.
  • Per-call scalars become CPU inputs, so a new value needs no recompilation.
  • In-place buffers are wired input-to-output so the optimiser sees the mutation.
Synthesize

Generate the C++ classes

  • An OrtCustomOp class for ONNX Runtime.
  • An IPluginV2DynamicExt + IPluginCreator for TensorRT.
  • The PTX entry-slot argument array is rebuilt and the grid is evaluated in C++.
Build

Compile and harden

  • nvcc + g++ produce the shared libraries.
  • PTX ISA versions are clamped for older driver JITs; conda CUDA header layouts are resolved.
  • A 16-byte alignment guard stages sub-aligned tensor slices through an aligned buffer.

The run pipeline

At run time, PyTorch's GPU addresses are handed straight to the engine.

The KernelLens run pipeline for ONNX Runtime and TensorRT
One run call dispatches to either lane. ONNX Runtime: create the session, bind raw CUDA pointers, RunWithBindings. TensorRT: deserialize into an execution context, setTensorAddress, enqueue. No host staging copies.
import torch, triton, triton.language as tl
import kernel_lens as kl

class RMSNorm(torch.nn.Module):
    def forward(self, x):
        B, M, N = x.shape
        out = torch.empty_like(x)
        rmsnorm_kernel[(B, M)](x, self.weight, out, x.stride(0), x.stride(1),
                               out.stride(0), out.stride(1),
                               N=N, eps=1e-6, BLOCK_SIZE=triton.next_power_of_2(N))
        return out

x = torch.randn(4, 512, 4096, device="cuda")
compiled = kl.compile(RMSNorm().cuda(), (x,), name="llama3_rmsnorm",
                      backends=["onnx", "tensorrt"])   # compile once

out_ort = compiled.run((x,), backend="onnx")            # run in ONNX Runtime
out_trt = compiled.run((x,), backend="tensorrt")        # run in TensorRT
The code you don't write

Nine lines of Python, or 1,137 lines of C++.

The same thing — one Triton kernel running as a native operator in ONNX Runtime and TensorRT — from either side. On the left is everything the user writes. On the right is what KernelLens generates, and what you would otherwise write and maintain by hand.

With KernelLens — Python
import kernel_lens as kl

# the Triton kernel + module you already wrote
compiled = kl.compile(
    RMSNorm(4096).cuda(), (x,),
    name="llama3_rmsnorm",
    backends=["onnx", "tensorrt"],
)

out_ort = compiled.run((x,), backend="onnx")
out_trt = compiled.run((x,), backend="tensorrt")

That is the whole deployment. No C++, no CMake, no graph patching.

Without — C++ you write by hand
// 1. port the kernel to CUDA  (rmsnorm.cu)
// 2. OrtCustomOp for ONNX Runtime
struct RmsnormOp : Ort::CustomOpBase<...> {
  const char* GetName() const { return "rmsnorm"; }
  ONNXTensorElementDataType GetInputType(size_t i) const;
  // ... + a Compute() that rebuilds the PTX argument array
};
// 3. TensorRT plugin
class RmsnormPlugin : public nvinfer1::IPluginV2DynamicExt {
  DimsExprs getOutputDimensions(...) noexcept override;
  bool supportsFormatCombination(...) noexcept override;
  int32_t enqueue(...) noexcept override;      // launch
  size_t getSerializationSize() const noexcept override;
  void serialize(void*) const noexcept override;
  IPluginV2DynamicExt* clone() const noexcept override;
};
class RmsnormPluginCreator : public nvinfer1::IPluginCreator { /* ... */ };
// 4. CMakeLists.txt + nvcc + g++
// 5. a PyTorch C++ extension that patches the FX graph
// 6. drive the engines: Ort::Session + RunWithBindings,
//    IExecutionContext + setTensorAddress + enqueue

1,137 lines of generated C++ across 6 files for this one kernel — before CMake, the FX-graph patch, or the engine-driving code.

1,137 lines of C++ · what a hand-written plugin takes■ ONNX Runtime   ■ TensorRT
ort_plugins/…Op.cugenerated C++
686
trt_plugins/…Plugin.cu
230
trt_plugins/…Plugin.h
113
ort_plugins/…Op.h
60
ort_plugins/register_ops.cpp
25
trt_plugins/register_plugins.cpp
23
KernelLens writes all of it. The user writes nine lines of Python. Measured with tests/measure_codegen.py on one fused RMSNorm kernel built for both backends.
Results

Fold the standard layers. Keep the kernels.

The headline: a block whose attention is a custom squared-ReLU flash kernel, whose normalisations and gated activation are custom kernels, and whose projections and residuals are standard operators. Measured on an RTX 5060 Ti (Blackwell, sm_120a).

0×

faster than the folded-only torch.compile formulation, which must materialise the O(N²) attention

0×

faster than the unfused Triton-kernel path, because the engine folds the standard layers around the kernel

0.00e+00

max difference vs native Triton for the research kernels inside ONNX Runtime

464 MB

peak VRAM in a 4-layer LLaMA decoder, versus 478 MB eager and 473 MB torch.compile

Folding + kernels vs the alternatives

Same block, three ways to run it. A. Folded only expresses the attention with standard operators and lets the compiler fold everything (what you get if you cannot ship the kernel). B. Kernels only runs the custom kernels with the standard layers unfused (the usual way to use a Triton kernel) — in its torch.compile form the kernel is captured, not reimplemented: since PyTorch 2.1 TorchDynamo records a @triton.jit launch as a triton_kernel_wrapper_mutation node with no graph break, and Inductor emits a call to your kernel while fusing only the surrounding operators. C. Both is KernelLens.

Every way to run the same two-layer block · lower is betterbar length = latency
A · Folded onlyeager, standard ops
28.04 ms
A · Folded onlytorch.compile
15.15 ms
C · KernelLensONNX Runtime
9.27 ms
B · Kernels onlyeager Triton
8.24 ms
C · KernelLensTensorRT
7.63 ms
B · Kernels onlytorch.compile
7.55 ms
Folding keeps the standard layers fused; a custom kernel path cannot fold, and a folded-only path cannot express the kernel at all. TensorRT is 1.99× faster than the best folded-only formulation.

Two-layer block, D=512, H=8, S=4096, batch 1. Relative is versus the folded-only torch.compile row. The kernel wins where folding would materialise; folding wins on the element-wise chain around the projections. KernelLens is the only configuration that has both.

Single-operator latency

Single-operator latency
fp32, CUDA events, log scale. Hover the bars.
PyTorch Eager torch.compile Native Triton KernelLens ORT

The C++ plugin runs the same kernel behind an operator boundary, within 6–13% of native Triton on the three larger operators. The value is deployment, not raw speed on a single op.

End-to-end 4-layer LLaMA decoder

End-to-end decoder
D=1024, custom RMSNorm / SwiGLU / RoPE kernels. Pick a metric.

The TensorRT plugin nearly halves time-to-first-token (4.14 ms vs 7.85/7.92 ms) and attains the lowest peak VRAM, with total generation on par with the baselines.

Numerical parity

Maximum absolute difference versus native Tritonbit-identical   near-exact
LLaMA 3 / Mistral fused RMSNorm0.00e+00Liger / PaLM SwiGLU0.00e+00LLaMA 3 / Qwen 2.5 RoPE0.00e+00Liger fused softmax + cross-entropy0.00e+00FP8 scaled GEMM, INT4 W4A16 GEMM ORT only0.00e+00Fused residual + RMSNorm≤9.5e-07Mamba SSM recurrence4.8e-07In-place mutation, fp32 & fp160.00e+00
Bit-identical on both backends for the research kernels, the conformance patterns and the quantized GEMMs. Two multi-operator fusions land just above zero, and the large-vocabulary (128k) cross-entropy reduction reaches 1.9e-06; we report them rather than round away.

Parity is against native Triton execution of the same kernel.

Coverage: module patterns

Parity on specific operators is not enough — the compiler has to handle the many ways a module can call a kernel. A conformance suite (tests/test_conformance.py) exercises fourteen patterns against both backends; all pass at 0.00e+00.

Graph shapes
  • Two kernels in one graph
  • The same kernel launched twice — its own ONNX op and plugin per launch
  • Multiple outputs, including different shapes
  • Kernels inside a Python loop and behind a conditional
  • Kernels delegating to nested @triton.jit helpers
  • A 3-D launch grid
Data
  • Dynamic host scalars
  • Strided / non-contiguous inputs
  • Dynamic batch sizes
  • In-place mutation of an input buffer
  • fp16 and bf16
  • Boundary: ONNX Runtime 1.30 cannot bind bf16 to a custom op, so bf16 models use TensorRT, which handles them exactly.

Real pretrained models

Real pretrained models
OPT, every decoder LayerNorm replaced by the Triton RMSNorm kernel. Prefill latency, S=512, A100-SXM4-80GB, log scale. Pick a precision.
PyTorch Eager torch.compile KernelLens ORT KernelLens TensorRT

The result splits by precision. In fp32, torch.compile barely beats eager (1.02–1.12×) because it leaves the operators around the kernel unfused, while KernelLens fuses them: 3.2–5.5× faster, growing with model size. In fp16, torch.compile optimises well and KernelLens only matches it — TensorRT edges it by 1.06–1.22×, and ONNX Runtime is slower (0.67–0.84×). Keeping kernels pays when the baseline leaves the graph unfused; when it already fuses, we break even. Accuracy tracks precision rather than speed: fp32 holds the same greedy token on ≥98.8% of positions, fp16 under TensorRT on 93.4–97.9%, and OPT-350M in fp16 under ORT is bit-exact.

One engine, many shapes

kl.compile(..., dynamic_shapes=True) exports the leading dimension as a symbolic axis, so a single engine serves any size in a declared range — instead of one engine per shape.

Compile once, serve every shape · maximum absolute difference versus native TritonONNX Runtime   TensorRT
compiled at batch 8then served batches 1, 2, 4, 8 and 16 through that engine
0.00e+00
compiled at 524,288 elementsthen served 65,536 · 131,072 · 524,288 · 1,048,576
0.00e+00
two architecturesBlackwell sm_120a and Ampere sm_80, 16/16 conformance on each
16/16
Measured with tests/test_conformance.py, which compiles once and then runs every shape through that one engine. Both backends, both architectures, bit-exact. Off by default, because a shape-specialised engine is what the benchmarks above measure.
Why an engine

TensorRT is not always faster. Here is when it is.

torch.compile is a strong baseline — it fuses, and since PyTorch 2.1 it captures your Triton kernels rather than breaking on them (verified: zero graph breaks). So the honest answer to "why an engine at all?" is not "raw speed". Here is the head-to-head, on the same RTX 5060 Ti, with the custom kernels captured on both sides.

torch.compile versus KernelLens TensorRT · upper bar = torch.compile, lower = TensorRTbar length = latency
LLaMA decoder prefillS=512, fp16 · TensorRT wins by 1.90×
7.92
4.14
Conv model + custom kernelfp32 · torch.compile wins by 1.10×
10.40
11.40
Conv model + custom kernelfp16 · torch.compile wins by 1.84×
4.45
8.20
Transformer blockfp32 · tie wins
7.55
7.63
We report both directions. TensorRT wins decisively on GEMM-heavy transformer prefill and loses on convolution-heavy graphs, where the engine's convolution performance is the gap. A fixed-factor claim would be wrong.

TensorRT wins on GEMM-heavy transformer prefill, where its kernels and folding beat Inductor's generated GEMMs. On convolution-heavy graphs it is still behind, even after two fixes: the plugin now declares channel-last formats for pointwise kernels (so the engine no longer inserts reformats around it), and it now enforces the dtype the kernel was compiled for (previously the engine silently ran fp16 models in fp32). Together these took the fp16 conv case from 10.86 to 8.20 ms with exact parity; the remaining gap is the engine's convolution performance, not the plugin.

Why an engine wins

Structural, not just numerical

  • No Python interpreter and no libtorch in the serving path — no GIL, a small footprint, deployable from C++, Rust, Go or Java services.
  • A serialized engine that cold-starts without recompiling.
  • Engine-level VRAM planning: 464 MB peak versus 473/478 MB in our decoder.
  • Dynamic shapes handled by optimization profiles rather than recompiles and guards.
  • Quantized inference (INT8/FP8 with calibration), where TensorRT is mature.
Why the kernel wins

Folding cannot express everything

  • If you cannot ship the kernel, the folded-only formulation is 2× slower — it must materialise the O(N²) attention.
  • The engine folds the standard layers around your kernel; the unfused path cannot.
  • Custom kernels stay kernels for exactly the operations folding cannot express efficiently.
Install & API

Three lines to a C++ engine.

pip install kernel-lens          # core compiler
pip install kernel-lens[ort]     # + ONNX Runtime backend
pip install kernel-lens[trt]     # + TensorRT backend
pip install kernel-lens[all]     # both
kl.compile(model, inputs, name, backends)

Trace, export ONNX, synthesize and build the requested plugins. Returns a CompiledModel.

compiled.run(inputs, backend)

Execute with zero-copy VRAM binding; returns torch tensors.

kl.load(name)

Reload a compiled model from the cache without recompiling (production cold start).

kl.extract_libs(compiled, dest_dir)

Copy the .onnx, .so and .engine artifacts into a deploy directory.

kl.set_verbose(True)

Print manifests, AST decisions, grid expressions and launch geometry.

Requirements: Linux x86-64, Python ≥ 3.10, CUDA ≥ 12.0, PyTorch ≥ 2.0, Triton ≥ 2.1, and nvcc + g++ on PATH. TensorRT 8.6 through 11 is supported — see which TensorRT versions. Environment: KERNEL_LENS_CACHE_DIR (put it on a persistent volume), KERNEL_LENS_PLUGIN_API (auto, v2 or v3), KERNEL_LENS_HEADER_CACHE, KERNEL_LENS_CCBIN (host compiler for nvcc; CUDA toolkits before 12.9 reject GCC 13’s exception specifiers, so set this to g++-12 there), KERNEL_LENS_DEBUG, TENSORRT_INCLUDE_DIR.

Status

Where the work stands.

Questions

Fair objections.

Does KernelLens make my kernel faster than Triton?

It runs the identical kernel behind a C++ operator boundary, so a single operator is within 6–13% of native Triton. The win is the combination: the engine folds the standard layers around your kernel, which the unfused path cannot, and the folded-only path cannot express the kernel at all. On a two-layer block that is 1.99× versus folded-only and 1.08× versus the unfused kernel path.

Then why use it?

Because production serves C++ engines. KernelLens lets a custom Triton kernel live inside TensorRT or ONNX Runtime, where the standard layers are folded and tuned, and the memory planner covers everything — and it removes the hand-written plugin.

Is the parity exact?

Against native Triton, 0.00e+00 — bit-identical — for the research kernels, the conformance patterns and the quantized GEMMs on both backends. Two workloads that fuse several operators (residual+RMSNorm, Mamba's scan) land at ≤9.5e-07 and 4.8e-07, which we report rather than round to zero.

Which TensorRT versions?

8.6 through 11, automatically. TensorRT 10 deprecated the whole IPluginV2* plugin hierarchy and TensorRT 11 removed it, so plugin_api="auto" (the default) emits IPluginV3 plugins where the runtime supports them and IPluginV2DynamicExt plugins below TensorRT 10. The pip TensorRT wheels ship no headers, so the set matching the installed runtime is fetched from the TensorRT OSS repository on first use and cached; a header/runtime major mismatch is reported rather than crashing inside TensorRT. Verified on TensorRT 10.16 and 11.3, 16/16 conformance on each.

Which hardware?

Two architectures: an RTX 5060 Ti (sm_120a, Blackwell) for the folding, latency, end-to-end and quantized results, and an A100-SXM4-80GB (sm_80, Ampere) for the conformance, parity and real-model results. Results are reported per configuration and never blended. KernelLens targets CUDA C++ engines on NVIDIA hardware; other vendors and clusters are not extrapolated.

Paper

Compiling PyTorch Triton Kernels into C++ Inference Plugins.

The technical report derives the parameter taxonomy, the manual pipeline, and the KernelLens compile and run pipelines, and reports the folding, parity, latency, end-to-end and quantized results. A TMLR-formatted variant is generated from the same source.

Read the paper (PDF) TMLR version Source & reproduction scripts

@article{duc2026kernellens,
  title       = {KernelLens: Compiling PyTorch Triton Kernels into C++ Inference Plugins},
  author      = {Duc, Justin},
  institution = {Galiad Research},
  year        = {2026},
  version     = {1.2.1},
  doi         = {10.5281/zenodo.23194523}
}