Skip to content

feat(conversion): run aten._grouped_mm in TensorRT with a grouped GEMM kernel - #5021

Open
shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:grouped-mm-kernel
Open

shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:grouped-mm-kernel

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #5014, #5016 and #5017.

What is wrong today

In a mixture-of-experts (MoE) layer, aten._grouped_mm multiplies each row by its own
expert's weight. Torch-TensorRT lowers it densely, every row times every expert's weight,
so large layers and dynamic row counts go to PyTorch and split the graph.

What this change does

A second converter, opt-in and tried first, inserts a TensorRT plugin that runs a Triton grouped
matrix multiply. It computes only each row's own group, in float32, and reads offs on the GPU.
Its PTX is stored in the engine, so running it needs no Triton. A kernel that cannot be built
falls back to the dense converter.

New setting: grouped_mm_kernel, default False. True keeps large and dynamic-row MoE
layers in TensorRT, so a MoE program can build as one engine.
The cost: the engine needs a driver at least as new as the build machine's.

It is used for bfloat16 or float16 with int32 offs, no bias, more than one group, fixed K,
N and group count (rows may be dynamic), sm_80 or newer, and Triton 3.5+ and cuda-python
at build time. When the GPU, the packages, a
hardware-compatible engine or Windows cross-compilation rule it out, one warning says why.
On sm_100+ it runs sm_80 instructions and can be slower than PyTorch, so measure it.

Results

One MoE layer (128 experts, dim 3072, top-8, bfloat16) exported to ExecuTorch, kernel on,
median of 50 runs, on an earlier version of this change:

GPU, tokens this change (1 engine) op in the CUDA backend eager
H100, 3500 3.31 ms 4.08 ms (4 engines + 3 CUDA parts) 3.47 ms
H100, 8740 6.68 ms 7.16 ms 6.82 ms
A100, 3500 5.58 ms does not export on sm_80 8.58 ms

Tests

  • test_moe_aten.py: both converters, the kernel's conditions and warnings.
  • New test_000_grouped_mm_kernel.py: dynamic rows, empty groups, bad offsets, a failed
    compile, shared memory limits, sm_90 and sm_100 code, and the TensorRT runtime alone.
  • New executorch/test_grouped_mm_kernel.py: a .pte run without Triton by ExecuTorch.

Ran on H100 and A100 from this branch. The ExecuTorch test ran on H100 with a release/2.15
build of the same change; its runtime package is not built for main yet.

…he dense lowering

The aten._grouped_mm converter multiplies every row by the weight of every
group and then masks away the unwanted results. A mixture-of-experts layer
with 128 experts therefore does 128 times the work and builds a
[groups, rows, N] temporary tensor. For large layers this is far slower than
PyTorch's grouped matmul, which only computes each row's own group, and it
can need more GPU memory than the device has.

A new setting, grouped_mm_dense_max_macs (default 2**38), makes
grouped_mm_validator decline the op when the dense lowering would do more
multiply-adds than that (groups * rows * K * N). Above 2**38 the dense
engine was slower than PyTorch on every GPU measured (H100 and A100). Below
it nothing changes, so small MoE graphs still stay in one engine.

The op is still converted when it has one group (no wasted work), under
require_full_compilation=True, and when the setting is None.
convert_exported_program_to_serialized_trt_engine builds one engine with no
PyTorch fallback, so it always converts the op. Settings saved before this
change load with None, so refit rebuilds the same engines.

A declined op runs in PyTorch under torch_tensorrt.compile and torch.compile,
and one warning per node names the setting. The setting
rejects booleans and non-positive values. When saving for ExecuTorch it needs a
partitioner that takes it, such as the CUDA partitioner. PyTorch's grouped
matmul copies the offsets to the host when it has no fast kernel (float16,
float32, GPUs before Hopper), so such a module cannot be captured whole by
CUDA graphs. The docs name None for both cases.
…imit

