Skip to content

Import torch only when the Python runtime needs it, warn before outputs follow inputs, and upload bfloat16 without torch - #24321

Open
shoumikhin wants to merge 9 commits into
pytorch:mainfrom
shoumikhin:torch-free-outputs
Open

shoumikhin wants to merge 9 commits into
pytorch:mainfrom
shoumikhin:torch-free-outputs

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

Importing executorch.runtime imports PyTorch whenever it is installed, even when a program only passes NumPy arrays. That costs about 2 s and 500 MB of peak memory. Not loading it would change outputs: today NumPy inputs give torch.Tensor outputs whenever PyTorch is installed. So the switch is announced first, and some bugs are fixed:

  • bool() of an ExecuTorchResult is always True, and int() and float() read its raw bytes as text.
  • With EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT=executorch, calls fail with AttributeError while another thread imports torch, or while sys.modules["torch"] is None. With a lazy import, every user would see this.
  • Without PyTorch, bfloat16 host data cannot be uploaded to a GPU: the buffer protocol has no bfloat16 format.

What this change does

  • Importing executorch.runtime no longer imports PyTorch, unless EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT=torch. The first call that returns a tensor imports it, so outputs do not change yet.
  • Each call whose outputs will change later issues a FutureWarning that says how to choose. Calls without tensor inputs, such as get_attribute(), do not warn.
  • bool(), int() and float() of an ExecuTorchResult raise TypeError.
  • A half-imported torch, or a None for it in sys.modules, is ignored until torch can be imported.
  • A torch that fails with an ImportError counts as missing, as before. Other errors are raised by the calls that need torch, with a note on how to skip it, not by importing ExecuTorch.
  • Bindings built without CUDA support import torch to wait for CUDA work.
  • ExecuTorchResult.from_host() takes a keyword-only dtype. For an ml_dtypes bfloat16 array x:
ExecuTorchResult.from_host(x.view(np.uint16), dtype="bfloat16")

What changes later

Tensor inputs of a call Now Later
any torch.Tensor torch.Tensor torch.Tensor
only NumPy arrays, other buffers, DLPack or ExecuTorchResult torch.Tensor, with the warning ExecuTorchResult
none torch.Tensor torch.Tensor
PyTorch missing ExecuTorchResult ExecuTorchResult

EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT=executorch picks ExecuTorchResult for every call and never loads PyTorch.

Tested

On Linux x86_64, each commit was built with and without CUDA support. At each commit, the pybindings, runtime, no-ATen, bundled program and CUDA DLPack suites fail only where main fails too, and that commit's new tests fail without it. Tests that need a GPU and bindings without CUDA support, which CI skips, were run locally.

@shoumikhin shoumikhin added the release notes: api Changes to public facing apis (any interfaces, pybinded runtime methods, etc.) label Oct 30, 2026
@pytorch-bot

pytorch-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 3 New Failures, 1 Pending, 2 Unrelated Failures

As of commit c3eb82b with merge base 75af78c (image):

NEW FAILURES - The following jobs have failed:

FLAKY - The following job failed but was likely due to flakiness present on trunk:

BROKEN TRUNK - The following job failed but was present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

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 Oct 30, 2026
@github-actions github-actions Bot added ciflow/trunk module: arm Issues related to arm backend labels Oct 30, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 20m 33s —— View job


Needs Discussion: picking the output type on every call from sys.modules lets it change partway through a process and changes a stable API's default with no deprecation step, and test_method_rejects_aliased_input_whose_storage_changed is now always skipped.

Detailed review

PR Review: #24321

Summary

This PR stops the portable Python bindings from importing PyTorch. Each call returns torch.Tensor outputs only if a fully imported torch is already in sys.modules, and ExecuTorchResult otherwise, unless EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT fixes the type. Two design problems need a decision first: the output type now depends on global import state at the time of each call, and the default of a stable API changes without deprecation. Several test and doc defects need fixing whichever way that goes, including a test that is now always skipped. All 8 changed files were reviewed. Nothing was built or run; the findings come from reading the code.

API Design

  • The output type depends on hidden global state that is read on every call (extension/pybindings/pybindings.cpp:175-183). Method.execute, Module.forward, get_outputs and get_attribute return whichever type matches whether anything in the process has imported torch yet, so the type can change between two calls on the same Method:

    • Code whose first execute() runs before torch is imported gets ExecuTorchResult. After a function-local import torch, or a dependency that imports torch (torchvision, executorch.exir), the same calls return torch.Tensor. Code that stacks or compares results then sees mixed types. The description's behavior-change note only covers scripts that never import torch.
    • A CuPy program that follows this page's advice (out.to_host(), docs/source/runtime-python-api-reference.rst:44) gets an AttributeError once any dependency imports torch, because torch.Tensor has no to_host.
    • A library built on executorch.runtime cannot control what it gets back. The only lever is an otherwise unused import torch, which this PR adds in pte_metadata.py:28. In the default mode _tensor_output is None, so nothing reports which type a call will return.

    Fix the choice once instead of on every call. That can be per process at import (today's behavior, with torch-free as the opt-in), or per Runtime/Program through an explicit option, with the env var as the process default. Then document the contract on Method.execute.

  • The Method.execute docstring still states the old rule (runtime/__init__.py:174-176): "typically torch.Tensor objects. Without PyTorch, tensor outputs are ExecuTorchResult objects." .. autoclass:: Method renders it right above the new paragraph that contradicts it. The page intro (docs/source/runtime-python-api-reference.rst:3) still says execution "returns torch.Tensor values", and points to "Without PyTorch" only for running "without PyTorch installed". Fix this →

Backward Compatibility

  • The default changes with no deprecation step or warning. executorch.runtime is not marked experimental, so it is Stable under docs/source/api-life-cycle.md:22-31. Stable APIs "can be changed, but not in a breaking way" unless they go through Deprecated first. Any caller that has torch installed but has not imported it yet now gets ExecuTorchResult, and the failure shows up late or not at all:

    • Duck-typed code breaks silently. Without the new import, the pattern at examples/arm/person_detection_example_cortex_m/utils/pte_metadata.py:35 (result.item() if hasattr(result, "item") else result) would put ExecuTorchResult objects into a dict[str, float | int].
    • Calling .item(), .numpy() or torch.* directly on outputs raises an AttributeError far from the cause.

    Either take the opt-in alternative the description mentions, or ship one release that warns once, then change the default in the next release. The warning would fire when an ExecuTorchResult is returned, the env var is unset, and importlib.util.find_spec("torch") finds torch (that call does not import it).

Thread Safety

  • The handling of a thread that imports torch during a call relies on a private importlib detail, and the test only fakes it. loaded_torch_module() reads __spec__._initializing (extension/pybindings/pybindings_executorch_result.h:103-108). The only in-tree test sets that flag by hand on a types.ModuleType (extension/pybindings/test/test_pybindings.py:199-203), so it checks the assumption, not the race. The 10-of-10 threaded run from the description is not in the repo. Two options:
    • Add a deterministic threaded test: a fake torch package on a temporary sys.path whose __init__ blocks on an event while the main thread calls execute(). The broken case already builds a fake package this way.
    • Use PyImport_GetModule(). It is public C API and returns a strong reference. Since CPython 3.9 it waits for another thread's in-progress import instead of returning the partly imported module, so a call that races the import would get torch outputs rather than ExecuTorchResult. Low confidence on the waiting behavior: I could not run a check here.

Testing

  • test_method_rejects_aliased_input_whose_storage_changed is now always skipped (extension/pybindings/test/test_pybindings.py:493). It skips unless _C._tensor_output == "torch", and _tensor_output is now None unless the env var is set (pybindings.cpp:4660-4665).
    • No CI job runs it. Only .github/workflows/pull.yml:199,210 (executorch, for the no-ATen suite) and .ci/scripts/test-cuda-build.sh:92-95 (the CUDA DLPack test) set the env var.
    • What it covers is still on by default, because this file imports torch: the aliased-input views from aliased_torch_output / module_aliased_torch_output, whose signatures this PR changes.
    • Fix: use if _C._tensor_output == "executorch":, the same flip the PR makes in test_pybindings_no_aten.py:479-483. Fix this →
  • test_buffer_input_does_not_import_torch no longer checks the output type, and its result depends on test order (test_pybindings.py:157-160).
    • loaded_torch_module() stores torch in a static variable the first time it finds it (pybindings_executorch_result.h:97-111). If an earlier test in the same process already did that, this test gets a torch.Tensor even though it removed torch from sys.modules. Run alone, it gets ExecuTorchResult.
    • The loosened np.asarray check accepts both types. The "Decided for each call" comment (pybindings.cpp:175-176) is only true until torch is first found.
    • Separately, the torch branch of configure_portable_tensor_output() no longer calls (void)loaded_torch_module(); after the import (pybindings.cpp:203-204). Run alone with the env var set to torch, this test likely now raises "PyTorch tensor output was requested, but torch is not imported" (pybindings.cpp:1707-1714). It used to pass.
    • Fix: restore that call. Then either assert ExecuTorchResult in a fresh subprocess, or drop the test, since test_tensor_output_import_policy already covers torch installed but not imported. Fix this →
  • assert "torch" in str(error) passes for almost any import failure (test_pybindings.py:212). "executorch" contains "torch", so an ImportError that names an ExecuTorch module passes too. Check each case's root message instead: "torch is unavailable" for no, "torch is broken" for broken, and the halted-import message for none. Fix this →
  • The switch to torch outputs after import torch is only tested through module(...) (test_pybindings.py:236-239). Method.execute/get_outputs (pybindings.cpp:3859) and get_attribute (pybindings.cpp:3897) each make the decision where they are called, and none of them is tested across the switch. CUDA CI only runs the two fixed settings (.ci/scripts/test-cuda-build.sh:92), so the new default never runs on CUDA, even though hand_over_device_output now takes the per-call value. Fix this →

Code Quality

  • The example imports torch only to change the output type (examples/arm/person_detection_example_cortex_m/utils/pte_metadata.py:27-28). Its caller deploy/generate_qparams_header.py imports only argparse, sys, pathlib and this module. It now loads all of PyTorch to read 8 scalars, which is the cost this PR is meant to remove. values[name] = np.asarray(result).item() works for ExecuTorchResult, CPU torch tensors and Python scalars, without torch. Fix this →
  • The directory from tempfile.mkdtemp() is never deleted (test_pybindings.py:195), so each broken subprocess leaves a fake torch package behind in the temp directory. Create it under the test's TemporaryDirectory, for example by passing the path through argv. Fix this →

Recommendation

Needs Discussion

This draft asks for discussion of the per-call detection and of changing the default without deprecation. That decision shapes the rest of the change. Whichever way it goes, the always-skipped aliasing test, the stale Method.execute docstring and the weak test assertions need fixing before merge.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude review this PR

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 5m 10s —— View job


Needs Discussion: the output type still flips partway through a process depending on whether anything has imported torch yet, and a stable API's default changes with no deprecation step; the aliasing test is still always skipped and the Method.execute docstring is still stale.

Detailed review

PR Review: #24321

Summary

This PR stops the portable Python bindings from importing PyTorch. Each call returns torch.Tensor outputs only if a fully imported torch is already in sys.modules, and ExecuTorchResult otherwise. A separate commit (8903f4c) fixes the order, speed and dropping of runtime log lines that wait for the writer thread.

The three reviewers agreed on the main design problem: the output type depends on global import state that is read on every call. Some items from the earlier review are fixed: the policy test now checks Method.execute/get_outputs, and the env-var torch path does work, because loaded_torch_module() finds torch lazily. Others are still open: the always-skipped aliasing test, the stale docstring, the loosened buffer-input test, and the example's side-effect import.

All 8 files were reviewed. Nothing was built or run, so every finding comes from reading the code. The checkout is shallow, so the diff was taken against the PR's parent commit f1240a2.

Thread Safety

  • The writer thread now waits with no time limit on a full non-blocking fd 2, and later main-thread lines pile up behind it (extension/pybindings/pybindings.cpp:226-231). poll(&stderr_fd, 1, -1) replaces the old drop-on-EAGAIN.
    • The batch stays unwritten if the pipe's reader is alive but never drains. Then own_deferred_lines_wait() stays true, so every later line from that thread is deferred behind it and eventually capped at the 1 MiB limit. That includes lines bound for a replaced, perfectly writable sys.stderr such as capsys or redirect_stderr.
    • There is no deadlock, because the GIL is not held and exit only waits 100 ms. Still, one stuck pipe can now silence all later redirected output.
    • A bounded wait, for example poll(..., 1000), that then drops the rest of the batch would keep the new no-drop behavior for slow readers and limit the damage from a dead one. poll returning an error (other than EINTR) should also end the loop instead of retrying forever.
    • (2 of 3 reviewers; suspected, not reproduced.)
  • The "is torch still being imported" check relies on a private importlib detail, and no test runs a real concurrent import (extension/pybindings/pybindings_executorch_result.h:103-108).
    • __spec__._initializing is CPython-internal.
    • The initializing case (test_pybindings.py:218-222) only plants a fake module that never finishes importing. So the race from the description, and the switch to torch.Tensor once the import completes, are both untested.
    • If sys.modules["torch"] is a LazyLoader module, reading __spec__ runs the real import, so the bindings would import torch themselves. Low confidence: not run.
    • Consider PyImport_GetModule(), which is public API. Also consider a deterministic threaded test: a fake torch package on a temporary sys.path whose __init__ blocks on an event while the main thread calls execute().

Backward Compatibility

  • The default of a stable API changes with no deprecation step. executorch.runtime is not marked experimental, so under docs/source/api-life-cycle.md it should go through Deprecated before a breaking change.
    • A caller that has torch installed but hasn't imported it yet now gets ExecuTorchResult, which has no .item(), .numpy() or torch.* interop. The AttributeError shows up far from the cause.
    • Duck-typed code fails silently. Without the new import, pte_metadata.py:35 (result.item() if hasattr(result, "item") else result) would put ExecuTorchResult objects into a dict[str, float | int].
    • The private _C._tensor_output now reports None by default instead of "torch"/"executorch" (test_pybindings.py:235), so nothing reports which type a call will return.
    • Either take the opt-in alternative from the description, or warn once for a release. The warning would fire when an ExecuTorchResult is returned, the env var is unset, and importlib.util.find_spec("torch") finds torch (that call does not import it).

API Design

  • The output type can change between two calls on the same Method (extension/pybindings/pybindings.cpp:690-698).
    • Code whose first execute() runs before torch is imported gets ExecuTorchResult. After any later import of torch, the same call returns torch.Tensor. That import can come from a dependency (transformers, executorch.exir), a pytest plugin, or a function-local import torch.
    • The same script can also behave differently under a test runner than when run directly.
    • The old README promised: "importing PyTorch later does not change the output type". That guarantee is dropped and nothing replaces it.
    • A library on top of executorch.runtime cannot control the type except through a side-effect import torch or the process-wide env var.
    • Decide once instead of on every call: at import (today's behavior, with torch-free as an opt-in), or per Runtime/Program through an explicit option, with the env var as the default. Then document the contract on Method.execute.
    • (3 of 3 reviewers.)
  • The Method.execute docstring still describes the old rule (runtime/__init__.py:174-176): "typically torch.Tensor objects. Without PyTorch, tensor outputs are ExecuTorchResult objects." .. autoclass:: Method renders it right next to the new wording that contradicts it. Fix this →

Testing

  • test_method_rejects_aliased_input_whose_storage_changed is always skipped (extension/pybindings/test/test_pybindings.py:1433).
    • It skips unless _C._tensor_output == "torch", and that value is now None by default, even though this file imports torch and the outputs are torch tensors.
    • No CI job sets the env var to torch for this suite (only pull.yml sets executorch, and the CUDA script runs only the DLPack test). So the aliased-view checks behind aliased_torch_output now never run.
    • Skip only when it is "executorch", the same flip the PR makes in test_pybindings_no_aten.py.
    • (2 of 3 reviewers.) Fix this →
  • test_buffer_input_does_not_import_torch checks neither contract for the output type, and its result depends on test order (test_pybindings.py:166-179).
    • loaded_torch_module() caches torch in a static the first time it sees it (pybindings_executorch_result.h:97-112). After an earlier test has run, popping sys.modules["torch"] still yields torch.Tensor. Run alone, the test gets ExecuTorchResult.
    • The loosened np.asarray check accepts both, so the test passes either way.
    • The "Decided for each call" comment (pybindings.cpp:690) is likewise true only until torch is first seen.
    • Either assert ExecuTorchResult in a fresh subprocess, or drop this check, since test_tensor_output_import_policy already covers torch installed but not imported. (2 of 3 reviewers.)
  • Weak assertions in the policy-test script (test_pybindings.py:214, :231):
    • assert "torch" in str(error) also matches any message that names "executorch". Check each case's root message instead: "torch is unavailable", "torch is broken", and the halted-import message.
    • tempfile.mkdtemp() leaves a fake torch package behind on every broken run. Create it under the test's TemporaryDirectory, passed in through argv.
  • Parts of commit 8903f4c have no test:
    • The writer stopping once finalization begins (the exiting/writer_entering handshake and the !python_still_running path).
    • Writing one line at a time so that lines stay whole on a pipe.
    • In test_lines_waiting_for_a_full_stderr, assertGreater(speed, 0.05) (:1205) catches the "hundreds of times slower" regression, but would pass a 20x slowdown. assertNotIn("runner lines: 0", ...) (:1202) proves only that some lines arrived, not that none were dropped. Comparing the drained count with the count of lines the runner emits would test the no-drop claim.

Code Quality

  • The example loads all of PyTorch just to change the output type (examples/arm/person_detection_example_cortex_m/utils/pte_metadata.py:28). This is the cost the PR is meant to remove. values[name] = np.asarray(result).item() works for ExecuTorchResult, CPU torch tensors and scalars, without torch. Fix this →
  • If the merge fails with bad_alloc, the lines come out in the order this commit set out to fix (pybindings.cpp:405-410). The exception is swallowed, the waiting main_thread_logs stay behind, and the call's own lines are written first. Minor, and memory exhaustion is rare. Defer the call's lines behind them in that branch instead of writing them directly.

Recommendation

Needs Discussion

The draft asks whether to change the default, and the reviewers agree that deciding the type on every call from sys.modules is the part to reconsider. Deciding once (at import, or per Runtime) gives callers a type they can rely on. Whichever way that goes, two things need fixing before merge: the always-skipped aliasing test and the stale Method.execute docstring.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude review this PR

@claude

claude Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 5m 17s —— View job


Needs Discussion: the output type still changes partway through a process depending on whether anything has imported torch yet. The new LLM-runner setup (2b3c024) can be delayed indefinitely, and if it fails, its error is raised in unrelated main-thread code. Four items from earlier reviews are still open: the always-skipped aliasing test, the stale Method.execute docstring, the order-dependent buffer test, and the example's side-effect import torch.

Detailed review

PR Review: #24321

Summary

This PR stops the portable Python bindings from importing PyTorch. Each call returns torch.Tensor only if torch is already in sys.modules, and ExecuTorchResult otherwise. Commit 8903f4c fixes how waiting log lines are written, and commit 2b3c024 sets up log routing when only the LLM runner loads the library.

Three independent reviewers covered all 8 files, and every finding below was re-checked against the code. Nothing was built or run. Since the last review, commit 2b3c024 is new, and the earlier open items are still open.

Thread Safety

  • The setup that 2b3c024 schedules at load can be delayed with no limit, can be dropped silently, and its error lands in unrelated code (extension/pybindings/pybindings.cpp:5994-5995, :674-685). Three reviewers reported this.
    • Delayed: a pending call runs only when Python's main thread next runs bytecode. Suppose the runner is first imported on a worker thread while the main thread is blocked in Thread.join(), a C event loop, or an embedder that never runs bytecode. Until the main thread runs bytecode again, main_thread_id stays 0, there is no atexit hook, and there is no fork handler. That is the same line loss the commit fixes, in a narrower window. A fork() in that window also gives the child no handler for the inherited DeferredLogs mutex.
    • Dropped: the return value of Py_AddPendingCall only feeds log_routing_scheduled, and nothing reads that variable. If the queue is full, setup never runs on the runner-only path.
    • Error placement: set_up_log_routing_after_load returns -1 with an error set. Python then raises that error at whatever main-thread bytecode runs next, which may sit inside a user's try/except with no connection to ExecuTorch. The comment at :638-639 says "A setup that raised runs again on the next call", but on the runner-only path nothing calls it again. Only an import of portable_lib (:5479) retries it.
    • Fix: in the pending call, report the error with PyErr_WriteUnraisable and return 0. Also call set_up_log_routing() from the first runner or binding entry that holds the GIL, so setup does not depend on the pending call alone.
    • Low confidence on the subinterpreter case: on 3.12+, the pending call targets the main interpreter, while set_up is a process-wide static.
  • set_up = true is set before the Python calls that can release the GIL (pybindings.cpp:646). While the main thread is partway through setup, a worker importing portable_lib returns early and goes on with main_thread_id == 0. If the main thread's setup then fails, the worker's import has already succeeded with no routing. The comment acknowledges that another thread can enter here, but not this result. Suspected, and hard to hit.
  • A non-blocking fd 2 whose reader never drains it now holds back all later lines for the rest of the process (pybindings.cpp:226-230). Three reviewers reported this.
    • poll(&stderr_fd, 1, -1) never returns. So writer_running stays true, and both deferred_lines_wait() and own_deferred_lines_wait() stay true.
    • From then on, every line logged outside a call and every line from a later call is queued behind the stuck batch. That includes lines bound for a writable replaced sys.stderr such as capsys or redirect_stderr. They are capped at 1 MiB and the oldest are dropped. Before this commit, EAGAIN dropped one batch and later lines still came through.
    • The return value of poll is not checked, so a POLLERR/POLLHUP combined with repeated EAGAIN would spin.
    • Fix: use a bounded poll such as 1 s, drop the rest of the batch on timeout, and leave the loop on a poll error. Also mention this case in the README's list of hangs.

API Design

  • The output type can change between two calls on the same Method (pybindings.cpp:739-746, pybindings_executorch_result.h:97-112). Three reviewers reported this.
    • The type follows sys.modules["torch"] at each call. A later import by a dependency, a pytest plugin, a function-local import torch, or another thread changes what the same code gets back.
    • ExecuTorchResult has to_host() and no .item(), while torch.Tensor has the reverse, so code written against one type breaks after the switch. The pte_metadata.py change shows the workaround callers need.
    • The torch module is also cached in a static the first time it is found (pybindings_executorch_result.h:97). After that, removing torch from sys.modules no longer gives ExecuTorchResult, so "only if torch is already imported" is only true until the first lookup.
    • Fix: decide once, at import or per Runtime/Program, with the env var as the default. Or make the per-call rule opt-in, as the description already proposes. Either way, executorch.runtime is not marked experimental, so changing its default without a deprecation step goes against docs/source/api-life-cycle.md.
  • The Method.execute docstring is still stale (runtime/__init__.py:174-176). It says outputs are "typically torch.Tensor objects. Without PyTorch, tensor outputs are ExecuTorchResult objects." Under the new rule, outputs are ExecuTorchResult whenever torch has not been imported, even if it is installed. This is still open from earlier reviews. Fix this →

Testing

  • test_method_rejects_aliased_input_whose_storage_changed is still always skipped (extension/pybindings/test/test_pybindings.py:1457). It skips unless _C._tensor_output == "torch", but that value is now None by default, even though this file imports torch. Skip only when the value is "executorch". This is still open from earlier reviews. Fix this →
  • test_buffer_input_does_not_import_torch accepts either output type, so whether it passes says nothing, and the type it gets depends on test order (test_pybindings.py:176-179). Because of the static cache above, it gets torch.Tensor after any earlier test that found torch, and ExecuTorchResult when run alone. Assert the exact type in a fresh subprocess. Two reviewers reported this.
  • test_lines_waiting_for_a_full_stderr can stop guarding the fix on a slow machine:
    • The drain starts after a fixed 2 s sleep. If TextLLMRunner(...) construction takes about 1.2 s or more, as it can under pytest -n auto, draining has begun before the 0.5 s timing window. The speed ratio is then measured with nothing waiting, so the old spinning code would also pass. Start the drain from an event, after checking that lines are still waiting.
    • assertNotIn("runner lines: 0", ...) shows that some lines arrived, not that none were lost. Compare against the count the runner logs when stderr has room.
  • No test covers the runner-only paths most likely to go wrong: a library first loaded on a worker thread, and a failed pending-call setup.
  • The policy-test script still has two weak spots (test_pybindings.py:214, :231). tempfile.mkdtemp() leaves a fake torch package behind on every run. "torch" in str(error) also matches any message that names "executorch". Both are still open from earlier reviews.

Code Quality

  • The example still loads all of PyTorch only to change the output type (examples/arm/person_detection_example_cortex_m/utils/pte_metadata.py:27-28). That is the cost this PR is meant to remove. values[name] = np.asarray(result).item() works for both output types, without torch. Fix this →
  • A test comment gives the wrong reason (test_pybindings.py:692): "Python is exiting, so the runner's lines cannot wait for another thread." atexit hooks run last-registered first, so log_at_exit runs before the bindings' exit hook, while exiting is still false. The lines are written directly because fd 2 has room. The test itself is still valid.

Recommendation

Needs Discussion

The default still has to be decided: per-call detection from sys.modules, or a type fixed once. Whichever way that goes, two items block merge: the always-skipped aliasing test and the stale docstring. The 2b3c024 setup should also stop raising its error into unrelated main-thread code.

@shoumikhin
shoumikhin force-pushed the torch-free-outputs branch 2 times, most recently from 87e0d97 to 00ffa2e Compare November 5, 2026 01:36
@shoumikhin shoumikhin changed the title Return torch tensors from the Python runtime only when torch is imported Import torch only when the Python runtime needs it, and warn before outputs follow inputs Nov 5, 2026
@shoumikhin shoumikhin removed the module: arm Issues related to arm backend label Nov 5, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 32m 47s —— View job


Request Changes: in bindings built without CUDA support, execute() on CUDA inputs now fails unless torch is already imported, and the one-time FutureWarning is used up if its first occurrence is filtered out; how a broken torch should fail and the announced rule for calls without tensor inputs also need a decision before the warning text ships.

Detailed review

PR Review: #24321

Summary

The PR makes the portable Python bindings import torch only when a call keeps returning torch.Tensor outputs, warns that calls without torch.Tensor inputs will return ExecuTorchResult outputs in a future release, ignores a torch that is blocked or still importing, and makes bool(), int() and float() of an ExecuTorchResult raise TypeError. It has a CUDA regression, a warning that can be lost, two behaviors that need a decision, and a small latch bug. Three independent reviewers covered all 8 changed files, and I re-checked each finding below by reading the code. Nothing was built or run. The diff is against dadc829, the parent of the PR's first commit, because the shallow checkout has no merge base with origin/main.

Code Quality

  • torch_missing stays set after torch comes back (extension/pybindings/pybindings.cpp:795-799, :818; 3 of 3 reviewers; confirmed by reading). The latch is checked before loaded_torch_module(). If one NumPy-input call runs while sys.modules["torch"] is None, the resulting ModuleNotFoundError sets the latch for the rest of the process. After torch is restored and imported, NumPy-input calls still return ExecuTorchResult with no warning, while calls without tensor inputs return torch.Tensor (:851-853). That contradicts the contract at :788-790, which keeps torch.Tensor outputs "whenever torch can be imported". The default mode is also untested here. test_torch_that_is_not_ready_is_ignored (test_pybindings.py:535) blocks torch only with EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT=executorch (:573-575), and in that mode portable_tensor_output() returns at :848-849 before it reaches keep_torch_outputs(). Fix: check loaded_torch_module() before the latch, and add a test run with the variable unset. Fix this →

API Design

  • Calls without tensor inputs will change type with import order (pybindings.cpp:739-746, :851-852; extension/pybindings/README.md:102-103; docs/source/runtime-python-api-reference.rst:51; 2 of 3 reviewers). Under the announced rule, a call without tensor inputs returns torch.Tensor only if torch is already imported. So get_attribute() (pybindings.cpp:4852-4855), bundled verification (:2960-3000) and methods that take no inputs will return different types depending on whether unrelated code imported torch first. The warning already tells users to depend on this ("import torch before a call without tensor inputs"). The only stable choice is a process-wide environment variable, and it must be set before import. Consider a fixed type for these calls, or an explicit option on Runtime, load_program() or load_method() that defaults to the environment variable. Decide this before the warning text ships, because users will write code against it.

Backward Compatibility

  • CUDA inputs fail in bindings built without CUDA support unless torch is already imported (pybindings.cpp:2342-2363; pybindings_executorch_result.h:124-132; pybindings_cuda.h:64-67; 3 of 3 reviewers; confirmed by tracing the code, not run). These builds wait for CUDA work through torch only if torch is already in sys.modules. Otherwise synchronize_cuda_device() throws "CUDA tensor interchange requires bindings built with CUDA support". On main, portable_lib imported torch at load, so whenever torch was installed, the wait went through torch. Now, with torch installed but not imported, three paths throw:

    • set_inputs([cupy_array]) followed by execute(). execute_installed() waits at :4751, and no output type is picked until get_outputs().
    • run_method() (:3744-3748) or call() (:4778-4779) on a method that returns no tensor, because returns_tensor && keep_torch_outputs() (:855) never imports torch.
    • get_attribute() of a CUDA attribute, which waits at :4843-4845 before it picks a type.

    extension/pybindings/README.md:149-152 documents the new condition, but the PR description does not list it, and the error blames the build instead of saying that importing torch works. Fix: in builds without EXECUTORCH_PYBINDINGS_CUDA, import torch before these waits when it is not loaded and the override is not executorch, and mention importing torch in the error. Fix this →

  • The one-time FutureWarning is used up even when nobody sees it (pybindings.cpp:822-834; 3 of 3 reviewers; confirmed by reading). warned is set before PyErr_WarnEx, and it is reset only when an error filter raises. Suppose the first call that keeps torch outputs runs under an ignore filter, for example inside a library's warm-up warnings.catch_warnings() block. Then no later call warns, even under -W always or simplefilter("always"). This warning is the only notice before outputs change, so those callers will break without warning when the default flips. The comment at :827-828 says this matches plain Python warnings, but it does not: Python clears its warning registry when filters change, and always shows every warning. Separately, the text says "ExecuTorch returned", but the warning is issued before the method runs (:829-830), so a call that then fails still claims it returned outputs. Fix: drop warned and warn on every call that keeps torch outputs. The default filter still shows the warning once per call site, and each such call pays one caller_stacklevel() frame walk. Then change "returned" to "returns", and update both the tests that expect one warning per process and extension/pybindings/README.md:103-104. Fix this →

  • A torch that fails to import now breaks every call, with no warning period (pybindings.cpp:800-817; 3 of 3 reviewers; confirmed by reading). On main, portable_lib.py caught an ImportError from import torch, such as a missing CUDA library, and the bindings returned ExecuTorchResult outputs. Now only a ModuleNotFoundError for torch itself counts as missing, so every call that keeps torch outputs re-raises. The PR description lists this, but it is the only change here that skips the FutureWarning period. The failure is also not cached, so every call runs torch's import again. After a partial import, a retry can raise a different error than the first one and hide the original cause (suspected, not run). Either keep main's fallback, with a warning, until the release that changes the default, or cache the first exception and re-raise it.

Recommendation

Request Changes

The CUDA regression and the lost warning affect callers who change nothing, so fix them before merge. Decide the broken-torch behavior and the rule for calls without tensor inputs before the warning text ships, since it commits users to both. The torch_missing fix is small.

@shoumikhin shoumikhin changed the title Import torch only when the Python runtime needs it, and warn before outputs follow inputs Import torch only when the Python runtime needs it, warn before outputs follow inputs, and upload bfloat16 without torch Nov 7, 2026
@shoumikhin
shoumikhin marked this pull request as ready for review November 7, 2026 05:59
Copilot AI balanced review requested due to automatic review settings November 7, 2026 05:59
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

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 insufficient remaining quota. Try requesting a review at a lower effort level.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 49m 49s —— View job


Approve: nothing blocks merge. Four minor items remain: from_host(dtype=) can't take host data for qint8 or qint32, one case where torch_missing stays set, no setting that applies the announced rule, and thin tests for 315be40.

Detailed review

PR Review: #24321

Summary

This PR makes the portable Python bindings import torch only when a call returns torch.Tensor outputs or waits for CUDA work. It warns with a FutureWarning at each call whose outputs a later release will change, and adds ExecuTorchResult.from_host(dtype=) to upload bfloat16 data without torch. Nothing blocks merge, and the four items below are minor. Three reviewers covered all 10 files against origin/main (75af78c), and each finding was re-checked by reading the code. Nothing was built or run. Points from earlier reviews are not repeated.

Testing

  • 315be40 has a test for only one of the three paths it fixes, and no CI job runs that test (extension/pybindings/test/test_pybindings.py:4164-4172; 3 of 3 reviewers; confirmed by reading).
    • The commit fixes CUDA inputs in bindings built without CUDA support in three places: set_inputs() followed by execute(), a method that returns no tensor, and get_attribute() of a CUDA attribute. The test covers only the first.
    • The test skips without a CUDA device and also skips when CudaBackend is registered. CPU jobs have no device. The pybindings CUDA job (.ci/scripts/test-cuda-build.sh) builds the CUDA backend and runs only test_pybindings_cuda_dlpack.py. The PR description says the test was run locally.
    • So CI would not catch a regression in synchronize_device_for_call (extension/pybindings/pybindings.cpp:2377-2384) on any of the three paths.
    • Fix this →

API Design

  • from_host(dtype="qint8") and dtype="qint32" always fail with host data (extension/pybindings/pybindings.cpp:1359-1372; 1 reviewer; confirmed by reading).
    • BufferTensor reads buffer formats b and i as int8 and int32, never as qint8 or qint32, so the same-dtype check never matches.
    • Unsigned data is refused too, because the table gives these two dtypes a buffer format (extension/pybindings/pybindings_executorch_result.h:53,55).
    • So from_host(np.zeros(4, np.int8), dtype="qint8") raises an error telling the caller to "Pass data of that dtype". No buffer can have that dtype, yet a qint8 result exports its data as b.
    • In ATen builds, an unknown dtype name still raises RuntimeError, not the ValueError that e1681af says it now raises. scalar_type_from_name (pybindings_executorch_result.h:83) loops up to c10's Undefined, which lies past the 30-entry table, so python_dtype throws. This has little impact, since ATen builds cannot upload anyway (2 of 3 reviewers).
    • Fix this →
  • No setting applies the announced rule (extension/pybindings/pybindings.cpp:739-745, :851-869; extension/pybindings/portable_lib.py:43-47; 2 of 3 reviewers; confirmed by reading).
    • The warning says outputs will follow inputs, but neither setting it names does that.
    • executorch also returns ExecuTorchResult for calls with torch.Tensor inputs and for calls without tensor inputs. torch keeps today's rule.
    • Example: a program passes torch tensors to one method and NumPy arrays to another. It cannot try the new rule before it ships, because executorch also changes the outputs of its torch calls.
    • A third value that applies the rule without warning, named in the warning text, would give such programs a way to migrate early.
    • Fix this →

Backward Compatibility

  • If torch is blocked once through sys.modules, it counts as missing until something else imports it (extension/pybindings/pybindings.cpp:792-819; 2 of 3 reviewers; confirmed by reading).
    • While sys.modules["torch"] is None, importing torch raises ModuleNotFoundError, which sets torch_missing. The latch is ignored only once torch is in sys.modules.
    • Example: before anything has imported torch, a test runs one NumPy call under monkeypatch.setitem(sys.modules, "torch", None). Every later NumPy call returns ExecuTorchResult with no warning (:828-830).
    • The same calls return torch.Tensor in a fresh process, and on main, where torch was imported along with ExecuTorch. So test results depend on test order.
    • Fix this →

Recommendation

Approve

Nothing blocks merge. The from_host check and the torch_missing case are small fixes worth making first. The tests and the new setting can follow.

The Python runtime checks each input against torch.Tensor through the torch
it finds in sys.modules, and keeps the first one it finds. That can be a
torch that another thread is still importing, which has no Tensor yet, or
the None that blocks an import. If torch was not imported when the bindings
loaded, for example with EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT set to
executorch, calls then raise AttributeError, even ones with only NumPy
inputs.

Now the runtime uses torch only once it is fully imported, and keeps looking
until then. Reading the import state of the module runs Python code, so the
runtime holds a reference to the module before it reads it. Without that,
code that removes the module from sys.modules during the read would leave
the runtime with a freed object.

Tested on Linux x86_64 with a source build: a new test makes the call while
another thread is halfway through importing a torch package, and with
sys.modules["torch"] set to None. Without this change both cases fail with
AttributeError. Another case puts an object in sys.modules that removes
itself when its import state is read; the call must not crash.
An ExecuTorchResult supports the buffer protocol but has no truth value or
number conversion of its own. So Python falls back to defaults that can give
wrong answers without an error: bool(result) is always True, and int() and
float() parse the raw bytes as text, so a uint8 result that holds 55 (the
character "7") gives 7.

Now each of them raises TypeError and says how to convert the result first:

    bool(result)
    # TypeError: ExecuTorchResult has no truth value. Convert it first, for
    # example with numpy.asarray(result) or torch.from_dlpack(result).

Tested with the standalone no-ATen build: a new test checks that each of
them raises TypeError, and it fails without this change.
…warn before outputs follow inputs

Importing executorch.runtime imports PyTorch whenever it is installed, even
for a program that only passes NumPy arrays. Only setting
EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT=executorch avoids that, and never
loading PyTorch for NumPy callers would change what they get back. So that
change has to be announced first.

Without the setting, importing executorch.runtime no longer imports torch.
The outputs stay as they are today: torch.Tensor whenever PyTorch is
installed, and ExecuTorchResult without it. So a program with only NumPy
inputs still loads torch, at its first call that returns a tensor.

A future release will make tensor outputs follow each call's tensor inputs,
including inputs kept from an earlier call:

- any torch.Tensor input: torch.Tensor outputs;
- only NumPy, other buffer, DLPack or ExecuTorchResult inputs:
  ExecuTorchResult outputs, even when PyTorch is installed;
- no tensor inputs: torch.Tensor outputs only if torch is already imported.

get_attribute() and bundled program verification count as calls without
tensor inputs.

The first call whose outputs will change shows one FutureWarning, which
says how to keep torch.Tensor outputs or get ExecuTorchResult outputs now.
A call that runs the method picks the type first, so with an error filter
for this FutureWarning, such a call raises before it runs the method. A
torch that is installed but fails to import now makes each call that needs
it raise its own error, with a note on how to skip torch. Before, an
ImportError meant ExecuTorchResult outputs, and any other error made the
import of ExecuTorch fail. EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT still fixes
the type:
"executorch" never imports torch, and "torch" imports it when ExecuTorch is
imported.

The Buck target of the pybindings test now depends on executorch.runtime,
which the new test imports.

Tested on Linux x86_64 with Python 3.12 and a CUDA build of PyTorch: the
pybindings, runtime, bundled program and CUDA DLPack suites have no
failures that main does not have, and the standalone no-ATen job passes
with and without PyTorch. Importing executorch.runtime takes 0.04 s and
18 MB of peak memory instead of 1.9 s and 506 MB. A new test runs each case
of the rule in a fresh process. Its checks of the new behaviour fail on a
build without this change; the other checks pin behaviour that must stay
the same.
Without PyTorch, ExecuTorchResult.from_host() is how host data is copied
to a CUDA device for a method input. It reads the host data through the
Python buffer protocol, which has no format for bfloat16. So bfloat16 host
data could not be uploaded: an ml_dtypes bfloat16 array cannot export a
buffer, and its uint16 view uploads as uint16, which a bfloat16 input
refuses.

from_host() now takes a keyword-only dtype, such as "bfloat16", spelled the
way ExecuTorchResult.dtype spells it:

    ExecuTorchResult.from_host(x.view(np.uint16), dtype="bfloat16")

This is the reverse of how a result of that dtype already comes out, so
dtype accepts data in the buffer format that such a result exports: the
format of that dtype, which int8 shares with qint8 and int32 with qint32,
or, for a dtype the buffer protocol cannot describe, unsigned integers of
the same width. Anything else raises ValueError, so data is never
reinterpreted by mistake. A CPU ExecuTorchResult source must match its own
dtype. Without dtype, nothing changes.

The lookup by name is shared with the one for torch dtypes, and stops at
UInt64, the last dtype that has a Python name. The lookup for torch dtypes
used to go on to the end of the dtype enum, which is past the end of the
table in every build, and much further in ATen builds. So an unknown torch
dtype raised a RuntimeError from the dtype table, instead of the ValueError
that the lookup was written to raise.

Tested on Linux x86_64 with a CUDA build and the standalone no-ATen build:
new checks of accepted data, refused data and an unknown dtype name fail
without this change, and bfloat16 data uploaded from 16-bit integers runs
through a CUDA method.
A call that imports torch to keep torch.Tensor outputs remembers when torch
is missing, so later calls do not look for it again. That memory stuck
when torch became available later:

- It was checked before the check for an imported torch. So after a call
  found torch missing, a torch imported later was not used: calls with
  NumPy inputs still returned ExecuTorchResult outputs, while calls without
  tensor inputs returned torch.Tensor.
- A None in sys.modules, which blocks an import, also set it. After the None
  was removed, no later call imported torch, so NumPy inputs gave
  ExecuTorchResult outputs, unlike in a fresh process. So a test suite that
  blocks torch in one test made later tests depend on their order.

Now the memory is checked only while torch is not loaded, and a None in
sys.modules makes only the calls that see it skip torch. The test that
blocks torch now also runs without EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT, the
only mode that reaches this code. It removes the None and expects the next
call to import torch, and it imports torch after a call that found it
missing.
The FutureWarning that announces the change of output types was shown
once per process. The flag that tracked it was set before the warning was
issued, so if the first such call ran under an ignore filter, for example
inside a library's warnings.catch_warnings() block, no later call warned,
even under simplefilter("always"). This warning is the only notice before
the default changes.

Issue it at every such call and let Python's warning filters decide what
shows. The default filter still shows it once per call site. The message
now says "returns" rather than "returned", since it is issued before the
method runs.

A test that captures stderr during a call now ignores this warning. It
passed before only because an earlier test in the same process had used
up the one-time warning.
When importing torch raised an ImportError, for example because one of its
shared libraries is missing, the bindings used to return ExecuTorchResult
outputs, as if torch were not installed. When the torch import moved to
the first call that needs it, only a ModuleNotFoundError for torch itself
counted as missing, so such a torch made every call raise. That was the
only change in output type that had no warning period.

Count every ImportError as missing again, so these setups keep working as
they did. Any other error from importing torch is still raised, with a note
on how to run without it.

The import now goes through importlib. Before Python 3.13, a thread that
waits for another thread's import of torch gets a KeyError from
PyImport_ImportModule when that import fails, not the ImportError. So with
PyImport_ImportModule, when 2 threads make their first calls at the same
time, the waiting one would raise while the other returns ExecuTorchResult
outputs. A new test makes the second call while the first one is importing
a torch that then fails with an ImportError.
The announced rule said that a call without tensor inputs, such as
get_attribute(), a bundled program check, or a method that takes no
inputs, would later return torch.Tensor only if torch was already
imported. So its type would depend on whether unrelated code imported
torch first, and the warning told users to rely on that import order.

Such calls keep returning torch.Tensor outputs whenever torch can be
imported, as they do today, and the future release keeps that. They no
longer warn, since their outputs will not change. Calls whose
tensor inputs are not torch.Tensor still warn, as before. The warning,
the README, the API reference and the Method.execute docstring describe
the new rule.

Tested on Linux x86_64: the output type test checks that calls without
tensor inputs return torch.Tensor outputs without a warning, and it fails
without this change.
…port

Bindings built without CUDA support have no CUDA runtime of their own, so
they wait for CUDA work through torch. Before torch was imported lazily,
importing ExecuTorch imported it whenever it was installed. After the lazy
import, a wait found torch missing unless something else had imported it,
and failed with an error that blamed the build. That broke CUDA inputs from
other libraries, such as CuPy, in set_inputs() followed by execute(), and
in a call to a method that returns no tensor.

A wait now imports torch, unless EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT is
executorch, which keeps torch out. get_attribute() waits the same way.
When torch is still not there, the error says that imported torch would
also work, and the note on a torch that fails to import names the wait as
a reason too.

A test passes CUDA memory from the CUDA driver, so nothing else imports
torch. In a fresh process each, set_inputs() with execute(), and a call to
a method without outputs, must import torch. With the setting at
executorch, the wait must fail with the new error and leave torch out. The
test needs a GPU and bindings built without CUDA support, so CI skips it.
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

Copilot AI balanced review requested due to automatic review settings November 7, 2026 20:13

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 insufficient remaining quota. Try requesting a review at a lower effort level.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 57m 55s —— View job


Approve, with two open points: the output type can only be chosen for the whole process, with no way to opt into the announced rule, and the new CUDA-wait test never runs in CI.

Detailed review

PR Review: #24321

Summary

The PR makes the portable bindings import torch only at the first call that needs it, warns at each call whose outputs will change from torch.Tensor to ExecuTorchResult, and lets ExecuTorchResult.from_host take dtypes such as bfloat16. Nothing blocks merge, and most items below are small, local fixes. The diff was taken against 75af78c, the PR's parent, because the shallow checkout has no merge base with origin/main. Three independent reviewers covered all 11 files. Nothing was built or run.

API Design

  • The output type can only be chosen for the whole process, and neither choice applies the announced rule. This is still open from the previous review.
    • EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT is read once, when the extension loads (extension/pybindings/pybindings.cpp:747-769). A library that embeds ExecuTorch cannot choose a type for its own calls without choosing it for the whole process.
    • executorch also changes calls with torch inputs, and torch keeps today's behavior. So nobody can test against the future rule before it ships.
    • Calls without tensor inputs import torch and return torch.Tensor, both now and after the switch. These are get_attribute (:4901-4904), bundled verify (:3024), and methods without inputs. 222f9da chose this so that the type does not depend on what imported torch first. The cost is that after the switch, a NumPy-only program that reads an attribute still imports torch. It then gets both output types, with no warning beforehand.
    • An option on Runtime, Program, or Method that defaults to the environment variable would address both problems. It needs a value that applies the announced rule now.
  • ExecuTorchResult refuses bool(), int(), and float() even when it has one element (pybindings.cpp:5824-5829), but torch.Tensor and NumPy arrays convert. Code such as float(loss) or int(outputs[0]), which works on today's torch outputs, will raise after the switch. The FutureWarning (:739-745) does not mention this. Either convert one-element results, or name the gap in the warning and the migration docs.
  • The docs describe the torch import more narrowly than the code does. Fix this →
    • docs/source/runtime-python-api-reference.rst:49 and extension/pybindings/README.md:48-50 say that any other error from importing PyTorch "is raised by every call that returns a tensor". On bindings built without CUDA support, every call that waits for CUDA work also raises it (pybindings.cpp:2374-2381), including calls that return no tensor. The note at :813-817 says so, but the docs do not.
    • README.md:159-162 reads as if an installed torch were enough to read CUDA inputs. Under the executorch setting, the caller must import torch first.
    • The warning ends "With executorch, PyTorch is not loaded" (pybindings.cpp:745). PyTorch can still be loaded, and for CUDA inputs on bindings without CUDA support it must be. "ExecuTorch does not import PyTorch" would be accurate.
    • The Returns section of Method.execute (runtime/__init__.py:175-177) says tensor outputs are torch.Tensor whenever PyTorch is installed. It does not say that a torch whose import raises ImportError gives ExecuTorchResult outputs.
  • from_host with an ExecuTorchResult source accepts only that result's own dtype (pybindings.cpp:5796-5800), but a buffer source accepts other names. For example, dtype="qint8" is refused for an int8 result but accepted for np.asarray(result) (host_data_dtype, :1346-1371). A misspelled name reports "does not match the result's dtype" instead of "Unsupported dtype". The docstring (:5818-5823), pybindings.pyi:73-84, and README.md:147-155 describe only the buffer case. Fix this →

Infrastructure

  • Low confidence: test_pybindings_lib (extension/pybindings/test/BUCK:75-85) builds the same test_pybindings.py, which uses executorch.runtime (test_pybindings.py:4172). Only test_pybindings_portable_lib got the //executorch/runtime:runtime dependency (BUCK:47). No target in the repo depends on the library, so this matters only if something outside the repo runs its tests.

Testing

  • The import-to-wait path from c3eb82b never runs in CI. This is still open from the previous review.
    • test_cuda_input_imports_torch_to_wait_without_cuda_support (extension/pybindings/test/test_pybindings.py:4235-4355) needs a CUDA device and bindings built without CUDA support.
    • CPU jobs have no device. The CUDA job builds with CUDA support and runs only test_pybindings_cuda_dlpack.py (.ci/scripts/test-cuda-build.sh:95).
    • The get_attribute wait (pybindings.cpp:4892-4894) has no test case at all.
    • One idea for CPU runners, which I have not tried: pass a ctypes-built kDLCUDA DLPack input to a method without outputs. In the default mode, the call should fail inside torch.cuda.synchronize, leaving torch in sys.modules. With executorch, it should raise the "requires bindings built with CUDA support" error.
  • The two-thread ImportError case depends on time.sleep(0.5) (test_pybindings.py:461). It was written to test a call that waits on another thread's import of torch, which raises KeyError before Python 3.13 (pybindings.cpp:804-806). If the second thread has not reached the import within 0.5 s, the case passes without testing that wait. The directory from its tempfile.mkdtemp() (:437) is never removed. Fix this →

Performance

  • In the default mode, while torch can be imported, every call with NumPy or DLPack inputs runs caller_stacklevel and PyErr_WarnEx (pybindings.cpp:853-856). This happens even after the default filter has stopped showing the warning. For each frame, caller_stacklevel (:773-785) does several Python attribute lookups and builds a std::string. I did not measure the cost, but every call in a loop pays it. fd3b686 chose to warn at every call; walking the frames with the C frame API would make each warning cheaper. Fix this →

Code Quality

  • If importing torch fails with an error other than ImportError, every later call imports it again (pybindings.cpp:803-825), because nothing records the failure. A failed import leaves its loaded submodules, such as torch._C, in sys.modules, so each retry runs torch/__init__.py against them. Each retry costs time and can raise a different, misleading error. If a retry raises ImportError, torch_missing is set and later calls quietly return ExecuTorchResult. I have not reproduced this. The test (test_pybindings.py:401-428) blocks torch in find_spec, so torch is never partly imported and this path is never reached. Fix this →
  • loaded_torch_module() keeps forever the first sys.modules["torch"] that looks ready (pybindings_executorch_result.h:106-128). For example, a test might put a types.ModuleType("torch") stub there around the first call. After that, is_torch_tensor (pybindings.cpp:1373-1383) raises AttributeError at every call, even after the real torch is imported. Before this PR, the default mode cached the real torch when the extension loaded. The "vanishing" case (test_pybindings.py:621-634) accepts that AttributeError, so the tests do not catch this. Fix this →
  • caller_stacklevel skips every module whose name starts with executorch. (pybindings.cpp:778-780), not only the wrappers its comment names. When other ExecuTorch Python code calls the bindings, the warning points at that code's caller. For a test module that Buck names executorch.…, it points into unittest. Fix this →

Recommendation

Approve

Nothing blocks merge. Users will act on the FutureWarning text, so before it ships in a release, it is worth deciding on the per-object option, or at least on a setting value that applies the announced rule. The other items are local fixes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/trunk CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: api Changes to public facing apis (any interfaces, pybinded runtime methods, etc.)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants