Skip to content

Commit 6c80b09

Browse files
[KernelGen][Nvidia] Add fake_quantize_per_channel_affine operator with Triton kernel (flagos-ai#5450)
* Add fake_quantize_per_channel_affine operator - Implement Triton kernel for per-channel fake quantization - Add accuracy tests with multiple shapes and axis configurations - Add performance benchmark - Register in operators.yaml All tests passed (14/14). * fix: initialize per-channel benchmark config Signed-off-by: xuanzhengdu-eng <xuanzhengdu@gmail.com> --------- Signed-off-by: xuanzhengdu-eng <xuanzhengdu@gmail.com>
1 parent 1bfff66 commit 6c80b09

6 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
import torch
17+
18+
from . import base, consts
19+
20+
21+
@pytest.mark.fake_quantize_per_channel_affine
22+
def test_fake_quantize_per_channel_affine():
23+
class BenchmarkFakeQuantizePerChannelAffine(base.Benchmark):
24+
"""
25+
Benchmark fake_quantize_per_channel_affine operator
26+
"""
27+
28+
axis_configs = (0, 1)
29+
DEFAULT_SHAPES = [
30+
(4, 4),
31+
(64, 64),
32+
(128, 256),
33+
(512, 512),
34+
(1024, 1024),
35+
(2, 3, 128, 128),
36+
(8, 16, 64, 64),
37+
]
38+
39+
def set_shapes(self, shape_file_path=None):
40+
self.shapes = self.DEFAULT_SHAPES
41+
42+
def get_input_iter(self, dtype):
43+
for shape in self.shapes:
44+
for axis in self.axis_configs:
45+
if axis >= len(shape):
46+
continue
47+
inp = torch.randn(shape, dtype=dtype, device="cuda")
48+
n_channels = shape[axis]
49+
scale = (
50+
torch.rand(n_channels, dtype=torch.float32, device="cuda") * 0.1
51+
+ 0.01
52+
)
53+
zero_point = torch.zeros(
54+
n_channels, dtype=torch.int32, device="cuda"
55+
)
56+
quant_min = 0
57+
quant_max = 255
58+
yield inp, scale, zero_point, axis, quant_min, quant_max
59+
60+
def forward(self, inp, scale, zero_point, axis, quant_min, quant_max):
61+
return torch.fake_quantize_per_channel_affine(
62+
inp, scale, zero_point, axis, quant_min, quant_max
63+
)
64+
65+
bench = BenchmarkFakeQuantizePerChannelAffine(
66+
op_name="fake_quantize_per_channel_affine",
67+
torch_op=torch.fake_quantize_per_channel_affine,
68+
dtypes=consts.FLOAT_DTYPES,
69+
)
70+
bench.run()

conf/operators.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3437,6 +3437,18 @@ ops:
34373437
- NeuralNetwork
34383438
stages:
34393439
- alpha: '5.3'
3440+
- id: fake_quantize_per_channel_affine
3441+
description: Applies fake quantization per channel with affine parameters (scale and zero_point).
3442+
for:
3443+
- fake_quantize_per_channel_affine
3444+
labels:
3445+
- aten
3446+
- pointwise
3447+
- KernelGen
3448+
kind:
3449+
- Quantization
3450+
stages:
3451+
- alpha: '5.4'
34403452
- id: fill_scalar
34413453
description: Fills a scalar with the specified value.
34423454
for:

src/flag_gems/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ def torch_ge(v):
484484
("exponential_", exponential_),
485485
("eye", eye),
486486
("eye.m", eye_m),
487+
("fake_quantize_per_channel_affine", fake_quantize_per_channel_affine),
487488
("feature_dropout", feature_dropout),
488489
("feature_dropout_", feature_dropout_),
489490
("fill.Scalar", fill_scalar),

src/flag_gems/ops/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@
300300
from flag_gems.ops.exponential_ import exponential_
301301
from flag_gems.ops.eye import eye
302302
from flag_gems.ops.eye_m import eye_m
303+
from flag_gems.ops.fake_quantize_per_channel_affine import (
304+
fake_quantize_per_channel_affine,
305+
)
303306
from flag_gems.ops.feature_dropout import feature_dropout, feature_dropout_
304307
from flag_gems.ops.fft import fft
305308
from flag_gems.ops.fill import (
@@ -1140,6 +1143,7 @@
11401143
"exponential_",
11411144
"eye",
11421145
"eye_m",
1146+
"fake_quantize_per_channel_affine",
11431147
"feature_dropout",
11441148
"feature_dropout_",
11451149
"fft",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
17+
import torch
18+
import triton
19+
import triton.language as tl
20+
21+
from flag_gems.runtime import torch_device_fn
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
@triton.jit
27+
def _round_half_to_even(x):
28+
floor_x = tl.floor(x)
29+
fraction = x - floor_x
30+
floor_is_even = (floor_x % 2.0) == 0.0
31+
return tl.where(
32+
fraction > 0.5,
33+
floor_x + 1.0,
34+
tl.where((fraction < 0.5) | floor_is_even, floor_x, floor_x + 1.0),
35+
)
36+
37+
38+
@triton.jit
39+
def fake_quantize_per_channel_affine_kernel(
40+
input_ptr,
41+
scale_ptr,
42+
zero_point_ptr,
43+
output_ptr,
44+
n_elements,
45+
n_channels,
46+
channel_stride,
47+
quant_min,
48+
quant_max,
49+
BLOCK_SIZE: tl.constexpr,
50+
):
51+
pid = tl.program_id(axis=0)
52+
block_start = pid * BLOCK_SIZE
53+
offsets = block_start + tl.arange(0, BLOCK_SIZE)
54+
mask = offsets < n_elements
55+
56+
x = tl.load(input_ptr + offsets, mask=mask, other=0.0)
57+
channel_idx = (offsets // channel_stride) % n_channels
58+
scale = tl.load(scale_ptr + channel_idx, mask=mask, other=1.0)
59+
zero_point = tl.load(zero_point_ptr + channel_idx, mask=mask, other=0.0)
60+
61+
x_fp32 = x.to(tl.float32)
62+
scale_fp32 = scale.to(tl.float32)
63+
zero_point_fp32 = zero_point.to(tl.float32)
64+
x_quantized = _round_half_to_even(x_fp32 / scale_fp32) + zero_point_fp32
65+
x_clamped = tl.minimum(tl.maximum(x_quantized, quant_min), quant_max)
66+
output = (x_clamped - zero_point_fp32) * scale_fp32
67+
68+
tl.store(output_ptr + offsets, output, mask=mask)
69+
70+
71+
def fake_quantize_per_channel_affine(
72+
input, scale, zero_point, axis, quant_min, quant_max
73+
):
74+
logger.debug("GEMS FAKE_QUANTIZE_PER_CHANNEL_AFFINE")
75+
76+
if not isinstance(input, torch.Tensor):
77+
raise TypeError("input must be a torch.Tensor")
78+
79+
input = input.contiguous()
80+
scale = scale.contiguous()
81+
zero_point = zero_point.contiguous()
82+
83+
n_elements = input.numel()
84+
if n_elements == 0:
85+
return torch.empty_like(input)
86+
87+
shape = input.shape
88+
n_channels = shape[axis]
89+
90+
channel_stride = 1
91+
for i in range(axis + 1, len(shape)):
92+
channel_stride *= shape[i]
93+
94+
output = torch.empty_like(input)
95+
96+
BLOCK_SIZE = 1024
97+
grid = lambda meta: (triton.cdiv(n_elements, meta["BLOCK_SIZE"]),)
98+
99+
with torch_device_fn.device(input.device):
100+
fake_quantize_per_channel_affine_kernel[grid](
101+
input,
102+
scale,
103+
zero_point,
104+
output,
105+
n_elements,
106+
n_channels,
107+
channel_stride,
108+
quant_min,
109+
quant_max,
110+
BLOCK_SIZE=BLOCK_SIZE,
111+
)
112+
113+
return output
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
import torch
17+
18+
import flag_gems
19+
20+
from .accuracy_utils import gems_assert_close, to_reference
21+
22+
QUANT_SHAPES = [(4, 4), (16, 32), (2, 3, 4), (8, 16, 32)]
23+
24+
25+
@pytest.mark.fake_quantize_per_channel_affine
26+
@pytest.mark.parametrize("shape", QUANT_SHAPES)
27+
@pytest.mark.parametrize("axis", [0, 1])
28+
@pytest.mark.parametrize("dtype", [torch.float16, torch.float32, torch.bfloat16])
29+
@pytest.mark.parametrize("quant_min, quant_max", [(0, 255), (-128, 127)])
30+
def test_accuracy_fake_quantize_per_channel_affine(
31+
shape, axis, dtype, quant_min, quant_max
32+
):
33+
if axis >= len(shape):
34+
pytest.skip(f"axis {axis} >= ndim {len(shape)}")
35+
36+
inp = torch.randn(shape, dtype=dtype, device=flag_gems.device)
37+
n_channels = shape[axis]
38+
scale = (
39+
torch.rand(n_channels, dtype=torch.float32, device=flag_gems.device) * 0.1
40+
+ 0.01
41+
)
42+
zero_point = torch.randint(
43+
quant_min,
44+
quant_max + 1,
45+
(n_channels,),
46+
dtype=torch.int32,
47+
device=flag_gems.device,
48+
)
49+
50+
ref_inp = to_reference(inp)
51+
ref_scale = to_reference(scale)
52+
ref_zero_point = to_reference(zero_point)
53+
54+
ref_out = torch.fake_quantize_per_channel_affine(
55+
ref_inp, ref_scale, ref_zero_point, axis, quant_min, quant_max
56+
)
57+
58+
with flag_gems.use_gems():
59+
res_out = torch.fake_quantize_per_channel_affine(
60+
inp, scale, zero_point, axis, quant_min, quant_max
61+
)
62+
63+
gems_assert_close(res_out, ref_out, dtype=dtype)
64+
65+
66+
@pytest.mark.fake_quantize_per_channel_affine
67+
@pytest.mark.parametrize("shape", [(2, 3, 4, 5)])
68+
@pytest.mark.parametrize("axis", [0, 1, 2, 3])
69+
def test_accuracy_fake_quantize_per_channel_affine_multi_dim(shape, axis):
70+
inp = torch.randn(shape, dtype=torch.float32, device=flag_gems.device)
71+
n_channels = shape[axis]
72+
scale = (
73+
torch.rand(n_channels, dtype=torch.float32, device=flag_gems.device) * 0.1
74+
+ 0.01
75+
)
76+
zero_point = torch.randint(
77+
0, 255, (n_channels,), dtype=torch.int32, device=flag_gems.device
78+
)
79+
80+
ref_inp = to_reference(inp)
81+
ref_scale = to_reference(scale)
82+
ref_zero_point = to_reference(zero_point)
83+
84+
ref_out = torch.fake_quantize_per_channel_affine(
85+
ref_inp, ref_scale, ref_zero_point, axis, 0, 255
86+
)
87+
88+
with flag_gems.use_gems():
89+
res_out = torch.fake_quantize_per_channel_affine(
90+
inp, scale, zero_point, axis, 0, 255
91+
)
92+
93+
gems_assert_close(res_out, ref_out, dtype=torch.float32)
94+
95+
96+
@pytest.mark.fake_quantize_per_channel_affine
97+
def test_accuracy_fake_quantize_per_channel_affine_half_to_even():
98+
inp = torch.tensor(
99+
[[-3.5, -2.5, -1.5, -0.5, 0.5, 1.5, 2.5, 3.5]],
100+
dtype=torch.float32,
101+
device=flag_gems.device,
102+
)
103+
scale = torch.ones(8, dtype=torch.float32, device=flag_gems.device)
104+
zero_point = torch.zeros(8, dtype=torch.int32, device=flag_gems.device)
105+
ref_out = torch.fake_quantize_per_channel_affine(
106+
to_reference(inp), to_reference(scale), to_reference(zero_point), 1, -128, 127
107+
)
108+
109+
with flag_gems.use_gems():
110+
res_out = torch.fake_quantize_per_channel_affine(
111+
inp, scale, zero_point, 1, -128, 127
112+
)
113+
114+
gems_assert_close(res_out, ref_out, dtype=torch.float32)
115+
116+
117+
@pytest.mark.fake_quantize_per_channel_affine
118+
def test_accuracy_fake_quantize_per_channel_affine_empty():
119+
inp = torch.empty((2, 0, 3), dtype=torch.float32, device=flag_gems.device)
120+
scale = torch.empty(0, dtype=torch.float32, device=flag_gems.device)
121+
zero_point = torch.empty(0, dtype=torch.int32, device=flag_gems.device)
122+
123+
with flag_gems.use_gems():
124+
result = torch.fake_quantize_per_channel_affine(
125+
inp, scale, zero_point, 1, 0, 255
126+
)
127+
128+
assert result.shape == inp.shape
129+
assert result.dtype == inp.dtype

0 commit comments

Comments
 (0)