CUDA lets a kernel use more than 48 KiB of dynamic shared memory only after
the launcher opts the kernel in with cuFuncSetAttribute. TensorRT launches
AOT plugin kernels without doing that (pytorch#5010). So a triton_op or cutedsl_op
kernel that needs more than 48 KiB builds into an engine without complaint,
and then every run of the engine fails with a generic plugin assertion that
gives no reason. Large Triton tiles with software pipelining often need more
than 48 KiB, so this is easy to hit.

The capability validators of triton_op and cutedsl_op now decline such a
kernel, the same way they decline hardware-compatible engines. The op and
its eager_fn stay registered, TensorRT leaves the op to PyTorch, and a
warning names the cause and the fix (fewer pipeline stages or smaller
tiles). An op without an eager_fn could not run anywhere, so for it
registration fails with the same explanation instead. Kernels at or below
48 KiB are unchanged.

ptx_op and cuda_kernel_op callers with their own aot_fn are not checked:
TensorRT launches their kernels the same way, so their shared_mem must stay
at or below 48 KiB too. The docs now say so for both.

Signed-off-by: Anthony Shoumikhin <shoumikhin@meta.com>
…ent hint

When Triton launches a kernel the normal way, it looks at each pointer
argument and tells the compiler which ones are 16-byte aligned. That hint
is what lets the compiler use 16-byte vector loads, asynchronous copies
and software pipelining.

triton_op compiles the kernel ahead of time, before any pointer exists,
so it never passes the hint, and every triton_op kernel gets scalar memory
accesses only. Such a kernel can run several times slower inside a
TensorRT engine than the same kernel launched by Triton.

triton_op now takes pointer_alignment, with the same meaning and default
as in cutedsl_op: the byte alignment the kernel may assume for every
pointer, or a mapping from pointer names to alignments. Any alignment
above 1 becomes Triton's divisibility hint for that pointer, capped at 16
as Triton's own launcher does, and names that are not pointer parameters
are rejected. The default of 1 adds no hint, so existing registrations
compile exactly as before. Whoever registers the op guarantees the declared
alignment for every buffer TensorRT binds to it, engine inputs included;
an offset view whose byte offset is not a multiple of it does not meet it,
and the resulting misaligned access is a sticky CUDA error. Values must be
powers of two from 1 to 256. Both frontends now share one pointer_alignment
parser.

Signed-off-by: Anthony Shoumikhin <shoumikhin@meta.com>
…M kernel

Mixture-of-experts layers sort their rows by expert and call
aten._grouped_mm, where each row is multiplied only by its own expert's
weight. The existing converter lowers this densely: every row is
multiplied by every expert's weight and the unwanted results are masked
away. With 128 experts that is 128 times the work, so large layers are
left to PyTorch, and each one splits the graph into more engines. The
dense converter also needs a static row count, so with a dynamic number
of tokens the op always runs in PyTorch.

This adds a second converter for the op, opt-in through a new setting,
grouped_mm_kernel (default False), and tried first when set. It inserts
a TensorRT plugin that runs a Triton grouped matrix multiply. The kernel
reads the group offsets on the GPU, so nothing is copied to the host,
and it computes each row only against its own group's weight, with
float32 accumulation as in PyTorch. The kernel is compiled ahead of time
when the engine is built, through torch_tensorrt.kernels, and its PTX is
stored in the engine. Running the engine needs no Triton. Rows past the
last offset belong to no group, and the kernel writes zeros there, as
the dense converter does, so the output never holds stale memory.

With grouped_mm_kernel=True the new converter is used when all of these
hold: bfloat16 or float16 matrices, int32 offsets, the (rows, K) x
(groups, K, N) form without bias, out_dtype unset or equal to the input
dtype, more than one group, static K, N and group count (the row count
may be dynamic), a GPU with compute capability 8.0 or newer, TensorRT
plugins, Triton 3.5 or newer and cuda-python installed, an engine that
is not hardware compatible, and no Windows cross-compilation.
cuda-python reads the driver's PTX limit; without it the PTX could be
too new for the driver, so the kernel declines. The setting is off by
default because an engine with the kernel needs a CUDA driver at least
as new as the build machine's. Otherwise the dense converter and
grouped_mm_dense_max_macs apply as before. When the setting is on but
the GPU, the installed packages or other settings rule the kernel out,
one warning says why. The dense converter's warning suggests the setting
only when it is off and the kernel would likely take the op, checked
without starting Triton. cross_compile_for_windows does not get the
setting, since the kernel never runs there.

The validator compiles the kernel, so a kernel that cannot be built for
a plugin launch declines the op instead of failing the engine build.
The result, success or failure, is cached per shape and GPU, and a
failure is logged once as a warning. Offsets outside mat1, past its end
or negative, are invalid, but the kernel never reads or writes outside
mat1 or the output for them.

The kernel uses 128 x 128 x 32 tiles. TensorRT gives a plugin kernel at
most 48 KiB of shared memory today, so it uses 3 pipeline stages where
they fit and 2 otherwise. From sm_100 on, Triton's default code for
tl.dot uses tensor memory, which a plugin launch cannot set up, so there
the kernel is compiled with sm_80 instructions (mma.sync). The PTX still
targets the device, and compile_triton_to_ptx gets an arch argument for
this. On those GPUs the kernel can be slower than PyTorch's grouped
matrix multiply at large row counts, and the docs say to measure it.
The kernel declares mat1, mat2 and the output 16-byte aligned
(pointer_alignment=16), so Triton uses 16-byte loads and pipelining.
TensorRT allocates its own tensors 256-byte aligned and requires engine
inputs and outputs to be bound 256-byte aligned. The offsets get no
hint: a load of a few offsets gains nothing from one.

The tests hold one case per behaviour. The converter tests check which
converter took each op. The validator tests check that the kernel is
opt-in, the dtype, bias, out_dtype, offsets and one-group conditions,
the warning when Triton, the driver's PTX limit, the GPU or the settings
rule the kernel out, and when the dense warning suggests the setting.
The kernel tests skip at run time whenever the validator would decline
for the environment. Kernel tests compile it for sm_90 and sm_100 on any
GPU, check the pipeline-stage fallback and the shared memory error, and
cover a dynamic row count in one engine, a compile failure that falls
back and is tried once, and engine-only exports run in a fresh process
that loads only TensorRT and PyTorch: one checks the result, and one
checks that offsets past the row count or negative never read or write
outside mat1 or the output and that rows past the last offset are zero.
An ExecuTorch test runs a .pte file with the kernel in a fresh process
that loads neither Triton nor Torch-TensorRT.

Signed-off-by: Anthony Shoumikhin <shoumikhin@meta.com>
@meta-cla meta-cla Bot added the cla signed label Nov 12, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation component: tests Issues re: Tests component: conversion Issues re: Conversion stage component: core Issues re: The core compiler component: converters Issues re: Specific op converters component: api [Python] Issues re: Python API component: dynamo Issues relating to the `torch.compile` or `torch._dynamo.export` paths labels Nov 12, 2026
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 13, 2026
…s in TensorRT

Torch-TensorRT runs `aten._grouped_mm`, the grouped matrix multiply of
mixture-of-experts layers, inside TensorRT only with its opt-in
`grouped_mm_kernel` setting (pytorch/TensorRT#5021). Without it, each
such layer stays outside TensorRT, and Praxis-1's `velocity` program
splits into many TensorRT engines with CUDA backend parts between them.
A recipe had no way to pass a setting to the backend's compiler.

`Part` gets `compile_options`, settings of the backend's compiler for
that part alone, empty by default. They go to the compile step as JSON,
so a part refuses values that JSON cannot hold. The trace step hands
them to the compile step of the part's program, which passes them to
the backend's `compile` as the keyword argument `compile_options`, and
only for a part that has some. So a backend whose `compile` takes only
the program and the path, in LeRobot or in a `lerobot_backend_<name>`
package, keeps working unchanged.

Only `executorch_tensorrt` takes them. It adds them to its own
Torch-TensorRT settings. It refuses a setting that the installed
Torch-TensorRT does not have, since Torch-TensorRT would ignore it
without a word. It also refuses one that it already passes, from its
own settings, the user's `--backend` flags or the arguments of its call,
so a recipe never replaces them silently.

The Praxis-1 recipe sets `grouped_mm_kernel` on `velocity` and
`decoder`, the parts that hold the mixture-of-experts layers, only when
the backend is `executorch_tensorrt`. With that backend it also checks,
before any weight loads, that Torch-TensorRT is installed and that its
`CompilationSettings` has the setting. It checks the setting itself
rather than a version. A Torch-TensorRT without the setting is refused
with a ValueError that names the installed version,
pytorch/TensorRT#5021 and Torch-TensorRT 2.15.1. A missing
Torch-TensorRT gets the same install hint that the compile step gives,
which moves into `ExportBackendConfig.install_error` for both.

Tested on CPU with PyTorch 2.15 and ExecuTorch 1.6:
- a backend whose `compile` takes only the program and the path still
  exports;
- a part's settings reach, through the trace and compile steps, a
  backend that takes them; in an export of a tiny Praxis-1, only
  `velocity` and `decoder` get them, and the other programs get no
  keyword argument;
- the TensorRT backend hands a part's settings to a fake Torch-TensorRT
  with its own, and refuses one that the fake does not have, and one
  that it already passes;
- a tiny Praxis-1's `velocity` and `decoder` ask for `grouped_mm_kernel`
  with `executorch_tensorrt`, and no part asks for anything with
  `executorch_cuda`;
- with `executorch_tensorrt`, the recipe refuses before the policy loads
  a fake Torch-TensorRT without the setting and a missing one, and goes
  on with one that has it; with `executorch_cuda` it checks nothing;
- a part refuses compile options that JSON cannot hold.
Each of these tests fails with its part of the change removed. The
export and Praxis-1 tests pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla signed component: api [Python] Issues re: Python API component: conversion Issues re: Conversion stage component: converters Issues re: Specific op converters component: core Issues re: The core compiler component: dynamo Issues relating to the `torch.compile` or `torch._dynamo.export` paths component: tests Issues re: Tests documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants