Skip to content

Commit 0e9c8fb

Browse files
authored
[TRTLLM-13581][test] Add moe fp4 gpu dequant and unpack optimization (NVIDIA#15714)
Signed-off-by: rosong11 <rosong@nvidia.com>
1 parent 0ccc7ec commit 0e9c8fb

5 files changed

Lines changed: 226 additions & 14 deletions

File tree

cpp/tensorrt_llm/kernels/quantization.cu

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,71 @@ template void invokeFP4Quantization<__nv_fp8_e4m3, 32>(int b, int m, int n, __nv
429429
int multiProcessorCount, cudaStream_t stream);
430430
#endif
431431

432+
__global__ void unpackInt4PackedTensorToInt8Kernel(int8_t* output, int8_t const* input, int64_t numPacked)
433+
{
434+
for (int64_t idx = static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x; idx < numPacked;
435+
idx += static_cast<int64_t>(gridDim.x) * blockDim.x)
436+
{
437+
int8_t const packed = input[idx];
438+
// The double shift sign-extends the low nibble; the high nibble uses an arithmetic shift.
439+
int8_t const elt0 = static_cast<int8_t>(packed << 4) >> 4;
440+
int8_t const elt1 = static_cast<int8_t>(packed >> 4);
441+
output[2 * idx] = elt0;
442+
output[2 * idx + 1] = elt1;
443+
}
444+
}
445+
446+
__global__ void mxfp4DequantizeUnswizzledKernel(float* output, uint8_t const* weight, uint8_t const* scale,
447+
int64_t numPacked, int64_t k, int64_t scaleStride, int64_t groupSize)
448+
{
449+
// Keep the LUT identical to the CPU reference implementation in weightOnlyQuantOp.cpp.
450+
float const fp4Lut[16]
451+
= {0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 3.0f, 4.0f, 6.0f, 0.0f, -0.5f, -1.0f, -1.5f, -2.0f, -3.0f, -4.0f, -6.0f};
452+
auto const* scaleE8m0 = reinterpret_cast<__nv_fp8_e8m0 const*>(scale);
453+
454+
for (int64_t packedIdx = static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x; packedIdx < numPacked;
455+
packedIdx += static_cast<int64_t>(gridDim.x) * blockDim.x)
456+
{
457+
uint8_t const packed = weight[packedIdx];
458+
uint8_t const low = packed & 0xF;
459+
uint8_t const high = (packed & 0xF0) >> 4;
460+
461+
int64_t const scaleNIdx = packedIdx / (k / 2);
462+
int64_t const scaleKIdx = ((packedIdx * 2) % k) / groupSize;
463+
// static_cast<float>(__nv_fp8_e8m0) decodes the e8m0 scale, matching the CPU reference exactly.
464+
float const scaleValue = static_cast<float>(scaleE8m0[scaleNIdx * scaleStride + scaleKIdx]);
465+
466+
output[2 * packedIdx] = fp4Lut[low] * scaleValue;
467+
output[2 * packedIdx + 1] = fp4Lut[high] * scaleValue;
468+
}
469+
}
470+
471+
void invokeUnpackInt4PackedTensorToInt8(int8_t* output, int8_t const* input, int64_t numPacked, cudaStream_t stream)
472+
{
473+
if (numPacked <= 0)
474+
{
475+
return;
476+
}
477+
constexpr int block = 256;
478+
int64_t const numBlocks = (numPacked + block - 1) / block;
479+
dim3 const grid(static_cast<unsigned int>(std::min<int64_t>(numBlocks, 65535)));
480+
unpackInt4PackedTensorToInt8Kernel<<<grid, block, 0, stream>>>(output, input, numPacked);
481+
}
482+
483+
void invokeMxfp4DequantizeUnswizzled(float* output, uint8_t const* weight, uint8_t const* scale, int64_t numPacked,
484+
int64_t k, int64_t scaleStride, int64_t groupSize, cudaStream_t stream)
485+
{
486+
if (numPacked <= 0)
487+
{
488+
return;
489+
}
490+
constexpr int block = 256;
491+
int64_t const numBlocks = (numPacked + block - 1) / block;
492+
dim3 const grid(static_cast<unsigned int>(std::min<int64_t>(numBlocks, 65535)));
493+
mxfp4DequantizeUnswizzledKernel<<<grid, block, 0, stream>>>(
494+
output, weight, scale, numPacked, k, scaleStride, groupSize);
495+
}
496+
432497
} // namespace kernels
433498

434499
TRTLLM_NAMESPACE_END

cpp/tensorrt_llm/kernels/quantization.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ template <typename T>
9191
void computePerTokenGlobalScaleForFP4Quantization(int b, int m, int n, T const* input, int const* tokensPerBatch,
9292
float* globalScale, int multiProcessorCount, cudaStream_t stream = 0);
9393

94+
// Unpack a packed int4 tensor (two signed 4-bit values per byte) into int8.
95+
// output must have 2 * numPacked elements.
96+
void invokeUnpackInt4PackedTensorToInt8(
97+
int8_t* output, int8_t const* input, int64_t numPacked, cudaStream_t stream = 0);
98+
99+
// Dequantize an unswizzled MXFP4 weight (two e2m1 values per byte) using a linear-layout
100+
// e8m0 block scale. output must have 2 * numPacked floats.
101+
// k is the unpacked column count (weight.size(1) * 2), scaleStride is scale.size(1).
102+
void invokeMxfp4DequantizeUnswizzled(float* output, uint8_t const* weight, uint8_t const* scale, int64_t numPacked,
103+
int64_t k, int64_t scaleStride, int64_t groupSize, cudaStream_t stream = 0);
104+
94105
} // namespace kernels
95106

96107
TRTLLM_NAMESPACE_END

cpp/tensorrt_llm/thop/weightOnlyQuantOp.cpp

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
#include "tensorrt_llm/common/config.h"
1818
#include "tensorrt_llm/common/cudaBf16Wrapper.h"
1919
#include "tensorrt_llm/kernels/cutlass_kernels/cutlass_preprocessors.h"
20+
#include "tensorrt_llm/kernels/quantization.h"
2021
#include "tensorrt_llm/thop/thUtils.h"
2122

23+
#include <c10/cuda/CUDAGuard.h>
24+
2225
#if defined(TORCH_VERSION_MAJOR) \
2326
&& ((TORCH_VERSION_MAJOR > 1) || ((TORCH_VERSION_MAJOR == 1) && (TORCH_VERSION_MINOR >= 9)))
2427
#define TORCH_IS_AT_LEAST_v190
@@ -283,7 +286,6 @@ Tensor add_bias_and_interleave_int8s(Tensor weight)
283286

284287
Tensor unpack_int4_packed_tensor_to_int8(Tensor weight)
285288
{
286-
CHECK_CPU(weight);
287289
CHECK_CONTIGUOUS(weight);
288290
TORCH_CHECK(weight.numel() != 0, "weight should not be empty tensor");
289291
TORCH_CHECK(weight.dtype() == torch::kInt8, "Weight must be a packed int8 tensor");
@@ -295,6 +297,17 @@ Tensor unpack_int4_packed_tensor_to_int8(Tensor weight)
295297
}
296298
int8_tensor_size[weight.dim() - 1] *= 2;
297299

300+
if (weight.is_cuda())
301+
{
302+
at::cuda::CUDAGuard const device_guard(weight.device());
303+
Tensor unpacked_weight
304+
= torch::empty(int8_tensor_size, torch::dtype(torch::kInt8).device(weight.device()).requires_grad(false));
305+
auto stream = at::cuda::getCurrentCUDAStream(weight.device().index());
306+
tensorrt_llm::kernels::invokeUnpackInt4PackedTensorToInt8(
307+
get_ptr<int8_t>(unpacked_weight), get_ptr<int8_t>(weight), weight.numel(), stream);
308+
return unpacked_weight;
309+
}
310+
298311
Tensor unpacked_weight
299312
= torch::zeros(int8_tensor_size, torch::dtype(torch::kInt8).device(torch::kCPU).requires_grad(false));
300313

@@ -357,8 +370,6 @@ Tensor mxfp4_dequantize_unswizzled(Tensor weight, Tensor scale, int64_t group_si
357370
// weight (n, k / 2)
358371
// scale (n, k / group_size)
359372

360-
CHECK_CPU(weight);
361-
CHECK_CPU(scale);
362373
CHECK_CONTIGUOUS(weight);
363374
CHECK_CONTIGUOUS(scale);
364375
TORCH_CHECK(weight.numel() != 0, "weight should not be empty tensor");
@@ -368,12 +379,27 @@ Tensor mxfp4_dequantize_unswizzled(Tensor weight, Tensor scale, int64_t group_si
368379
TORCH_CHECK(weight.size(0) == scale.size(0))
369380
TORCH_CHECK(weight.size(1) * 2 == scale.size(1) * group_size)
370381

371-
uint8_t* weight_packed_ptr = get_ptr<uint8_t>(weight);
372-
__nv_fp8_e8m0* scale_ptr = reinterpret_cast<__nv_fp8_e8m0*>(get_ptr<uint8_t>(scale));
373-
374382
int const n = weight.size(0);
375383
int const k = weight.size(1) * 2;
376384

385+
if (weight.is_cuda())
386+
{
387+
TORCH_CHECK(scale.is_cuda(), "scale must be a CUDA tensor when weight is on CUDA");
388+
TORCH_CHECK(weight.device() == scale.device(), "weight and scale must be on the same device");
389+
at::cuda::CUDAGuard const device_guard(weight.device());
390+
Tensor dequant_weight
391+
= torch::empty({n, k}, torch::dtype(torch::kFloat).device(weight.device()).requires_grad(false));
392+
auto stream = at::cuda::getCurrentCUDAStream(weight.device().index());
393+
tensorrt_llm::kernels::invokeMxfp4DequantizeUnswizzled(get_ptr<float>(dequant_weight), get_ptr<uint8_t>(weight),
394+
get_ptr<uint8_t>(scale), weight.numel(), k, scale.size(1), group_size, stream);
395+
return dequant_weight;
396+
}
397+
398+
TORCH_CHECK(scale.is_cpu(), "scale must be a CPU tensor when weight is on CPU");
399+
400+
uint8_t* weight_packed_ptr = get_ptr<uint8_t>(weight);
401+
__nv_fp8_e8m0* scale_ptr = reinterpret_cast<__nv_fp8_e8m0*>(get_ptr<uint8_t>(scale));
402+
377403
Tensor dequant_weight = torch::empty({n, k}, torch::dtype(torch::kFloat).device(torch::kCPU).requires_grad(false));
378404
float* dequant_weight_ptr = get_ptr<float>(dequant_weight);
379405

tests/unittest/_torch/modules/moe/quantize_utils.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,17 +2182,17 @@ def load_weights(self, weights_list: List[Dict]):
21822182
# mxfp4_dequantize_unswizzled returns (out_features, in_features)
21832183
# which matches F.linear weight layout (out, in). Do NOT transpose.
21842184
w1_dequant = (
2185-
unpacker(w1.cpu(), s1.cpu(), scaling_group_size)
2185+
unpacker(w1, s1, scaling_group_size)
21862186
.to(dtype=self.dtype, device="cuda")
21872187
.contiguous()
21882188
)
21892189
w3_dequant = (
2190-
unpacker(w3.cpu(), s3.cpu(), scaling_group_size)
2190+
unpacker(w3, s3, scaling_group_size)
21912191
.to(dtype=self.dtype, device="cuda")
21922192
.contiguous()
21932193
)
21942194
w2_dequant = (
2195-
unpacker(w2.cpu(), s2.cpu(), scaling_group_size)
2195+
unpacker(w2, s2, scaling_group_size)
21962196
.to(dtype=self.dtype, device="cuda")
21972197
.contiguous()
21982198
)
@@ -2439,17 +2439,17 @@ def load_weights(self, weights_list: List[Dict]):
24392439
# Note: mxfp4_dequantize_unswizzled returns shape (out_features, in_features)
24402440
# which matches F.linear weight layout (out, in). Do NOT transpose.
24412441
w1_dequant = (
2442-
unpacker(w1.cpu(), s1.cpu(), scaling_group_size)
2442+
unpacker(w1, s1, scaling_group_size)
24432443
.to(dtype=self.dtype, device="cuda")
24442444
.contiguous()
24452445
)
24462446
w3_dequant = (
2447-
unpacker(w3.cpu(), s3.cpu(), scaling_group_size)
2447+
unpacker(w3, s3, scaling_group_size)
24482448
.to(dtype=self.dtype, device="cuda")
24492449
.contiguous()
24502450
)
24512451
w2_dequant = (
2452-
unpacker(w2.cpu(), s2.cpu(), scaling_group_size)
2452+
unpacker(w2, s2, scaling_group_size)
24532453
.to(dtype=self.dtype, device="cuda")
24542454
.contiguous()
24552455
)
@@ -2770,10 +2770,10 @@ def _unpack_weights(self, weight: torch.Tensor) -> torch.Tensor:
27702770
unpacker = torch.ops.trtllm.unpack_int4_packed_tensor_to_int8
27712771
# ModelOpt W4A8 packs pairs of 4b weights in the output dimension into one 8b element.
27722772
if self.weight_loading_mode == MoEWeightLoadingMode.VANILLA:
2773-
return unpacker(weight.cpu().T.contiguous()).cuda()
2773+
return unpacker(weight.T.contiguous()).cuda()
27742774
# The custom W4A8 quantization script packs pairs of 4b weight in the input dimension.
27752775
else:
2776-
return unpacker(weight.cpu()).T.contiguous().cuda()
2776+
return unpacker(weight.contiguous()).T.contiguous().cuda()
27772777

27782778
def load_weights(self, weights: List[Dict]):
27792779
"""Store raw quantized weights for forward computation."""
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""GPU vs CPU golden tests for trtllm weight-only quant utility ops.
16+
17+
These ops used to be CPU-only; the CUDA branch must produce bit-identical
18+
results to the original CPU reference implementation in weightOnlyQuantOp.cpp.
19+
"""
20+
21+
import pytest
22+
import torch
23+
24+
import tensorrt_llm # noqa: F401 (registers the trtllm torch ops)
25+
26+
pytestmark = pytest.mark.skipif(
27+
not torch.cuda.is_available(), reason="CUDA is required for GPU vs CPU comparison"
28+
)
29+
30+
31+
@pytest.mark.parametrize(
32+
"shape",
33+
[
34+
(8, 16),
35+
(4, 32),
36+
(1, 1),
37+
(3, 5, 7),
38+
(2, 3, 4, 9),
39+
],
40+
)
41+
def test_unpack_int4_packed_tensor_to_int8_gpu_matches_cpu(shape):
42+
op = torch.ops.trtllm.unpack_int4_packed_tensor_to_int8
43+
44+
packed = torch.randint(-128, 128, shape, dtype=torch.int8)
45+
46+
cpu_out = op(packed)
47+
gpu_out = op(packed.cuda())
48+
49+
assert gpu_out.is_cuda
50+
assert gpu_out.dtype == torch.int8
51+
# Output last dim is doubled, everything else identical.
52+
assert list(gpu_out.shape[:-1]) == list(packed.shape[:-1])
53+
assert gpu_out.shape[-1] == packed.shape[-1] * 2
54+
55+
# Integer unpack must be exactly equal.
56+
assert torch.equal(gpu_out.cpu(), cpu_out)
57+
58+
59+
@pytest.mark.parametrize("group_size", [32, 128])
60+
@pytest.mark.parametrize(
61+
"n, k",
62+
[
63+
(8, 256),
64+
(4, 512),
65+
(1, 128),
66+
(16, 1024),
67+
],
68+
)
69+
def test_mxfp4_dequantize_unswizzled_gpu_matches_cpu(n, k, group_size):
70+
op = torch.ops.trtllm.mxfp4_dequantize_unswizzled
71+
72+
# weight: (n, k/2), two packed e2m1 codes per byte -> any uint8 value is valid.
73+
weight = torch.randint(0, 256, (n, k // 2), dtype=torch.uint8)
74+
75+
# scale: (n, k/group_size), interpreted as e8m0 (2^(e-127)).
76+
# Restrict the exponent to a stable finite range so neither CPU nor GPU
77+
# produces NaN (0xFF) or Inf (very large exponents); see plan doc.
78+
scale = torch.randint(118, 135, (n, k // group_size), dtype=torch.uint8)
79+
80+
cpu_out = op(weight, scale, group_size)
81+
gpu_out = op(weight.cuda(), scale.cuda(), group_size)
82+
83+
assert gpu_out.is_cuda
84+
assert gpu_out.dtype == torch.float32
85+
assert list(gpu_out.shape) == [n, k]
86+
87+
# LUT value * power-of-two scale, same decode on both sides -> bit exact.
88+
torch.testing.assert_close(gpu_out.cpu(), cpu_out, rtol=0, atol=0)
89+
90+
91+
def test_unpack_int4_non_contiguous_raises():
92+
op = torch.ops.trtllm.unpack_int4_packed_tensor_to_int8
93+
packed = torch.randint(-128, 128, (8, 16), dtype=torch.int8).cuda()
94+
non_contiguous = packed.t()
95+
assert not non_contiguous.is_contiguous()
96+
with pytest.raises(RuntimeError):
97+
op(non_contiguous)
98+
99+
100+
def test_mxfp4_dequantize_device_mismatch_raises():
101+
op = torch.ops.trtllm.mxfp4_dequantize_unswizzled
102+
n, k, group_size = 4, 256, 32
103+
weight = torch.randint(0, 256, (n, k // 2), dtype=torch.uint8).cuda()
104+
scale = torch.randint(118, 135, (n, k // group_size), dtype=torch.uint8) # CPU
105+
with pytest.raises(RuntimeError):
106+
op(weight, scale, group_size)
107+
108+
109+
if __name__ == "__main__":
110+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)