From 1c95f89a34c00d18b9ea2b660b2e27a2a5f1bc6e Mon Sep 17 00:00:00 2001 From: Jake Stevens Date: Thu, 29 Oct 2026 16:42:49 -0700 Subject: [PATCH 1/2] Remove extra EOF blank lines from Native binding tests Summary: The GitHub `lintrunner` NEWLINE check failed because `test_bindings.py` ended with an extra blank line. Remove that one byte in the `fbcode` and `xplat` mirrors without changing the Python AST or the Native wheel behavior. Diff Fixer repair for D126686193, remote task 40309690311948479, dispatched version 1878529630252145, starting commit b40849f6ec2c57229a071973ce5e41c8f58eb73f. Authored with assistance from Metamate. Reviewed By: SS-JIA Differential Revision: D126686193 --- .../examples/vision/run_classification.py | 51 +++++ backends/native/pybindings/CMakeLists.txt | 45 +++- backends/native/pybindings/__init__.py | 4 + backends/native/pybindings/_native.pyi | 39 ++++ backends/native/pybindings/py.typed | 0 backends/native/pybindings/pybindings.cpp | 205 ++++++++++++++++++ .../native/pybindings/test/test_bindings.py | 128 +++++++++++ backends/native/python/CMakeLists.txt | 6 + backends/native/python/README.md | 86 +++++++- backends/native/python/pyproject.toml | 8 +- 10 files changed, 559 insertions(+), 13 deletions(-) create mode 100644 backends/native/examples/vision/run_classification.py create mode 100644 backends/native/pybindings/_native.pyi create mode 100644 backends/native/pybindings/py.typed create mode 100644 backends/native/pybindings/pybindings.cpp create mode 100644 backends/native/pybindings/test/test_bindings.py diff --git a/backends/native/examples/vision/run_classification.py b/backends/native/examples/vision/run_classification.py new file mode 100644 index 00000000000..5f23f01137a --- /dev/null +++ b/backends/native/examples/vision/run_classification.py @@ -0,0 +1,51 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Run an exported FP32 classifier using only executorch-native and NumPy.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +from executorch_native import Model + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", type=Path, required=True) + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--expected", type=Path) + parser.add_argument("--categories", type=Path) + parser.add_argument("--method", default="forward") + parser.add_argument("--engine", choices=("ggml", "vulkan"), default="ggml") + parser.add_argument("--rtol", type=float, default=1e-3) + parser.add_argument("--atol", type=float, default=1e-3) + args = parser.parse_args() + model = Model.load(args.package) + info = model.method_info(args.method) + if len(info.inputs) != 1 or len(info.outputs) != 1: + parser.error("expected one input and one output") + if info.inputs[0].dtype != np.float32 or info.outputs[0].dtype != np.float32: + parser.error("this example requires FP32 inputs and outputs") + image = np.fromfile(args.input, dtype=np.float32).reshape(info.inputs[0].shape) + session = model.create_session(engine=args.engine) + (output,) = session.run([image], method=args.method) + logits = output.reshape(-1) + labels = args.categories.read_text().splitlines() if args.categories else [] + print(f"engine: {args.engine}; method: {args.method}") + for index in np.argsort(logits)[::-1][:5]: + label = labels[index] if index < len(labels) else f"class_{index}" + print(f"{index:4d} {label:28s} {logits[index]:10.4f}") + if args.expected: + reference = np.fromfile(args.expected, dtype=np.float32).reshape(output.shape) + np.testing.assert_allclose(output, reference, rtol=args.rtol, atol=args.atol) + print("PASS: logits match the reference") + + +if __name__ == "__main__": + main() diff --git a/backends/native/pybindings/CMakeLists.txt b/backends/native/pybindings/CMakeLists.txt index 09848fdb792..7a2fc8b5619 100644 --- a/backends/native/pybindings/CMakeLists.txt +++ b/backends/native/pybindings/CMakeLists.txt @@ -4,8 +4,51 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +find_package( + Python + COMPONENTS Interpreter Development.Module + REQUIRED +) +find_package(pybind11 CONFIG REQUIRED) +pybind11_add_module(_native MODULE pybindings.cpp) +target_link_libraries(_native PRIVATE executorch_native_sdk) +target_include_directories(_native PRIVATE "${EXECUTORCH_ROOT}/..") +set_target_properties(_native PROPERTIES INSTALL_RPATH "$ORIGIN/lib") +install(TARGETS _native LIBRARY DESTINATION . COMPONENT executorch_native) +execute_process( + COMMAND + "${Python_EXECUTABLE}" -c [=[ +from importlib.metadata import distribution + +package = distribution("pybind11") +for path in package.files or (): + if path.name.casefold().split(".")[0] in ("license", "copying"): + license_path = package.locate_file(path) + if license_path.is_file(): + print(license_path) +]=] + OUTPUT_VARIABLE _pybind11_licenses + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _pybind11_license_status + ERROR_VARIABLE _pybind11_license_error +) +if(NOT _pybind11_license_status EQUAL 0 OR NOT _pybind11_licenses) + message( + FATAL_ERROR + "Cannot locate pybind11 license files using ${Python_EXECUTABLE}. " + "Reinstall pybind11 with its license files before building the Native wheel. " + "${_pybind11_license_error}" + ) +endif() +string(REPLACE "\n" ";" _pybind11_licenses "${_pybind11_licenses}") +install( + FILES ${_pybind11_licenses} + DESTINATION share/licenses/pybind11 + COMPONENT executorch_native +) + install( - FILES __init__.py __main__.py + FILES __init__.py __main__.py _native.pyi py.typed DESTINATION . COMPONENT executorch_native ) diff --git a/backends/native/pybindings/__init__.py b/backends/native/pybindings/__init__.py index 96b85c49193..1c7239aec76 100644 --- a/backends/native/pybindings/__init__.py +++ b/backends/native/pybindings/__init__.py @@ -6,6 +6,10 @@ from pathlib import Path +from executorch_native._native import MethodInfo, Model, Session, TensorInfo + +__all__ = ["MethodInfo", "Model", "Session", "TensorInfo", "cmake_prefix_path"] + def cmake_prefix_path() -> str: """Return the installed Native SDK prefix for CMake consumers.""" diff --git a/backends/native/pybindings/_native.pyi b/backends/native/pybindings/_native.pyi new file mode 100644 index 00000000000..6332f608dc5 --- /dev/null +++ b/backends/native/pybindings/_native.pyi @@ -0,0 +1,39 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from collections.abc import Sequence +from os import PathLike +from typing import Any + +import numpy as np +from numpy.typing import NDArray + +class TensorInfo: + @property + def shape(self) -> list[int]: ... + @property + def dtype(self) -> np.dtype[Any]: ... + +class MethodInfo: + @property + def name(self) -> str: ... + @property + def inputs(self) -> list[TensorInfo]: ... + @property + def outputs(self) -> list[TensorInfo]: ... + +class Model: + @staticmethod + def load(path: str | PathLike[str]) -> Model: ... + @property + def method_names(self) -> list[str]: ... + def method_info(self, name: str = "forward") -> MethodInfo: ... + def create_session(self, engine: str = "ggml") -> Session: ... + +class Session: + def run( + self, inputs: Sequence[NDArray[Any]], method: str = "forward" + ) -> list[NDArray[Any]]: ... diff --git a/backends/native/pybindings/py.typed b/backends/native/pybindings/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/backends/native/pybindings/pybindings.cpp b/backends/native/pybindings/pybindings.cpp new file mode 100644 index 00000000000..eebcd9db5bd --- /dev/null +++ b/backends/native/pybindings/pybindings.cpp @@ -0,0 +1,205 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace py = pybind11; + +namespace { + +py::dtype numpy_dtype(ptn::ScalarType dtype) { + switch (dtype) { + case ptn::kByte: + return py::dtype::of(); + case ptn::kChar: + return py::dtype::of(); + case ptn::kShort: + return py::dtype::of(); + case ptn::kInt: + return py::dtype::of(); + case ptn::kLong: + return py::dtype::of(); + case ptn::kHalf: + return py::dtype("float16"); + case ptn::kFloat: + return py::dtype::of(); + case ptn::kDouble: + return py::dtype::of(); + case ptn::kBool: + return py::dtype::of(); + case ptn::kUInt16: + return py::dtype::of(); + case ptn::kUInt32: + return py::dtype::of(); + case ptn::kUInt64: + return py::dtype::of(); + case ptn::kBFloat16: + throw py::type_error( + "bfloat16 tensors are not supported by NumPy bindings"); + } + throw py::type_error("unsupported Native tensor dtype"); +} + +py::array allocate_tensor(const ptn::TensorInfo& info) { + const auto itemsize = ptn::element_size(info.dtype()); + const auto limit = std::numeric_limits::max(); + if (info.nbytes() > static_cast(limit)) { + throw std::overflow_error("tensor is too large for NumPy"); + } + std::vector strides; + for (const auto stride : info.strides()) { + if (stride > limit / static_cast(itemsize)) { + throw std::overflow_error("tensor stride is too large for NumPy"); + } + strides.push_back(stride * itemsize); + } + return py::array( + numpy_dtype(info.dtype()), + std::vector(info.sizes().begin(), info.sizes().end()), + strides); +} + +std::unique_ptr create_host(const std::string& engine) { + if (engine == "ggml") { + return ptn::GgmlEngineHost::create(); + } + if (engine == "vulkan") { + return ptn::VulkanEngineHost::create(); + } + throw std::invalid_argument("engine must be 'ggml' or 'vulkan'"); +} + +class Session final { + public: + Session(const ptn::Model& model, const std::string& engine) + : model_(model), + host_(create_host(engine)), + session_(model_.create_session(*host_)) {} + + std::vector run( + const py::sequence& inputs, + const std::string& method) { + const auto info = model_.method_info(method); + if (inputs.size() != info.inputs().size()) { + throw py::value_error("input count does not match the method"); + } + std::vector input_arrays; + std::vector input_views; + for (size_t i = 0; i < info.inputs().size(); ++i) { + if (!py::isinstance(inputs[i])) { + throw py::type_error("inputs must be NumPy arrays"); + } + const auto input = py::cast(inputs[i]); + const auto& spec = info.inputs()[i]; + if (!input.dtype().equal(numpy_dtype(spec.dtype()))) { + throw py::type_error("input dtype does not match the method"); + } + if (static_cast(input.ndim()) != spec.sizes().size() || + !std::equal( + spec.sizes().begin(), spec.sizes().end(), input.shape())) { + throw py::value_error("input shape does not match the method"); + } + // Own input storage in the model's layout before releasing the GIL. + auto owned = allocate_tensor(spec); + owned.attr("__setitem__")(py::ellipsis(), input); + input_views.emplace_back( + spec.dtype(), + spec.sizes(), + spec.strides(), + owned.data(), + owned.nbytes()); + input_arrays.push_back(std::move(owned)); + } + std::vector outputs; + std::vector output_views; + for (const auto& spec : info.outputs()) { + auto output = allocate_tensor(spec); + output_views.emplace_back( + spec.dtype(), + spec.sizes(), + spec.strides(), + output.mutable_data(), + output.nbytes()); + outputs.push_back(std::move(output)); + } + { + py::gil_scoped_release release; + std::lock_guard lock(mutex_); + session_.run(method, input_views, output_views); + } + return outputs; + } + + private: + ptn::Model model_; + std::unique_ptr host_; + ptn::Session session_; + std::mutex mutex_; +}; + +} // namespace + +PYBIND11_MODULE(_native, module) { + py::class_(module, "TensorInfo") + .def_property_readonly( + "shape", + [](const ptn::TensorInfo& info) { + return std::vector( + info.sizes().begin(), info.sizes().end()); + }) + .def_property_readonly("dtype", [](const ptn::TensorInfo& info) { + return numpy_dtype(info.dtype()); + }); + py::class_(module, "MethodInfo") + .def_property_readonly("name", &ptn::MethodInfo::name) + .def_property_readonly("inputs", &ptn::MethodInfo::inputs) + .def_property_readonly("outputs", &ptn::MethodInfo::outputs); + py::class_(module, "Session") + .def( + "run", + &Session::run, + py::arg("inputs"), + py::arg("method") = "forward", + "Run a method synchronously and return independently owned NumPy outputs."); + py::class_(module, "Model") + .def_static( + "load", + [](const std::filesystem::path& path) { + return ptn::Model::load_file(path.string()); + }, + py::arg("path"), + py::call_guard()) + .def_property_readonly( + "method_names", + [](const ptn::Model& model) { + const auto names = model.method_names(); + return std::vector(names.begin(), names.end()); + }) + .def("method_info", &ptn::Model::method_info, py::arg("name") = "forward") + .def( + "create_session", + [](const ptn::Model& model, const std::string& engine) { + return std::make_unique(model, engine); + }, + py::arg("engine") = "ggml", + py::call_guard()); +} diff --git a/backends/native/pybindings/test/test_bindings.py b/backends/native/pybindings/test/test_bindings.py new file mode 100644 index 00000000000..77051e0c83a --- /dev/null +++ b/backends/native/pybindings/test/test_bindings.py @@ -0,0 +1,128 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import gc +import os +import subprocess +import sys +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from tempfile import TemporaryDirectory + +import numpy as np +from executorch_native import Model + + +class BindingsTest(unittest.TestCase): + def setUp(self) -> None: + self.package = Path(os.environ["NATIVE_TEST_PACKAGE"]).resolve() + self.engine = os.environ.get("NATIVE_TEST_ENGINE", "ggml") + self.model = Model.load(self.package) + self.session = self.model.create_session(self.engine) + self.input = np.arange(1, 7, dtype=np.float32).reshape(1, 6) + + def test_metadata_and_session_lifetime(self) -> None: + self.assertEqual(self.model.method_names, ["forward"]) + info = self.model.method_info() + del self.model + gc.collect() + self.assertEqual(info.name, "forward") + self.assertEqual(info.inputs[0].shape, [1, 6]) + self.assertEqual(info.outputs[0].dtype, np.float32) + (output,) = self.session.run([self.input]) + np.testing.assert_array_equal(output, self.input * 2) + + def test_output_ownership_and_strided_input(self) -> None: + data = np.arange(12, dtype=np.float32).reshape(1, 12)[:, ::-2] + data.flags.writeable = False + (first,) = self.session.run([data]) + (second,) = self.session.run([self.input]) + del self.session + gc.collect() + self.assertTrue(first.flags.owndata) + np.testing.assert_array_equal(first, data * 2) + np.testing.assert_array_equal(second, self.input * 2) + + def test_invalid_inputs_do_not_poison_session(self) -> None: + for inputs, error, message in [ + ([], ValueError, "count"), + ([self.input.astype(np.float64)], TypeError, "dtype"), + ([self.input.astype(">f4")], TypeError, "dtype"), + ([self.input.reshape(6)], ValueError, "shape"), + ([self.input.tolist()], TypeError, "NumPy"), + ]: + with self.subTest(message=message): + with self.assertRaisesRegex(error, message): + self.session.run(inputs) + with self.assertRaisesRegex(ValueError, "unknown method"): + self.session.run([self.input], method="missing") + (output,) = self.session.run([self.input]) + np.testing.assert_array_equal(output, self.input * 2) + + def test_concurrent_calls_are_serialized(self) -> None: + inputs = [self.input + i for i in range(8)] + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda x: self.session.run([x])[0], inputs)) + for actual, source in zip(results, inputs): + np.testing.assert_array_equal(actual, source * 2) + + def test_invalid_engine(self) -> None: + with self.assertRaisesRegex(ValueError, "engine must be"): + self.model.create_session("unknown") + + def test_missing_file(self) -> None: + with TemporaryDirectory() as directory: + missing = Path(directory) / "missing.ptn" + with self.assertRaisesRegex(RuntimeError, "cannot open") as error: + Model.load(missing) + self.assertIn(str(missing), str(error.exception)) + + def test_vision_example(self) -> None: + with TemporaryDirectory() as directory: + input_path = Path(directory) / "input.bin" + expected_path = Path(directory) / "expected.bin" + categories_path = Path(directory) / "categories.txt" + self.input.tofile(input_path) + (self.input * 2).tofile(expected_path) + categories_path.write_text("\n".join(f"label_{i}" for i in range(6))) + for extra_args in ( + [], + [ + "--expected", + str(expected_path), + "--categories", + str(categories_path), + ], + ): + with self.subTest(extra_args=extra_args): + result = subprocess.run( + [ + sys.executable, + "-m", + "executorch_native.vision", + "--package", + str(self.package), + "--input", + str(input_path), + "--engine", + self.engine, + *extra_args, + ], + cwd=directory, + check=True, + capture_output=True, + text=True, + timeout=60, + ) + if extra_args: + self.assertIn("PASS: logits match", result.stdout) + self.assertIn("label_5", result.stdout) + else: + self.assertNotIn("PASS: logits match", result.stdout) + self.assertIn("class_5", result.stdout) diff --git a/backends/native/python/CMakeLists.txt b/backends/native/python/CMakeLists.txt index 345ff430e86..55808aa2505 100644 --- a/backends/native/python/CMakeLists.txt +++ b/backends/native/python/CMakeLists.txt @@ -137,6 +137,12 @@ install( PATTERN "runner_utils.cpp" PATTERN "runner_utils.h" ) +install( + FILES ../examples/vision/run_classification.py + DESTINATION . + RENAME vision.py + COMPONENT executorch_native +) install( FILES ../examples/runner_utils/runner_utils.h DESTINATION include/executorch/backends/native/examples/runner_utils diff --git a/backends/native/python/README.md b/backends/native/python/README.md index 48279f1893e..c2041ca17e0 100644 --- a/backends/native/python/README.md +++ b/backends/native/python/README.md @@ -1,4 +1,4 @@ -# ExecuTorch Native SDK wheel +# ExecuTorch Native Python runtime and SDK wheel This directory contains the standalone wheel build recipe. Python sources live alongside the runtime in [`../pybindings`](../pybindings); CMake installs them as @@ -6,13 +6,73 @@ alongside the runtime in [`../pybindings`](../pybindings); CMake installs them a `executorch-native` packages the Native runtime, Vulkan engine (including compiled shaders), and GGML CPU engine. It installs into `executorch_native`, independently -of the existing `executorch` distribution. It has no PyTorch dependency and does -not install exporters or Python execution bindings. Export `.ptn` files using a -matching version of `executorch` on the development machine. +of the existing `executorch` distribution. Python execution uses NumPy and the +same shared runtime as C++ consumers, with no PyTorch runtime dependency. +Export `.ptn` files using a compatible version of `executorch` on the development +machine; exporters are not bundled in this wheel. -The initial wheel supports Linux. It contains C++20 headers and a shared library; -it is independent of the CPython ABI, but platform and C++ ABI compatibility still -apply. Vulkan execution requires a system Vulkan loader and a compatible driver. +The initial wheel supports Linux. Build a wheel for each supported CPython version +and architecture; the Python extension uses the CPython ABI. Vulkan execution +requires a system Vulkan loader and a compatible driver. GGML runs on the CPU +without a Vulkan installation. + +## Install and run Python + +The wheel is not yet published to PyPI. Install a built, repaired wheel; once +published, install with `python -m pip install executorch-native`. + +```sh +python -m pip install /path/to/executorch_native-*.whl +``` + +Export a `.ptn` and its preprocessed input with the existing +[Native vision exporter](../../../examples/native/vision/export.py) from the +ExecuTorch source root. Exporting runs on a development machine with PyTorch, +torchvision, Pillow, and a compatible ExecuTorch exporter; it does not require the +Native wheel. + +```sh +python -m executorch.examples.native.vision.export \ + --model mv2 --image /path/to/photo.jpg --out-dir native-demo +``` + +The exporter writes the model, FP32 input and reference logits, and labels under +`native-demo/mv2/`. Load the `.ptn` and run it with Native and NumPy: + +```python +import numpy as np +from executorch_native import Model + +model = Model.load("native-demo/mv2/mv2.ptn") +spec = model.method_info().inputs[0] +image = np.fromfile("native-demo/mv2/mv2_input.bin", dtype=spec.dtype).reshape(spec.shape) +session = model.create_session() # GGML CPU; use engine="vulkan" for Vulkan. +(logits,) = session.run([image]) +print(logits.argmax()) +``` + +Models expose `method_names` and `method_info(name)` for input/output shapes and +NumPy dtypes. `session.run(inputs, method="forward")` returns a list of owned NumPy +arrays. Input shapes and dtypes must match exactly; noncontiguous inputs are copied +into the model's layout. Bfloat16 I/O is not supported. Dynamic inputs currently +use their serialized upper-bound shapes, matching the C++ Model API. Sessions own +their model and engine lifetimes, serialize concurrent calls, and release the GIL +during execution. Returned arrays remain valid after another call or session deletion. + +The Python equivalent of the C++ vision runner accepts the same `.ptn`, raw FP32 +input/reference, and optional categories files. It prints the top five predictions +and, with `--expected`, checks all logits at `rtol=1e-3, atol=1e-3`: + +```sh +python -m executorch_native.vision \ + --package native-demo/mv2/mv2.ptn \ + --input native-demo/mv2/mv2_input.bin \ + --expected native-demo/mv2/mv2_expected.bin \ + --categories native-demo/mv2/categories.txt +``` + +The Python runner lives beside the C++ runner in `backends/native/examples/vision`; +the wheel installs it as `executorch_native.vision`. ## Use the installed SDK @@ -101,4 +161,14 @@ ctest --test-dir sdk-test --output-on-failure Add `-DNATIVE_TEST_VULKAN=ON` to test both engines when a Vulkan device is available. The installed vision example also accepts this fixture with `--input /tmp/native-fixture/input.bin --expected /tmp/native-fixture/expected.bin`. -The fixture generator needs `flatc`; consumers of the wheel do not. +The fixture generator needs `flatc`; consumers of the wheel do not. Run the Python +binding tests against the installed wheel: + +```sh +NATIVE_TEST_PACKAGE=/tmp/native-fixture/add.ptn \ + python -m unittest discover -s backends/native/pybindings/test -p test_bindings.py +``` + +Set `NATIVE_TEST_ENGINE=vulkan` to exercise the same tests on Vulkan. The tests +use the untrained fixture, including a subprocess run of `executorch_native.vision` +with custom inputs, labels, and reference. diff --git a/backends/native/python/pyproject.toml b/backends/native/python/pyproject.toml index da272f5137a..9b7bc3256b5 100644 --- a/backends/native/python/pyproject.toml +++ b/backends/native/python/pyproject.toml @@ -1,22 +1,22 @@ [build-system] -requires = ["scikit-build-core>=0.11", "pyyaml", "zstd", "certifi"] +requires = ["scikit-build-core>=0.11", "pybind11>=3.0", "pyyaml", "zstd", "certifi"] build-backend = "scikit_build_core.build" [project] name = "executorch-native" dynamic = ["version"] -description = "ExecuTorch Native C++ SDK with Vulkan and GGML engines" +description = "ExecuTorch Native Python runtime and C++ SDK with Vulkan and GGML engines" readme = "README.md" requires-python = ">=3.11" license = "BSD-3-Clause" +dependencies = ["numpy>=1.26"] [tool.scikit-build] cmake.version = ">=3.26,<4" -build.targets = ["executorch_native_sdk"] +build.targets = ["executorch_native_sdk", "_native"] install.components = ["executorch_native"] wheel.install-dir = "executorch_native" wheel.packages = [] -wheel.py-api = "py3" [tool.scikit-build.metadata.version] provider = "scikit_build_core.metadata.regex" From f38ba82693fe0d39304ab8362e9287878f761469 Mon Sep 17 00:00:00 2001 From: Jake Stevens Date: Thu, 29 Oct 2026 16:42:49 -0700 Subject: [PATCH 2/2] Build Native wheels in ExecuTorch release CI Summary: Add dedicated Linux x86_64 and aarch64 wheel workflows for `executorch-native`, sharing ExecuTorch's release/nightly triggers, Python versions, and `BUILD_VERSION`. Native gets its own workflows because the reusable PyTorch workflow keys its concurrency group and artifact names by repository, so two packages from one repository cannot share a workflow run. Build and repair the standalone GGML/Vulkan wheel, then export PTN fixtures and test GGML plus the Python binding suite in a fresh environment containing only Native and NumPy. Nova's smoke test is disabled for Native because it requires a pinned torch dependency; the post-build script performs a stricter clean-environment test instead. The Native wheels are not uploaded yet: the `executorch-native` workflows pass a non-`push` trigger event to the reusable workflow, so repaired wheels stay as workflow artifacts until the package is registered for PyPI, the nightly index, and release promotion. This is a separate commit above the Native wheel and Python bindings changes. Authored with AI assistance from Codex. Reviewed By: SS-JIA Differential Revision: D126830796 --- .../tests/test_native_wheel_version.py | 56 +++++++++++ .ci/scripts/wheel/envvar_native.sh | 18 ++++ .ci/scripts/wheel/export_native.py | 60 ++++++++++++ .ci/scripts/wheel/post_build_native.sh | 37 +++++++ .ci/scripts/wheel/pre_build_native.sh | 30 ++++++ .ci/scripts/wheel/test_native.py | 97 +++++++++++++++++++ .../build-wheels-native-aarch64-linux.yml | 73 ++++++++++++++ .../workflows/build-wheels-native-linux.yml | 72 ++++++++++++++ backends/native/python/README.md | 42 +++++++- backends/native/python/pyproject.toml | 6 +- backends/native/python/setup.py | 38 ++++++++ backends/native/python/wheel_version.py | 17 ++++ 12 files changed, 542 insertions(+), 4 deletions(-) create mode 100644 .ci/scripts/tests/test_native_wheel_version.py create mode 100644 .ci/scripts/wheel/envvar_native.sh create mode 100644 .ci/scripts/wheel/export_native.py create mode 100755 .ci/scripts/wheel/post_build_native.sh create mode 100755 .ci/scripts/wheel/pre_build_native.sh create mode 100644 .ci/scripts/wheel/test_native.py create mode 100644 .github/workflows/build-wheels-native-aarch64-linux.yml create mode 100644 .github/workflows/build-wheels-native-linux.yml create mode 100644 backends/native/python/setup.py create mode 100644 backends/native/python/wheel_version.py diff --git a/.ci/scripts/tests/test_native_wheel_version.py b/.ci/scripts/tests/test_native_wheel_version.py new file mode 100644 index 00000000000..25f9b2c43e8 --- /dev/null +++ b/.ci/scripts/tests/test_native_wheel_version.py @@ -0,0 +1,56 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import importlib.util +import os +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[3] +if ROOT.name == "oss": + ROOT = ROOT.parent +SPEC = importlib.util.spec_from_file_location( + "wheel_version", ROOT / "backends/native/python/wheel_version.py" +) +assert SPEC is not None and SPEC.loader is not None +VERSION = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VERSION) + + +class NativeWheelVersionTest(unittest.TestCase): + def test_invalid_metadata_configuration(self) -> None: + for field, settings in ( + ("dependencies", {}), + ("version", {"input": "version.txt"}), + ): + with self.subTest(field=field, settings=settings): + with self.assertRaisesRegex( + ValueError, "Only version metadata without settings is supported" + ): + VERSION.dynamic_metadata(field, settings) + + def test_build_version_overrides_source_version(self) -> None: + for version in ("1.6.0", "1.6.0rc2", "1.7.0.dev20261021"): + with self.subTest(version=version), patch.dict( + os.environ, {"BUILD_VERSION": f" {version}\n"} + ): + self.assertEqual(VERSION.dynamic_metadata("version", {}), version) + + def test_local_build_uses_version_file(self) -> None: + for version in ("", " \n"): + with self.subTest(version=version), patch.dict( + os.environ, {"BUILD_VERSION": version} + ): + self.assertEqual( + VERSION.dynamic_metadata("version", {}), + (ROOT / "version.txt").read_text().strip(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.ci/scripts/wheel/envvar_native.sh b/.ci/scripts/wheel/envvar_native.sh new file mode 100644 index 00000000000..578d3032014 --- /dev/null +++ b/.ci/scripts/wheel/envvar_native.sh @@ -0,0 +1,18 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +export NATIVE_WHEEL_ROOT="${GITHUB_WORKSPACE}/${REPOSITORY}" +export NATIVE_WHEEL_DEPS="${GITHUB_WORKSPACE}/native-wheel-deps" +export CMAKE_PREFIX_PATH="${NATIVE_WHEEL_DEPS}/libzip:${NATIVE_WHEEL_DEPS}:${NATIVE_WHEEL_DEPS}/shaderc${CMAKE_PREFIX_PATH:+:${CMAKE_PREFIX_PATH}}" +export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-4}" +export FLATC_EXECUTABLE="${NATIVE_WHEEL_DEPS}/flatbuffers/flatc" +export CMAKE_ARGS="${CMAKE_ARGS:-} -DFLATC_EXECUTABLE=${FLATC_EXECUTABLE} -DGLSLC_PATH=${NATIVE_WHEEL_DEPS}/shaderc/bin/glslc -DEXECUTORCH_NATIVE_EXTRA_LICENSES=${NATIVE_WHEEL_DEPS}/libzip-1.11.4/LICENSE" +export PIP_EXTRA_INDEX_URL="${PIP_EXTRA_INDEX_URL:+${PIP_EXTRA_INDEX_URL} }https://pypi.org/simple" + +# Nova sources this hook before invoking setup.py, allowing a subdirectory build. +if [[ "$(pwd)" == "${NATIVE_WHEEL_ROOT}" ]]; then + cd "${NATIVE_WHEEL_ROOT}/backends/native/python" || exit 1 +fi diff --git a/.ci/scripts/wheel/export_native.py b/.ci/scripts/wheel/export_native.py new file mode 100644 index 00000000000..2d1af600f27 --- /dev/null +++ b/.ci/scripts/wheel/export_native.py @@ -0,0 +1,60 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Generate Native wheel fixtures with the exporter from this checkout.""" + +import argparse +from pathlib import Path + +import numpy as np +import torch +from executorch.exir.native import to_native + + +class Add(torch.nn.Module): + def forward(self, value: torch.Tensor) -> torch.Tensor: + return value + value + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + torch.manual_seed(0) + conv_bn = torch.nn.Sequential( + torch.nn.Conv2d(3, 4, kernel_size=3, padding=1), + torch.nn.BatchNorm2d(4), + torch.nn.ReLU(), + ) + with torch.no_grad(): + conv_bn[1].running_mean.copy_(torch.linspace(-1, 1, 4)) + conv_bn[1].running_var.copy_(torch.linspace(0.5, 1.5, 4)) + conv_bn[1].weight.copy_(torch.linspace(0.8, 1.2, 4)) + conv_bn[1].bias.copy_(torch.linspace(-0.2, 0.2, 4)) + models = { + "add": (Add(), torch.arange(1, 7, dtype=torch.float32).reshape(1, 6)), + "conv_bn": ( + conv_bn, + torch.randn(1, 3, 8, 8), + ), + } + for name, (model, value) in models.items(): + model.eval() + with torch.no_grad(): + expected = model(value) + to_native(torch.export.export(model, (value,))).save( + str(args.output / f"{name}.ptn") + ) + np.savez( + args.output / f"{name}.npz", + input=value.numpy(), + expected=expected.numpy(), + ) + + +if __name__ == "__main__": + main() diff --git a/.ci/scripts/wheel/post_build_native.sh b/.ci/scripts/wheel/post_build_native.sh new file mode 100755 index 00000000000..44c17a2f3d0 --- /dev/null +++ b/.ci/scripts/wheel/post_build_native.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +set -euxo pipefail + +cd "${NATIVE_WHEEL_ROOT}/backends/native/python" +wheels=(dist/executorch_native-*.whl) +[[ ${#wheels[@]} == 1 && -f "${wheels[0]}" ]] +python -m auditwheel repair "${wheels[0]}" --wheel-dir repaired +rm "${wheels[0]}" +mv repaired/*.whl dist/ +wheels=(dist/executorch_native-*.whl) +[[ ${#wheels[@]} == 1 && -f "${wheels[0]}" ]] + +# Only the export environment needs PyTorch and the source checkout. +torchao_version=$(cd "${NATIVE_WHEEL_ROOT}" && python -c \ + 'from install_requirements import TORCHAO_NIGHTLY_VERSION; print(TORCHAO_NIGHTLY_VERSION)') +python -m pip install torch "torchao==${torchao_version}" numpy flatbuffers packaging pyyaml ruamel.yaml tabulate \ + --index-url https://download.pytorch.org/whl/test/cpu \ + --extra-index-url https://download.pytorch.org/whl/nightly/cpu \ + --extra-index-url https://pypi.org/simple +mkdir -p "${NATIVE_WHEEL_DEPS}/export-source" +ln -s "${NATIVE_WHEEL_ROOT}" "${NATIVE_WHEEL_DEPS}/export-source/executorch" +PYTHONPATH="${NATIVE_WHEEL_DEPS}/export-source" python \ + "${NATIVE_WHEEL_ROOT}/.ci/scripts/wheel/export_native.py" \ + --output "${NATIVE_WHEEL_DEPS}/fixtures" +python "${NATIVE_WHEEL_ROOT}/.ci/scripts/wheel/test_native.py" \ + --wheel "${wheels[0]}" --source "${NATIVE_WHEEL_ROOT}" \ + --fixtures "${NATIVE_WHEEL_DEPS}/fixtures" + +# Nova's artifact steps also consume dist at the repository root. +mkdir -p "${NATIVE_WHEEL_ROOT}/dist" +cp "${wheels[0]}" "${NATIVE_WHEEL_ROOT}/dist/" diff --git a/.ci/scripts/wheel/pre_build_native.sh b/.ci/scripts/wheel/pre_build_native.sh new file mode 100755 index 00000000000..77cf82c2a42 --- /dev/null +++ b/.ci/scripts/wheel/pre_build_native.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +set -euxo pipefail + +python -m pip install build 'setuptools>=77' 'cmake>=3.26,<4' ninja auditwheel patchelf +python -m unittest discover -s "${NATIVE_WHEEL_ROOT}/.ci/scripts/tests" \ + -p test_native_wheel_version.py +mkdir -p "${NATIVE_WHEEL_DEPS}" +conda create -y -p "${NATIVE_WHEEL_DEPS}/shaderc" -c conda-forge shaderc zlib + +curl --fail --location --retry 3 https://libzip.org/download/libzip-1.11.4.tar.xz \ + --output "${NATIVE_WHEEL_DEPS}/libzip.tar.xz" +tar -xJf "${NATIVE_WHEEL_DEPS}/libzip.tar.xz" -C "${NATIVE_WHEEL_DEPS}" +cmake -S "${NATIVE_WHEEL_DEPS}/libzip-1.11.4" -B "${NATIVE_WHEEL_DEPS}/libzip-build" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${NATIVE_WHEEL_DEPS}/libzip" \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_TOOLS=OFF -DBUILD_REGRESS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_DOC=OFF \ + -DENABLE_BZIP2=OFF -DENABLE_LZMA=OFF -DENABLE_ZSTD=OFF \ + -DENABLE_OPENSSL=OFF -DENABLE_GNUTLS=OFF -DENABLE_MBEDTLS=OFF +cmake --build "${NATIVE_WHEEL_DEPS}/libzip-build" --target install --parallel + +cmake -S "${NATIVE_WHEEL_ROOT}/third-party/flatbuffers" \ + -B "${NATIVE_WHEEL_DEPS}/flatbuffers" \ + -DCMAKE_BUILD_TYPE=Release -DFLATBUFFERS_BUILD_TESTS=OFF +cmake --build "${NATIVE_WHEEL_DEPS}/flatbuffers" --target flatc --parallel diff --git a/.ci/scripts/wheel/test_native.py b/.ci/scripts/wheel/test_native.py new file mode 100644 index 00000000000..c4dae947201 --- /dev/null +++ b/.ci/scripts/wheel/test_native.py @@ -0,0 +1,97 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Install a repaired Native wheel into a clean environment and test GGML.""" + +import argparse +import os +import shutil +import subprocess +import tempfile +import venv +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--fixtures", type=Path, required=True) + args = parser.parse_args() + wheel = args.wheel.resolve() + source = args.source.resolve() + fixtures = args.fixtures.resolve() + with tempfile.TemporaryDirectory(prefix="native-wheel-test-") as directory: + work = Path(directory) + venv.create(work / "venv", with_pip=True) + python = work / "venv/bin/python" + env = os.environ.copy() + for name in ("PYTHONPATH", "PYTHONHOME", "LD_LIBRARY_PATH", "LD_PRELOAD"): + env.pop(name, None) + env["PYTHONNOUSERSITE"] = "1" + subprocess.run( + [str(python), "-m", "pip", "install", str(wheel)], + cwd=work, + env=env, + check=True, + timeout=600, + ) + subprocess.run( + [str(python), "-m", "pip", "check"], + cwd=work, + env=env, + check=True, + timeout=60, + ) + shutil.copytree(fixtures, work / "fixtures") + shutil.copyfile( + source / "backends/native/pybindings/test/test_bindings.py", + work / "test_bindings.py", + ) + subprocess.run( + [ + str(python), + "-I", + "-c", + """ +import importlib.metadata +import importlib.util +from pathlib import Path +import numpy as np +from executorch_native import Model + +for name in ('torch', 'executorch'): + assert importlib.util.find_spec(name) is None, name +installed = {d.metadata['Name'].lower() for d in importlib.metadata.distributions()} +assert installed <= {'pip', 'setuptools', 'executorch-native', 'numpy'}, installed +for name in ('add', 'conv_bn'): + package = Path('fixtures') / f'{name}.ptn' + with np.load(package.with_suffix('.npz')) as data: + session = Model.load(package).create_session('ggml') + (actual,) = session.run([data['input']]) + np.testing.assert_allclose(actual, data['expected'], rtol=1e-4, atol=1e-4) + print(f'PASS: {package.name} with GGML') +""", + ], + cwd=work, + env=env, + check=True, + timeout=120, + ) + env["NATIVE_TEST_PACKAGE"] = str(work / "fixtures/add.ptn") + env["NATIVE_TEST_ENGINE"] = "ggml" + subprocess.run( + [str(python), "-m", "unittest", "-v", "test_bindings"], + cwd=work, + env=env, + check=True, + timeout=300, + ) + print(f"PASS: clean install of {wheel.name}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build-wheels-native-aarch64-linux.yml b/.github/workflows/build-wheels-native-aarch64-linux.yml new file mode 100644 index 00000000000..adebefce4fa --- /dev/null +++ b/.github/workflows/build-wheels-native-aarch64-linux.yml @@ -0,0 +1,73 @@ +# From https://github.com/pytorch/test-infra/wiki/Using-Nova-Reusable-Build-Workflows +name: Build Aarch64 Linux Native Wheels + +on: + pull_request: + paths: + - .ci/**/* + - .github/workflows/build-wheels-native-aarch64-linux.yml + - '**/CMakeLists.txt' + - backends/native/**/* + - backends/vulkan/**/* + - backends/transforms/**/* + - exir/**/* + - schema/**/* + - examples/**/* + - version.txt + - .gitmodules + - pyproject.toml + - setup.py + - install_utils.py + - tools/cmake/**/* + push: + branches: + - nightly + - release/* + tags: + # NOTE: Binary build pipelines should only get triggered on release candidate builds + # Release candidate tags look like: v1.11.0-rc1 + - v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+ + - ciflow/binaries/* + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}-${{ github.ref_type == 'branch' && github.sha }}-${{ github.event_name == 'workflow_dispatch' }} + cancel-in-progress: true + +jobs: + generate-matrix: + uses: pytorch/test-infra/.github/workflows/generate_binary_build_matrix.yml@main + with: + package-type: wheel + os: linux-aarch64 + test-infra-repository: pytorch/test-infra + test-infra-ref: main + runner-fleet: osdc + with-cuda: disabled + with-rocm: disabled + python-versions: '["3.11", "3.12", "3.13", "3.14", "3.15"]' + + build: + needs: generate-matrix + permissions: + id-token: write + contents: read + name: executorch-native + uses: pytorch/test-infra/.github/workflows/build_wheels_linux.yml@main + with: + repository: pytorch/executorch + ref: "" + test-infra-repository: pytorch/test-infra + test-infra-ref: main + build-matrix: ${{ needs.generate-matrix.outputs.matrix }} + submodules: recursive + env-var-script: .ci/scripts/wheel/envvar_native.sh + pre-script: ../../../.ci/scripts/wheel/pre_build_native.sh + post-script: ../../../.ci/scripts/wheel/post_build_native.sh + package-name: executorch-native + # The post-build script installs the wheel in a clean environment and + # tests it; Nova's smoke test requires a pinned torch dependency. + run-smoke-test: false + # Only "push" uploads; executorch-native is not published yet. + trigger-event: development + architecture: aarch64 diff --git a/.github/workflows/build-wheels-native-linux.yml b/.github/workflows/build-wheels-native-linux.yml new file mode 100644 index 00000000000..ac71b95f0a5 --- /dev/null +++ b/.github/workflows/build-wheels-native-linux.yml @@ -0,0 +1,72 @@ +# From https://github.com/pytorch/test-infra/wiki/Using-Nova-Reusable-Build-Workflows +name: Build Linux Native Wheels + +on: + pull_request: + paths: + - .ci/**/* + - .github/workflows/build-wheels-native-linux.yml + - '**/CMakeLists.txt' + - backends/native/**/* + - backends/vulkan/**/* + - backends/transforms/**/* + - exir/**/* + - schema/**/* + - examples/**/* + - version.txt + - .gitmodules + - pyproject.toml + - setup.py + - install_utils.py + - tools/cmake/**/* + push: + branches: + - nightly + - release/* + tags: + # NOTE: Binary build pipelines should only get triggered on release candidate builds + # Release candidate tags look like: v1.11.0-rc1 + - v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+ + - ciflow/binaries/* + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}-${{ github.ref_type == 'branch' && github.sha }}-${{ github.event_name == 'workflow_dispatch' }} + cancel-in-progress: true + +jobs: + generate-matrix: + uses: pytorch/test-infra/.github/workflows/generate_binary_build_matrix.yml@main + with: + package-type: wheel + os: linux + test-infra-repository: pytorch/test-infra + test-infra-ref: main + runner-fleet: osdc + with-cuda: disabled + with-rocm: disabled + python-versions: '["3.11", "3.12", "3.13", "3.14", "3.15"]' + + build: + needs: generate-matrix + permissions: + id-token: write + contents: read + name: executorch-native + uses: pytorch/test-infra/.github/workflows/build_wheels_linux.yml@main + with: + repository: pytorch/executorch + ref: "" + test-infra-repository: pytorch/test-infra + test-infra-ref: main + build-matrix: ${{ needs.generate-matrix.outputs.matrix }} + submodules: recursive + env-var-script: .ci/scripts/wheel/envvar_native.sh + pre-script: ../../../.ci/scripts/wheel/pre_build_native.sh + post-script: ../../../.ci/scripts/wheel/post_build_native.sh + package-name: executorch-native + # The post-build script installs the wheel in a clean environment and + # tests it; Nova's smoke test requires a pinned torch dependency. + run-smoke-test: false + # Only "push" uploads; executorch-native is not published yet. + trigger-event: development diff --git a/backends/native/python/README.md b/backends/native/python/README.md index c2041ca17e0..f12640d9b70 100644 --- a/backends/native/python/README.md +++ b/backends/native/python/README.md @@ -137,12 +137,52 @@ manylinux image for each supported architecture; building on a newer Linux host does not establish compatibility with older systems. GGML uses portable CPU settings, with native tuning and optional x86 instruction sets disabled, rather than assuming the build machine's instruction set is available on every consumer. -Keep the existing ExecuTorch release version; the wheel reads `version.txt`. +The wheel reads `version.txt` for local builds. `BUILD_VERSION` overrides it in +release and nightly builds, just as it does for the `executorch` wheel. The build reuses existing CMake targets and installs only the Native wheel component. Building source distributions from this subdirectory is not supported; build wheels directly from the complete checkout. +## Release and nightly CI + +Dedicated Linux x86_64 and aarch64 wheel workflows build `executorch-native` +with the same triggers, Python version matrix, source revision, and reusable +PyTorch wheel workflow as `executorch`. Native has its own workflows because +the reusable workflow keys its concurrency group and artifact names by +repository, so two packages from one repository cannot share a workflow run. +Native includes both GGML and Vulkan in each wheel; it does not have separate +CUDA variants. The initial platform coverage is Linux. + +The existing wheel builder invokes `setup.py bdist_wheel`. The small `setup.py` in this directory +forwards that command to the PEP 517 recipe above. The Native hooks build libzip +and FlatBuffers in the manylinux environment, install the shader compiler, repair +the wheel, and test it. + +The wheels are not published yet. The workflows pass a non-`push` trigger event +for `executorch-native`, so the reusable workflow keeps the repaired wheels as +workflow artifacts and does not upload them. Before publishing, the package must +be registered with the release infrastructure: the PyPI project, the +download.pytorch.org nightly index, and release promotion. Then pass +`github.event_name` through as for `executorch`. + +The post-build check exports add and Conv/BatchNorm/ReLU models using the source +revision being released. It then creates a fresh virtual environment and installs +only the repaired Native wheel with its declared dependencies. It checks GGML +results against PyTorch, runs the Python binding suite, and rejects an +environment containing Torch or ExecuTorch. Vulkan hardware is not required +for this CPU check. + +To reproduce the check from the ExecuTorch source root, first export in your +development environment, then let the test create the clean runtime environment: + +```sh +python .ci/scripts/wheel/export_native.py --output /tmp/native-fixtures +python .ci/scripts/wheel/test_native.py \ + --wheel /path/to/repaired/executorch_native-*.whl \ + --source . --fixtures /tmp/native-fixtures +``` + ## Validate the installed wheel Generate the small test model on the build machine, then configure the tests diff --git a/backends/native/python/pyproject.toml b/backends/native/python/pyproject.toml index 9b7bc3256b5..ca25f1ad0e6 100644 --- a/backends/native/python/pyproject.toml +++ b/backends/native/python/pyproject.toml @@ -12,6 +12,7 @@ license = "BSD-3-Clause" dependencies = ["numpy>=1.26"] [tool.scikit-build] +experimental = true cmake.version = ">=3.26,<4" build.targets = ["executorch_native_sdk", "_native"] install.components = ["executorch_native"] @@ -19,6 +20,5 @@ wheel.install-dir = "executorch_native" wheel.packages = [] [tool.scikit-build.metadata.version] -provider = "scikit_build_core.metadata.regex" -input = "../../../version.txt" -regex = "(?P[^\\s]+)" +provider = "wheel_version" +provider-path = "." diff --git a/backends/native/python/setup.py b/backends/native/python/setup.py new file mode 100644 index 00000000000..ea72486c43f --- /dev/null +++ b/backends/native/python/setup.py @@ -0,0 +1,38 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Bridge Nova's legacy bdist_wheel command to the standalone PEP 517 build.""" + +import subprocess +import sys +from pathlib import Path + +from setuptools import setup +from setuptools.command.bdist_wheel import bdist_wheel +from wheel_version import dynamic_metadata + + +class NativeWheel(bdist_wheel): + def run(self) -> None: + subprocess.run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--outdir", + str(Path(self.dist_dir).resolve()), + str(Path(__file__).resolve().parent), + ], + check=True, + ) + + +setup( + version=dynamic_metadata("version", {}), + packages=[], + cmdclass={"bdist_wheel": NativeWheel}, +) diff --git a/backends/native/python/wheel_version.py b/backends/native/python/wheel_version.py new file mode 100644 index 00000000000..b176350a4e8 --- /dev/null +++ b/backends/native/python/wheel_version.py @@ -0,0 +1,17 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import os +from pathlib import Path + + +def dynamic_metadata(field: str, settings: dict[str, object]) -> str: + if field != "version" or settings: + raise ValueError("Only version metadata without settings is supported") + return ( + os.environ.get("BUILD_VERSION", "").strip() + or (Path(__file__).resolve().parents[3] / "version.txt").read_text().strip() + )