Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
11e9007
add support for row-wise quanted input for grouped gemm
YangFei1990 Jul 23, 2026
d234cd8
doc change
YangFei1990 Jul 25, 2026
376b94c
implement for backward
YangFei1990 Jul 26, 2026
782835c
merge from main
YangFei1990 Jul 26, 2026
855f480
allow scaled_bias + frozen weights in prequant path
YangFei1990 Jul 26, 2026
8ef7e7e
allow bias + frozen weight path in prequantized input
YangFei1990 Jul 26, 2026
6015c91
use .copy to create group tensor
YangFei1990 Jul 30, 2026
49ba134
merge from main
YangFei1990 Jul 30, 2026
12f1dbf
move the implementation to c++ layer
YangFei1990 Jul 30, 2026
14fedf0
Merge branch 'main' into mxfp8_input_groupedgemm
YangFei1990 Jul 30, 2026
e3990dd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 30, 2026
c1e4663
bug fix for operation ordering
YangFei1990 Jul 31, 2026
a349d0d
add comprehensive tests
YangFei1990 Jul 31, 2026
a3219de
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 31, 2026
7c850ef
Merge branch 'main' into mxfp8_input_groupedgemm
YangFei1990 Jul 31, 2026
d8e8b1c
Merge branch 'main' into mxfp8_input_groupedgemm
YangFei1990 Aug 4, 2026
3bbf778
rename to group_requantize
YangFei1990 Aug 5, 2026
a677627
merge from main
YangFei1990 Aug 6, 2026
e890104
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2026
76662c2
rename func with inplace tag
YangFei1990 Aug 6, 2026
f2ff2fc
resolve merge
YangFei1990 Aug 6, 2026
a056c6d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2026
ce2e5a3
refactor the requantize function
YangFei1990 Aug 6, 2026
8a1c89f
merge from main
YangFei1990 Aug 6, 2026
226405d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2026
3032804
minor fix for test
YangFei1990 Aug 7, 2026
791abaa
Merge branch 'main' into mxfp8_input_groupedgemm
YangFei1990 Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
403 changes: 403 additions & 0 deletions tests/pytorch/test_grouped_mlp.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions transformer_engine/pytorch/csrc/extensions.h
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ py::object bgrad_group_quantize(const at::Tensor &tensor, py::handle quantizer,
std::optional<at::Tensor> last_dims,
std::optional<at::Tensor> tensor_offsets);

py::object group_requantize_columnwise_and_swizzle_rowwise_(
py::handle grouped_x, py::handle columnwise_quantizer, const size_t num_tensors,
std::optional<at::Tensor> first_dims, DType otype, std::optional<at::Tensor> tensor_offsets,
bool return_dequantized);

std::vector<py::object> multi_tensor_quantize(const std::vector<at::Tensor> &tensor_list,
std::vector<py::handle> quantizer_list);

Expand Down
60 changes: 60 additions & 0 deletions transformer_engine/pytorch/csrc/extensions/cast.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,66 @@ py::object group_dequantize(const py::handle &input, transformer_engine::DType o
return py::reinterpret_borrow<py::object>(out_py);
}

py::object group_requantize_columnwise_and_swizzle_rowwise_(
py::handle grouped_x, py::handle columnwise_quantizer, const size_t num_tensors,
std::optional<at::Tensor> first_dims, DType otype, std::optional<at::Tensor> tensor_offsets,
bool return_dequantized) {

@vthumbe1503 vthumbe1503 Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
py::object group_requantize_columnwise_and_swizzle_rowwise_(
py::handle grouped_x, py::handle columnwise_quantizer, const size_t num_tensors,
std::optional<at::Tensor> first_dims, DType otype, std::optional<at::Tensor> tensor_offsets,
bool return_dequantized) {
py::object group_requantize(
py::handle grouped_x, py::handle quantizer, const size_t num_tensors,
std::optional<at::Tensor> first_dims, DType otype, std::optional<at::Tensor> tensor_offsets,
bool return_dequantized) {

I suggest to make this function generic.

The intent of this function as follows

  1. If quantizer.rowwise_usage is true and grouped_x has rowwise data/scales, then grouped_swizzle
  2. If quantizer.columnwise_usage is true and grouped_x has columnwise_data/scales, then grouped_swizzle
  3. If quantizer.rowwise_usage is true and grouped_x doesnt have rowwise_data/scales, then dequant + requant with swizzle fusion
  4. If quantizer.columnwise_usage is true and grouped_zx doesnt have columnwise_data/scales, then dequant + requant with swizzle fusion

3 --> is a unrealistic case and we can throw an error in that case as well

Also, we should make sure to gather all swizzling directions once(rowwise, colwise or both rowwise/colwise) and call grouped_swizzle once.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we should make this function generic, the purpose of it is to handle the mxfp8 output from dispatch, and we are also talking about make this a fused kernel with dispatch's permutation, that is the major reason to refactor it from python into c++ layer. cc @phu0ngng

@vthumbe1503 vthumbe1503 Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the thing is, for now it is now serving the purpose of
mxfp8 dispatch --> mxfp8 gemm ready input

But it can be this as well in the future
mxfp8 dispatch --> nvfp4 gemm ready input

Now if your intent is to retain the quantization between input and output
like mxfp8 dispatch --> mxfp8 gemm ready input
or nvfp4 dispatch --> nvfp4 gemm ready input
I am ok with that as well. But that should mentioned in the comment.

group_requantize_columnwise_and_swizzle_rowwise_ --> This as a name seems too specific and encodes too much information in the name of the function which isnt needed. Quantizer already has the information with optimize_for_gemm=True/False which tells whether to swizzle or not and which direction to swizzle. So just keeping the name as "group_requantize" should suffice.

As far as fused kernels are concerned, the special case where fusion is available, only that case can be replaced with the fused kernel

In general, I want to differentiate between use-case of a function and intent of the function. Use-case is dispatch --> gemm-ready handling. But the Intent of the function is --> requantize to the best of the ability. And that means if you have already quantized data along a direction, then to make gemm ready you just need to swizzle it. However if you dont have quantized data along a direction, then to make it gemm ready dequant + quant + swizzle(based on quantizer config)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently in your function 1 and 4 that I mentioned above is handled. We can throw error for 2 and 3 as well based on quantizer config and grouped_x.quantizer config. So I am not asking to implement the generic function. But keep the interface and name of the function generic. So that in future if we want to implement new feature we dont have to change the name and signature of the function

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Renamed to group_requantize and added one additional assert. Currently the function only takes rowwise_data and column quantizer, but we can extend in the future.

init_extension();

NVTE_CHECK(!grouped_x.attr("rowwise_data").is_none(),
"Pre-quantized MXFP8 grouped input is missing rowwise data.");
NVTE_CHECK(!grouped_x.attr("scale_inv").is_none(),
"Pre-quantized MXFP8 grouped input is missing rowwise scales.");
NVTE_CHECK(!grouped_x.attr("_with_gemm_swizzled_scales").cast<bool>(),
"Pre-quantized MXFP8 grouped input must have unswizzled scales.");
NVTE_CHECK(grouped_x.attr("columnwise_data").is_none(),
"Pre-quantized MXFP8 grouped input must be rowwise-only.");
if (!grouped_x.attr("quantizer").is_none()) {
// The GEMM consumes the input's rowwise data verbatim while the wgrad GEMM consumes the
// columnwise copy built here, so a dtype mismatch would make the two directions disagree.
NVTE_CHECK(grouped_x.attr("quantizer").attr("dtype").cast<DType>() ==
columnwise_quantizer.attr("dtype").cast<DType>(),
"Pre-quantized MXFP8 grouped input and the columnwise quantizer disagree on the "
"FP8 dtype.");
}

const auto logical_shape = grouped_x.attr("logical_shape").cast<py::tuple>();
const auto total_tokens = logical_shape[0].cast<size_t>();
const auto hidden_dim = logical_shape[1].cast<size_t>();
// Each group's token count must be a multiple of 128 too, so that every group's scales start
// on a swizzle-tile boundary. Those counts live on the device (host reads would break CUDA
// graph capture), so that half is the caller's contract rather than an assertion.
NVTE_CHECK(total_tokens % 128 == 0 && hidden_dim % 128 == 0,
"Pre-quantized MXFP8 grouped input requires dims that are multiples of 128, but got (",
total_tokens, ", ", hidden_dim, ").");

// Dequantize first: it reads the rowwise scales, which the swizzle below replaces.
auto dequantized_grouped = group_dequantize(grouped_x, otype);
auto dequantized =
dequantized_grouped.attr("rowwise_data")
.cast<at::Tensor>()
.view({static_cast<int64_t>(total_tokens), static_cast<int64_t>(hidden_dim)});

// Swizzle the rowwise scales before attaching any columnwise data: a rowwise-only swizzle
// resets columnwise_scale_inv to None, which would strand the columnwise data below with a
// null scale pointer.
grouped_swizzle_for_gemm(grouped_x, /*rowwise=*/true, /*columnwise=*/false);

// Rebuild the columnwise copy the wgrad GEMM needs. It cannot be derived from the rowwise
// data because the two directions scale along perpendicular axes. The caller hands us a
// columnwise-only, optimize_for_gemm quantizer, so the kernel emits swizzled columnwise
// scales directly.
auto columnwise = group_quantize(dequantized, columnwise_quantizer, num_tensors, first_dims,
std::nullopt, tensor_offsets, std::nullopt);
grouped_x.attr("columnwise_data") = columnwise.attr("columnwise_data");
grouped_x.attr("columnwise_scale_inv") = columnwise.attr("columnwise_scale_inv");

if (return_dequantized) {
return py::cast(dequantized);
}
return py::none();
}

namespace {

void multi_tensor_quantize_impl(const std::vector<TensorWrapper> &input_list,
Expand Down
7 changes: 7 additions & 0 deletions transformer_engine/pytorch/csrc/extensions/pybind.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("bgrad_group_quantize", transformer_engine::pytorch::bgrad_group_quantize,
py::arg("tensor"), py::arg("quantizer"), py::arg("num_tensors"), py::arg("first_dims"),
py::arg("last_dims") = py::none(), py::arg("tensor_offsets") = py::none());
m.def("group_requantize_columnwise_and_swizzle_rowwise_",
transformer_engine::pytorch::group_requantize_columnwise_and_swizzle_rowwise_,
"Rebuild the columnwise copy of a rowwise-prequantized MXFP8 grouped tensor and swizzle "
"its rowwise scales for GEMM, in place",
py::arg("grouped_x"), py::arg("columnwise_quantizer"), py::arg("num_tensors"),
py::arg("first_dims"), py::arg("otype"), py::arg("tensor_offsets") = py::none(),
py::arg("return_dequantized") = false);
m.def("bgrad_quantize", transformer_engine::pytorch::bgrad_quantize,
"Compute bias gradient and quantize", py::arg("input"), py::arg("quantizer"));
m.def("generic_gemm", transformer_engine::pytorch::gemm, "Compute GEMM (matrix-matrix multiply)",
Expand Down
17 changes: 17 additions & 0 deletions transformer_engine/pytorch/csrc/extensions/swizzle.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,23 @@ std::optional<SwizzledGroupedScales> maybe_swizzle_grouped_tensor(GroupedTensorW
tensor_offsets.data_ptr, static_cast<DType>(tensor_offsets.dtype), tensor_offsets.shape);
}

// Varying per-tensor dimensions. Leaving these unset declares the grouped tensor uniform,
// which selects the uniform-shape swizzle kernel.
const auto first_dims = input.get_first_dims();
if (first_dims.data_ptr != nullptr) {
swizzle_input.set_first_dims(first_dims.data_ptr, static_cast<DType>(first_dims.dtype),
first_dims.shape);
swizzle_output.set_first_dims(first_dims.data_ptr, static_cast<DType>(first_dims.dtype),
first_dims.shape);
}
const auto last_dims = input.get_last_dims();
if (last_dims.data_ptr != nullptr) {
swizzle_input.set_last_dims(last_dims.data_ptr, static_cast<DType>(last_dims.dtype),
last_dims.shape);
swizzle_output.set_last_dims(last_dims.data_ptr, static_cast<DType>(last_dims.dtype),
last_dims.shape);
}

// Per-tensor logical dimensions (uniform-shape grouped tensor).
const size_t num_tensors = input.num_tensors();
const auto logical_shape_nvte = input.logical_shape();
Expand Down
10 changes: 10 additions & 0 deletions transformer_engine/pytorch/ops/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..torch_version import torch_version
from ..quantization import FP8GlobalStateManager
from ..tensor.float8_tensor import Float8Tensor
from ..tensor.mxfp8_tensor import MXFP8Quantizer
from ..quantized_tensor import QuantizedTensorStorage
from ..utils import canonicalize_dtype

Expand Down Expand Up @@ -66,6 +67,15 @@ def maybe_dequantize(
return tensor


def make_columnwise_gemm_quantizer(quantizer: MXFP8Quantizer) -> MXFP8Quantizer:
"""Copy of ``quantizer`` configured to emit only GEMM-swizzled columnwise data."""
columnwise_quantizer = quantizer.copy()
columnwise_quantizer.set_usage(rowwise=False, columnwise=True)
columnwise_quantizer.optimize_for_gemm = True
columnwise_quantizer.internal = True
return columnwise_quantizer


def maybe_autocast_dtype(
*,
device_type: str = "cuda",
Expand Down
102 changes: 94 additions & 8 deletions transformer_engine/pytorch/ops/basic/grouped_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import torch

import transformer_engine_torch as tex
from ...constants import DType
from ...constants import DType, TE_DType
from ...cpp_extensions import general_grouped_gemm, general_grouped_gemm_for_grouped_tensor
from ...distributed import CudaRNGStatesTracker
from ...module._common import WeightGradStore
Expand Down Expand Up @@ -48,6 +48,7 @@
get_dummy_wgrads_for_params,
get_main_grad_from_param,
is_quantized_tensor,
make_columnwise_gemm_quantizer,
maybe_dequantize,
validate_or_alloc_output,
view_main_grad_as_grouped_buffer,
Expand Down Expand Up @@ -1201,6 +1202,11 @@ def _fuser_forward_split_quantize(
out_buffer: Optional[torch.Tensor] = None,
) -> tuple[torch.Tensor, tuple[Optional[torch.Tensor], ...]]:
"""Legacy ``tex.split_quantize`` + ``general_grouped_gemm`` flow."""
if isinstance(input_, GroupedTensor):
raise NotImplementedError(
"Pre-quantized GroupedTensor input is only supported on the "
"graph-safe grouped-tensor path."
)
num_groups = self.num_groups
has_bias = self.has_bias

Expand Down Expand Up @@ -1325,11 +1331,46 @@ def _fuser_forward_grouped_tensor(

# Flatten to 2D so the first dim is the total token count.
original_shape = list(input_.size())
x = maybe_dequantize(input_, dtype).reshape(-1, self.in_features)
total_tokens = x.size(0)
prequantized_mxfp8_input = (
with_quantized_compute
and isinstance(input_, GroupedTensor)
and isinstance(input_quantizers[0], MXFP8Quantizer)
and isinstance(input_.quantizer, MXFP8Quantizer)
Comment thread
YangFei1990 marked this conversation as resolved.
Outdated
)
if prequantized_mxfp8_input:
# GroupedTensor forbids reshape and is already in the canonical
# (total_tokens, in_features) layout; just validate the shape.
if input_.dim() != 2 or input_.size(-1) != self.in_features:
raise ValueError(
"GroupedTensor input must have shape (total_tokens, "
f"{self.in_features}), but got {tuple(input_.size())}."
)
total_tokens = input_.size(0)
else:
x = maybe_dequantize(input_, dtype).reshape(-1, self.in_features)
total_tokens = x.size(0)

@timmoon10 timmoon10 Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a special case for MXFP8 input is quite hacky. If we try to understand the code, this entire code section is converting the input (previously assumed to be BF16, but now may be grouped MXFP8) to the format needed by GGEMM. We can lift all of this into a clean helper function:

def _convert_input_to_grouped_tensor(input_, ...):

    # Do nothing if input is already in expected format
    if input_is_in_expected_format:
        return input_

    # Fast requantize impls
    if input_is_mxfp8 and compute_is_mxfp8:
        return tex.group_requantize...(x)
    if fancy_future_fused_impl_is_available:
        return tex.fancy_future_fused_impl(...)

    # Fallback: dequantize if needed and group quantize
    x = maybe_dequantize(input_)
    grouped_x = tex.group_quantize(x, ...)
    return grouped_x

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are four sites that these checks are involved, fwd_grouped_linear (this one), bwd_grouped_linear, fwd_grouped_mlp, bwd_grouped_mlp, the paths have common parts, but also differ a lot, e.g. quantize entry point, handle of dbias, NVFP4 path, fallback paths, I feel if we create a single helper function to handle all cases, the function itself might be complicated with a lot of conditional branches. What are your recommendations?


# Build the input GroupedTensor.
if with_quantized_compute:
if prequantized_mxfp8_input:
# Rowwise-only MXFP8 input (e.g. FP8 token dispatch): feed the
# rowwise data to the forward GEMM as-is, manufacture the
# columnwise copy needed by the wgrad GEMM, and swizzle the
# rowwise scales for the GEMM.
grouped_x = input_.copy()
if weight_requires_grad:
tex.group_requantize_columnwise_and_swizzle_rowwise_(
grouped_x,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment, it would be better if we just pass in plain torch tensor into this API (please refer to our API design for grouped_quantize, and the grouped tensor descriptor is created in C++ for less overhead

make_columnwise_gemm_quantizer(input_quantizers[0]),
num_groups,
split_sizes,
TE_DType[dtype],
tensor_offsets=base_split_offsets * self.in_features,
)
else:
# No wgrad, so no columnwise copy is needed. The forward GEMM
# still requires swizzled rowwise scales.
tex.grouped_swizzle_for_gemm(grouped_x, rowwise=True, columnwise=False)
elif with_quantized_compute:
input_quantizer = input_quantizers[0]
input_quantizer.set_usage(rowwise=True, columnwise=weight_requires_grad)
input_quantizer.optimize_for_gemm = True
Expand Down Expand Up @@ -1682,8 +1723,25 @@ def _fuser_backward_grouped_tensor(

# Flatten grad_output to 2D (total_tokens, out_features)
# to figure out total tokens.
dy_2d = grad_output.reshape(-1, self.out_features)
total_tokens = dy_2d.size(0)
prequantized_mxfp8_grad = (
with_quantized_compute
and isinstance(grad_output, GroupedTensor)
and isinstance(ctx.grad_output_quantizers[0], MXFP8Quantizer)
and isinstance(grad_output.quantizer, MXFP8Quantizer)
)
if prequantized_mxfp8_grad:
# GroupedTensor forbids reshape and is already in the canonical
# (total_tokens, out_features) layout; just validate the shape.
if grad_output.dim() != 2 or grad_output.size(-1) != self.out_features:
raise ValueError(
"GroupedTensor grad output must have shape (total_tokens, "
f"{self.out_features}), but got {tuple(grad_output.size())}."
)
dy_2d = None
total_tokens = grad_output.size(0)
else:
dy_2d = grad_output.reshape(-1, self.out_features)
Comment thread
vthumbe1503 marked this conversation as resolved.
total_tokens = dy_2d.size(0)

# Build the grad_output GroupedTensor.
# Optionally get dbias is fusion available with bgrad_group_quantize
Expand All @@ -1700,7 +1758,34 @@ def _fuser_backward_grouped_tensor(
fuse_bgrad = isinstance(grad_output_quantizer, MXFP8Quantizer) or (
isinstance(grad_output_quantizer, Float8BlockQuantizer) and ctx.input_requires_grad
)
if has_bias and not self._scale_bias and fuse_bgrad:
if prequantized_mxfp8_grad:
# Rowwise-only MXFP8 grad output (e.g. FP8 token dispatch): reuse the
# rowwise data for the dgrad GEMM and manufacture the columnwise copy
# for wgrad. Bias grads are reduced from the dequantized grad below,
# which is only kept when there is a bias.
grouped_dy = grad_output.copy()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be better if we let the group_requantize_columnwise_and_swizzle_rowwise_ API accepts regular 2D inputs or dY, convert to grouped tensor in C++ for less CPU overhead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that It would reduce CPU overheads, however we would loose generality of the function. If we want to use it for NVFP4 later, then we would need to pass amax as well. I think for now, it is better to consume grouped tensor

if ctx.weight_requires_grad:
dy_2d = tex.group_requantize_columnwise_and_swizzle_rowwise_(
grouped_dy,
make_columnwise_gemm_quantizer(grad_output_quantizer),
num_groups,
split_sizes,
TE_DType[dtype],
tensor_offsets=base_split_offsets * self.out_features,
return_dequantized=has_bias,
)
else:
# No wgrad, so no columnwise copy is needed. Dequantize before
# swizzling: dequantization reads the unswizzled rowwise scales.
dy_2d = (
tex.group_dequantize(grouped_dy, TE_DType[dtype]).rowwise_data.view(
total_tokens, self.out_features
)
if has_bias
else None
)
tex.grouped_swizzle_for_gemm(grouped_dy, rowwise=True, columnwise=False)
elif has_bias and not self._scale_bias and fuse_bgrad:
grouped_dy, dbias_packed = tex.bgrad_group_quantize(
dy_2d, grad_output_quantizer, num_groups, split_sizes
)
Expand Down Expand Up @@ -1735,7 +1820,8 @@ def _fuser_backward_grouped_tensor(
offsets=base_split_offsets,
)
elif dbias_packed is None:
# BF16/FP16 path
# BF16/FP16 and pre-quantized MXFP8 paths, neither of which fuses dbias
# into a quantize kernel.
dbias_packed = compute_grouped_dbias(dy_2d, base_split_offsets, num_groups)
if self.single_grouped_bias:
final_bias_grads = [dbias_packed.to(dtype=dtype)]
Expand Down
Loading
Loading