Skip to content

Erase the cat/slice/select nop ops after memory planning (#22651) - #22651

Open
mcremon-meta wants to merge 1 commit into
mainfrom
export-D118922409
Open

Erase the cat/slice/select nop ops after memory planning (#22651)#22651
mcremon-meta wants to merge 1 commit into
mainfrom
export-D118922409

Conversation

@mcremon-meta

@mcremon-meta mcremon-meta commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary:

_cat_nop, _slice_copy_nop and _select_copy_nop do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

Why it has to live inside the memory planning pass. A standalone pass is
not possible:

  • Before planning the nodes cannot go, because the node is what carries the
    placement constraint. Remove it early and the aliasing never happens.

  • After planning nothing may run at all:

    WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.

    THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS

    THE LAST PASS BEFORE THE EMITTER.

    (exir/program/_program.py)

CadenceMemoryPlanning.run already mutates the graph after mem_planning.run

  • SimplifyIdmaOpsPass retargets nodes and runs dead code elimination there -
    so that slot is the one point where placement is decided but the program is not
    yet emitted. This joins it.

The assumptions that warning refers to are real. Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips find_peak_memory_usage and, in executorch/util, the activation memory
profiler. update_all_tensors_lifetime alone does not fix it, because
update_tensor_lifetime only ever widens a lifetime:

end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. _refresh_lifetimes uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside call rather than at class scope, because
their schemas come from ops_registrations and memory_planning does not
import it - resolving at import time breaks any module that imports
CadenceMemoryPlanning without having registered the ops first.

The kernels stay, deliberately.

Reviewed By: DrJessop

Differential Revision: D118922409

@pytorch-bot

pytorch-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22651

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit 48b0e78 with merge base a33ac10 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 9, 2026
@meta-codesync

meta-codesync Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@mcremon-meta has exported this pull request. If you are a Meta employee, you can view the originating Diff in D118922409.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@meta-codesync meta-codesync Bot changed the title Erase the cat/slice/select nop ops after memory planning Erase the cat/slice/select nop ops after memory planning (#22651) Sep 9, 2026
meta-codesync Bot pushed a commit that referenced this pull request Sep 9, 2026
Summary:

`_cat_nop`, `_slice_copy_nop` and `_select_copy_nop` do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

**Why it has to live inside the memory planning pass.** A standalone pass is
not possible:

- Before planning the nodes cannot go, because the node is what carries the
  placement constraint. Remove it early and the aliasing never happens.
- After planning nothing may run at all:

    # WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.
    # THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS
    # THE LAST PASS BEFORE THE EMITTER.
    (exir/program/_program.py)

`CadenceMemoryPlanning.run` already mutates the graph after `mem_planning.run`
- `SimplifyIdmaOpsPass` retargets nodes and runs dead code elimination there -
so that slot is the one point where placement is decided but the program is not
yet emitted. This joins it.

**The assumptions that warning refers to are real.** Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips `find_peak_memory_usage` and, in executorch/util, the activation memory
profiler. `update_all_tensors_lifetime` alone does not fix it, because
`update_tensor_lifetime` only ever widens a lifetime:

    end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. `_refresh_lifetimes` uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside `call` rather than at class scope, because
their schemas come from `ops_registrations` and `memory_planning` does not
import it - resolving at import time breaks any module that imports
`CadenceMemoryPlanning` without having registered the ops first.

**The kernels stay, deliberately.** They are dead for anything compiled after
this, but 14 checked-in `.pte` artifacts under `arvr/libraries/audio/xr2/` and
`arvr/projects/audio/research/tacf/xr2/` were built before it and still contain
`aten::_cat_nop` / `aten::_slice_copy_nop` instructions. Removing the kernels
makes those models abort at load with "Missing operator". Deleting them is a
follow-up, once every prebuilt artifact has been regenerated. Nothing here
changes what an existing `.pte` can run.

Measured on wakeword stage 2 split_n9, RT600 (HiFi4), one frame, per-op
profiling **off** (profiled totals are not usable here: instrumentation costs
roughly a fixed amount per instruction, so deleting instructions deletes
instrumentation that would not exist in production, overstating this by ~5x):

  | configuration                       | total cycles |    .pte |
  | before                              |      689,335 | 510,800 |
  | after                               |      680,790 | 509,392 |

8,545 cycles, 1.23%, and 1,408 bytes. Ten instructions go away.

Differential Revision: D118922409
meta-codesync Bot pushed a commit that referenced this pull request Sep 9, 2026
Summary:

`_cat_nop`, `_slice_copy_nop` and `_select_copy_nop` do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

**Why it has to live inside the memory planning pass.** A standalone pass is
not possible:

- Before planning the nodes cannot go, because the node is what carries the
  placement constraint. Remove it early and the aliasing never happens.
- After planning nothing may run at all:

    # WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.
    # THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS
    # THE LAST PASS BEFORE THE EMITTER.
    (exir/program/_program.py)

`CadenceMemoryPlanning.run` already mutates the graph after `mem_planning.run`
- `SimplifyIdmaOpsPass` retargets nodes and runs dead code elimination there -
so that slot is the one point where placement is decided but the program is not
yet emitted. This joins it.

**The assumptions that warning refers to are real.** Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips `find_peak_memory_usage` and, in executorch/util, the activation memory
profiler. `update_all_tensors_lifetime` alone does not fix it, because
`update_tensor_lifetime` only ever widens a lifetime:

    end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. `_refresh_lifetimes` uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside `call` rather than at class scope, because
their schemas come from `ops_registrations` and `memory_planning` does not
import it - resolving at import time breaks any module that imports
`CadenceMemoryPlanning` without having registered the ops first.

**The kernels stay, deliberately.** They are dead for anything compiled after
this, but 14 checked-in `.pte` artifacts under `arvr/libraries/audio/xr2/` and
`arvr/projects/audio/research/tacf/xr2/` were built before it and still contain
`aten::_cat_nop` / `aten::_slice_copy_nop` instructions. Removing the kernels
makes those models abort at load with "Missing operator". Deleting them is a
follow-up, once every prebuilt artifact has been regenerated. Nothing here
changes what an existing `.pte` can run.

Measured on wakeword stage 2 split_n9, RT600 (HiFi4), one frame, per-op
profiling **off** (profiled totals are not usable here: instrumentation costs
roughly a fixed amount per instruction, so deleting instructions deletes
instrumentation that would not exist in production, overstating this by ~5x):

  | configuration                       | total cycles |    .pte |
  | before                              |      689,335 | 510,800 |
  | after                               |      680,790 | 509,392 |

8,545 cycles, 1.23%, and 1,408 bytes. Ten instructions go away.

Differential Revision: D118922409
meta-codesync Bot pushed a commit that referenced this pull request Sep 9, 2026
Summary:

`_cat_nop`, `_slice_copy_nop` and `_select_copy_nop` do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

**Why it has to live inside the memory planning pass.** A standalone pass is
not possible:

- Before planning the nodes cannot go, because the node is what carries the
  placement constraint. Remove it early and the aliasing never happens.
- After planning nothing may run at all:

    # WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.
    # THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS
    # THE LAST PASS BEFORE THE EMITTER.
    (exir/program/_program.py)

`CadenceMemoryPlanning.run` already mutates the graph after `mem_planning.run`
- `SimplifyIdmaOpsPass` retargets nodes and runs dead code elimination there -
so that slot is the one point where placement is decided but the program is not
yet emitted. This joins it.

**The assumptions that warning refers to are real.** Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips `find_peak_memory_usage` and, in executorch/util, the activation memory
profiler. `update_all_tensors_lifetime` alone does not fix it, because
`update_tensor_lifetime` only ever widens a lifetime:

    end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. `_refresh_lifetimes` uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside `call` rather than at class scope, because
their schemas come from `ops_registrations` and `memory_planning` does not
import it - resolving at import time breaks any module that imports
`CadenceMemoryPlanning` without having registered the ops first.

**The kernels stay, deliberately.**

Differential Revision: D118922409
meta-codesync Bot pushed a commit that referenced this pull request Sep 9, 2026
Summary:

`_cat_nop`, `_slice_copy_nop` and `_select_copy_nop` do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

**Why it has to live inside the memory planning pass.** A standalone pass is
not possible:

- Before planning the nodes cannot go, because the node is what carries the
  placement constraint. Remove it early and the aliasing never happens.
- After planning nothing may run at all:

    # WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.
    # THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS
    # THE LAST PASS BEFORE THE EMITTER.
    (exir/program/_program.py)

`CadenceMemoryPlanning.run` already mutates the graph after `mem_planning.run`
- `SimplifyIdmaOpsPass` retargets nodes and runs dead code elimination there -
so that slot is the one point where placement is decided but the program is not
yet emitted. This joins it.

**The assumptions that warning refers to are real.** Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips `find_peak_memory_usage` and, in executorch/util, the activation memory
profiler. `update_all_tensors_lifetime` alone does not fix it, because
`update_tensor_lifetime` only ever widens a lifetime:

    end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. `_refresh_lifetimes` uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside `call` rather than at class scope, because
their schemas come from `ops_registrations` and `memory_planning` does not
import it - resolving at import time breaks any module that imports
`CadenceMemoryPlanning` without having registered the ops first.

**The kernels stay, deliberately.**

Differential Revision: D118922409
meta-codesync Bot pushed a commit that referenced this pull request Sep 10, 2026
Summary:

`_cat_nop`, `_slice_copy_nop` and `_select_copy_nop` do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

**Why it has to live inside the memory planning pass.** A standalone pass is
not possible:

- Before planning the nodes cannot go, because the node is what carries the
  placement constraint. Remove it early and the aliasing never happens.
- After planning nothing may run at all:

    # WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.
    # THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS
    # THE LAST PASS BEFORE THE EMITTER.
    (exir/program/_program.py)

`CadenceMemoryPlanning.run` already mutates the graph after `mem_planning.run`
- `SimplifyIdmaOpsPass` retargets nodes and runs dead code elimination there -
so that slot is the one point where placement is decided but the program is not
yet emitted. This joins it.

**The assumptions that warning refers to are real.** Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips `find_peak_memory_usage` and, in executorch/util, the activation memory
profiler. `update_all_tensors_lifetime` alone does not fix it, because
`update_tensor_lifetime` only ever widens a lifetime:

    end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. `_refresh_lifetimes` uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside `call` rather than at class scope, because
their schemas come from `ops_registrations` and `memory_planning` does not
import it - resolving at import time breaks any module that imports
`CadenceMemoryPlanning` without having registered the ops first.

**The kernels stay, deliberately.**

Reviewed By: DrJessop

Differential Revision: D118922409
Summary:

`_cat_nop`, `_slice_copy_nop` and `_select_copy_nop` do nothing at runtime. A
cat is only rewritten to its nop form once memory planning can place every
input at a contiguous offset inside the output, and a slice or select likewise
once its output can be colocated inside its input, so by the time planning has
run they hold by construction. The instructions that remain are pure dispatch
overhead.

This erases them, pointing their consumers at the output buffer the producers
have already written in place. A nop that cannot be erased raises rather than
being skipped: these ops exist only to carry a placement constraint from
constraint generation to here, so one reaching the emitter is a compiler bug.

**Why it has to live inside the memory planning pass.** A standalone pass is
not possible:

- Before planning the nodes cannot go, because the node is what carries the
  placement constraint. Remove it early and the aliasing never happens.
- After planning nothing may run at all:

    # WARNING: DO NOT ADD ANY MORE PASSES AFTER MEMORY PLANNING PASS.
    # THERE ARE A LOT OF ASSUMPTIONS IN THE STACK THAT MEMORY PLANNING IS
    # THE LAST PASS BEFORE THE EMITTER.
    (exir/program/_program.py)

`CadenceMemoryPlanning.run` already mutates the graph after `mem_planning.run`
- `SimplifyIdmaOpsPass` retargets nodes and runs dead code elimination there -
so that slot is the one point where placement is decided but the program is not
yet emitted. This joins it.

**The assumptions that warning refers to are real.** Spec lifetimes are node
indices, so erasing nodes leaves them pointing past the end of the graph. That
trips `find_peak_memory_usage` and, in executorch/util, the activation memory
profiler. `update_all_tensors_lifetime` alone does not fix it, because
`update_tensor_lifetime` only ever widens a lifetime:

    end = node_idx if end is None or end < node_idx else end

so a stale larger bound survives. `_refresh_lifetimes` uses the first call to
identify exactly which specs the recompute reaches, clears those, and rebuilds
them. Clearing a wider set would leave specs the recompute never revisits stuck
at None, which reads as "no lifetime" and silently drops them from the memory
reports. With that, no change is needed outside the Cadence backend - the shared
executorch diagnostics work unmodified.

The nop targets are looked up inside `call` rather than at class scope, because
their schemas come from `ops_registrations` and `memory_planning` does not
import it - resolving at import time breaks any module that imports
`CadenceMemoryPlanning` without having registered the ops first.

**The kernels stay, deliberately.**

Reviewed By: DrJessop

Differential Revision: D118922409
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant