pymc-devs / pymc-devs/pytensor

PERF: map pt.logsumexp pattern to native mx.logsumexp (1.5× speedup)

Open
#2,091 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement graph rewriting mlx
Dominant language
Python
Stars
644
Forks
208
Avg merge
2d 14h
Merged PRs (30d)
16

Description

Part of #2085.

Current implementation

pt.logsumexp(x, axis=...) is not a single op — it's built in pytensor/tensor/math.py from Max + Exp + Sum + Log + Add, with an Isinf/Switch guard for -inf inputs. After lower_xtensor and canonicalization, the resulting MLX-dispatched graph for pt.logsumexp(x, axis=(1, 2)) on a 4-D input looks like:

Composite{(i0 + log(i1))}
 ├─ Max{axes=[1, 2]}(x)
 └─ Sum{axes=[1, 2]}
    └─ Composite{switch(isinf(max), exp(max), exp(x - max))}
       ├─ Isinf(ExpandDims(Max(x)))
       ├─ Exp(ExpandDims(Max(x)))
       ├─ x
       └─ ExpandDims(Max(x))

That's Max + ExpandDims + Exp + Isinf + Switch (Composite) + Sum + Composite{add(log)} — 7 separate dispatched calls plus the inf-guard work that MLX absorbs less efficiently than a single fused kernel.

MLX exposes mx.logsumexp(x, axis=...) as a native op. It is available as of MLX 0.31.2 (verified: getattr(mx, "logsumexp", None) is not None).

Reproducer + metrics

import time, statistics, mlx.core as mx, numpy as np
import pytensor, pytensor.tensor as pt

def sync(): mx.synchronize()
def bench(fn, *args, n=50, runs=5):
    sync()
    for _ in range(8):
        out = fn(*args); mx.eval(out)
    sync()
    out_t = []
    for _ in range(runs):
        sync(); t0 = time.perf_counter()
        for _ in range(n):
            out = fn(*args); mx.eval(out)
        sync()
        out_t.append((time.perf_counter() - t0) / n)
    return statistics.median(out_t) * 1e6

A, B, C, D = 4, 64, 64, 256
rng = np.random.default_rng(0)
x_mx = mx.array(rng.standard_normal((A, B, C, D)).astype(np.float32))
mx.eval(x_mx); sync()

@mx.compile
def raw_native(x): return mx.logsumexp(x, axis=(1, 2))

@mx.compile
def raw_manual(x):
    m = mx.max(x, axis=(1, 2), keepdims=True)
    return m.squeeze((1, 2)) + mx.log(mx.sum(mx.exp(x - m), axis=(1, 2)))

x_pt = pt.tensor4("x", dtype="float32")
f_pt = pytensor.function([x_pt], pt.logsumexp(x_pt, axis=(1, 2)), mode="MLX")
f_pt.trust_input = True

print(f"raw mx.logsumexp:        {bench(raw_native, x_mx):.1f} us")
print(f"raw manual (no guard):   {bench(raw_manual, x_mx):.1f} us")
print(f"pytensor MLX (current):  {bench(f_pt, x_mx):.1f} us")
Implementation Median (us)
mx.logsumexp 365
Raw manual (no inf-guard) 352
PyTensor MLX (current, with inf-guard) 548

1.5× speedup by using mx.logsumexp. Saves ~33 % over the current PyTensor MLX path.

Proposed change

Two options, ordered by preference:

Option A (preferred): match the pattern at MLX rewrite time

Add an MLX-only rewrite that recognises the canonical logsumexp subgraph (Composite{add(log)} of Max and Sum(switch(isinf(max), exp(max), exp(x-max)))) and replaces it with a thin MLXLogSumExp(axis) Op that dispatches to mx.logsumexp. Keeps the inf-safety semantics because mx.logsumexp itself handles -inf correctly.

Files:

  • pytensor/link/mlx/ops.pyMLXLogSumExp Op
  • pytensor/link/mlx/dispatch/fused.py — dispatcher
  • pytensor/link/mlx/rewriting/fused_logsumexp.py — pattern matcher
# dispatch
@mlx_funcify.register(MLXLogSumExp)
def mlx_funcify_MLXLogSumExp(op, **kwargs):
    axis = op.axis
    def logsumexp(x):
        return mx.logsumexp(x, axis=axis)
    return logsumexp
Option B (simpler, but heavier): teach pt.logsumexp to emit a recognisable Op

Add a LogSumExp(axis, keepdims) Op in pytensor/tensor/math.py that the helper builds when the user calls pt.logsumexp(...). Other backends keep the current Composite via perform/lowering rewrite; the MLX backend dispatches LogSumExp directly to mx.logsumexp.

This option is more invasive (touches pytensor/tensor/math.py and the public API), but avoids fragile pattern matching.

Acceptance criteria

  • PyTensor pt.logsumexp graph compiled with mode="MLX" runs in ≤ 400 µs at the shape above (vs. 548 µs today).
  • Numerics match mx.logsumexp within 1e-5, including for inputs containing -inf.
  • Existing pt.logsumexp tests continue to pass on Numba/JAX backends (no regression for non-MLX modes).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading pytensor/link/mlx/ops.py, pytensor/link/mlx/dispatch/fused.py, and pytensor/link/mlx/rewriting/fused_logsumexp.py, then run the reproducer to inspect the current MLX graph and timing. Choose and implement one of the proposed approaches, verify the benchmark is at most 400 µs with matching -inf numerics, and run the existing logsumexp tests on Numba and JAX.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, performance
Issue type
Refactor
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.