Open
feat(quant): support torch.compile for per-block quantization ops #66
feat(quant): support torch.compile for per-block quantization ops #66
Open
daisysjAdd on 1 day ago
Total: 17 files changed+857-60
@@ -4,7 +4,7 @@ from tilelang.ascend import language as T
4from cann_ops_tilelang.config import require_ascend, should_print_kernel_source4from cann_ops_tilelang.config import require_ascend, should_print_kernel_source
5 5 
6from .cast_back_kernel import get_cast_back_kernel_ascend6from .cast_back_kernel import get_cast_back_kernel_ascend
7-from .common import get_cast_input_and_config, get_logical_hidden7+from .common import get_cast_input_and_config, get_logical_hidden, get_quant_vector_cores
8from .types import QuantTensor8from .types import QuantTensor
9 9 
10 10 
@@ -24,30 +24,55 @@ def cast_back(
24 24 
25 Returns:25 Returns:
26 Dequantized tensor in the requested format.26 Dequantized tensor in the requested format.
27+ 
28+ Fixed-shape npugraph_ex inference is supported. No autograd formula is registered.
27 """29 """
28- require_ascend()30+ num_cores = get_quant_vector_cores() if x[0].shape[0] else 0
31+ return _cast_back_impl(x, fmt, x_block_size, x_special_fmt, num_cores)
32+ 
33+ 
34+def _validate_cast_back(x, x_sf, fmt, x_block_size, x_special_fmt):
29 assert fmt in ('bf16', 'fp32')35 assert fmt in ('bf16', 'fp32')
30 out_dtype = torch.bfloat16 if fmt == 'bf16' else torch.float3236 out_dtype = torch.bfloat16 if fmt == 'bf16' else torch.float32
31 37 
32- x, x_sf = x
33 if x.device.type != 'npu' or x_sf.device.type != 'npu':38 if x.device.type != 'npu' or x_sf.device.type != 'npu':
34 raise ValueError('x and its scaling factors must be NPU tensors.')39 raise ValueError('x and its scaling factors must be NPU tensors.')
35 40 
36 assert x_special_fmt is None, f'Unsupported special format: {x_special_fmt}'41 assert x_special_fmt is None, f'Unsupported special format: {x_special_fmt}'
37 42 
38- num_tokens, hidden = x.shape43+ _, hidden = x.shape
39 assert x_sf.dim() == 244 assert x_sf.dim() == 2
40 assert x_sf.dtype in (torch.int16, torch.int32, torch.float32)45 assert x_sf.dtype in (torch.int16, torch.int32, torch.float32)
41 46 
42 hidden = get_logical_hidden(hidden, x.dtype)47 hidden = get_logical_hidden(hidden, x.dtype)
43 assert hidden % 64 == 048 assert hidden % 64 == 0
44 49 
45- x, x_sf, in_config = get_cast_input_and_config((x, x_sf), x_block_size)50+ _, x_sf, in_config = get_cast_input_and_config((x, x_sf), x_block_size)
51+ # Mirror the kernel's static block/layout restrictions in fake execution.
52+ assert in_config.sf_block in ((1, 32), (1, 128), (32, 1), (32, 32), (128, 1), (128, 128))
53+ assert not (in_config.sf_block == (32, 1) and in_config.use_tma_aligned_col_major_sf)
54+ return x_sf, in_config, hidden, out_dtype
55+ 
56+ 
57+@torch.library.custom_op(
58+ 'cann_ops_tilelang::cast_back',
59+ mutates_args=(),
60+ schema='(Tensor[] x, str fmt, SymInt[] x_block_size, str? x_special_fmt, int num_cores) -> Tensor',
61+)
62+def _cast_back_impl(x, fmt, x_block_size, x_special_fmt, num_cores):
63+ x_block_size = tuple(x_block_size)
64+ require_ascend()
65+ data, scale = x
66+ x_sf, in_config, hidden, out_dtype = _validate_cast_back(data, scale, fmt, x_block_size, x_special_fmt)
67+ x = data
68+ num_tokens = x.shape[0]
46 out = torch.empty((num_tokens, hidden), dtype=out_dtype, device=x.device)69 out = torch.empty((num_tokens, hidden), dtype=out_dtype, device=x.device)
47 if num_tokens == 0:70 if num_tokens == 0:
48 return out71 return out
49 72 
50- kernel = get_cast_back_kernel_ascend(hidden=hidden, in_config=in_config, out_dtype=T.dtype(out_dtype))73+ kernel = get_cast_back_kernel_ascend(
74+ hidden=hidden, in_config=in_config, out_dtype=T.dtype(out_dtype), n_cores=num_cores
75+ )
51 76 
52 if should_print_kernel_source():77 if should_print_kernel_source():
53 print(kernel.get_kernel_source())78 print(kernel.get_kernel_source())
@@ -73,5 +98,14 @@ def per_token_cast_back(
73 98 
74 Returns:99 Returns:
75 Dequantized tensor in the requested format.100 Dequantized tensor in the requested format.
101+ 
102+ Fixed-shape npugraph_ex inference is supported. No autograd formula is registered.
76 """103 """
77 return cast_back(x, fmt, (1, num_per_channels), x_special_fmt=x_special_fmt)104 return cast_back(x, fmt, (1, num_per_channels), x_special_fmt=x_special_fmt)
105+ 
106+ 
107+@_cast_back_impl.register_fake
108+def _cast_back_fake(x, fmt, x_block_size, x_special_fmt, num_cores):
109+ data, scale = x
110+ _, _, hidden, out_dtype = _validate_cast_back(data, scale, fmt, tuple(x_block_size), x_special_fmt)
111+ return torch.empty((data.shape[0], hidden), dtype=out_dtype, device=data.device)
@@ -4,7 +4,6 @@ import tilelang
4from tilelang.ascend import language as T4from tilelang.ascend import language as T
5from tilelang.ascend.language import simd as S5from tilelang.ascend.language import simd as S
6 6 
7-from cann_ops_tilelang.config import get_num_vector_cores
8from cann_ops_tilelang.utils import align_up as align7from cann_ops_tilelang.utils import align_up as align
9from cann_ops_tilelang.utils import ceil_div8from cann_ops_tilelang.utils import ceil_div
10 9 
@@ -16,9 +15,9 @@ def get_cast_back_kernel_ascend(
16 hidden: int,15 hidden: int,
17 in_config: CastInputConfig,16 in_config: CastInputConfig,
18 out_dtype: T.dtype = T.bfloat16,17 out_dtype: T.dtype = T.bfloat16,
18+ *,
19+ n_cores: int,
19):20):
20- n_cores = get_num_vector_cores()
21- 
22 num_per_tokens, num_per_channels = in_config.sf_block21 num_per_tokens, num_per_channels = in_config.sf_block
23 pack_factor = get_packed_ue8m0_pack_factor() if in_config.use_packed_ue8m0 else 122 pack_factor = get_packed_ue8m0_pack_factor() if in_config.use_packed_ue8m0 else 1
24 assert (num_per_tokens, num_per_channels) in ((1, 32), (1, 128), (32, 1), (32, 32), (128, 1), (128, 128))23 assert (num_per_tokens, num_per_channels) in ((1, 32), (1, 128), (32, 1), (32, 32), (128, 1), (128, 128))
@@ -1,16 +1,32 @@
1import functools1import functools
2+import os
2from dataclasses import dataclass3from dataclasses import dataclass
3from typing import Union # noqa: UP035 - TileLang macro annotations require typing.Union4from typing import Union # noqa: UP035 - TileLang macro annotations require typing.Union
4 5 
5import torch6import torch
6from tilelang.ascend import language as T7from tilelang.ascend import language as T
7 8 
8-from cann_ops_tilelang.config import get_amax_clamp_for_quant9+from cann_ops_tilelang.config import get_amax_clamp_for_quant, get_num_ai_cores
9from cann_ops_tilelang.utils import ceil_div, str_to_dtype10from cann_ops_tilelang.utils import ceil_div, str_to_dtype
10 11 
11from .types import QuantTensor12from .types import QuantTensor
12 13 
13 14 
15+def get_quant_vector_cores() -> int:
16+ # Compiled wrappers need this metadata without tracing require_ascend.
17+ # Keep mutable tuning settings visible to Dynamo so they remain guarded.
18+ detected = get_num_ai_cores() * 2
19+ # Guard the key set as well, including an unset-to-set environment change.
20+ key = 'OPS_TILELANG_NUM_VECTOR_CORES'
21+ override = os.environ[key] if key in tuple(os.environ) else None
22+ if override is None:
23+ return detected
24+ requested = int(override)
25+ if requested <= 0 or requested > detected:
26+ raise ValueError(f'OPS_TILELANG_NUM_VECTOR_CORES must be in [1, {detected}], got {requested}.')
27+ return requested
28+ 
29+ 
14@dataclass(frozen=True)30@dataclass(frozen=True)
15class BaseCastConfig:31class BaseCastConfig:
16 torch_dtype: torch.dtype = torch.float8_e4m3fn32 torch_dtype: torch.dtype = torch.float8_e4m3fn
@@ -303,20 +319,20 @@ def unpack_from_e2m1fn_x2(x: torch.Tensor, out_dtype: torch.dtype = torch.float3
303 if x.ndim == 0:319 if x.ndim == 0:
304 raise ValueError('x must have at least 1 dimension so the last dim can be doubled')320 raise ValueError('x must have at least 1 dimension so the last dim can be doubled')
305 321 
322+ # Device-side fills avoid temporary host scalar copies during graph capture.
323+ high_shift = torch.full((), 4, dtype=x.dtype, device=x.device)
324+ sign_shift = torch.full((), 3, dtype=torch.int16, device=x.device)
325+ exponent_shift = torch.full((), 1, dtype=torch.int16, device=x.device)
306 lo = (x & 0x0F).to(torch.int16)326 lo = (x & 0x0F).to(torch.int16)
307- hi = ((x >> 4) & 0x0F).to(torch.int16)327+ hi = ((x >> high_shift) & 0x0F).to(torch.int16)
308 328 
309 def decode_fp4_e2m1(n: torch.Tensor) -> torch.Tensor:329 def decode_fp4_e2m1(n: torch.Tensor) -> torch.Tensor:
310 # n in [0..15], layout: s(1) | e(2) | m(1)330 # n in [0..15], layout: s(1) | e(2) | m(1)
311- s = (n >> 3) & 0x1331+ s = (n >> sign_shift) & 0x1
312- e = (n >> 1) & 0x3332+ e = (n >> exponent_shift) & 0x3
313 m = n & 0x1333 m = n & 0x1
314 334 
315- sign = torch.where(335+ sign = torch.where(s == 1, -1.0, 1.0)
316- s == 1,
317- torch.tensor(-1.0, device=n.device),
318- torch.tensor(1.0, device=n.device),
319- )
320 336 
321 bias = 1337 bias = 1
322 338 
@@ -326,7 +342,7 @@ def unpack_from_e2m1fn_x2(x: torch.Tensor, out_dtype: torch.dtype = torch.float3
326 342 
327 # normal: e in {1,2,3} -> value = sign * 2^(e-bias) * (1 + m/2)343 # normal: e in {1,2,3} -> value = sign * 2^(e-bias) * (1 + m/2)
328 norm = (1.0 + m.to(torch.float32) * 0.5) * torch.pow(344 norm = (1.0 + m.to(torch.float32) * 0.5) * torch.pow(
329- torch.tensor(2.0, device=n.device),345+ 2.0,
330 (e - bias).to(torch.float32),346 (e - bias).to(torch.float32),
331 )347 )
332 348 
@@ -1,17 +1,38 @@
1import torch1import torch
2 2 
3-from cann_ops_tilelang.config import require_ascend, should_print_kernel_source3+from cann_ops_tilelang.config import get_amax_clamp_for_quant, require_ascend, should_print_kernel_source
4from cann_ops_tilelang.quant.common import (4from cann_ops_tilelang.quant.common import (
5 QuantTensor,5 QuantTensor,
6 alloc_scaling_factors,6 alloc_scaling_factors,
7 cast_epilogue,7 cast_epilogue,
8 get_cast_input_and_config,8 get_cast_input_and_config,
9- get_cast_output_config,9+ get_cast_output_config_impl,
10+ get_quant_vector_cores,
10)11)
11 12 
12from .per_block_cast_kernel import get_per_block_cast_kernel_asc13from .per_block_cast_kernel import get_per_block_cast_kernel_asc
13 14 
14 15 
16+def _validate_per_block_cast(x, fmt, block_size, options, clamp_min_value, sf=None, sf_only=False):
17+ assert x.device.type == 'npu'
18+ assert x.is_contiguous() and x.dim() == 2
19+ num_tokens, hidden = x.shape
20+ 
21+ assert num_tokens % 128 == 0
22+ assert hidden % 128 == 0
23+ _validate_per_block_scale(sf, sf_only, options)
24+ 
25+ x, _, in_config = get_cast_input_and_config(x, None)
26+ out_config = get_cast_output_config_impl(fmt, block_size, *options, clamp_min_value)
27+ return in_config, out_config
28+ 
29+ 
30+def _validate_per_block_scale(sf, sf_only, options):
31+ use_tma_aligned_col_major_sf, _, use_packed_ue8m0 = options
32+ if sf is not None:
33+ assert not sf_only and not use_tma_aligned_col_major_sf and not use_packed_ue8m0
34+ 
35+ 
15def per_block_cast_impl(36def per_block_cast_impl(
16 x: torch.Tensor,37 x: torch.Tensor,
17 fmt: str,38 fmt: str,
@@ -21,6 +42,9 @@ def per_block_cast_impl(
21 use_packed_ue8m0: bool = False,42 use_packed_ue8m0: bool = False,
22 sf_only: bool = False,43 sf_only: bool = False,
23 sf: torch.Tensor | None = None,44 sf: torch.Tensor | None = None,
45+ *,
46+ clamp_min_value: float,
47+ num_cores: int,
24) -> torch.Tensor | QuantTensor:48) -> torch.Tensor | QuantTensor:
25 """49 """
26 sf_only: If True, only compute and return sf factors.50 sf_only: If True, only compute and return sf factors.
@@ -31,24 +55,19 @@ def per_block_cast_impl(
31 is provided, or a tuple ``(out, out_sf)`` with quantized output and sf factors.55 is provided, or a tuple ``(out, out_sf)`` with quantized output and sf factors.
32 """56 """
33 require_ascend()57 require_ascend()
34- assert x.device.type == 'npu'58+ options = [use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0]
35- assert x.is_contiguous() and x.dim() == 259+ in_config, out_config = _validate_per_block_cast(
60+ x, fmt, block_size, options, clamp_min_value, sf=sf, sf_only=sf_only
61+ )
36 num_tokens, hidden = x.shape62 num_tokens, hidden = x.shape
37 63 
38- assert num_tokens % 128 == 0
39- assert hidden % 128 == 0
40- if sf is not None:
41- assert not sf_only and not use_tma_aligned_col_major_sf and not use_packed_ue8m0
42- 
43- x, _, in_config = get_cast_input_and_config(x, None)
44- out_config = get_cast_output_config(fmt, block_size, use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0)
45- 
46 kernel = get_per_block_cast_kernel_asc(64 kernel = get_per_block_cast_kernel_asc(
47 hidden=hidden,65 hidden=hidden,
48 in_config=in_config,66 in_config=in_config,
49 out_config=out_config,67 out_config=out_config,
50 sf_only=sf_only,68 sf_only=sf_only,
51 cast_only=(sf is not None),69 cast_only=(sf is not None),
70+ n_cores=num_cores,
52 )71 )
53 72 
54 if should_print_kernel_source():73 if should_print_kernel_source():
@@ -102,7 +121,14 @@ def per_block_cast(
102 Returns:121 Returns:
103 Tuple of quantized output and sf factors.122 Tuple of quantized output and sf factors.
104 """123 """
105- return per_block_cast_impl(x, fmt, block_size, use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0)124+ return _per_block_cast_op(
125+ x,
126+ fmt,
127+ list(block_size),
128+ [use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0],
129+ get_amax_clamp_for_quant(fmt),
130+ get_quant_vector_cores(),
131+ )
106 132 
107 133 
108def per_block_cast_with_sf_only(134def per_block_cast_with_sf_only(
@@ -126,8 +152,13 @@ def per_block_cast_with_sf_only(
126 Returns:152 Returns:
127 Scaling factors only153 Scaling factors only
128 """154 """
129- return per_block_cast_impl(155+ return _per_block_cast_op_with_sf_only(
130- x, fmt, block_size, use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0, sf_only=True156+ x,
157+ fmt,
158+ list(block_size),
159+ [use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0],
160+ get_amax_clamp_for_quant(fmt),
161+ get_quant_vector_cores(),
131 )162 )
132 163 
133 164 
@@ -154,4 +185,94 @@ def per_block_cast_with_precomputed_sf(
154 Returns:185 Returns:
155 Casted tensor using the precomputed sf factors.186 Casted tensor using the precomputed sf factors.
156 """187 """
157- return per_block_cast_impl(x, fmt, block_size, use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0, sf=sf)188+ return _per_block_cast_op_with_precomputed_sf(
189+ x,
190+ fmt,
191+ list(block_size),
192+ [use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0],
193+ get_amax_clamp_for_quant(fmt),
194+ get_quant_vector_cores(),
195+ sf,
196+ )
197+ 
198+ 
199+def _registered_block_kwargs(x, fmt, block_size, options, clamp_min_value, num_cores):
200+ return dict(
201+ x=x,
202+ fmt=fmt,
203+ block_size=tuple(block_size),
204+ use_tma_aligned_col_major_sf=options[0],
205+ round_sf=options[1],
206+ use_packed_ue8m0=options[2],
207+ clamp_min_value=clamp_min_value,
208+ num_cores=num_cores,
209+ )
210+ 
211+ 
212+@torch.library.custom_op('cann_ops_tilelang::per_block_cast', mutates_args=())
213+def _per_block_cast_op(
214+ x: torch.Tensor,
215+ fmt: str,
216+ block_size: list[int],
217+ options: list[bool],
218+ clamp_min_value: float,
219+ num_cores: int,
220+) -> tuple[torch.Tensor, torch.Tensor]:
221+ return per_block_cast_impl(**_registered_block_kwargs(x, fmt, block_size, options, clamp_min_value, num_cores))
222+ 
223+ 
224+@_per_block_cast_op.register_fake
225+def _per_block_cast_fake(x, fmt, block_size, options, clamp_min_value, num_cores):
226+ _, config = _validate_per_block_cast(x, fmt, tuple(block_size), options, clamp_min_value)
227+ data = torch.empty(
228+ (x.shape[0], x.shape[1] if fmt == 'e4m3' else x.shape[1] // 2),
229+ dtype=config.torch_dtype,
230+ device=x.device,
231+ )
232+ scale = cast_epilogue(alloc_scaling_factors(x.shape, config, device=x.device), config)
233+ return data, scale
234+ 
235+ 
236+@torch.library.custom_op('cann_ops_tilelang::per_block_cast_with_sf_only', mutates_args=())
237+def _per_block_cast_op_with_sf_only(
238+ x: torch.Tensor,
239+ fmt: str,
240+ block_size: list[int],
241+ options: list[bool],
242+ clamp_min_value: float,
243+ num_cores: int,
244+) -> torch.Tensor:
245+ return per_block_cast_impl(
246+ **_registered_block_kwargs(x, fmt, block_size, options, clamp_min_value, num_cores), sf_only=True
247+ )
248+ 
249+ 
250+@_per_block_cast_op_with_sf_only.register_fake
251+def _per_block_cast_with_sf_only_fake(x, fmt, block_size, options, clamp_min_value, num_cores):
252+ _, config = _validate_per_block_cast(x, fmt, tuple(block_size), options, clamp_min_value, sf_only=True)
253+ return cast_epilogue(alloc_scaling_factors(x.shape, config, device=x.device), config)
254+ 
255+ 
256+@torch.library.custom_op('cann_ops_tilelang::per_block_cast_with_precomputed_sf', mutates_args=())
257+def _per_block_cast_op_with_precomputed_sf(
258+ x: torch.Tensor,
259+ fmt: str,
260+ block_size: list[int],
261+ options: list[bool],
262+ clamp_min_value: float,
263+ num_cores: int,
264+ sf: torch.Tensor,
265+) -> torch.Tensor:
266+ return per_block_cast_impl(
267+ **_registered_block_kwargs(x, fmt, block_size, options, clamp_min_value, num_cores), sf=sf
268+ )
269+ 
270+ 
271+@_per_block_cast_op_with_precomputed_sf.register_fake
272+def _per_block_cast_with_precomputed_sf_fake(x, fmt, block_size, options, clamp_min_value, num_cores, sf):
273+ _, config = _validate_per_block_cast(x, fmt, tuple(block_size), options, clamp_min_value, sf=sf)
274+ return torch.empty(
275+ (x.shape[0], x.shape[1] if fmt == 'e4m3' else x.shape[1] // 2),
276+ dtype=config.torch_dtype,
277+ device=x.device,
278+ )
@@ -7,7 +7,6 @@ import tilelang
7from tilelang.ascend import language as T7from tilelang.ascend import language as T
8from tilelang.ascend.language import simd as S8from tilelang.ascend.language import simd as S
9 9 
10-from cann_ops_tilelang.config import get_num_vector_cores
11from cann_ops_tilelang.quant.common import (10from cann_ops_tilelang.quant.common import (
12 CastInputConfig,11 CastInputConfig,
13 CastOutputConfig,12 CastOutputConfig,
@@ -22,10 +21,10 @@ def get_per_block_cast_kernel_asc(
22 hidden: int,21 hidden: int,
23 in_config: CastInputConfig,22 in_config: CastInputConfig,
24 out_config: CastOutputConfig,23 out_config: CastOutputConfig,
24+ n_cores: int,
25 sf_only: bool = False,25 sf_only: bool = False,
26 cast_only: bool = False,26 cast_only: bool = False,
27):27):
28- n_cores = get_num_vector_cores()
29 num_per_tokens, num_per_channels = out_config.sf_block28 num_per_tokens, num_per_channels = out_config.sf_block
30 assert num_per_tokens in (32, 128) and num_per_channels in (32, 128)29 assert num_per_tokens in (32, 128) and num_per_channels in (32, 128)
31 assert out_config.dtype in (T.float4_e2m1fn, T.float8_e4m3fn)30 assert out_config.dtype in (T.float4_e2m1fn, T.float8_e4m3fn)
@@ -1,13 +1,14 @@
1import torch1import torch
2 2 
3-from cann_ops_tilelang.config import require_ascend, should_print_kernel_source3+from cann_ops_tilelang.config import get_amax_clamp_for_quant, require_ascend, should_print_kernel_source
4from cann_ops_tilelang.quant.common import (4from cann_ops_tilelang.quant.common import (
5 QuantTensor,5 QuantTensor,
6 alloc_scaling_factors,6 alloc_scaling_factors,
7 cast_epilogue,7 cast_epilogue,
8 get_cast_input_and_config,8 get_cast_input_and_config,
9- get_cast_output_config,9+ get_cast_output_config_impl,
10 get_logical_hidden,10 get_logical_hidden,
11+ get_quant_vector_cores,
11)12)
12 13 
13from .per_block_cast_lossless_kernel import get_per_block_cast_lossless_kernel_asc14from .per_block_cast_lossless_kernel import get_per_block_cast_lossless_kernel_asc
@@ -36,7 +37,13 @@ def per_block_cast_lossless(
36 Returns:37 Returns:
37 A tuple ``(out, out_sf)`` with FP8 output and sf-factor tensor.38 A tuple ``(out, out_sf)`` with FP8 output and sf-factor tensor.
38 """39 """
39- require_ascend()40+ options = [use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0]
41+ return _per_block_cast_lossless_impl(
42+ x, fmt, x_block_size, out_block_size, options, get_amax_clamp_for_quant(fmt), get_quant_vector_cores()
43+ )
44+ 
45+ 
46+def _validate_per_block_cast_lossless(x, fmt, x_block_size, out_block_size, options, clamp_min_value):
40 x, x_sf, in_config = get_cast_input_and_config(x, x_block_size)47 x, x_sf, in_config = get_cast_input_and_config(x, x_block_size)
41 48 
42 assert fmt == 'e4m3' and x.dtype == torch.int8, 'lossless cast only supports e2m1 -> e4m3 input'49 assert fmt == 'e4m3' and x.dtype == torch.int8, 'lossless cast only supports e2m1 -> e4m3 input'
@@ -47,7 +54,7 @@ def per_block_cast_lossless(
47 assert num_tokens % 128 == 0, f'lossless cast requires num_tokens % 128 == 0, got {num_tokens}'54 assert num_tokens % 128 == 0, f'lossless cast requires num_tokens % 128 == 0, got {num_tokens}'
48 assert hidden % 32 == 055 assert hidden % 32 == 0
49 56 
50- out_config = get_cast_output_config(fmt, out_block_size, use_tma_aligned_col_major_sf, round_sf, use_packed_ue8m0)57+ out_config = get_cast_output_config_impl(fmt, out_block_size, *options, clamp_min_value)
51 58 
52 assert x_block_size == (1, 32), 'ascend lossless only supports in (1, 32)'59 assert x_block_size == (1, 32), 'ascend lossless only supports in (1, 32)'
53 assert out_block_size in (60 assert out_block_size in (
@@ -55,12 +62,32 @@ def per_block_cast_lossless(
55 (1, 128),62 (1, 128),
56 (128, 128),63 (128, 128),
57 ), 'ascend lossless only supports out (32, 32) / (1, 128) / (128, 128)'64 ), 'ascend lossless only supports out (32, 32) / (1, 128) / (128, 128)'
65+ return x, x_sf, in_config, out_config
66+ 
67+ 
68+@torch.library.custom_op('cann_ops_tilelang::per_block_cast_lossless', mutates_args=())
69+def _per_block_cast_lossless_impl(
70+ x: list[torch.Tensor],
71+ fmt: str,
72+ x_block_size: list[int],
73+ out_block_size: list[int],
74+ options: list[bool],
75+ clamp_min_value: float,
76+ num_cores: int,
77+) -> tuple[torch.Tensor, torch.Tensor]:
78+ require_ascend()
79+ x, x_sf, in_config, out_config = _validate_per_block_cast_lossless(
80+ tuple(x), fmt, tuple(x_block_size), tuple(out_block_size), options, clamp_min_value
81+ )
82+ num_tokens, physical_hidden = x.shape
83+ hidden = get_logical_hidden(physical_hidden, x.dtype)
58 # FP4 is stored as packed int8; reinterpret as the 2x-packed fp4 dtype the kernel expects.84 # FP4 is stored as packed int8; reinterpret as the 2x-packed fp4 dtype the kernel expects.
59 x = x.view(torch.float4_e2m1fn_x2)85 x = x.view(torch.float4_e2m1fn_x2)
60 kernel = get_per_block_cast_lossless_kernel_asc(86 kernel = get_per_block_cast_lossless_kernel_asc(
61 hidden=hidden,87 hidden=hidden,
62 in_config=in_config,88 in_config=in_config,
63 out_config=out_config,89 out_config=out_config,
90+ num_cores=num_cores,
64 )91 )
65 92 
66 if should_print_kernel_source():93 if should_print_kernel_source():
@@ -79,3 +106,14 @@ def per_block_cast_lossless(
79 out_sf = cast_epilogue(out_sf, out_config)106 out_sf = cast_epilogue(out_sf, out_config)
80 107 
81 return out, out_sf108 return out, out_sf
109+ 
110+ 
111+@_per_block_cast_lossless_impl.register_fake
112+def _per_block_cast_lossless_fake(x, fmt, x_block_size, out_block_size, options, clamp_min_value, num_cores):
113+ x, _, _, config = _validate_per_block_cast_lossless(
114+ tuple(x), fmt, tuple(x_block_size), tuple(out_block_size), options, clamp_min_value
115+ )
116+ shape = (x.shape[0], get_logical_hidden(x.shape[1], x.dtype))
117+ out = torch.empty(shape, dtype=config.torch_dtype, device=x.device)
118+ scale = cast_epilogue(alloc_scaling_factors(shape, config, device=x.device), config)
119+ return out, scale
@@ -7,7 +7,6 @@ import tilelang
7from tilelang.ascend import language as T7from tilelang.ascend import language as T
8from tilelang.ascend.language import simd as S8from tilelang.ascend.language import simd as S
9 9 
10-from cann_ops_tilelang.config import get_num_vector_cores
11from cann_ops_tilelang.quant.common import (10from cann_ops_tilelang.quant.common import (
12 CastInputConfig,11 CastInputConfig,
13 CastOutputConfig,12 CastOutputConfig,
@@ -61,7 +60,9 @@ def init_fp4_to_fp8_map(fp4_to_fp8_map_ub, invalid_exp_ub):
61 60 
62 61 
63@tilelang.jit()62@tilelang.jit()
64-def get_per_block_cast_lossless_kernel_asc(hidden: int, in_config: CastInputConfig, out_config: CastOutputConfig):63+def get_per_block_cast_lossless_kernel_asc(
64+ hidden: int, in_config: CastInputConfig, out_config: CastOutputConfig, num_cores: int
65+):
65 assert in_config.dtype == T.float4_e2m1fn and out_config.dtype == T.float8_e4m3fn66 assert in_config.dtype == T.float4_e2m1fn and out_config.dtype == T.float8_e4m3fn
66 assert in_config.sf_block == (1, 32)67 assert in_config.sf_block == (1, 32)
67 assert out_config.sf_block in ((32, 32), (1, 128), (128, 128))68 assert out_config.sf_block in ((32, 32), (1, 128), (128, 128))
@@ -118,7 +119,6 @@ def get_per_block_cast_lossless_kernel_asc(hidden: int, in_config: CastInputConf
118 vec_size = 64119 vec_size = 64
119 assert vec_size * num_per_channels >= block_k120 assert vec_size * num_per_channels >= block_k
120 121 
121- num_cores = get_num_vector_cores()
122 sf_dtype, in_pack_factor = get_sf_storage(in_config, T.uint32)122 sf_dtype, in_pack_factor = get_sf_storage(in_config, T.uint32)
123 num_sf_load_cols = num_sf_cols_per_block // in_pack_factor123 num_sf_load_cols = num_sf_cols_per_block // in_pack_factor
124 out_sf_dtype, out_pack_factor = get_sf_storage(out_config, T.float32)124 out_sf_dtype, out_pack_factor = get_sf_storage(out_config, T.float32)
@@ -1,12 +1,13 @@
1import torch1import torch
2 2 
3-from cann_ops_tilelang.config import require_ascend, should_print_kernel_source3+from cann_ops_tilelang.config import get_amax_clamp_for_quant, require_ascend, should_print_kernel_source
4from cann_ops_tilelang.quant.common import (4from cann_ops_tilelang.quant.common import (
5 QuantTensor,5 QuantTensor,
6 alloc_scaling_factors,6 alloc_scaling_factors,
7 cast_epilogue,7 cast_epilogue,
8 get_cast_input_and_config,8 get_cast_input_and_config,
9- get_cast_output_config,9+ get_cast_output_config_impl,
10+ get_quant_vector_cores,
10 get_sf_shape,11 get_sf_shape,
11)12)
12 13 
@@ -35,11 +36,33 @@ def per_channel_cast(
35 36 
36 Returns:37 Returns:
37 A tuple ``(out, out_sf)`` with FP8 output and row-major sf-factor tensor.38 A tuple ``(out, out_sf)`` with FP8 output and row-major sf-factor tensor.
39+ 
40+ Fixed-shape npugraph_ex inference is supported. Clamp configuration is an
41+ explicit internal graph input; no autograd formula is registered.
38 """42 """
39- require_ascend()43+ clamp_min_value = get_amax_clamp_for_quant(fmt)
44+ data, scale = x if isinstance(x, tuple) else (x, None)
45+ return _per_channel_cast_impl(
46+ data,
47+ scale,
48+ fmt,
49+ num_per_tokens,
50+ round_sf,
51+ num_per_channels,
52+ use_packed_ue8m0,
53+ clamp_min_value,
54+ get_quant_vector_cores(),
55+ )
56+ 
57+ 
58+def _validate_per_channel_cast(x, fmt, num_per_tokens, round_sf, num_per_channels, use_packed_ue8m0, clamp_min_value):
40 x, x_sf_invs, in_config = get_cast_input_and_config(x, None if num_per_channels is None else (1, num_per_channels))59 x, x_sf_invs, in_config = get_cast_input_and_config(x, None if num_per_channels is None else (1, num_per_channels))
41 60 
42 assert fmt == 'e4m3'61 assert fmt == 'e4m3'
62+ if in_config.with_sf:
63+ assert x.dtype == torch.float8_e4m3fn, 'per_channel_cast with scaling factors requires e4m3 input'
64+ else:
65+ assert x.dtype == torch.bfloat16, 'per_channel_cast without scaling factors requires bf16 input'
43 assert x.device.type == 'npu'66 assert x.device.type == 'npu'
44 assert x.dim() == 2 and x.is_contiguous()67 assert x.dim() == 2 and x.is_contiguous()
45 assert num_per_tokens in (32, 128)68 assert num_per_tokens in (32, 128)
@@ -53,11 +76,44 @@ def per_channel_cast(
53 if in_config.use_tma_aligned_col_major_sf and in_config.use_packed_ue8m0:76 if in_config.use_tma_aligned_col_major_sf and in_config.use_packed_ue8m0:
54 x_sf_invs = x_sf_invs.view(torch.int16)77 x_sf_invs = x_sf_invs.view(torch.int16)
55 78 
56- out_config = get_cast_output_config(fmt, (num_per_tokens, 1), False, round_sf, use_packed_ue8m0)79+ out_config = get_cast_output_config_impl(
80+ fmt, (num_per_tokens, 1), False, round_sf, use_packed_ue8m0, clamp_min_value
81+ )
82+ quant_max = 448.0
83+ assert out_config.clamp_min_value >= quant_max * (2 ** (-126)), (
84+ 'Ascend scale exponent fast path requires clamp to normal number'
85+ )
57 assert num_tokens % 128 == 086 assert num_tokens % 128 == 0
87+ return x, x_sf_invs, in_config, out_config
88+ 
89+ 
90+@torch.library.custom_op('cann_ops_tilelang::per_channel_cast', mutates_args=())
91+def _per_channel_cast_impl(
92+ data: torch.Tensor,
93+ scale: torch.Tensor | None,
94+ fmt: str,
95+ num_per_tokens: int,
96+ round_sf: bool,
97+ num_per_channels: int | None,
98+ use_packed_ue8m0: bool,
99+ clamp_min_value: float,
100+ num_cores: int,
101+) -> tuple[torch.Tensor, torch.Tensor]:
102+ source = data if scale is None else (data, scale)
103+ require_ascend()
104+ x, x_sf_invs, in_config, out_config = _validate_per_channel_cast(
105+ source,
106+ fmt,
107+ num_per_tokens,
108+ round_sf,
109+ num_per_channels,
110+ use_packed_ue8m0,
111+ clamp_min_value,
112+ )
113+ num_tokens, hidden = x.shape
58 out = torch.empty((num_tokens, hidden), dtype=out_config.torch_dtype, device=x.device)114 out = torch.empty((num_tokens, hidden), dtype=out_config.torch_dtype, device=x.device)
59 out_sf = alloc_scaling_factors((num_tokens, hidden), out_config, device=x.device)115 out_sf = alloc_scaling_factors((num_tokens, hidden), out_config, device=x.device)
60- kernel = get_per_channel_cast_kernel_asc(hidden, in_config=in_config, out_config=out_config)116+ kernel = get_per_channel_cast_kernel_asc(hidden, in_config=in_config, out_config=out_config, num_cores=num_cores)
61 117 
62 if should_print_kernel_source():118 if should_print_kernel_source():
63 print(kernel.get_kernel_source())119 print(kernel.get_kernel_source())
@@ -66,3 +122,22 @@ def per_channel_cast(
66 kernel(x, out, out_sf, x_sf_invs)122 kernel(x, out, out_sf, x_sf_invs)
67 out_sf = cast_epilogue(out_sf, out_config)123 out_sf = cast_epilogue(out_sf, out_config)
68 return out, out_sf124 return out, out_sf
125+ 
126+ 
127+@_per_channel_cast_impl.register_fake
128+def _per_channel_cast_fake(
129+ data, scale, fmt, num_per_tokens, round_sf, num_per_channels, use_packed_ue8m0, clamp_min_value, num_cores
130+):
131+ source = data if scale is None else (data, scale)
132+ _, _, _, config = _validate_per_channel_cast(
133+ source,
134+ fmt,
135+ num_per_tokens,
136+ round_sf,
137+ num_per_channels,
138+ use_packed_ue8m0,
139+ clamp_min_value,
140+ )
141+ output = torch.empty(data.shape, dtype=config.torch_dtype, device=data.device)
142+ scales = alloc_scaling_factors(tuple(data.shape), config, data.device)
143+ return output, cast_epilogue(scales, config)
@@ -4,7 +4,6 @@ import tilelang
4from tilelang.ascend import language as T4from tilelang.ascend import language as T
5from tilelang.ascend.language import simd as S5from tilelang.ascend.language import simd as S
6 6 
7-from cann_ops_tilelang.config import get_num_vector_cores
8from cann_ops_tilelang.quant.common import (7from cann_ops_tilelang.quant.common import (
9 CastInputConfig,8 CastInputConfig,
10 CastOutputConfig,9 CastOutputConfig,
@@ -19,6 +18,7 @@ def get_per_channel_cast_kernel_asc(
19 hidden: int,18 hidden: int,
20 in_config: CastInputConfig,19 in_config: CastInputConfig,
21 out_config: CastOutputConfig,20 out_config: CastOutputConfig,
21+ num_cores: int,
22):22):
23 # bf16 hidden >= 2048 时用 nbg=1ļ¼ˆčæ­ä»£ę•°ēæ»å€ļ¼ŒMTE2 čÆ·ę±‚ę›“é¢‘ē¹ļ¼Œbf16 2048 改善 ~4%);23 # bf16 hidden >= 2048 时用 nbg=1ļ¼ˆčæ­ä»£ę•°ēæ»å€ļ¼ŒMTE2 čÆ·ę±‚ę›“é¢‘ē¹ļ¼Œbf16 2048 改善 ~4%);
24 # 小 hidden(128) äæęŒ nbg=2 仄聚合 pack_rowļ¼Œéæå…é€€åŒ–ć€‚24 # 小 hidden(128) äæęŒ nbg=2 仄聚合 pack_rowļ¼Œéæå…é€€åŒ–ć€‚
@@ -91,8 +91,6 @@ def get_per_channel_cast_kernel_asc(
91 else:91 else:
92 num_blocks_per_group = 192 num_blocks_per_group = 1
93 93 
94- num_cores = get_num_vector_cores()
95- 
96 num_tokens = T.dynamic('num_tokens')94 num_tokens = T.dynamic('num_tokens')
97 sf_stride = T.dynamic('sf_stride')95 sf_stride = T.dynamic('sf_stride')
98 sf_shape = get_sf_shape((num_tokens, hidden), out_config)96 sf_shape = get_sf_shape((num_tokens, hidden), out_config)
@@ -1,9 +1,9 @@
1import torch1import torch
2from tilelang.ascend import language as T2from tilelang.ascend import language as T
3 3 
4-from cann_ops_tilelang.config import require_ascend, should_print_kernel_source4+from cann_ops_tilelang.config import get_amax_clamp_for_quant, require_ascend, should_print_kernel_source
5 5 
6-from .common import get_cast_output_config6+from .common import get_cast_output_config_impl, get_quant_vector_cores
7from .per_token_cast_and_cast_back_kernel import get_per_token_cast_and_cast_back_kernel_asc7from .per_token_cast_and_cast_back_kernel import get_per_token_cast_and_cast_back_kernel_asc
8 8 
9 9 
@@ -21,26 +21,56 @@ def per_token_cast_and_cast_back(
21 fmt: Quantized format used for cast-and-cast-back (``'e4m3'`` or ``'e2m1'``).21 fmt: Quantized format used for cast-and-cast-back (``'e4m3'`` or ``'e2m1'``).
22 num_per_channels: Number of channels in each scaling block (32, 64 or 128).22 num_per_channels: Number of channels in each scaling block (32, 64 or 128).
23 round_sf: Whether to round scaling factors to powers of two.23 round_sf: Whether to round scaling factors to powers of two.
24+ 
25+ Returns None and mutates only x. Fixed-shape npugraph_ex inference is
26+ supported; the registered operator declares the same mutation contract.
24 """27 """
25- require_ascend()28+ clamp_min_value = get_amax_clamp_for_quant(fmt)
29+ _cast_and_back_impl(x, fmt, num_per_channels, round_sf, clamp_min_value, get_quant_vector_cores())
30+ return None
31+ 
32+ 
33+def _validate_cast_and_back(x, fmt, num_per_channels, round_sf, clamp_min_value):
26 if x.device.type != 'npu':34 if x.device.type != 'npu':
27 raise ValueError(f'x must be an NPU tensor, got {x.device}.')35 raise ValueError(f'x must be an NPU tensor, got {x.device}.')
28 assert x.dim() == 236 assert x.dim() == 2
37+ assert x.dtype in (torch.bfloat16, torch.float16, torch.float32), (
38+ 'Ascend per-token cast and cast-back only supports bf16, fp16 or fp32 input'
39+ )
29 assert x.stride(1) == 140 assert x.stride(1) == 1
30 assert fmt in ('e4m3', 'e2m1')41 assert fmt in ('e4m3', 'e2m1')
31- assert num_per_channels in (32, 64, 128)42+ assert num_per_channels in (32, 64, 128), (
43+ 'Ascend per-token cast and cast-back only supports num_per_channels=32/64/128'
44+ )
32 45 
33- num_tokens, hidden = x.shape46+ _, hidden = x.shape
47+ assert hidden % 64 == 0, 'Ascend per-token cast and cast-back hidden must be a multiple of 64'
34 assert hidden % num_per_channels == 048 assert hidden % num_per_channels == 0
35 49 
36- out_config = get_cast_output_config(fmt, (1, num_per_channels), round_sf=round_sf)50+ out_config = get_cast_output_config_impl(fmt, (1, num_per_channels), False, round_sf, False, clamp_min_value)
37 assert out_config.sf_block[0] == 151 assert out_config.sf_block[0] == 1
38 52 
53+ return out_config
54+ 
55+ 
56+@torch.library.custom_op('cann_ops_tilelang::per_token_cast_and_cast_back', mutates_args=('x',))
57+def _cast_and_back_impl(
58+ x: torch.Tensor,
59+ fmt: str,
60+ num_per_channels: int,
61+ round_sf: bool,
62+ clamp_min_value: float,
63+ num_cores: int,
64+) -> None:
65+ require_ascend()
66+ out_config = _validate_cast_and_back(x, fmt, num_per_channels, round_sf, clamp_min_value)
67+ num_tokens, hidden = x.shape
39 kernel = get_per_token_cast_and_cast_back_kernel_asc(68 kernel = get_per_token_cast_and_cast_back_kernel_asc(
40 hidden=hidden,69 hidden=hidden,
41 token_stride=x.stride(0),70 token_stride=x.stride(0),
42 dtype=T.dtype(x.dtype),71 dtype=T.dtype(x.dtype),
43 out_config=out_config,72 out_config=out_config,
73+ n_cores=num_cores,
44 )74 )
45 75 
46 if should_print_kernel_source():76 if should_print_kernel_source():
@@ -48,3 +78,8 @@ def per_token_cast_and_cast_back(
48 78 
49 if num_tokens > 0:79 if num_tokens > 0:
50 kernel(x)80 kernel(x)
81+ 
82+ 
83+@_cast_and_back_impl.register_fake
84+def _cast_and_back_fake(x, fmt, num_per_channels, round_sf, clamp_min_value, num_cores):
85+ _validate_cast_and_back(x, fmt, num_per_channels, round_sf, clamp_min_value)
@@ -2,7 +2,6 @@ import tilelang
2from tilelang.ascend import language as T2from tilelang.ascend import language as T
3from tilelang.ascend.language import simd as S3from tilelang.ascend.language import simd as S
4 4 
5-from cann_ops_tilelang.config import get_num_vector_cores
6from cann_ops_tilelang.utils import align_up as align5from cann_ops_tilelang.utils import align_up as align
7 6 
8from .common import CastOutputConfig7from .common import CastOutputConfig
@@ -102,6 +101,7 @@ def get_per_token_cast_and_cast_back_kernel_asc(
102 token_stride: int,101 token_stride: int,
103 dtype: T.dtype,102 dtype: T.dtype,
104 out_config: CastOutputConfig,103 out_config: CastOutputConfig,
104+ n_cores: int,
105):105):
106 num_per_channels = out_config.sf_block[1]106 num_per_channels = out_config.sf_block[1]
107 is_bf16_in = dtype == T.bfloat16107 is_bf16_in = dtype == T.bfloat16
@@ -120,7 +120,6 @@ def get_per_token_cast_and_cast_back_kernel_asc(
120 round_carry = 0x003FFFFF if is_fp4_out else 0x001FFFFF120 round_carry = 0x003FFFFF if is_fp4_out else 0x001FFFFF
121 quant_max_exp = 0x01000000 if is_fp4_out else 0x04000000121 quant_max_exp = 0x01000000 if is_fp4_out else 0x04000000
122 122 
123- n_cores = get_num_vector_cores()
124 num_stages = 4 if hidden >= 2560 else 3123 num_stages = 4 if hidden >= 2560 else 3
125 124 
126 block_m, block_k, tile_width = _get_block_shape(hidden, dtype, num_stages, num_per_channels, n_cores)125 block_m, block_k, tile_width = _get_block_shape(hidden, dtype, num_stages, num_per_channels, n_cores)
@@ -189,3 +189,120 @@ def test_per_token_cast_compile_then_cast_back(fmt):
189 restored = cann_ops_tilelang.per_token_cast_back((data, sf), 'fp32', num_per_channels=32)189 restored = cann_ops_tilelang.per_token_cast_back((data, sf), 'fp32', num_per_channels=32)
190 assert_equal(restored, cast_back(reference, 'fp32', (1, 32)))190 assert_equal(restored, cast_back(reference, 'fp32', (1, 32)))
191 del data, sf191 del data, sf
192+ 
193+ 
194+@pytest.mark.parametrize(
195+ ('entry', 'fmt', 'out_dtype', 'block_size', 'packed'),
196+ [('block', 'e4m3', 'fp32', (32, 32), False), ('token', 'e2m1', 'bf16', (1, 32), True)],
197+)
198+@torch.inference_mode()
199+def test_cast_back_compile(entry, fmt, out_dtype, block_size, packed):
200+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
201+ 
202+ compiled = compile_quant_graph(
203+ cann_ops_tilelang.cast_back if entry == 'block' else cann_ops_tilelang.per_token_cast_back
204+ )
205+ source = None
206+ records = []
207+ for step in range(3):
208+ raw = torch.randn((33, 256), dtype=torch.bfloat16, device=get_device())
209+ refreshed = cast(raw, fmt, block_size, round_sf=True, use_packed_ue8m0=packed)
210+ if step == 1:
211+ source[0].view(torch.uint8).copy_(refreshed[0].view(torch.uint8))
212+ source[1].copy_(refreshed[1])
213+ else:
214+ source = refreshed
215+ snapshots = tuple(value.clone() for value in source)
216+ expected = cast_back(source, out_dtype, block_size)
217+ scale_arg = block_size if entry == 'block' else block_size[1]
218+ result, events = call_quant_graph(compiled, source, out_dtype, scale_arg)
219+ records.append(events)
220+ assert_equal(result, expected)
221+ for value, before in zip(source, snapshots, strict=True):
222+ assert_equal(value, before)
223+ del result
224+ check_quant_graph_replay(records)
225+ from cann_ops_tilelang.quant.common import get_quant_vector_cores
226+ 
227+ torch.library.opcheck(
228+ torch.ops.cann_ops_tilelang.cast_back.default,
229+ (source, out_dtype, block_size, None, get_quant_vector_cores()),
230+ # TorchNPU schema_check uses allclose, which does not support FP8 inputs.
231+ # FP4 runs the same schema check; both cases check read-only inputs above.
232+ test_utils=('test_faketensor',) if fmt == 'e4m3' else ('test_schema', 'test_faketensor'),
233+ )
234+ 
235+ 
236+@torch.inference_mode()
237+def test_unpack_fp4_compile():
238+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
239+ 
240+ # Independent table for all sixteen E2M1 codes, including signed zero.
241+ levels = torch.tensor([0, 0.5, 1, 1.5, 2, 3, 4, 6, -0.0, -0.5, -1, -1.5, -2, -3, -4, -6], device=get_device())
242+ compiled = compile_quant_graph(cann_ops_tilelang.unpack_from_e2m1fn_x2)
243+ values = torch.arange(256, device=get_device(), dtype=torch.int32)
244+ x = values.to(torch.uint8).view(torch.int8).reshape(16, 16)
245+ records = []
246+ for step in range(3):
247+ if step == 1:
248+ x.copy_(((values + 17) % 256).to(torch.uint8).view(torch.int8).reshape_as(x))
249+ elif step == 2:
250+ x = torch.flip(x, dims=[0]).clone()
251+ before = x.clone()
252+ codes = x.to(torch.int32)
253+ expected = torch.stack((levels[codes & 15], levels[(codes >> 4) & 15]), dim=-1).reshape(16, 32)
254+ if step == 1:
255+ # Reuse temporary host scalar buffers before replaying the captured graph.
256+ host_buffers = [torch.full((), 7, dtype=torch.int32, pin_memory=True) for _ in range(128)]
257+ result, events = call_quant_graph(compiled, x)
258+ if step == 1:
259+ del host_buffers
260+ records.append(events)
261+ assert_equal(result, expected)
262+ assert_equal(x, before)
263+ del result
264+ check_quant_graph_replay(records)
265+ 
266+ 
267+@pytest.mark.parametrize('entry', ['block', 'token'])
268+@torch.inference_mode()
269+def test_cast_back_compile_special_values(entry):
270+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
271+ 
272+ block = (32, 32) if entry == 'block' else (1, 32)
273+ compiled = compile_quant_graph(
274+ cann_ops_tilelang.cast_back if entry == 'block' else cann_ops_tilelang.per_token_cast_back
275+ )
276+ source = None
277+ records = []
278+ for step in range(3):
279+ if entry == 'block':
280+ data = torch.full((33, 256), float(step), dtype=torch.bfloat16, device=get_device())
281+ data = data.to(torch.float8_e4m3fn)
282+ else:
283+ data = torch.full((33, 128), 0x22 * step, dtype=torch.int8, device=get_device())
284+ scale = torch.full(((33 + block[0] - 1) // block[0], 8), 1.0, dtype=torch.float32, device=get_device())
285+ if step:
286+ scale.fill_(2.0 if step == 1 else 0.5)
287+ row = 0 if step == 1 else scale.shape[0] - 1
288+ scale[row, 0] = float('nan')
289+ scale[row, 1] = float('inf')
290+ scale[row, 2] = -float('inf')
291+ if step == 1:
292+ source[0].view(torch.uint8).copy_(data.view(torch.uint8))
293+ source[1].copy_(scale)
294+ else:
295+ source = (data, scale)
296+ snapshots = tuple(value.clone() for value in source)
297+ expected = cast_back(source, 'fp32', block)
298+ scale_arg = block if entry == 'block' else block[1]
299+ result, events = call_quant_graph(compiled, source, 'fp32', scale_arg)
300+ records.append(events)
301+ # NaN payloads are unspecified; finite values, signed zeros and infinities remain bit-exact.
302+ nan_mask = torch.isnan(expected)
303+ assert_equal(torch.isnan(result), nan_mask)
304+ assert_equal(result[~nan_mask], expected[~nan_mask])
305+ for value, before in zip(source, snapshots, strict=True):
306+ assert_equal(value, before)
307+ del result
308+ check_quant_graph_replay(records)
@@ -1,6 +1,7 @@
1import os1import os
2 2 
3import pytest3import pytest
4+import torch
4 5 
5import cann_ops_tilelang6import cann_ops_tilelang
6from cann_ops_tilelang.utils import str_to_dtype7from cann_ops_tilelang.utils import str_to_dtype
@@ -120,3 +121,86 @@ def test_per_block_cast_benchmark(benchmark_timer, benchmark_record, params):
120 time_us=t_us,121 time_us=t_us,
121 bandwidth_gbs=num_bytes / t_us / 1e3,122 bandwidth_gbs=num_bytes / t_us / 1e3,
122 )123 )
124+ 
125+ 
126+@pytest.mark.parametrize('entry', ['cast', 'sf_only'])
127+@pytest.mark.parametrize(
128+ ('fmt', 'dtype', 'block_size', 'tma', 'packed'),
129+ [
130+ ('e4m3', torch.bfloat16, (32, 32), False, False),
131+ ('e2m1', torch.float32, (128, 128), True, True),
132+ ],
133+)
134+@torch.inference_mode()
135+def test_per_block_cast_compile(entry, fmt, dtype, block_size, tma, packed):
136+ from cann_ops_tilelang.config import get_amax_clamp_for_quant
137+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
138+ 
139+ op = cann_ops_tilelang.per_block_cast if entry == 'cast' else cann_ops_tilelang.per_block_cast_with_sf_only
140+ compiled = compile_quant_graph(op)
141+ kw = dict(use_tma_aligned_col_major_sf=tma, round_sf=True, use_packed_ue8m0=packed)
142+ x = torch.zeros((128, 256), dtype=dtype, device='npu')
143+ records = []
144+ for step in range(3):
145+ if step == 1:
146+ x.normal_()
147+ elif step == 2:
148+ x = torch.randn_like(x)
149+ before = x.clone()
150+ expected = cast(x, fmt, block_size, **kw)
151+ result, events = call_quant_graph(compiled, x, fmt, block_size, **kw)
152+ records.append(events)
153+ outputs = result if entry == 'cast' else (result,)
154+ references = expected if entry == 'cast' else (expected[1],)
155+ for actual, reference in zip(outputs, references, strict=True):
156+ assert_equal(actual, reference)
157+ assert_equal(x, before)
158+ del result, outputs, actual
159+ check_quant_graph_replay(records)
160+ registered = getattr(
161+ torch.ops.cann_ops_tilelang, 'per_block_cast' if entry == 'cast' else 'per_block_cast_with_sf_only'
162+ )
163+ from cann_ops_tilelang.quant.common import get_quant_vector_cores
164+ 
165+ torch.library.opcheck(
166+ registered.default,
167+ (x, fmt, list(block_size), [tma, True, packed], get_amax_clamp_for_quant(fmt), get_quant_vector_cores()),
168+ test_utils=('test_schema', 'test_faketensor'),
169+ )
170+ 
171+ 
172+@pytest.mark.parametrize('fmt', ['e4m3', 'e2m1'])
173+@torch.inference_mode()
174+def test_per_block_cast_precomputed_compile(fmt):
175+ from cann_ops_tilelang.config import get_amax_clamp_for_quant
176+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
177+ 
178+ compiled = compile_quant_graph(cann_ops_tilelang.per_block_cast_with_precomputed_sf)
179+ block_size = (32, 32)
180+ x = torch.randn((128, 256), dtype=torch.bfloat16, device='npu')
181+ sf = cast(x, fmt, block_size, round_sf=True)[1]
182+ records = []
183+ for step in range(3):
184+ if step == 1:
185+ x.neg_()
186+ sf.mul_(2)
187+ elif step == 2:
188+ x = torch.randn_like(x)
189+ sf = cast(x, fmt, block_size, round_sf=True)[1]
190+ before = (x.clone(), sf.clone())
191+ expected = cast(x, fmt, block_size, sf=sf)
192+ result, events = call_quant_graph(compiled, x, fmt, block_size, sf)
193+ records.append(events)
194+ assert_equal(result.view(torch.uint8), expected.view(torch.uint8))
195+ assert result.shape == expected.shape and result.dtype == expected.dtype
196+ assert_equal(x, before[0])
197+ assert_equal(sf, before[1])
198+ del result
199+ check_quant_graph_replay(records)
200+ from cann_ops_tilelang.quant.common import get_quant_vector_cores
201+ 
202+ torch.library.opcheck(
203+ torch.ops.cann_ops_tilelang.per_block_cast_with_precomputed_sf.default,
204+ (x, fmt, list(block_size), [False, False, False], get_amax_clamp_for_quant(fmt), get_quant_vector_cores(), sf),
205+ test_utils=('test_schema', 'test_faketensor'),
206+ )
@@ -133,3 +133,62 @@ def test_per_block_cast_lossless_benchmark(benchmark_timer, benchmark_record, pa
133 time_us=t_us,133 time_us=t_us,
134 bandwidth_gbs=num_bytes / t_us / 1e3,134 bandwidth_gbs=num_bytes / t_us / 1e3,
135 )135 )
136+ 
137+ 
138+@pytest.mark.parametrize(
139+ ('out_block', 'tma', 'packed'),
140+ [
141+ ((32, 32), False, False),
142+ ((1, 128), True, True),
143+ ((128, 128), False, True),
144+ ],
145+)
146+@torch.inference_mode()
147+def test_per_block_cast_lossless_compile(out_block, tma, packed):
148+ from cann_ops_tilelang.config import get_amax_clamp_for_quant
149+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
150+ 
151+ compiled = compile_quant_graph(cann_ops_tilelang.per_block_cast_lossless)
152+ kw = dict(use_tma_aligned_col_major_sf=tma, round_sf=True, use_packed_ue8m0=packed)
153+ source = None
154+ records = []
155+ for step in range(3):
156+ raw = clamp_abs_ratio(torch.randn((128, 256), dtype=torch.float32, device='npu'))
157+ refreshed = cast(raw, 'e2m1', (1, 32), **kw)
158+ if step == 1:
159+ source[0].copy_(refreshed[0])
160+ source[1].copy_(refreshed[1])
161+ else:
162+ source = refreshed
163+ snapshots = tuple(value.clone() for value in source)
164+ strides = tuple(value.stride() for value in source)
165+ expected = cast_back(source, 'fp32', (1, 32))
166+ reference = cann_ops_tilelang.per_block_cast_lossless(source, 'e4m3', (1, 32), out_block, **kw)
167+ result, events = call_quant_graph(compiled, source, 'e4m3', (1, 32), out_block, **kw)
168+ records.append(events)
169+ assert_equal(cast_back(result, 'fp32', out_block), expected)
170+ assert_equal(result[0].view(torch.uint8), reference[0].view(torch.uint8))
171+ assert_equal(result[1], reference[1])
172+ for actual, ref in zip(result, reference, strict=True):
173+ assert actual.shape == ref.shape and actual.stride() == ref.stride()
174+ assert actual.dtype == ref.dtype and actual.device == ref.device
175+ for value, before, stride in zip(source, snapshots, strides, strict=True):
176+ assert_equal(value, before, check_stride=False)
177+ assert value.stride() == stride
178+ del result, actual
179+ check_quant_graph_replay(records)
180+ from cann_ops_tilelang.quant.common import get_quant_vector_cores
181+ 
182+ torch.library.opcheck(
183+ torch.ops.cann_ops_tilelang.per_block_cast_lossless.default,
184+ (
185+ source,
186+ 'e4m3',
187+ [1, 32],
188+ list(out_block),
189+ [tma, True, packed],
190+ get_amax_clamp_for_quant('e4m3'),
191+ get_quant_vector_cores(),
192+ ),
193+ test_utils=('test_schema', 'test_faketensor'),
194+ )
@@ -1,6 +1,7 @@
1import os1import os
2 2 
3import pytest3import pytest
4+import torch
4 5 
5import cann_ops_tilelang6import cann_ops_tilelang
6from cann_ops_tilelang.utils import str_to_dtype7from cann_ops_tilelang.utils import str_to_dtype
@@ -155,3 +156,122 @@ def test_per_channel_cast_benchmark(benchmark_timer, benchmark_record, params):
155 time_us=t_us,156 time_us=t_us,
156 bandwidth_gbs=num_bytes / t_us / 1e3,157 bandwidth_gbs=num_bytes / t_us / 1e3,
157 )158 )
159+ 
160+ 
161+def _refresh_per_channel_cast_input(source, raw, step, prequantized):
162+ refreshed = cast(raw, 'e4m3', (1, 128), round_sf=True) if prequantized else raw
163+ if step != 1:
164+ return refreshed
165+ if prequantized:
166+ source[0].view(torch.uint8).copy_(refreshed[0].view(torch.uint8))
167+ source[1].copy_(refreshed[1])
168+ else:
169+ source.copy_(refreshed)
170+ return source
171+ 
172+ 
173+def _check_per_channel_cast_registration(source, token_group, kw):
174+ from cann_ops_tilelang.config import get_amax_clamp_for_quant
175+ 
176+ data, scale = source if isinstance(source, tuple) else (source, None)
177+ from cann_ops_tilelang.quant.common import get_quant_vector_cores
178+ 
179+ torch.library.opcheck(
180+ torch.ops.cann_ops_tilelang.per_channel_cast.default,
181+ (
182+ data,
183+ scale,
184+ 'e4m3',
185+ token_group,
186+ kw['round_sf'],
187+ kw['num_per_channels'],
188+ kw['use_packed_ue8m0'],
189+ get_amax_clamp_for_quant('e4m3'),
190+ get_quant_vector_cores(),
191+ ),
192+ # FP8 allclose is unavailable in TorchNPU's standard schema checker.
193+ # The BF16 case covers schema; both cases verify input bytes in the caller.
194+ test_utils=('test_faketensor',) if scale is not None else ('test_schema', 'test_faketensor'),
195+ )
196+ 
197+ 
198+@pytest.mark.parametrize('prequantized', [False, True])
199+@torch.inference_mode()
200+def test_per_channel_cast_compile(prequantized):
201+ from cann_ops_tilelang.config import get_device
202+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
203+ 
204+ packed = prequantized
205+ token_group = 128 if prequantized else 32
206+ # Two token groups keep every packed scale byte meaningful in this core case.
207+ tokens = 256 if prequantized else 128
208+ channel_group = 128 if prequantized else None
209+ kw = dict(round_sf=packed, num_per_channels=channel_group, use_packed_ue8m0=packed)
210+ compiled = compile_quant_graph(cann_ops_tilelang.per_channel_cast)
211+ source = None
212+ records = []
213+ for step in range(3):
214+ raw = torch.randn((tokens, 256), dtype=torch.bfloat16, device=get_device())
215+ source = _refresh_per_channel_cast_input(source, raw, step, prequantized)
216+ inputs = source if isinstance(source, tuple) else (source,)
217+ snapshots = tuple(value.clone() for value in inputs)
218+ expected = cast(
219+ source,
220+ 'e4m3',
221+ (token_group, 1),
222+ x_block_size=(1, 128) if prequantized else None,
223+ round_sf=packed,
224+ use_packed_ue8m0=packed,
225+ )
226+ result, events = call_quant_graph(compiled, source, 'e4m3', token_group, **kw)
227+ records.append(events)
228+ for actual, reference in zip(result, expected, strict=True):
229+ assert_equal(actual, reference)
230+ for value, before in zip(inputs, snapshots, strict=True):
231+ assert_equal(value, before)
232+ del result, actual
233+ check_quant_graph_replay(records)
234+ _check_per_channel_cast_registration(source, token_group, kw)
235+ 
236+ 
237+@torch.inference_mode()
238+def test_per_channel_cast_compile_special_values():
239+ from cann_ops_tilelang.config import get_device
240+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
241+ 
242+ compiled = compile_quant_graph(cann_ops_tilelang.per_channel_cast)
243+ x = torch.zeros((128, 256), dtype=torch.bfloat16, device=get_device())
244+ finite_columns = torch.ones(256, dtype=torch.bool, device=x.device)
245+ finite_columns[0] = finite_columns[32] = finite_columns[64] = False
246+ records = []
247+ for step in range(3):
248+ if step == 1:
249+ x.fill_(0.5)
250+ elif step == 2:
251+ x = torch.full_like(x, -1.25)
252+ if step:
253+ x[:, 96:128].zero_()
254+ row = 0 if step == 1 else 32
255+ x[row, 0] = float('nan')
256+ x[row, 32] = float('inf')
257+ x[row, 64] = -float('inf')
258+ before = x.clone()
259+ reference = cast(x, 'e4m3', (32, 1), round_sf=True)
260+ eager = cann_ops_tilelang.per_channel_cast(x, 'e4m3', 32, round_sf=True)
261+ result, events = call_quant_graph(compiled, x, 'e4m3', 32, round_sf=True)
262+ records.append(events)
263+ for actual, expected in zip(result, eager, strict=True):
264+ assert_equal(actual, expected)
265+ # Existing non-finite quantization differs from cast_ref. Check graph/eager above;
266+ # independently check every column for zero input, and finite-only columns otherwise.
267+ if step == 0:
268+ for actual, expected in zip(result, reference, strict=True):
269+ assert_equal(actual, expected)
270+ else:
271+ assert_equal(
272+ result[0].view(torch.uint8)[:, finite_columns], reference[0].view(torch.uint8)[:, finite_columns]
273+ )
274+ assert_equal(result[1][:, finite_columns], reference[1][:, finite_columns])
275+ assert_equal(x, before)
276+ del result, actual, eager, reference
277+ check_quant_graph_replay(records)
@@ -103,3 +103,74 @@ def test_per_token_cast_and_cast_back_benchmark(benchmark_timer, benchmark_recor
103 time_us=t_us,103 time_us=t_us,
104 bandwidth_gbs=num_bytes / t_us / 1e3,104 bandwidth_gbs=num_bytes / t_us / 1e3,
105 )105 )
106+ 
107+ 
108+@torch.inference_mode()
109+def test_per_token_cast_and_cast_back_compile():
110+ from cann_ops_tilelang.config import get_amax_clamp_for_quant
111+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
112+ 
113+ compiled = compile_quant_graph(cann_ops_tilelang.per_token_cast_and_cast_back)
114+ backing = torch.randn((31, 512), dtype=torch.bfloat16, device=get_device())
115+ records = []
116+ for step in range(3):
117+ if step == 1:
118+ backing.neg_().add_(0.125)
119+ elif step == 2:
120+ backing = torch.randn_like(backing)
121+ x = backing[:, :256]
122+ before = backing.clone()
123+ expected = cast_back(cast(x, 'e2m1', (1, 32), round_sf=True), 'bf16', (1, 32))
124+ pointer, stride, offset = x.data_ptr(), x.stride(), x.storage_offset()
125+ result, events = call_quant_graph(compiled, x, 'e2m1', 32, round_sf=True)
126+ records.append(events)
127+ assert result is None
128+ assert_equal(x, expected, check_stride=False)
129+ assert_equal(backing[:, 256:], before[:, 256:])
130+ assert (x.data_ptr(), x.stride(), x.storage_offset()) == (pointer, stride, offset)
131+ check_quant_graph_replay(records)
132+ from cann_ops_tilelang.quant.common import get_quant_vector_cores
133+ 
134+ torch.library.opcheck(
135+ torch.ops.cann_ops_tilelang.per_token_cast_and_cast_back.default,
136+ (x, 'e2m1', 32, True, get_amax_clamp_for_quant('e2m1'), get_quant_vector_cores()),
137+ test_utils=('test_schema', 'test_faketensor'),
138+ )
139+ 
140+ 
141+@torch.inference_mode()
142+def test_per_token_cast_and_cast_back_compile_special_values():
143+ from tests.testing.quant import call_quant_graph, check_quant_graph_replay, compile_quant_graph
144+ 
145+ compiled = compile_quant_graph(cann_ops_tilelang.per_token_cast_and_cast_back)
146+ backing = torch.zeros((31, 512), dtype=torch.bfloat16, device=get_device())
147+ records = []
148+ for step in range(3):
149+ if step == 1:
150+ backing.fill_(0.5)
151+ elif step == 2:
152+ backing = torch.full_like(backing, -1.25)
153+ x = backing[:, :256]
154+ finite_mask = torch.ones_like(x, dtype=torch.bool)
155+ if step:
156+ x[:, 96:128].zero_()
157+ row = 0 if step == 1 else x.shape[0] - 1
158+ x[row, 0] = float('nan')
159+ x[row, 32] = float('inf')
160+ x[row, 64] = -float('inf')
161+ finite_mask[row, :96] = False
162+ before = backing.clone()
163+ reference = cast_back(cast(x, 'e2m1', (1, 32), round_sf=True), 'bf16', (1, 32))
164+ eager = x.clone()
165+ cann_ops_tilelang.per_token_cast_and_cast_back(eager, 'e2m1', 32, round_sf=True)
166+ pointer, stride, offset = x.data_ptr(), x.stride(), x.storage_offset()
167+ result, events = call_quant_graph(compiled, x, 'e2m1', 32, round_sf=True)
168+ records.append(events)
169+ assert result is None
170+ assert_equal(x, eager, check_stride=False)
171+ # Non-finite quantization has an existing cast_ref discrepancy. Keep the
172+ # independent check bit-exact on zero input and all finite-only groups.
173+ assert_equal(x[finite_mask], reference[finite_mask])
174+ assert_equal(backing[:, 256:], before[:, 256:])
175+ assert (x.data_ptr(), x.stride(), x.storage_offset()) == (pointer, stride, offset)
176+ check_quant_graph_replay(records)
@@ -1,3 +1,5 @@
1+import logging
2+ 
1import torch3import torch
2 4 
3from cann_ops_tilelang.quant.common import get_packed_ue8m0_pack_factor, unpack_from_e2m1fn_x25from cann_ops_tilelang.quant.common import get_packed_ue8m0_pack_factor, unpack_from_e2m1fn_x2
@@ -65,3 +67,33 @@ def clear_unused_sf_col_pack(sf: torch.Tensor, num_tokens: int, num_per_tokens:
65 sf_unpacked = sf_uint8.view(aligned_num_token_blocks // pack_factor, -1, pack_factor)67 sf_unpacked = sf_uint8.view(aligned_num_token_blocks // pack_factor, -1, pack_factor)
66 sf_unpacked[num_token_blocks // pack_factor :, :, num_token_blocks % pack_factor :] = 068 sf_unpacked[num_token_blocks // pack_factor :, :, num_token_blocks % pack_factor :] = 0
67 return sf_uint8.view(aligned_num_token_blocks // pack_factor, -1)69 return sf_uint8.view(aligned_num_token_blocks // pack_factor, -1)
70+ 
71+ 
72+def compile_quant_graph(op):
73+ """Build an independent fixed-shape graph for a small correctness test."""
74+ torch.compiler.reset()
75+ return torch.compile(op, backend='npugraph_ex', fullgraph=True, dynamic=False)
76+ 
77+ 
78+def call_quant_graph(compiled, *args, **kwargs):
79+ """Return one call's output and evidence of capture/replay, not just Dynamo tracing."""
80+ with torch.profiler.profile(activities=[torch.profiler.ProfilerActivity.CPU]) as profile:
81+ result = compiled(*args, **kwargs)
82+ torch.npu.synchronize()
83+ events = {event.key: event.count for event in profile.key_averages()}
84+ names = ('acl_graph_capture', 'acl_graph_replay', 'fx_run_eagerly')
85+ counts = {name: events.get(name, 0) for name in names}
86+ logging.getLogger(__name__).info('NPU graph events: %s', counts)
87+ return result, counts
88+ 
89+ 
90+def check_quant_graph_replay(records):
91+ """Same-storage input updates must replay; mutated state may recapture after replacement."""
92+ if not any(record['acl_graph_capture'] for record in records):
93+ raise AssertionError(f'No NPU graph capture recorded: {records}')
94+ if records[1]['acl_graph_replay'] <= 0:
95+ raise AssertionError(f'The second call did not replay the NPU graph: {records}')
96+ if records[1]['acl_graph_capture'] != 0:
97+ raise AssertionError(f'The second call recaptured the NPU graph: {records}')
98+ if not all(record['fx_run_eagerly'] == 0 for record in records):
99+ raise AssertionError(f'Unexpected eager fallback: {records}')