Skip to content

Fix the crash when a program keeps its activations on the GPU - #21699

Closed
shoumikhin wants to merge 2 commits into
mainfrom
gh/shoumikhin/97/head
Closed

Fix the crash when a program keeps its activations on the GPU#21699
shoumikhin wants to merge 2 commits into
mainfrom
gh/shoumikhin/97/head

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The problem

ExecuTorch can export a model two ways for a GPU. The default copies your input from
main memory (the host) to the GPU for you. The other way skips those copies, so you
hand over memory that is already on the GPU and the output stays there. That second
way saves a round trip, which matters for a model in a loop.

The second way crashes. It crashes from Python and from C++, immediately, before your
data is touched:

[cuda_backend.cpp:429] Created new CUDA stream 0x10ae920 for method
Segmentation fault (core dumped)

The reason is one line. When the runtime puts your input into the buffer the memory
plan reserved, it always used a plain host copy:

std::memcpy(t_dst.mutable_data_ptr(), t_src.const_data_ptr(), t_src.nbytes());

If that destination is GPU memory, a host memcpy into it is undefined and the process
dies. The memory itself was allocated correctly on the GPU; only the copy assumed the
host.

The change

Route the copy through the device that owns the memory instead of assuming the host.
ExecuTorch already has an interface for this, and a backend registers an implementation
when it is linked in, so the core runtime still names no GPU code and still builds for
bare metal:

core (no GPU code)                        CUDA backend
copy_between_devices()
  -> get_device_allocator(CUDA) ────────  register_device_allocator(...)
  -> allocator->copy_host_to_device() ──► cudaMemcpy

A host to host copy keeps using memcpy and does not need any device registered.

One primitive was missing. The interface had host to device and device to host, but not
device to device, which is exactly what this feature needs: your input is already on the
GPU and the planned buffer is too. It is added with a default that returns
NotSupported, so an allocator that cannot do it declines instead of being forced to
implement it, and the CUDA one implements it.

Test plan

Added device_copy_test.cpp, which needs no GPU: the repository already has a mock
allocator that copies with memcpy and counts calls, so the routing is observable.

  • a host to host copy still works and does not touch the allocator
  • host to device, device to host, and device to device each reach the matching call
  • zero bytes is accepted, which a tensor with an empty dimension relies on

Before this change no test executed a program with device activations at all. The
closest one loads the method and tolerates failure without running it, so the crash had
no coverage.

Also built the runtime and ran the existing runtime and extension tests on Linux
x86_64.

Known gaps, not fixed here

Two problems on the same feature remain, and each deserves its own change:

  • The Python bindings build the source tensor without reading its device, so a GPU input
    is labelled as host memory. A caller passing GPU tensors from Python is still not
    correctly described to the runtime.
  • For a program whose output stays on the GPU, the Python return path reads that memory
    as if it were host memory.

[ghstack-poisoned]
@shoumikhin

shoumikhin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Stack from ghstack (oldest at bottom):

Copilot AI lite review requested due to automatic review settings August 9, 2026 20:58
@pytorch-bot

pytorch-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 1 New Failure, 124 Pending

As of commit 4fa7f46 with merge base 730b77a (image):

NEW FAILURE - The following job has failed:

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 Aug 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 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.

[ghstack-poisoned]
Copilot AI review requested due to automatic review settings August 9, 2026 22:38
shoumikhin added a commit that referenced this pull request Aug 9, 2026
## The problem

ExecuTorch can export a model two ways for a GPU. The default copies your input from
main memory (the host) to the GPU for you. The other way skips those copies, so you
hand over memory that is already on the GPU and the output stays there. That second
way saves a round trip, which matters for a model in a loop.

The second way crashes. It crashes from Python and from C++, immediately, before your
data is touched:

```
[cuda_backend.cpp:429] Created new CUDA stream 0x10ae920 for method
Segmentation fault (core dumped)
```

The reason is one line. When the runtime puts your input into the buffer the memory
plan reserved, it always used a plain host copy:

```cpp
std::memcpy(t_dst.mutable_data_ptr(), t_src.const_data_ptr(), t_src.nbytes());
```

If that destination is GPU memory, a host `memcpy` into it is undefined and the process
dies. The memory itself was allocated correctly on the GPU; only the copy assumed the
host.

## The change

Route the copy through the device that owns the memory instead of assuming the host.
ExecuTorch already has an interface for this, and a backend registers an implementation
when it is linked in, so the core runtime still names no GPU code and still builds for
bare metal:

```
core (no GPU code)                        CUDA backend
copy_between_devices()
  -> get_device_allocator(CUDA) ────────  register_device_allocator(...)
  -> allocator->copy_host_to_device() ──► cudaMemcpy
```

A host to host copy keeps using `memcpy` and does not need any device registered.

One primitive was missing. The interface had host to device and device to host, but not
device to device, which is exactly what this feature needs: your input is already on the
GPU and the planned buffer is too. It is added with a default that returns
`NotSupported`, so an allocator that cannot do it declines instead of being forced to
implement it, and the CUDA one implements it.

## Test plan

Added `device_copy_test.cpp`, which needs no GPU: the repository already has a mock
allocator that copies with `memcpy` and counts calls, so the routing is observable.

- a host to host copy still works and does not touch the allocator
- host to device, device to host, and device to device each reach the matching call
- zero bytes is accepted, which a tensor with an empty dimension relies on

Before this change no test executed a program with device activations at all. The
closest one loads the method and tolerates failure without running it, so the crash had
no coverage.

Also built the runtime and ran the existing runtime and extension tests on Linux
x86_64.

## Known gaps, not fixed here

Two problems on the same feature remain, and each deserves its own change:

- The Python bindings build the source tensor without reading its device, so a GPU input
  is labelled as host memory. A caller passing GPU tensors from Python is still not
  correctly described to the runtime.
- For a program whose output stays on the GPU, the Python return path reads that memory
  as if it were host memory.

ghstack-source-id: 3d35054
ghstack-comment-id: 5233812152
Pull-Request: #21699

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

shoumikhin added a commit that referenced this pull request Aug 9, 2026
## The problem

ExecuTorch can export a model two ways for a GPU. The default copies your input from
main memory (the host) to the GPU for you. The other way skips those copies, so you
hand over memory that is already on the GPU and the output stays there. That second
way saves a round trip, which matters for a model in a loop.

The second way crashes. It crashes from Python and from C++, immediately, before your
data is touched:

```
[cuda_backend.cpp:429] Created new CUDA stream 0x10ae920 for method
Segmentation fault (core dumped)
```

The reason is one line. When the runtime puts your input into the buffer the memory
plan reserved, it always used a plain host copy:

```cpp
std::memcpy(t_dst.mutable_data_ptr(), t_src.const_data_ptr(), t_src.nbytes());
```

If that destination is GPU memory, a host `memcpy` into it is undefined and the process
dies. The memory itself was allocated correctly on the GPU; only the copy assumed the
host.

## The change

Route the copy through the device that owns the memory instead of assuming the host.
ExecuTorch already has an interface for this, and a backend registers an implementation
when it is linked in, so the core runtime still names no GPU code and still builds for
bare metal:

```
core (no GPU code)                        CUDA backend
copy_between_devices()
  -> get_device_allocator(CUDA) ────────  register_device_allocator(...)
  -> allocator->copy_host_to_device() ──► cudaMemcpy
```

A host to host copy keeps using `memcpy` and does not need any device registered.

One primitive was missing. The interface had host to device and device to host, but not
device to device, which is exactly what this feature needs: your input is already on the
GPU and the planned buffer is too. It is added with a default that returns
`NotSupported`, so an allocator that cannot do it declines instead of being forced to
implement it, and the CUDA one implements it.

## Test plan

Added `device_copy_test.cpp`, which needs no GPU: the repository already has a mock
allocator that copies with `memcpy` and counts calls, so the routing is observable.

- a host to host copy still works and does not touch the allocator
- host to device, device to host, and device to device each reach the matching call
- zero bytes is accepted, which a tensor with an empty dimension relies on

Before this change no test executed a program with device activations at all. The
closest one loads the method and tolerates failure without running it, so the crash had
no coverage.

Also built the runtime and ran the existing runtime and extension tests on Linux
x86_64.

## Known gaps, not fixed here

Two problems on the same feature remain, and each deserves its own change:

- The Python bindings build the source tensor without reading its device, so a GPU input
  is labelled as host memory. A caller passing GPU tensors from Python is still not
  correctly described to the runtime.
- For a program whose output stays on the GPU, the Python return path reads that memory
  as if it were host memory.

ghstack-source-id: 3d35054
ghstack-comment-id: 5233812152
Pull-Request: #21699
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Closing this: the premise is wrong.

This change made the input copy device aware, on the assumption that a program exported to keep its activations on the GPU has to copy the caller's input into a buffer the runtime reserved. It does not, and it should not. An ExecuTorch tensor refers to memory rather than owning it, and the runtime already has a path that points at the caller's buffer with no copy at all.

The crash I was chasing came from my own export configuration. Memory planning allocates graph inputs by default, so the runtime reserved a GPU buffer for an input the caller already owned, and then filled it with a host memcpy. Exporting with MemoryPlanningPass(alloc_graph_input=False) selects the sharing path, and the program then runs correctly on a stock build with no change to the runtime:

graph inputs allocated:      768 planned bytes, set_input copies
graph inputs NOT allocated:  256 planned bytes, set_input shares the caller pointer

Verified from C++ with GPU memory in and out, matching eager PyTorch exactly on two GPU architectures. Copying there would defeat the purpose of the feature, which its own documentation states: the caller provides tensors already on the target device.

Two smaller changes replace this, each doing one thing:

  • document that the device-activation flow needs alloc_graph_input=False, and add a test that actually executes such a program, since none did
  • report a clear error when a device-planned tensor reaches the host copy, instead of crashing with no diagnostic

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants