Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .ci/scripts/wheel/test_cpp_sdk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# 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.

"""Checks that the installed wheel is usable as a C++ SDK.

The wheel ships a prebuilt runtime library plus a CMake package config, so a
standalone application can find_package(executorch) and link
executorch::runtime without building ExecuTorch from source. These checks run
against the installed wheel only; they never look at the source tree's build
directory.

Two properties are verified:

1. Exactly one shipped library defines the backend registry. Backends register
into a process-wide table owned by the runtime, so a second definition would
silently give the process two tables and let a backend register into the one
nobody reads.
2. A C++ consumer builds and runs against the wheel, and records a dependency
on the shipped runtime with a relocatable RUNPATH.
"""

import os
import re
import shutil
import subprocess
from pathlib import Path

# Registry entry points. A second definer of any of these means a second
# process-wide registry.
_REGISTRY_SYMBOLS = (
"executorch::runtime::register_backend",
"executorch::runtime::get_num_registered_backends",
"executorch::runtime::get_backend_class",
)

# `nm -DC` prints "<hexaddr> <kind> <name>" for a definition and
# " U <name>" for an undefined reference.
_DEFINED = re.compile(r"^[0-9a-fA-F]+\s+(?P<kind>[A-Za-z])\s+(?P<name>.+)$")

# Symbol kinds that mean the object owns the code or storage.
_OWNING_KINDS = frozenset("TtBbDdGgSsRrWV")

_CONSUMER_SOURCE = """\
#include <executorch/runtime/backend/interface.h>
#include <executorch/runtime/platform/runtime.h>

#include <cstdio>

int main() {
executorch::runtime::runtime_init();
std::printf(
"registered backends: %zu\\n",
(size_t)executorch::runtime::get_num_registered_backends());
return 0;
}
"""

_CONSUMER_CMAKE = """\
cmake_minimum_required(VERSION 3.24)
project(executorch_wheel_consumer CXX)
find_package(executorch REQUIRED)
add_executable(consumer consumer.cpp)
target_link_libraries(consumer PRIVATE executorch::runtime)
"""


def _installed_package_dir() -> Path:
"""The installed executorch package, never the source checkout."""
import executorch

return Path(list(executorch.__path__)[0]).resolve()


def _shipped_shared_objects(package_dir: Path):
return [
path
for path in sorted(package_dir.rglob("*.so*"))
if path.is_file() and not path.is_symlink()
]


def _defines_symbol(library: Path, symbol: str) -> bool:
result = subprocess.run(
["nm", "-DC", str(library)], capture_output=True, text=True, check=False
)
# A library nm cannot read would otherwise look like one that simply defines
# nothing, letting the single-definer checks pass without having actually
# inspected every shipped library.
assert result.returncode == 0, (
f"nm could not read {library}, so the symbol checks cannot be trusted: "
f"{result.stderr.strip()}"
)
for line in result.stdout.splitlines():
if symbol not in line:
continue
match = _DEFINED.match(line)
if (
match
and match.group("name").startswith(symbol)
and match.group("kind") in _OWNING_KINDS
):
return True
return False


def test_single_backend_registry() -> None:
"""Exactly one shipped library may define the backend registry."""
assert shutil.which("nm") is not None, "nm is required to inspect the wheel"

package_dir = _installed_package_dir()
libraries = _shipped_shared_objects(package_dir)
assert libraries, f"no shared libraries found under {package_dir}"

for symbol in _REGISTRY_SYMBOLS:
definers = [lib for lib in libraries if _defines_symbol(lib, symbol)]
pretty = [str(lib.relative_to(package_dir)) for lib in definers]
assert len(definers) == 1, (
f"expected exactly one library to define {symbol}, found "
f"{len(definers)}: {pretty}. More than one definition means the "
f"process has more than one backend registry."
)
print(f"✓ single backend registry across {len(libraries)} shipped libraries")


def test_cpp_consumer(work_dir: Path) -> None:
"""A standalone C++ app builds and runs against the installed wheel."""
assert shutil.which("cmake") is not None, "cmake is required to build a consumer"

package_dir = _installed_package_dir()
config = package_dir / "share" / "cmake" / "executorch-config.cmake"
assert config.is_file(), f"wheel is missing its CMake package config: {config}"

source_dir = work_dir / "consumer"
build_dir = work_dir / "consumer-build"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE)
(source_dir / "CMakeLists.txt").write_text(_CONSUMER_CMAKE)

subprocess.run(
[
"cmake",
"-S",
str(source_dir),
"-B",
str(build_dir),
f"-DCMAKE_PREFIX_PATH={config.parent}",
],
check=True,
)
subprocess.run(["cmake", "--build", str(build_dir)], check=True)

consumer = build_dir / "consumer"
# No LD_LIBRARY_PATH: the imported target is responsible for making the
# shipped runtime findable.
environment = {
key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH"
}
subprocess.run([str(consumer)], check=True, env=environment)
print("✓ C++ consumer builds and runs against the installed wheel")

_assert_runs_relocated(consumer, package_dir, work_dir, environment)

assert shutil.which("readelf") is not None, "readelf is required to check the ELF"

dynamic = subprocess.run(
["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True
).stdout
assert "libexecutorch.so" in dynamic, (
"the consumer does not depend on the shipped runtime; "
f"dynamic section was:\n{dynamic}"
)
assert "$ORIGIN" in dynamic, (
"the consumer has no $ORIGIN-relative RUNPATH, so it is not "
f"relocatable; dynamic section was:\n{dynamic}"
)
print("✓ consumer depends on the shipped runtime with a relocatable RUNPATH")


def _assert_runs_relocated(consumer, package_dir, work_dir, environment) -> None:
"""The app still runs after being moved away from the wheel.

Building in place leaves an absolute path to the wheel's lib directory in the
binary's RUNPATH, which resolves the runtime no matter what `$ORIGIN` says.
Copying the app next to a copy of the runtime, with that absolute entry
removed, is what actually proves the package is relocatable.

The layout mirrors what the package config supports: the app in `bin/` with
the libraries in a sibling `lib/`, which is what `$ORIGIN/../lib` resolves.
"""
if shutil.which("patchelf") is None:
print("- patchelf not available, skipping the relocated run")
return

deploy = work_dir / "deployed"
(deploy / "bin").mkdir(parents=True, exist_ok=True)
(deploy / "lib").mkdir(parents=True, exist_ok=True)
moved = deploy / "bin" / consumer.name
shutil.copy2(consumer, moved)
for library in (package_dir / "lib").glob("*.so*"):
shutil.copy2(library, deploy / "lib" / library.name)

# Keep only the $ORIGIN-relative entries, so nothing absolute can help.
current = subprocess.run(
["patchelf", "--print-rpath", str(moved)],
capture_output=True,
text=True,
check=True,
).stdout.strip()
relative = [entry for entry in current.split(":") if entry.startswith("$ORIGIN")]
assert relative, (
"the consumer has no $ORIGIN-relative RUNPATH entry, so it cannot be "
f"relocated; RUNPATH was: {current}"
)
subprocess.run(
["patchelf", "--set-rpath", ":".join(relative), str(moved)], check=True
)

subprocess.run([str(moved)], check=True, env=environment, cwd=str(deploy))
print("✓ consumer still runs when deployed beside a copy of the runtime")


def run_tests(work_dir: Path) -> None:
test_single_backend_registry()
test_cpp_consumer(work_dir)
9 changes: 9 additions & 0 deletions .ci/scripts/wheel/test_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
# LICENSE file in the root directory of this source tree.

import platform
import tempfile
from pathlib import Path

import test_base
import test_cpp_sdk
from examples.models import Backend, Model

if __name__ == "__main__":
Expand Down Expand Up @@ -41,6 +44,12 @@

test_base.test_cmsis_nn_install()

# The wheel ships a prebuilt C++ runtime and a CMake package config, so
# check that a standalone application can actually link and run against
# them, and that the process still has a single backend registry.
with tempfile.TemporaryDirectory() as work_dir:
test_cpp_sdk.run_tests(Path(work_dir))

test_base.run_tests(
model_tests=[
test_base.ModelTest(
Expand Down
10 changes: 10 additions & 0 deletions .ci/scripts/wheel/test_linux_aarch64.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

import tempfile
from pathlib import Path

import test_base
import test_cpp_sdk
from examples.models import Backend, Model

if __name__ == "__main__":
Expand All @@ -26,6 +30,12 @@
), f"OpenvinoBackend not found in registered backends: {registered}"
print("✓ OpenvinoBackend is registered")

# The wheel ships a prebuilt C++ runtime and a CMake package config, so check
# that a standalone application can actually link and run against them, and
# that the process still has a single backend registry.
with tempfile.TemporaryDirectory() as work_dir:
test_cpp_sdk.run_tests(Path(work_dir))

test_base.run_tests(
model_tests=[
test_base.ModelTest(
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build-wheels-aarch64-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ on:
paths:
- .ci/**/*
- .github/workflows/build-wheels-aarch64-linux.yml
- '**/CMakeLists.txt'
- examples/**/*
- pyproject.toml
- setup.py
- tools/cmake/**/*
push:
branches:
- nightly
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build-wheels-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ on:
paths:
- .ci/**/*
- .github/workflows/build-wheels-linux.yml
- '**/CMakeLists.txt'
- examples/**/*
- pyproject.toml
- setup.py
- tools/cmake/**/*
push:
branches:
- nightly
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build-wheels-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ on:
paths:
- .ci/**/*
- .github/workflows/build-wheels-macos.yml
- '**/CMakeLists.txt'
- examples/**/*
- pyproject.toml
- setup.py
- tools/cmake/**/*
push:
branches:
- nightly
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build-wheels-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ on:
paths:
- .ci/**/*
- .github/workflows/build-wheels-windows.yml
- '**/CMakeLists.txt'
- examples/**/*
- pyproject.toml
- setup.py
- tools/cmake/**/*
push:
branches:
- nightly
Expand Down
Loading
Loading