Skip to content

Numba: fuse AdvancedSubtensor->Elemwise->AdvancedIncSubtensor#2015

Merged
ricardoV94 merged 6 commits into
pymc-devs:mainfrom
ricardoV94:gather_scatter_fusion
Jun 1, 2026
Merged

Numba: fuse AdvancedSubtensor->Elemwise->AdvancedIncSubtensor#2015
ricardoV94 merged 6 commits into
pymc-devs:mainfrom
ricardoV94:gather_scatter_fusion

Conversation

@ricardoV94

@ricardoV94 ricardoV94 commented Mar 29, 2026

Copy link
Copy Markdown
Member

Summary

Introduce IndexedElemwise, an OpFromGraph that wraps AdvancedSubtensor + Elemwise + AdvancedIncSubtensor subgraphs so the Numba backend can generate a single loop with indirect indexing, avoiding materializing AdvancedSubtensor input arrays, and writing directly on the output buffer, doing the job of AdvancedIncSubtensor in the same loop, without having to loop again through the intermediate elemwise output.

Supports arbitrary-axis indexing, ND index arrays, multi-index groups, broadcast indices, repeated accumulation (inc/set modes), and multi-client outputs via Composite identity duplication.

Also included:

  • Extend FusionOptimizer to merge independent sibling subgraphs that share inputs but have no producer-consumer edge (e.g. f(x) and g(x))

Motivation

In hierarchical models with mu = beta[group_idx] * x + ..., the logp+gradient graph combines indexed reads and indexed updates in the same Elemwise (the forward expands group-level parameters via advanced subtensor, and the gradient accumulates back into the source via advanced inc subtensor).

A simple example

import numpy as np
import pytensor
import pytensor.tensor as pt
from pytensor.compile.mode import get_mode

numba_mode = get_mode("NUMBA")
numba_mode_before = numba_mode.excluding("fuse_indexed_elemwise")

x = pt.vector("x")
idx = pt.vector("idx", dtype=int)
value = pt.vector("value")

y = pt.zeros(100)
out = ((x[idx] - value) ** 2).sum()
grad_wrt_x = pt.grad(out, x)
fn_before = pytensor.function([x, value, idx], [out, grad_wrt_x], mode=numba_mode_before, trust_input=True)
fn_before.dprint(print_op_info=True, print_destroy_map=True)
# Sum{axes=None} [id A] 5
#  └─ Composite{...}.0 [id B] d={0: [0]} 1
#     ├─ AdvancedSubtensor1 [id C] 0
#     │  ├─ x [id D]
#     │  └─ idx [id E]
#     └─ value [id F]
# AdvancedIncSubtensor1{inplace,inc} [id G] d={0: [0]} 4
#  ├─ Alloc [id H] 3
#  │  ├─ [0.] [id I]
#  │  └─ Shape_i{0} [id J] 2
#  │     └─ x [id D]
#  ├─ Composite{...}.1 [id B] d={0: [0]} 1
#  │  └─ ···
#  └─ idx [id E]

# Inner graphs:

# Composite{...} [id B] d={0: [0]}
#  ← sqr [id K] 'o0'
#     └─ sub [id L] 't5'
#        ├─ i0 [id M]
#        └─ i1 [id N]
#  ← mul [id O] 'o1'
#     ├─ 2.0 [id P]
#     └─ sub [id L] 't5'
#        └─ ···

fn = pytensor.function([x, value, idx], [out, grad_wrt_x], mode=numba_mode, trust_input=True)
fn.dprint(print_op_info=True, print_destroy_map=True)

# Sum{axes=None} [id A] 3
#  └─ IndexedElemwise{Composite{...}}.0 [id B] d={1: [3]} 2
#     ├─ x [id C] (indexed read (idx_0))
#     ├─ value [id D]
#     ├─ idx [id E] (idx_0)
#     └─ Alloc [id F] 1 (buf_0)
#        ├─ [0.] [id G]
#        └─ Shape_i{0} [id H] 0
#           └─ x [id C]
# IndexedElemwise{Composite{...}}.1 [id B] d={1: [3]} 2 (indexed inc (buf_0, idx_0))
#  └─ ···

# Inner graphs:

# IndexedElemwise{Composite{...}} [id B] d={1: [3]}
#  ← Composite{...}.0 [id I]
#     ├─ AdvancedSubtensor1 [id J]
#     │  ├─ *0-<Vector(float64, shape=(?,))> [id K]
#     │  └─ *2-<Vector(int64, shape=(?,))> [id L]
#     └─ *1-<Vector(float64, shape=(?,))> [id M]
#  ← AdvancedIncSubtensor1{inplace,inc} [id N] d={0: [0]}
#     ├─ *3-<Vector(float64, shape=(?,))> [id O]
#     ├─ Composite{...}.1 [id I]
#     │  └─ ···
#     └─ *2-<Vector(int64, shape=(?,))> [id L]

# Composite{...} [id I]
#  ← sqr [id P] 'o0'
#     └─ sub [id Q] 't0'
#        ├─ i0 [id R]
#        └─ i1 [id S]
#  ← mul [id T] 'o1'
#     ├─ 2.0 [id U]
#     └─ sub [id Q] 't0'
#        └─ ···

x_test = np.arange(15, dtype="float64")
idx_test = np.random.randint(15, size=(10_000,))
value_test = np.random.normal(size=idx_test.shape)

logp_before, dlogp_before = fn_before(x_test, value_test, idx_test)
logp, dlogp = fn(x_test, value_test, idx_test)
np.testing.assert_allclose(logp_before, logp)
np.testing.assert_allclose(dlogp_before, dlogp)

%timeit fn_before(x_test, value_test, idx_test)  # 29.4 μs ± 2.57 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
%timeit fn(x_test, value_test, idx_test)  # 13.8 μs ± 136 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

Next step would be to also fuse the sum directly on the elemwise, so we end up with a single loop over the data.

@ricardoV94 ricardoV94 force-pushed the gather_scatter_fusion branch 2 times, most recently from 6d875d8 to 0ad6e2e Compare March 29, 2026 18:14
@ricardoV94 ricardoV94 changed the title Numba: fuse AdvancedSubtensor+Elemwise Numba: fuse AdvancedSubtensor->Elemwise->AdvancedIncSubtensor Mar 29, 2026
@ricardoV94 ricardoV94 force-pushed the gather_scatter_fusion branch 9 times, most recently from 41869a4 to a07997b Compare April 2, 2026 11:10
@ricardoV94 ricardoV94 force-pushed the gather_scatter_fusion branch 6 times, most recently from 2b65554 to f939f9c Compare April 5, 2026 20:32
@ricardoV94 ricardoV94 force-pushed the gather_scatter_fusion branch 8 times, most recently from 4cc068c to e5e5ea0 Compare May 25, 2026 08:46
@ricardoV94 ricardoV94 force-pushed the gather_scatter_fusion branch 5 times, most recently from 2758911 to cb1e83d Compare May 27, 2026 08:24
@ricardoV94 ricardoV94 marked this pull request as ready for review May 27, 2026 10:48
@ricardoV94 ricardoV94 requested a review from jessegrabowski May 27, 2026 10:49
@ricardoV94

ricardoV94 commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

Here is a benchmark experiment with a subset of models where the rewrite fires: https://ricardov94.github.io/pymc-model-catalogue/experiments.html#base=index_elemwise_fusion_curated_base&compare=index_elemwise_fusion_curated

Nice gains in eval time for quite some models

…ariables

When a shared variable's update is deleted but the variable is still
destroyed (mutated inplace) by a node in the copied graph, the shared
variable storage will still be mutated. Emit a UserWarning in this case.
Extend FusionOptimizer to merge independent subgraphs that share
inputs but have no producer-consumer edge (siblings like f(x) and g(x)).
The eager expansion only walks producer-consumer edges, missing these.

Also extract InplaceGraphOptimizer.try_inplace_on_node helper and
_insert_sorted_subgraph to deduplicate insertion-point logic.
Extract _decode_literal, _compute_vectorized_types, and
_codegen_return_outputs from the monolithic _vectorized intrinsic.
Add NO_SIZE sentinel. Rename variables in make_loop_call for clarity
(input→inp, output→out, idxs→loop_idxs, ptr→read_ptr/write_ptr,
val→read_val/write_val, core_arry_type→read_array_type/write_array_type).
Move _vectorized below make_loop_call. Fixes input_type.layout bug in
inplace type computation (was using leaked variable from prior loop).
@ricardoV94 ricardoV94 force-pushed the gather_scatter_fusion branch from cb1e83d to 9592fbe Compare June 1, 2026 12:42
from pytensor.link.numba.dispatch import basic as numba_basic


# Numba is missing getitem(array, Ellipsis), so o[...] += val fails.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: This is not equivalent, only in +=, but most of the times is what we want. Anyway better to use a custom fn in the store_core_outputs and only overload that

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store_inplace, inc_inplace.


Examples::

tgt[idx] += exp(x) → indexed_outputs=[((0,), 0, "inc")]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: check didn't we get rid of inc/set

@jessegrabowski jessegrabowski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we went over this in a call, Ricardo explained it to me and it seems good.

Add IndexedElemwise(OpFromGraph) and FuseIndexedElemwise graph rewriter
that detects AdvancedSubtensor1/AdvancedSubtensor on Elemwise inputs
and AdvancedIncSubtensor1/AdvancedIncSubtensor on outputs, fusing them
into a single vectorized loop with indirect indexing.

Supports arbitrary-axis indexing, ND index arrays, multi-index groups,
broadcast indices, repeated accumulation (inc/set modes), and
multi-client outputs via Composite identity duplication.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants