Skip to content

Commit 78060ed

Browse files
committed
Add a SymInt-free tensor specialization key for exact torch.Tensor args
_tensor_key wraps every size and stride element in _hashable_dim to normalize torch.SymInt into a hashable (shape_env_id, expr) pair. That wrap exists only for symbolic shapes, which appear on FakeTensors during tracing -- yet concrete tensors paid for it on every kernel call: two Python-level loops (sizes + strides) with an isinstance check per dimension, plus rebuilding each as a fresh tuple. For the default static_shapes=True path this was the bulk of per-tensor key extraction (~0.6us each, x number of tensor args, every call). Specialization-extractor dispatch is by exact type (_specialization_extractors.get(type(obj))), so the torch.Tensor and torch.nn.Parameter entries are only ever hit by objects whose sizes/strides are guaranteed concrete ints. Route those to a new _concrete_tensor_key that uses obj.size() (a torch.Size) and obj.stride() directly as key components. Both are tuple subclasses: they hash and compare identically to plain int tuples, so keys produced by the fast path and the SymInt path for the same concrete shape are interchangeable -- no cache invalidation, and the specialization axes (dtype, shape, stride, _dynamo_static_indices, int32/int64 index width, dynamic-shape buckets) are bit-identical. Anything that can carry SymInts keeps the safe path: * FakeTensor has its own dispatch entry -> _tensor_key (unchanged) * torch.Tensor *subclasses* (e.g. the JAX-export adapter) miss the exact-type dict and hit the isinstance fallback in _specialization_key, which now routes to _tensor_key explicitly (This is the same optimization as the Python-only part of upstream PR #2611, independently arrived at from profiling.) New test/test_tensor_key_fast_path.py pins down the key equivalence (fast vs wrapped key hash/compare equal under static and dynamic shapes, incl. strided tensors), the dispatch routing (concrete -> _concrete_tensor_key; FakeTensor and the subclass fallback -> _tensor_key), that a torch.Tensor subclass takes the SymInt-safe path and still shares a BoundKernel with a plain tensor of the same shape, and that bind() caching/distinguishing is unchanged. Also verified against test_misc, test_specialize, test_torch_compile (the torch.compile suite exercises the FakeTensor/SymInt path). Benchmark (B200, end-to-end wall time per call, add-style kernel, N=4096 fp32, HELION_AUTOTUNE_EFFORT=none, steady state, 20k iters): ``` n_args | baseline | prev commit | this commit -------+----------+-------------+------------ 2 | 24.14 us | 18.01 us | 17.25 us 8 | 33.05 us | 25.57 us | 24.50 us 16 | 43.56 us | 36.41 us | 32.16 us ``` Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> stack-info: PR: #2748, branch: yushangdi/stack/30
1 parent facf399 commit 78060ed

2 files changed

Lines changed: 188 additions & 4 deletions

File tree

helion/runtime/kernel.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,10 @@ def _specialization_key(self, obj: object) -> Hashable:
358358
extractor = _specialization_extractors[torch.fx.GraphModule]
359359
elif isinstance(obj, torch.Tensor):
360360
# torch.Tensor subclasses (e.g. the JAX-export adapter)
361-
# share the standard tensor specialization key.
362-
extractor = _specialization_extractors[torch.Tensor]
361+
# share the standard tensor specialization key. Use the
362+
# SymInt-safe extractor: unlike exact ``torch.Tensor``,
363+
# subclasses may carry symbolic sizes/strides.
364+
extractor = _specialization_extractors["tensor_subclass"]
363365
elif isinstance(obj, tuple) and hasattr(obj, "_fields"):
364366
# this is a namedtuple
365367
extractor = _specialization_extractors["namedtuple"]
@@ -1335,6 +1337,36 @@ def _hashable_dims(dims: Sequence[int | torch.SymInt]) -> tuple[Hashable, ...]:
13351337
return tuple(_hashable_dim(s) for s in dims)
13361338

13371339

1340+
def _concrete_tensor_key(fn: Kernel, obj: torch.Tensor) -> Hashable:
1341+
# Fast extractor for plain ``torch.Tensor`` / ``torch.nn.Parameter``:
1342+
# exact-type dispatch guarantees concrete int sizes/strides, so
1343+
# ``torch.Size`` and the stride tuple can be used directly (both are
1344+
# tuple subclasses that hash/compare identically to plain int tuples).
1345+
# The ``_hashable_dims`` wrap in ``_tensor_key`` exists only to
1346+
# normalize SymInts, which appear on FakeTensors during tracing.
1347+
si = getattr(obj, "_dynamo_static_indices", None)
1348+
static_indices = frozenset(si) if si is not None else _EMPTY_FROZENSET
1349+
if fn.settings.static_shapes:
1350+
return (obj.dtype, obj.size(), obj.stride(), static_indices)
1351+
bucketed = _bucketed_size(obj)
1352+
if fn.settings.index_dtype is None:
1353+
try:
1354+
needs_int64 = bool(obj.numel() > _INT32_INDEX_LIMIT)
1355+
except RuntimeError:
1356+
needs_int64 = True # unbacked SymInt
1357+
return (
1358+
obj.dtype,
1359+
bucketed,
1360+
needs_int64,
1361+
static_indices,
1362+
)
1363+
return (
1364+
obj.dtype,
1365+
bucketed,
1366+
static_indices,
1367+
)
1368+
1369+
13381370
def _tensor_key(fn: Kernel, obj: torch.Tensor) -> Hashable:
13391371
si = getattr(obj, "_dynamo_static_indices", None)
13401372
static_indices = frozenset(si) if si is not None else _EMPTY_FROZENSET
@@ -1414,9 +1446,17 @@ def _graph_module_key(fn: Kernel, obj: torch.fx.GraphModule) -> Hashable:
14141446
type[object] | str, Callable[[Kernel, object], Hashable]
14151447
# pyrefly: ignore [bad-assignment]
14161448
] = {
1417-
torch.Tensor: _tensor_key,
1418-
torch.nn.Parameter: _tensor_key,
1449+
# Exact-type dispatch (see ``_specialization_key``): plain tensors and
1450+
# Parameters always have concrete int sizes/strides and take the fast
1451+
# extractor. Subclasses (FakeTensor below, or anything hitting the
1452+
# ``isinstance`` fallback) go through SymInt-safe ``_tensor_key``.
1453+
torch.Tensor: _concrete_tensor_key,
1454+
torch.nn.Parameter: _concrete_tensor_key,
14191455
FakeTensor: _tensor_key,
1456+
# SymInt-safe extractor for torch.Tensor subclasses reached via the
1457+
# isinstance fallback in ``_specialization_key`` (string key so the
1458+
# fallback stays loosely typed, like "namedtuple" / "dataclass").
1459+
"tensor_subclass": _tensor_key,
14201460
torch.dtype: lambda fn, x: x,
14211461
torch.device: lambda fn, x: x,
14221462
int: _number_key,

test/test_tensor_key_fast_path.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for the ``_concrete_tensor_key`` specialization-key fast path.
2+
3+
``torch.Tensor`` and ``torch.nn.Parameter`` are dispatched (by exact type)
4+
to ``_concrete_tensor_key``, which uses ``tensor.size()`` / ``tensor.stride()``
5+
directly instead of the ``_hashable_dims`` SymInt-normalization wrap that
6+
``_tensor_key`` applies. Anything that can carry a SymInt -- ``FakeTensor``
7+
and ``torch.Tensor`` *subclasses* reached via the ``isinstance`` fallback --
8+
still goes through ``_tensor_key``.
9+
10+
These tests pin down:
11+
1. The fast-path key hashes and compares equal to the wrapped key, so
12+
existing BoundKernel / on-disk caches don't silently miss.
13+
2. The dispatch table routes concrete tensors, FakeTensors, and the
14+
subclass-fallback to the right extractor.
15+
3. Tensor subclasses take the SymInt-safe path without error.
16+
4. ``bind()`` still caches/distinguishes correctly across dtype and shape.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import unittest
22+
23+
import torch
24+
25+
import helion
26+
import helion.language as hl
27+
from helion.runtime.kernel import _concrete_tensor_key
28+
from helion.runtime.kernel import _specialization_extractors
29+
from helion.runtime.kernel import _tensor_key
30+
31+
32+
@helion.kernel(static_shapes=True)
33+
def _vector_add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
34+
out = torch.empty_like(x)
35+
for tile in hl.tile(x.size(0)):
36+
out[tile] = x[tile] + y[tile]
37+
return out
38+
39+
40+
@helion.kernel(static_shapes=False)
41+
def _vector_add_dynamic(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
42+
out = torch.empty_like(x)
43+
for tile in hl.tile(x.size(0)):
44+
out[tile] = x[tile] + y[tile]
45+
return out
46+
47+
48+
class TestTensorKeyFastPath(unittest.TestCase):
49+
def test_dispatch_routes_concrete_tensor_to_fast_path(self) -> None:
50+
"""``torch.Tensor`` / ``torch.nn.Parameter`` dispatch to the fast
51+
extractor; ``FakeTensor`` and the subclass fallback keep the
52+
SymInt-safe ``_tensor_key``."""
53+
from torch._subclasses.fake_tensor import FakeTensor
54+
55+
self.assertIs(_specialization_extractors[torch.Tensor], _concrete_tensor_key)
56+
self.assertIs(
57+
_specialization_extractors[torch.nn.Parameter], _concrete_tensor_key
58+
)
59+
self.assertIs(_specialization_extractors[FakeTensor], _tensor_key)
60+
# The fallback for torch.Tensor subclasses is registered under a
61+
# string key so the dispatch site stays loosely typed.
62+
self.assertIs(_specialization_extractors["tensor_subclass"], _tensor_key)
63+
64+
def test_fast_path_key_hash_matches_wrapped_static_shapes(self) -> None:
65+
"""Fast-path key must hash and compare identically to the wrapped
66+
key under static_shapes=True; otherwise cache entries created under
67+
one path silently miss under the other."""
68+
x = torch.empty(4096, dtype=torch.float32)
69+
fast = _concrete_tensor_key(_vector_add, x)
70+
wrapped = _tensor_key(_vector_add, x)
71+
self.assertEqual(hash(fast), hash(wrapped))
72+
self.assertEqual(fast, wrapped)
73+
74+
def test_fast_path_key_hash_matches_wrapped_dynamic_shapes(self) -> None:
75+
"""Equivalence must also hold on the dynamic-shape (bucketed) path,
76+
where the key carries the int32/int64 index-width bit."""
77+
x = torch.empty(4096, dtype=torch.float32)
78+
fast = _concrete_tensor_key(_vector_add_dynamic, x)
79+
wrapped = _tensor_key(_vector_add_dynamic, x)
80+
self.assertEqual(hash(fast), hash(wrapped))
81+
self.assertEqual(fast, wrapped)
82+
83+
def test_fast_path_key_matches_wrapped_for_strided_tensor(self) -> None:
84+
"""A non-contiguous (transposed) tensor exercises the stride
85+
component; the fast and wrapped keys must still agree."""
86+
x = torch.empty(8, 16, dtype=torch.float32).transpose(0, 1)
87+
self.assertFalse(x.is_contiguous())
88+
fast = _concrete_tensor_key(_vector_add, x)
89+
wrapped = _tensor_key(_vector_add, x)
90+
self.assertEqual(hash(fast), hash(wrapped))
91+
self.assertEqual(fast, wrapped)
92+
93+
def test_fast_path_uses_unwrapped_size_under_static_shapes(self) -> None:
94+
"""The size component is the raw ``torch.Size`` (the wrapped form is
95+
always a plain tuple), confirming the fast path actually skips
96+
``_hashable_dims``."""
97+
x = torch.empty(4096, dtype=torch.float32)
98+
key = _concrete_tensor_key(_vector_add, x)
99+
assert isinstance(key, tuple)
100+
self.assertIs(type(key[1]), torch.Size)
101+
self.assertEqual(tuple(key[1]), (4096,))
102+
self.assertEqual(key[2], (1,))
103+
104+
def test_subclass_routes_to_symint_safe_extractor(self) -> None:
105+
"""A ``torch.Tensor`` subclass misses the exact-type dict and takes
106+
the ``isinstance`` fallback -> ``_tensor_key`` (not the fast path).
107+
The computed key must match the fast path for the same shape so a
108+
subclass and a plain tensor share a BoundKernel."""
109+
110+
class MyTensor(torch.Tensor):
111+
pass
112+
113+
base = torch.empty(64, dtype=torch.float32)
114+
sub = base.as_subclass(MyTensor)
115+
self.assertIsNot(type(sub), torch.Tensor)
116+
# Goes through Kernel._specialization_key's isinstance fallback.
117+
sub_key = _vector_add._specialization_key(sub)
118+
plain_key = _vector_add._specialization_key(base)
119+
self.assertEqual(sub_key, plain_key)
120+
self.assertEqual(hash(sub_key), hash(plain_key))
121+
122+
def test_bind_caches_across_tensors_with_same_spec(self) -> None:
123+
"""``bind()`` reuses one BoundKernel for distinct tensor objects of
124+
the same dtype/shape/stride."""
125+
x1 = torch.randn(64, device="cpu")
126+
y1 = torch.randn(64, device="cpu")
127+
x2 = torch.randn(64, device="cpu")
128+
y2 = torch.randn(64, device="cpu")
129+
self.assertIs(_vector_add.bind((x1, y1)), _vector_add.bind((x2, y2)))
130+
131+
def test_bind_distinguishes_dtype_and_shape(self) -> None:
132+
x_f32 = torch.randn(64, dtype=torch.float32, device="cpu")
133+
x_f64 = torch.randn(64, dtype=torch.float64, device="cpu")
134+
x_big = torch.randn(128, dtype=torch.float32, device="cpu")
135+
self.assertIsNot(
136+
_vector_add.bind((x_f32, x_f32)), _vector_add.bind((x_f64, x_f64))
137+
)
138+
self.assertIsNot(
139+
_vector_add.bind((x_f32, x_f32)), _vector_add.bind((x_big, x_big))
140+
)
141+
142+
143+
if __name__ == "__main__":
144+
unittest.main()

0 commit comments

Comments
 (0)