Skip to content

Commit db76610

Browse files
committed
Numba: no-dup vectorization promise for distinct-index inc-scatter
Fusing a value-compute into an `inc`-scatter normally scalarizes the whole loop: the read-modify-write on out[idx[i]] is a possible cross-iteration dependency the LoopVectorizer can't rule out. When the indices are statically known to be distinct there is no such dependency, so we promise it to LLVM with an access group on every loop memory op plus `llvm.loop.parallel_accesses` on the latch -- the value-compute then vectorizes (LLVM scalarizes only the indexed stores, which AVX2 has no scatter for). - FuseIndexedElemwise gates the promise on `_has_unique_indices` (a constant with unique entries, or a `unique_indices` assumption), only for `inc` (`set` already vectorizes), recording it as a 4th `indexed_outputs` field that flows into the cache key. - make_loop_call emits a `distinct !{}` access group (a small MDValue subclass, since llvmlite can't emit distinct nodes and a uniqued !{} crashes the verifier) and tags the indexed RMW load/store, the index loads, and the input loads -- every memory op must join the group or LLVM's isAnnotatedParallel rejects the loop. Builds on the scratch-slot store redirection (prior commit). Validated: arithmetic value-compute goes 0 -> 3 packed ops with the promise; results unchanged with/without it. The transcendental win composes once a vector math library is wired (orthogonal veclib work).
1 parent 4e47539 commit db76610

4 files changed

Lines changed: 177 additions & 21 deletions

File tree

pytensor/link/numba/dispatch/elemwise.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ def impl(*outer_inputs):
794794

795795
return impl
796796

797-
cache_version = 2
797+
cache_version = 3
798798
if scalar_cache_key is None:
799799
key = None
800800
else:

pytensor/link/numba/dispatch/vectorize_codegen.py

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import numba
1010
import numpy as np
1111
from llvmlite import ir
12+
from llvmlite.ir.values import MDValue
1213
from numba import TypingError, types
1314
from numba.core import cgutils
1415
from numba.core.base import BaseContext
@@ -25,6 +26,31 @@
2526
ensure_self_ref_metadata_support()
2627

2728

29+
class _DistinctEmptyMetadata(MDValue):
30+
"""A ``distinct !{}`` metadata node, usable as an LLVM access group.
31+
32+
llvmlite's ``MDValue`` only emits *uniqued* ``!{}`` nodes, which LLVM rejects as
33+
access groups: an access group must be ``distinct`` so two function-local accesses
34+
are never considered identical (a plain uniqued ``!{}`` crashes the verifier). Each
35+
instance has its own identity, so every loop that asks for one gets a fresh group.
36+
"""
37+
38+
def __init__(self, parent):
39+
super().__init__(parent, [], name=str(len(parent.metadata)))
40+
41+
def descr(self, buf):
42+
buf += ("distinct !{}", "\n")
43+
44+
def __eq__(self, other):
45+
return self is other
46+
47+
def __ne__(self, other):
48+
return self is not other
49+
50+
def __hash__(self):
51+
return id(self)
52+
53+
2854
def encode_literals(literals: Sequence) -> str:
2955
return base64.encodebytes(pickle.dumps(literals)).decode()
3056

@@ -108,8 +134,8 @@ def _compute_idx_load_axes(indexed_inputs, indexed_outputs, idx_ndims):
108134
----------
109135
indexed_inputs : tuple of ((tuple[int, ...], int) | None)
110136
Per-index: (source input positions, source axis) or None.
111-
indexed_outputs : tuple of ((tuple[int, ...], int, str) | None)
112-
Per-index: (output positions, axis, mode) or None.
137+
indexed_outputs : tuple of ((tuple[int, ...], int, str, bool) | None)
138+
Per-index: (output positions, axis, mode, distinct) or None.
113139
idx_ndims : tuple of int
114140
Number of dimensions of each index array.
115141
"""
@@ -150,7 +176,7 @@ def find(x):
150176
for k, entry in enumerate(indexed_outputs):
151177
if entry is not None:
152178
root = find(k)
153-
_, out_axis, _ = entry
179+
_, out_axis, *_ = entry
154180
group_min_axis[root] = min(group_min_axis.get(root, out_axis), out_axis)
155181

156182
return tuple(
@@ -444,6 +470,7 @@ def make_loop_call(
444470
idx_bc: tuple[tuple[bool, ...], ...] | None = None,
445471
output_write_spec: tuple[tuple[tuple[int, int], ...] | None, ...] | None = None,
446472
inplace: tuple[tuple[int, int], ...] = (),
473+
distinct_outputs: frozenset = frozenset(),
447474
):
448475
safe = (False, False)
449476

@@ -475,6 +502,14 @@ def make_loop_call(
475502
]
476503
destroyed_inputs = {in_idx: out_idx for out_idx, in_idx in inplace}
477504

505+
# When an indexed-update output writes through statically-distinct indices, its
506+
# read-modify-write carries no cross-iteration dependency, so the loop can vectorize
507+
# the value-compute (LLVM scalarizes only the indexed stores). We promise this with an
508+
# access group on those RMW load/stores plus `llvm.loop.parallel_accesses` on the
509+
# innermost latch. The group must be a *distinct* node so it is never uniqued with
510+
# another loop's.
511+
access_group = _DistinctEmptyMetadata(mod) if distinct_outputs else None
512+
478513
zero = ir.Constant(ir.IntType(64), 0)
479514

480515
def _wrap_negative_index(idx_val, dim_size, signed):
@@ -541,6 +576,8 @@ def _wrap_negative_index(idx_val, dim_size, signed):
541576
val = builder.load(ptr)
542577
val.set_metadata("alias.scope", input_scope_set)
543578
val.set_metadata("noalias", output_scope_set)
579+
if access_group is not None:
580+
val.set_metadata("llvm.access.group", access_group)
544581
i64 = ir.IntType(64)
545582
if val.type != i64:
546583
if idx_arr_type.dtype.signed:
@@ -646,6 +683,11 @@ def _wrap_negative_index(idx_val, dim_size, signed):
646683
else:
647684
read_val.set_metadata("alias.scope", out_alias_sets[destination])
648685
read_val.set_metadata("noalias", out_noalias_sets[destination])
686+
# Every memory access in the loop must join the access group, or LLVM's
687+
# `isAnnotatedParallel` rejects the whole loop (one untagged load voids the
688+
# `llvm.loop.parallel_accesses` promise).
689+
if access_group is not None:
690+
read_val.set_metadata("llvm.access.group", access_group)
649691
else:
650692
# Retrieve array item at index
651693
# This is a streamlined version of Numba's `GUArrayArg.load`.
@@ -752,6 +794,8 @@ def _wrap_negative_index(idx_val, dim_size, signed):
752794
init_val = builder.load(write_ptr)
753795
init_val.set_metadata("alias.scope", out_alias_sets[output_i])
754796
init_val.set_metadata("noalias", out_noalias_sets[output_i])
797+
if output_i in distinct_outputs:
798+
init_val.set_metadata("llvm.access.group", access_group)
755799
builder.store(init_val, scratch)
756800
scratch_outputs.append((scratch, write_ptr, output_i))
757801
write_ptr = scratch
@@ -787,10 +831,24 @@ def _wrap_negative_index(idx_val, dim_size, signed):
787831
store = builder.store(out_val, write_ptr)
788832
store.set_metadata("alias.scope", out_alias_sets[output_i])
789833
store.set_metadata("noalias", out_noalias_sets[output_i])
790-
791-
# Close the loops
792-
for loop in loop_stack[::-1]:
793-
loop.__exit__(None, None, None)
834+
if output_i in distinct_outputs:
835+
store.set_metadata("llvm.access.group", access_group)
836+
837+
# Close the loops. Under a no-dup promise, tag the innermost loop's latch with
838+
# `llvm.loop.parallel_accesses` referencing the access group, so the vectorizer
839+
# treats the tagged RMW accesses as free of loop-carried dependencies. The latch is
840+
# the body block the builder sits in just before `for_range` emits its backedge.
841+
for depth, loop in enumerate(loop_stack[::-1]):
842+
if depth == 0 and access_group is not None:
843+
latch_block = builder.basic_block
844+
loop.__exit__(None, None, None)
845+
parallel_md = mod.add_metadata(
846+
[ir.MetaDataString(mod, "llvm.loop.parallel_accesses"), access_group]
847+
)
848+
loop_md = mod.add_metadata([parallel_md], self_ref=True)
849+
latch_block.terminator.set_metadata("llvm.loop", loop_md)
850+
else:
851+
loop.__exit__(None, None, None)
794852

795853

796854
@numba.extending.intrinsic(jit_options=_jit_options, prefer_literal=True)
@@ -916,7 +974,7 @@ def _vectorized(
916974
for k, entry in enumerate(indexed_outputs):
917975
if entry is None:
918976
continue
919-
sources, source_axis, _mode = entry
977+
sources, source_axis, _mode, *_ = entry
920978
for out_idx in sources:
921979
write_spec_dict.setdefault(out_idx, []).append((k, source_axis))
922980
# Write target buffers are appended to the outer inputs in ascending output
@@ -976,6 +1034,14 @@ def _vectorized(
9761034
write_idx_set = frozenset(
9771035
k for k, entry in enumerate(indexed_outputs) if entry is not None
9781036
)
1037+
# Output positions whose indexed update was flagged distinct-index by the rewriter
1038+
# (4th spec field) -> safe to emit the no-dup vectorization promise.
1039+
distinct_output_idxs = frozenset(
1040+
out_idx
1041+
for entry in indexed_outputs
1042+
if entry is not None and len(entry) > 3 and entry[3]
1043+
for out_idx in entry[0]
1044+
)
9791045

9801046
def codegen(ctx, builder, sig, args):
9811047
[
@@ -1159,6 +1225,7 @@ def codegen(ctx, builder, sig, args):
11591225
idx_bc=idx_broadcastable,
11601226
output_write_spec=output_write_spec,
11611227
inplace=inplace_pattern,
1228+
distinct_outputs=distinct_output_idxs,
11621229
)
11631230

11641231
return _codegen_return_outputs(

pytensor/tensor/rewriting/indexed_elemwise.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pytensor.scalar.basic import Composite
1818
from pytensor.tensor.elemwise import DimShuffle, Elemwise
1919
from pytensor.tensor.rewriting.elemwise import InplaceElemwiseOptimizer
20+
from pytensor.tensor.rewriting.subtensor import _has_unique_indices
2021
from pytensor.tensor.shape import Reshape, shape_padright
2122
from pytensor.tensor.subtensor import (
2223
AdvancedIncSubtensor,
@@ -240,16 +241,20 @@ class IndexedElemwise(OpFromGraph):
240241
indexed_outputs : tuple of ((tuple[int, ...], int, str) | None)
241242
One entry per index array k, parallel to ``indexed_inputs``.
242243
``None`` if index k has no write role.
243-
Otherwise ``(sources, source_axis, mode)``:
244+
Otherwise ``(sources, source_axis, mode, distinct)``:
244245
245246
- ``sources``: which Elemwise output positions are written
246247
through this index into the update target buffer.
247248
- ``source_axis``: which target-array axis is indexed.
248249
- ``mode``: ``"inc"`` (accumulate) or ``"set"`` (overwrite).
250+
- ``distinct``: whether the index entries are statically known to be
251+
duplicate-free (an ``inc`` then has no cross-iteration RMW dependency,
252+
so the Numba codegen may emit a loop-vectorization promise). Always
253+
``False`` for ``set`` (it vectorizes regardless).
249254
250255
Examples::
251256
252-
tgt[idx] += exp(x) → indexed_outputs=[((0,), 0, "inc")]
257+
tgt[idx] += exp(x) → indexed_outputs=[((0,), 0, "inc", False)]
253258
"""
254259

255260
def __init__(self, *args, indexed_inputs=(), indexed_outputs=(), **kwargs):
@@ -301,7 +306,7 @@ def _op_debug_information_IndexedElemwise(op, node):
301306
for k, entry in enumerate(op.indexed_outputs):
302307
if entry is None:
303308
continue
304-
sources, _source_axis, mode = entry
309+
sources, _source_axis, mode, *_ = entry
305310
buf_label = f"buf_{buf_counter}"
306311
buf_counter += 1
307312
idx_label = f"idx_{k}"
@@ -721,16 +726,22 @@ def _has_non_write_clients(out_idx):
721726
(tuple(reads), axis) if reads else None
722727
for (_, axis), (reads, _) in idx_groups.items()
723728
)
724-
indexed_outputs_spec = tuple(
725-
(
726-
tuple(writes),
727-
key[1],
728-
"set" if write_targets[writes[0]].op.set_instead_of_inc else "inc",
729+
indexed_outputs_spec_list = []
730+
for key, (_, writes) in idx_groups.items():
731+
if not writes:
732+
indexed_outputs_spec_list.append(None)
733+
continue
734+
mode = (
735+
"set" if write_targets[writes[0]].op.set_instead_of_inc else "inc"
729736
)
730-
if writes
731-
else None
732-
for key, (_, writes) in idx_groups.items()
733-
)
737+
# A distinct-index `inc` has no cross-iteration read-modify-write
738+
# dependency, so the numba codegen can emit a no-dup vectorization
739+
# promise. `set` already vectorizes without it, so only flag `inc`.
740+
distinct = mode == "inc" and _has_unique_indices(fgraph, key[0])
741+
indexed_outputs_spec_list.append(
742+
(tuple(writes), key[1], mode, distinct)
743+
)
744+
indexed_outputs_spec = tuple(indexed_outputs_spec_list)
734745

735746
outer_inputs = []
736747
for i, inp in enumerate(fgraph_inputs):

tests/link/numba/test_indexed_elemwise.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytensor.tensor as pt
77
from pytensor import Mode, function, get_mode
8+
from pytensor.assumptions import assume
89
from pytensor.tensor.rewriting.indexed_elemwise import IndexedElemwise
910
from pytensor.tensor.subtensor import (
1011
AdvancedIncSubtensor1,
@@ -571,6 +572,83 @@ def test_repeated_inc_with_read(self):
571572
np.testing.assert_allclose(fn(sv, tv.copy()), fn_u(sv, tv.copy()), rtol=1e-10)
572573

573574

575+
class TestNoDupPromise:
576+
"""A distinct-index ``inc`` scatter carries a no-dup vectorization promise: the
577+
rewriter flags it (4th ``indexed_outputs`` field) so the Numba codegen emits
578+
``llvm.loop.parallel_accesses``, letting the value-compute vectorize despite the
579+
indexed RMW. The flag is gated on statically-known-distinct indices (a constant with
580+
unique entries, or a ``unique_indices`` assumption) and only for ``inc`` (``set``
581+
vectorizes regardless)."""
582+
583+
@staticmethod
584+
def _write_spec(fn):
585+
for n in fn.maker.fgraph.toposort():
586+
if isinstance(n.op, IndexedElemwise):
587+
writes = [e for e in n.op.indexed_outputs if e is not None]
588+
assert len(writes) == 1
589+
return writes[0]
590+
raise AssertionError("no IndexedElemwise found")
591+
592+
def test_constant_unique_index_flagged(self):
593+
target = pt.vector("target", shape=(10,))
594+
x = pt.vector("x", shape=(5,))
595+
idx = np.array([0, 1, 2, 3, 4], dtype=np.int64) # unique
596+
fn = function([target, x], target[idx].inc(pt.exp(x)), mode=NUMBA_MODE)
597+
assert_fused(fn)
598+
_sources, _axis, mode, distinct = self._write_spec(fn)
599+
assert mode == "inc" and distinct is True
600+
601+
def test_constant_duplicate_index_not_flagged(self):
602+
target = pt.vector("target", shape=(10,))
603+
x = pt.vector("x", shape=(5,))
604+
idx = np.array([0, 0, 1, 2, 3], dtype=np.int64) # has a duplicate
605+
fn = function([target, x], target[idx].inc(pt.exp(x)), mode=NUMBA_MODE)
606+
_sources, _axis, _mode, distinct = self._write_spec(fn)
607+
assert distinct is False
608+
609+
def test_assumed_unique_index_flagged(self):
610+
target = pt.vector("target", shape=(10,))
611+
x = pt.vector("x")
612+
idx0 = pt.vector("idx", dtype="int64")
613+
idx = assume(idx0, unique_indices=True)
614+
fn = function([target, idx0, x], target[idx].inc(pt.exp(x)), mode=NUMBA_MODE)
615+
_sources, _axis, mode, distinct = self._write_spec(fn)
616+
assert mode == "inc" and distinct is True
617+
618+
def test_runtime_index_not_flagged(self):
619+
target = pt.vector("target", shape=(10,))
620+
x = pt.vector("x")
621+
idx0 = pt.vector("idx", dtype="int64")
622+
fn = function([target, idx0, x], target[idx0].inc(pt.exp(x)), mode=NUMBA_MODE)
623+
_sources, _axis, _mode, distinct = self._write_spec(fn)
624+
assert distinct is False
625+
626+
def test_set_mode_not_flagged(self):
627+
# `set` vectorizes without the promise, so it is never flagged even when unique.
628+
target = pt.vector("target", shape=(10,))
629+
x = pt.vector("x")
630+
idx0 = pt.vector("idx", dtype="int64")
631+
idx = assume(idx0, unique_indices=True)
632+
fn = function([target, idx0, x], target[idx].set(pt.exp(x)), mode=NUMBA_MODE)
633+
_sources, _axis, mode, distinct = self._write_spec(fn)
634+
assert mode == "set" and distinct is False
635+
636+
def test_assumed_unique_correctness(self):
637+
# The promise must not change results (exercises the metadata-emitting codegen).
638+
rng = np.random.default_rng(0)
639+
target = pt.vector("target", shape=(64,))
640+
x = pt.vector("x", shape=(64,))
641+
idx0 = pt.vector("idx", dtype="int64")
642+
idx = assume(idx0, unique_indices=True)
643+
fn, fn_u = fused_and_unfused([target, idx0, x], target[idx].inc(pt.exp(x)))
644+
assert_fused(fn)
645+
tv, xv = rng.normal(size=64), rng.normal(size=64)
646+
iv = rng.permutation(64).astype(np.int64) # genuinely unique
647+
np.testing.assert_allclose(
648+
fn(tv.copy(), iv, xv), fn_u(tv.copy(), iv, xv), rtol=1e-10
649+
)
650+
651+
574652
class TestShapeValidation:
575653
"""Test that mismatched index/input shapes raise runtime errors."""
576654

0 commit comments

Comments
 (0)