pymc-devs / pymc-devs/pytensor

Rewrite mit-mot Scans as sit-sot

Open
#1,687 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

gradients performance scan
Dominant language
Python
Stars
644
Forks
208
Avg merge
2d 14h
Merged PRs (30d)
16

Description

Description

Scan always uses mit-mot for reverse-mode autodiff. This is the most general approach as it allows arbitrary connection pattern between intermediate states and the function cost. However, many times users select only the last state, and the mit-mot is doing a useless reading / adding of zeros in each step (as all but the last step are disconnected).

Here is an example:

import pytensor
import pytensor.tensor as pt

x0 = pt.scalar("x0")
xs, _ = pytensor.scan(lambda x: x ** 2, outputs_info=[x0], n_steps=4)
g = pt.grad(xs[-1], x0)
pytensor.function([x0], g).dprint(print_shape=True)
Print results

Sum{axes=None} [id A] shape=() 12
 └─ Subtensor{start:stop:step} [id B] shape=(?,) 11
    ├─ Scan{grad_of_scan_fn, while_loop=False, inplace=all} [id C] shape=(?,) 10
    │  ├─ 4 [id D] shape=()
    │  ├─ Subtensor{start:stop:step} [id E] shape=(?,) 9
    │  │  ├─ Scan{scan_fn, while_loop=False, inplace=all} [id F] shape=(?,) 7
    │  │  │  ├─ 3 [id G] shape=()
    │  │  │  └─ SetSubtensor{:stop} [id H] shape=(4,) 5
    │  │  │     ├─ AllocEmpty{dtype='float64'} [id I] shape=(4,) 0
    │  │  │     │  └─ 4 [id D] shape=()
    │  │  │     ├─ ExpandDims{axis=0} [id J] shape=(1,) 3
    │  │  │     │  └─ x0 [id K] shape=()
    │  │  │     └─ 1 [id L] shape=()
    │  │  ├─ 3 [id M] shape=()
    │  │  ├─ -5 [id N] shape=()
    │  │  └─ -1 [id O] shape=()
    │  └─ Subtensor{::step} [id P] shape=(?,) 8
    │     ├─ IncSubtensor{start:} [id Q] shape=(5,) 6
    │     │  ├─ Alloc [id R] shape=(5,) 2
    │     │  │  ├─ [0.] [id S] shape=(1,)
    │     │  │  └─ 5 [id T] shape=()
    │     │  ├─ IncSubtensor{i} [id U] shape=(4,) 4
    │     │  │  ├─ Alloc [id V] shape=(4,) 1
    │     │  │  │  ├─ [0.] [id S] shape=(1,)
    │     │  │  │  └─ 4 [id D] shape=()
    │     │  │  ├─ 1.0 [id W] shape=()
    │     │  │  └─ -1 [id O] shape=()
    │     │  └─ 1 [id L] shape=()
    │     └─ -1 [id O] shape=()
    ├─ 4 [id X] shape=()
    ├─ 3 [id M] shape=()
    └─ -1 [id O] shape=()
Inner graphs:
Scan{grad_of_scan_fn, while_loop=False, inplace=all} [id C]
 ← Composite{((2.0 * i1 * i2) + i0)} [id Y] shape=()
    ├─ *2-<Scalar(float64, shape=())> [id Z] shape=() -> [id P]
    ├─ *1-<Scalar(float64, shape=())> [id BA] shape=() -> [id P]
    └─ *0-<Scalar(float64, shape=())> [id BB] shape=() -> [id E]
Scan{scan_fn, while_loop=False, inplace=all} [id F]
 ← Sqr [id BC] shape=()
    └─ *0-<Scalar(float64, shape=())> [id BB] shape=() -> [id H]

The MIT-MOT looks like

    │  └─ Subtensor{::step} [id P] shape=(?,) 8
    │     ├─ IncSubtensor{start:} [id Q] shape=(5,) 6
    │     │  ├─ Alloc [id R] shape=(5,) 2
    │     │  │  ├─ [0.] [id S] shape=(1,)
    │     │  │  └─ 5 [id T] shape=()
    │     │  ├─ IncSubtensor{i} [id U] shape=(4,) 4
    │     │  │  ├─ Alloc [id V] shape=(4,) 1
    │     │  │  │  ├─ [0.] [id S] shape=(1,)
    │     │  │  │  └─ 4 [id D] shape=()
    │     │  │  ├─ 1.0 [id W] shape=()
    │     │  │  └─ -1 [id O] shape=()
    │     │  └─ 1 [id L] shape=()
    │     └─ -1 [id O] shape=()

This is cleaned up a bit by #1666 , but if we read carefully (or evaluate it), we see it's just [1, 0, 0, 0, 0]

from pytensor.scan.op import Scan
from pytensor.graph.traversal import apply_ancestors

grad_scan = next(n for n in apply_ancestors([g]) if isinstance(n.op, Scan))
n_steps, forward_seq, _, mit_mot = grad_scan.inputs
mit_mot.eval({x0: 0.95})  # array([1., 0., 0., 0., 0.])

SIT-SOT should be more performant as it doesn't require materializing/reading the whole tape but only the last updated state (after the scan memsave rewrite that is)

equiv_scan_with_x0_masked, _ = pytensor.scan(
    lambda s, g_out: 2 * s * g_out, 
    sequences=[forward_seq],
    # Here we would put whatever the gradient at the last step is
    # It's one in our case
    outputs_info=[x0.ones_like()],
    n_steps=n_steps,
)
equiv_scan = equiv_scan_with_x0_masked[0].owner.inputs[0].owner.inputs[0]
equiv_scan.eval({x0: 0.95}), first_scan.out.eval({x0: 0.95})
# (array([1.        , 1.32684086, 2.16144035, 3.90139983, 7.41265968]),
#  array([1.        , 1.32684086, 2.16144035, 3.90139983, 7.41265968]))

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 at pytensor.scan.op.Scan and the reverse-mode gradient path shown in the issue; inspect how mit_mot is produced and compare it with the proposed SIT-SOT scan using forward_seq. Use the provided gradient example to verify equivalent results, and consider the work complete when the reverse scan avoids materializing the unnecessary full MIT-MOT tape.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, performance
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.