Skip to content

Improve device-init grouped linear module with single grouped weight support - #3224

Merged
vthumbe1503 merged 32 commits into
NVIDIA:mainfrom
zhongbozhu:improve_device_grouped_linear
Aug 12, 2026
Merged

Improve device-init grouped linear module with single grouped weight support #3224
vthumbe1503 merged 32 commits into
NVIDIA:mainfrom
zhongbozhu:improve_device_grouped_linear

Conversation

@zhongbozhu

@zhongbozhu zhongbozhu commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Description

Need this Mcore PR to make it work: NVIDIA/Megatron-LM#6000

Fixes numerical issues when using single weight for TE module grouped linear. Limit the single weight feature to the grouped tensor API instead of the legacy path.

E2E validation has been done and posted to the Megatron PR.

Performance:

Benchmarked using Qwen3.5 397B problem shape, tested the grouped linear FC1 FC2 layer time, including quantization kernels plus forward backward gemms.

image

Note: needs to pay extra attention to whether bias grad and weight grad are generated properly.

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

Please list the changes introduced in this PR:

  • Change A
  • Change B

Checklist:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

@github-actions github-actions Bot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Jul 20, 2026
@zhongbozhu
zhongbozhu force-pushed the improve_device_grouped_linear branch from aa3b9d1 to 47ba66a Compare July 20, 2026 22:36
Comment thread transformer_engine/pytorch/module/grouped_linear.py Outdated
@zhongbozhu
zhongbozhu force-pushed the improve_device_grouped_linear branch from ff7eee2 to a43f70f Compare July 20, 2026 22:53
Comment thread transformer_engine/pytorch/module/grouped_linear.py Outdated
is_grad_enabled = torch.is_grad_enabled()
num_gemms = self.num_gemms

if FP8GlobalStateManager.fp8_graph_capturing():

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.

Note: this code block was deleted because it was duplicated

@zhongbozhu
zhongbozhu marked this pull request as ready for review July 23, 2026 10:52
@zhongbozhu

Copy link
Copy Markdown
Collaborator Author

/te-ci pytorch

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends the GroupedLinear module and its op-fuser counterpart (ops/basic/grouped_linear.py) to fully support a single packed GroupedTensor weight and bias parameter, fixing numerical errors that existed in the previous split-based single-weight path. The single_grouped_weight / single_grouped_bias feature is now restricted to the native grouped-tensor GEMM path (CUDA m_splits + cuBLASLt grouped GEMM), and the legacy split-quantize path explicitly rejects grouped parameters with a RuntimeError.

Key changes:

  • A new public predicate is_module_grouped_tensor_path_supported(recipe, dtype) replaces the old per-class _is_grouped_tensor_path_supported static method, centralising capability detection and exposing it to callers (tests, benchmarks, MCore) without tight coupling to runtime context.
  • _get_weight_tensors() now returns [grouped_weight] (the raw GroupedTensor) instead of splitting it into per-expert members, correcting a shape mismatch (size(0) = num_gemms for 3-D grouped weight vs. out_features for 2-D discrete weight) that produced silent wrong results.
  • A new group_quantize output= parameter enables pointer-stable, in-place re-quantisation of the FP8 weight cache, satisfying CUDA graph replay requirements for microbatch weight caching.
  • Zero-work (all-zero m_splits) bias-add paths in the CUDA kernel and Python binding are hardened to handle null data pointers from valid zero-sized CUDA allocations.

Confidence Score: 4/5

  • This PR is safe to merge; the changes are well-scoped, accompanied by extensive tests, and fix several pre-existing bugs while adding a clearly useful single-parameter mode.
  • The PR fixes a real shape bug (size(0)size(-2) for 3D GroupedTensor), corrects a meta-device crash, eliminates a null-pointer heuristic in CUDA kernels, and adds comprehensive test coverage across unit, integration, and sanity levels. The new use_grouped_tensor explicit parameter cleanly replaces the env-var approach. The CUDA graph pointer-stability mechanism via group_quantize(output=) is a well-designed solution. Score is 4 rather than 5 only because the changes span C++/CUDA/Python across multiple abstraction levels, making it harder to fully verify all edge-case interactions (e.g., NVFP4 + single grouped weight, delay_wgrad + GroupedTensor backward on Blackwell) without hardware execution.
  • transformer_engine/pytorch/module/grouped_linear.py and transformer_engine/common/gemm/cublaslt_grouped_gemm.cu warrant careful attention due to the complexity of the weight-tensor routing logic and the multi-flag refactor of the CUDA grouped-GEMM setup function respectively.

Important Files Changed

Filename Overview
transformer_engine/pytorch/module/grouped_linear.py Primary module-level changes: new use_grouped_tensor param, is_module_grouped_tensor_path_supported public predicate, single grouped weight/bias support through _get_weight_tensors/_get_bias_tensors refactoring, critical size(0)→size(-2) shape bug fix for 3D GroupedTensor, CUDA-graph-stable weight caching via group_quantize(output=), and RuntimeError guard when bias grad unexpectedly appears on grouped-tensor backward path.
transformer_engine/pytorch/ops/basic/grouped_linear.py Op-fuser counterpart gains is_op_fuser_grouped_tensor_path_supported, renames _get_bias_tensors_get_discrete_bias_tensors, adds _get_packed_bias_tensor, overrides register_parameter to attach skip_backward_post_hook for post-construction weight registration, and fixes meta-device crash in _apply_delay_wgrad_param_hooks by using self._parameters.get("weight") instead of self.weight.
transformer_engine/common/gemm/cublaslt_grouped_gemm.cu Refactors setup_grouped_gemm_kernel to use explicit a_is_discrete/c_is_discrete/d_is_discrete flags instead of null-pointer heuristics; fixes bias-add kernel to perform zero-row early-return before has_data() assertions; corrects scale-pointer fill logic for discrete-A tensors.
transformer_engine/common/cast/mxfp8/group_quantize_mxfp8.cuh Fixes tensor_base_for_scales/tensor_rows_for_scales geometry derivation for the WITH_GEMM_SWIZZLED_SCALES && SAME_BOTH_DIMS case by computing member dimensions from first_logical_dim / num_tensors rather than the is_single_tensor heuristic, which was incorrect for packed multi-tensor inputs.
transformer_engine/pytorch/csrc/extensions/cast.cpp Adds optional output parameter to the group_quantize C++ extension enabling in-place re-quantization for CUDA graph pointer stability; validates output compatibility (same quantizer, num_tensors, logical_shape) and propagates noop_flag through quant_config_cpp.set_noop_tensor() for FP8 block-scaling recipes.
transformer_engine/pytorch/csrc/type_converters.cpp Adds get_initialized_storage_shape lambda that returns {0,0} for zero-sized tensor storage, distinguishing "empty but present" from "absent" storage in GroupedTensorFromPyTorchGroupedTensor to prevent null-pointer dereference on zero-work paths.
tests/pytorch/test_grouped_linear.py Comprehensive new tests for single grouped weight/bias: shape parity checks, MXFP8 workspace cache correctness, CUDA graph weight cache, delay-wgrad rejection of implicit fallback, and save_original_input consistency; existing tests migrated to is_module_grouped_tensor_path_supported.
tests/pytorch/test_grouped_mlp.py Adds test_single_grouped_bias_uses_registered_packed_storage, updates existing tests to use is_op_fuser_grouped_tensor_path_supported, and adds skip guard when grouped-tensor path is unsupported for single grouped param tests.
tests/pytorch/test_grouped_tensor.py Adds tests verifying group_quantize in-place reuse and noop semantics for both FP8 and MXFP8 recipes; upgrades torch.allclosetorch.testing.assert_close with explicit tolerances for dbias comparisons.
benchmarks/linear/benchmark_grouped_gemm_kernels.py New standalone benchmark for low-level grouped GEMM kernels (no autograd), comparing multi-stream vs cuBLASLt paths across all 6 expert GEMMs (FC1/FC2 × fwd/dgrad/wgrad).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["GroupedLinear.forward(input, m_splits)"] --> B{single_grouped_weight?}
    B -->|Yes| C["_get_weight_tensors() → [grouped_weight]<br/>num_weight_args = 1"]
    B -->|No| D["_get_weight_tensors() → [w0, w1, ..., wN]<br/>num_weight_args = num_gemms"]
    
    C --> E{is_module_grouped_tensor_path_supported?}
    D --> E
    
    E -->|Yes| F["_forward_grouped_tensor()"]
    E -->|No - single_grouped_weight| G["RuntimeError: single param unsupported on legacy path"]
    E -->|No - discrete weights| H["_forward_legacy_split_path()"]
    
    F --> I["_prepare_weights_for_grouped_tensor_gemm()"]
    I --> J{CUDA Graph safe?}
    J -->|Yes, in-place| K["group_quantize(weight, output=cached_weight)<br/>noop_flag avoids re-quantize"]
    J -->|No| L["group_quantize(weight) → new tensor"]
    K --> M["general_grouped_gemm_for_grouped_tensor()"]
    L --> M
    
    F --> N["_prepare_bias_for_grouped_tensor_gemm()"]
    N --> O{single_grouped_bias?}
    O -->|Yes| P["Return packed bias directly"]
    O -->|No| Q["Stack discrete biases → packed"]
    
    M --> R["Forward output tensor"]

    subgraph Backward
        R --> S["_backward_grouped_tensor()"]
        S --> T{single_grouped_weight?}
        T -->|Yes| U["wgrad shape: grouped_weight.shape<br/>(num_gemms, out, in)"]
        T -->|No| V["wgrad shape: per-expert (out, in)"]
        U --> W["backward_dw(): delay wgrad compute"]
        V --> W
        W --> X{use_grouped_tensor AND bias grad?}
        X -->|Yes| Y["RuntimeError: unexpected bias grad<br/>on grouped-tensor path"]
        X -->|No| Z["Return dgrad, wgrad"]
    end
Loading

Reviews (31): Last reviewed commit: "[pre-commit.ci] auto fixes from pre-comm..." | Re-trigger Greptile

Comment thread transformer_engine/pytorch/module/grouped_linear.py
Comment thread tests/pytorch/test_grouped_linear.py Outdated
Comment thread tests/pytorch/test_grouped_mlp.py Outdated

@timmoon10 timmoon10 left a comment

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.

The biggest change in this PR is that TE is abandoning any attempt to make single_grouped_weight=True a general feature. Things must be exactly right, or we crash. Given how delicate and experimental this feature has been, I'm not opposed.

The second change is that users must opt-in to access the grouped GEMM kernel. This is also reasonable, since it has alignment requirements for m_splits and it's helpful having a way for users to accept that stricter contract.

We are experiencing many test failures. Given that single_grouped_weight is no longer a general feature, I think it's reasonable we move the corresponding tests to test_grouped_linear.py and test_grouped_mlp.py.

Comment on lines +761 to +764
raise ValueError(
"The native grouped_tensor path requires CUDA m_splits. Pass a CUDA int64 "
"tensor, or select grouped_gemm_backend='legacy'."
)

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.

I get that the h2d memcpy is suboptimal, but it's trivially easy to handle. Erroring out seems excessively rigid.

Suggested change
raise ValueError(
"The native grouped_tensor path requires CUDA m_splits. Pass a CUDA int64 "
"tensor, or select grouped_gemm_backend='legacy'."
)
m_splits = m_splits.to(device=device)

We need to handle the d2h case anyways when the user has specified grouped_gemm_backend="grouped_tensor", but it's not supported and we fallback to split-quantize.

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.

But if the alignment is not provided in the first place, converting it to a device tensor also wouldn't work right, I am okay with another alignment check before adding this H2D copy.

Comment thread transformer_engine/pytorch/module/grouped_linear.py Outdated
@zhongbozhu
zhongbozhu force-pushed the improve_device_grouped_linear branch 3 times, most recently from b6a9482 to 25314b8 Compare July 25, 2026 07:08
Comment thread tests/pytorch/test_grouped_linear.py Outdated
@zhongbozhu

Copy link
Copy Markdown
Collaborator Author

/te-ci pytorch

@zhongbozhu
zhongbozhu force-pushed the improve_device_grouped_linear branch from 07b2f18 to bb6c1b9 Compare July 28, 2026 03:50
: tensor_base;
size_t tensor_base_for_scales = tensor_base;
size_t tensor_rows_for_scales = rows;
if constexpr (WITH_GEMM_SWIZZLED_SCALES && SHAPE_REP == ShapeRepresentation::SAME_BOTH_DIMS) {

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.

Note: this is for single weight quantize for mxfp8.

Before this change, the weight quantizer didn't have the first_dims because moe weights are uniform shape for both dimension. This will then trigger a CUDA illegal access because offsets_ptr=nullptr

@zhongbozhu
zhongbozhu force-pushed the improve_device_grouped_linear branch 2 times, most recently from 2734746 to 5fc5db7 Compare July 28, 2026 09:21
Comment thread transformer_engine/pytorch/csrc/type_converters.cpp Outdated
@zhongbozhu

Copy link
Copy Markdown
Collaborator Author

/te-ci pytorch

Signed-off-by: zhongboz <zhongboz@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
Signed-off-by: zhongboz <zhongboz@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
Signed-off-by: zhongboz <zhongboz@nvidia.com>
Signed-off-by: zhongboz <zhongboz@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
@zhongbozhu
zhongbozhu force-pushed the improve_device_grouped_linear branch from 45ee6d1 to ddddc3f Compare August 7, 2026 06:02
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
Comment thread transformer_engine/pytorch/module/grouped_linear.py
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
@zhongbozhu

Copy link
Copy Markdown
Collaborator Author

/te-ci pytorch L1

A_ptrs[idx] = a_is_discrete
? a_multi_tensor_args.data_ptrs[idx]
: (a_base == nullptr ? nullptr : a_base + (a_offset * a_bits_per_elem) / 8);
B_ptrs[idx] = b_base == nullptr ? nullptr : b_base + (b_offset * b_bits_per_elem) / 8;

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.

Do we need this?

Suggested change
B_ptrs[idx] = b_base == nullptr ? nullptr : b_base + (b_offset * b_bits_per_elem) / 8;
B_ptrs[idx] = b_base + (b_offset * b_bits_per_elem) / 8;

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.

why not, it's nice to have (although the real fix is in A_ptrs[idx])

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.

Additional thread divergence. Anyway, first_dims and last_dims should already be in the descriptors and they will be zero and we wont read it from it anyway.

Same applies for a_base == null_ptr and and C and D as well

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.

Real issue was we were computing a_is_discrete and c/d_is_discrete incorrectly

@zhongbozhu zhongbozhu Aug 8, 2026

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.

No harm keeping it here since, the root fix was:

Python-to-C++ conversion: A valid empty grouped storage has numel=0 and may have data_ptr=nullptr. It was converted as {nullptr, shape={0}}, which TE also uses to mean “storage is absent.” The converter now preserves the grouped tensor’s logical shape, so TE knows the empty storage exists and it won't early return in function select_grouped_operand.

Grouped GEMM setup: The code treated base_ptr == nullptr as meaning “discrete tensor list.” Therefore, valid all-empty grouped tensors were misclassified and read uninitialized discrete metadata. It now receives explicit a_is_discrete, c_is_discrete, and d_is_discrete flags.

@vthumbe1503 vthumbe1503 Aug 8, 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 null_ptr check originally was kepts as a way of determining discrete flags which was wrong as you mentioned. Now given that we already have the flags. Adding nullptr check is no longer needed.

There is no severe harm I agree. However, having it is extra code to read and extra compute(although negligible). Anyway compute_grouped_tensor_offsets function above is going to give a_offset, b_offset etc all zeros and we are going to nullptr even without the null check

Also, as I was seeing I see (for consistency perspective)
int64_t a_offset = a_is_discrete ? 0 : compute_grouped_tensor_offset(A_meta, idx);
being done only for a and not for c and d.

Comment thread tests/pytorch/test_grouped_linear.py Outdated
Comment thread transformer_engine/pytorch/module/grouped_linear.py Outdated
if dbias_packed is None:
dbias_packed = compute_grouped_dbias(dy_2d, base_split_offsets, N)
grad_biases = [dbias_packed[i].to(dtype=ctx.activation_dtype) for i in range(N)]
if ctx.single_grouped_bias:

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.

I guess we should eventually unified single_grouped_weight and single_grouped_bias into one parameter.

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.

sigh yes, but currently it's not and technically they can be totally orthogonal to each other as well, I have tested all the combinations already so it's fine

__all__ = ["GroupedLinear", "is_module_grouped_tensor_path_supported"]


def is_module_grouped_tensor_path_supported(

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.

Can we put this in pytorch/utils.py and even reuse for the ops. The function should be identical right?

Given that we eventually want to unify the module/ops code, would be better to not duplicate these pieces now.

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.

Thanks for review, I made a decision to specifically not to do it because they are currently different implementations and I agree with @timmoon10 's proposal to de-duplicate it. But for now, the right thing to do is to keep them separate.

vthumbe1503 and others added 2 commits August 8, 2026 11:16
Signed-off-by: Varun Thumbe <vthumbe@nvidia.com>
Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
@zhongbozhu

Copy link
Copy Markdown
Collaborator Author

/te-ci pytorch L1

fill_scale_ptr(a_scale_inv_ptrs, a_scale_base, A_meta, a_rowwise, a_scaling_mode);
} else {
a_scale_inv_ptrs[idx] = a_multi_tensor_args.scale_inv_ptrs[idx];
a_scale_inv_ptrs[idx] = nullptr;

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.

This is not needed. Would increase divergence

vthumbe1503
vthumbe1503 previously approved these changes Aug 11, 2026

@vthumbe1503 vthumbe1503 left a comment

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.

LGTM. My comments are nit. But we should clean those up

@zhongbozhu

Copy link
Copy Markdown
Collaborator Author

/te-ci pytorch L1

Signed-off-by: Zhongbo Zhu <zhongboz@nvidia.com>
@vthumbe1503
vthumbe1503 merged commit 2d80391 into NVIDIA:main Aug 12, 2026
10 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution PRs from external contributor outside the core maintainers, representing community-driven work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants