Skip to content

Commit 0b270a9

Browse files
committed
Update
[ghstack-poisoned]
2 parents 4699868 + e9abdd5 commit 0b270a9

1 file changed

Lines changed: 327 additions & 0 deletions

File tree

.ci/scripts/wheel/test_cpp_sdk.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
on the shipped runtime with a relocatable RUNPATH.
2323
"""
2424

25+
import importlib.util
2526
import os
2627
import re
2728
import shutil
2829
import subprocess
30+
import tempfile
2931
import sys
3032
from pathlib import Path
3133

@@ -284,8 +286,333 @@ def test_python_extensions_import() -> None:
284286
)
285287

286288

289+
_CUSTOM_OP_SOURCE = """\
290+
// A custom operator, built the way an out-of-tree project builds one: against the
291+
// shipped Python extension rather than an ExecuTorch source tree.
292+
#include <executorch/extension/kernel_util/make_boxed_from_unboxed_functor.h>
293+
#include <executorch/runtime/kernel/kernel_includes.h>
294+
295+
namespace {
296+
297+
executorch::aten::Tensor& custom_double_out(
298+
executorch::runtime::KernelRuntimeContext& context,
299+
const executorch::aten::Tensor& input,
300+
executorch::aten::Tensor& out) {
301+
(void)context;
302+
const float* in = input.const_data_ptr<float>();
303+
float* dst = out.mutable_data_ptr<float>();
304+
for (ssize_t i = 0; i < input.numel(); ++i) {
305+
dst[i] = in[i] * 2.0f;
306+
}
307+
return out;
308+
}
309+
310+
} // namespace
311+
312+
// The registration macro is the point of the check: it has to compile and resolve
313+
// against the registry the shipped extension provides.
314+
EXECUTORCH_LIBRARY(wheel_check, "custom_double.out", custom_double_out);
315+
"""
316+
317+
318+
_CUSTOM_OP_CMAKE = """\
319+
cmake_minimum_required(VERSION 3.28)
320+
project(custom_op_check CXX)
321+
322+
find_package(executorch REQUIRED)
323+
324+
add_library(custom_op_check SHARED custom_op.cpp)
325+
# The legacy contract: a custom-op library links the shipped Python extension,
326+
# which owns the operator registry it registers into.
327+
target_link_libraries(custom_op_check PRIVATE _portable_lib)
328+
"""
329+
330+
331+
def test_shipped_libraries_load() -> None:
332+
"""Every shipped library must depend only on things that exist.
333+
334+
The symbol checks prove each component is defined exactly once, but a library
335+
can still be unloadable if it needs something nothing provides, which is a
336+
packaging bug rather than a duplication bug.
337+
338+
A dependency the wheel ships elsewhere is fine even when `ldd` cannot resolve
339+
it: some extensions are loaded after `import torch` has already brought their
340+
dependencies into the process, so they intentionally carry no path to them.
341+
Only a name nothing in the wheel provides is a real problem.
342+
"""
343+
if shutil.which("ldd") is None:
344+
print("- ldd not available, skipping the load check")
345+
return
346+
347+
package_dir = _installed_package_dir()
348+
libraries = _shipped_shared_objects(package_dir)
349+
shipped = {library.name for library in libraries}
350+
351+
# A dependency is only excusable when the wheel ships it AND the loader can
352+
# actually reach it from the library that needs it. Loaded-later extensions
353+
# such as the Torch libraries are the real exception: they resolve once the
354+
# Python package that owns them is imported. Anything the wheel itself ships
355+
# must resolve here, because a RUNPATH applies to the library carrying it and
356+
# is not inherited on behalf of a dependency's own dependencies.
357+
broken = {}
358+
unreachable = {}
359+
unresolved = {}
360+
for library in libraries:
361+
resolved = subprocess.run(
362+
# -r resolves data and function symbols too, not just the NEEDED
363+
# entries. A SHARED link does not error on undefined symbols, so
364+
# without this an under-linked library passes here and fails at first
365+
# use instead.
366+
["ldd", "-r", str(library)],
367+
capture_output=True,
368+
text=True,
369+
check=False,
370+
# Any LD_LIBRARY_PATH in the build environment would paper over a
371+
# RUNPATH the shipped library is actually missing.
372+
env={
373+
key: value
374+
for key, value in os.environ.items()
375+
if key != "LD_LIBRARY_PATH"
376+
},
377+
)
378+
# ldd reports missing libraries on stdout but undefined symbols on stderr,
379+
# so both streams matter.
380+
combined = resolved.stdout + resolved.stderr
381+
missing = [
382+
line.split("=>")[0].strip()
383+
for line in combined.splitlines()
384+
if "not found" in line
385+
]
386+
# Interpreter symbols are excluded rather than whole files. A library that
387+
# is loaded by Python, whether a extension module or an ahead-of-time
388+
# plugin, resolves those only once an interpreter is running, so ldd can
389+
# never resolve them and their absence says nothing about packaging.
390+
# Filtering the symbols rather than guessing from the file name keeps the
391+
# check active for everything else those libraries need.
392+
undefined = [
393+
line.strip()
394+
for line in combined.splitlines()
395+
if "undefined symbol" in line
396+
and not re.search(r"undefined symbol:\s+_?Py", line)
397+
]
398+
if undefined:
399+
unresolved[str(library.relative_to(package_dir))] = undefined[:5]
400+
absent = [name for name in missing if name not in shipped]
401+
present_but_unreachable = [name for name in missing if name in shipped]
402+
if absent:
403+
broken[str(library.relative_to(package_dir))] = absent
404+
if present_but_unreachable:
405+
unreachable[str(library.relative_to(package_dir))] = present_but_unreachable
406+
407+
assert not broken, (
408+
"shipped libraries need dependencies that nothing provides, so they will "
409+
f"fail to load: {broken}"
410+
)
411+
assert not unreachable, (
412+
"shipped libraries need dependencies the wheel ships but the loader "
413+
"cannot reach from them, which usually means a missing RUNPATH entry: "
414+
f"{unreachable}"
415+
)
416+
assert not unresolved, (
417+
"shipped libraries reference symbols nothing provides, so they will fail "
418+
f"at first use rather than at load: {unresolved}"
419+
)
420+
print("✓ every shipped library resolves in an environment with torch present")
421+
422+
423+
def test_shipped_libraries_resolve_without_build_tree() -> None:
424+
"""A shipped library must resolve using only its relative runtime paths.
425+
426+
Packaging copies binaries out of the build directory, so they still carry the
427+
absolute paths they were linked with. On the machine that produced the wheel
428+
those paths exist, which means a library whose relative path is wrong can still
429+
resolve and look correct. Anywhere else it would fail.
430+
431+
Copy each library and its wheel-provided dependencies into a fresh tree that
432+
mirrors the wheel layout, drop every absolute runtime path, and check what is
433+
left is enough.
434+
"""
435+
if shutil.which("ldd") is None or shutil.which("patchelf") is None:
436+
print("- ldd or patchelf unavailable, skipping the relocated load check")
437+
return
438+
439+
package_dir = _installed_package_dir()
440+
libraries = _shipped_shared_objects(package_dir)
441+
environment = {
442+
key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH"
443+
}
444+
445+
with tempfile.TemporaryDirectory() as work_dir:
446+
root = Path(work_dir) / package_dir.name
447+
# Mirror the layout so a relative path such as $ORIGIN/../../lib still
448+
# points where it would in a real install.
449+
for library in libraries:
450+
target = root / library.relative_to(package_dir)
451+
target.parent.mkdir(parents=True, exist_ok=True)
452+
shutil.copy2(library, target)
453+
454+
broken = {}
455+
for library in libraries:
456+
target = root / library.relative_to(package_dir)
457+
current = subprocess.run(
458+
["patchelf", "--print-rpath", str(target)],
459+
capture_output=True,
460+
text=True,
461+
check=False,
462+
).stdout.strip()
463+
relative = [
464+
entry for entry in current.split(":") if entry.startswith("$ORIGIN")
465+
]
466+
subprocess.run(
467+
["patchelf", "--set-rpath", ":".join(relative), str(target)],
468+
# A failure here would leave the original absolute build paths in
469+
# place, and the check below would then pass by resolving through
470+
# them, which is exactly what this test exists to rule out.
471+
check=True,
472+
)
473+
resolved = subprocess.run(
474+
["ldd", str(target)],
475+
capture_output=True,
476+
text=True,
477+
check=False,
478+
env=environment,
479+
).stdout
480+
shipped = {item.name for item in libraries}
481+
all_missing = [
482+
line.split("=>")[0].strip()
483+
for line in resolved.splitlines()
484+
if "not found" in line
485+
]
486+
# Only wheel-provided dependencies are asserted on, because an external
487+
# one is expected to come from the environment. They are still reported,
488+
# since silently dropping them would hide a library that resolves only
489+
# through an absolute build path.
490+
missing = [name for name in all_missing if name in shipped]
491+
external = [name for name in all_missing if name not in shipped]
492+
if external:
493+
print(
494+
f"- {library.relative_to(package_dir)} also needs "
495+
f"{external} from the environment"
496+
)
497+
if missing:
498+
broken[str(library.relative_to(package_dir))] = missing
499+
500+
assert not broken, (
501+
"shipped libraries only resolve their wheel-provided dependencies "
502+
"through absolute build paths, so they would fail on any other "
503+
f"machine: {broken}"
504+
)
505+
print("✓ every shipped library resolves without the build tree")
506+
507+
508+
def test_custom_op_compiles(work_dir: Path) -> None:
509+
"""A custom operator compiles and links against the shipped extension.
510+
511+
This is how an out-of-tree project adds its own kernels, and it points at the
512+
Python extension rather than the runtime, so it is not covered by the consumer
513+
check above.
514+
"""
515+
assert shutil.which("cmake") is not None, "cmake is required to build a consumer"
516+
517+
package_dir = _installed_package_dir()
518+
if not list(package_dir.glob("extension/pybindings/_portable_lib*")):
519+
print("- the wheel ships no Python extension, skipping the custom op check")
520+
return
521+
522+
source_dir = work_dir / "custom-op"
523+
build_dir = work_dir / "custom-op-build"
524+
source_dir.mkdir(parents=True, exist_ok=True)
525+
(source_dir / "custom_op.cpp").write_text(_CUSTOM_OP_SOURCE)
526+
(source_dir / "CMakeLists.txt").write_text(_CUSTOM_OP_CMAKE)
527+
528+
configure = subprocess.run(
529+
[
530+
"cmake",
531+
"-S",
532+
str(source_dir),
533+
"-B",
534+
str(build_dir),
535+
f"-DCMAKE_PREFIX_PATH={package_dir}",
536+
],
537+
capture_output=True,
538+
text=True,
539+
check=False,
540+
)
541+
assert configure.returncode == 0, (
542+
"a custom operator project cannot configure against the wheel: "
543+
f"{(configure.stderr or configure.stdout).strip()[-600:]}"
544+
)
545+
546+
compiled = subprocess.run(
547+
["cmake", "--build", str(build_dir)],
548+
capture_output=True,
549+
text=True,
550+
check=False,
551+
)
552+
assert compiled.returncode == 0, (
553+
"a custom operator does not compile or link against the shipped extension: "
554+
f"{(compiled.stderr or compiled.stdout).strip()[-800:]}"
555+
)
556+
assert list(build_dir.rglob("libcustom_op_check.so")) or list(
557+
build_dir.rglob("custom_op_check.dll")
558+
), "the custom operator library was not produced"
559+
print("✓ a custom operator compiles against the shipped Python extension")
560+
561+
562+
def test_wheel_platform_tag() -> None:
563+
"""The wheel's declared platform tag must match what its libraries need.
564+
565+
A library that quietly picks up a newer dependency, or a newer minimum glibc,
566+
makes the wheel unusable on machines the tag says it supports. auditwheel is
567+
the tool that decides this, so ask it rather than guessing.
568+
569+
Only a contradiction between the tag and the contents fails here. Reports about
570+
instruction set extensions are left to the caller, because a prebuilt tool that
571+
ships in the wheel can legitimately require a newer baseline than the tag
572+
implies.
573+
"""
574+
if importlib.util.find_spec("auditwheel") is None:
575+
print("- auditwheel unavailable, skipping the platform tag check")
576+
return
577+
578+
wheels = sorted(Path(os.environ.get("WHEEL_DIR", ".")).glob("executorch-*.whl"))
579+
if not wheels:
580+
print("- no wheel file to inspect, skipping the platform tag check")
581+
return
582+
583+
result = subprocess.run(
584+
[sys.executable, "-m", "auditwheel", "show", str(wheels[-1])],
585+
capture_output=True,
586+
text=True,
587+
check=False,
588+
)
589+
# auditwheel wraps its verdict across lines, so compare on collapsed
590+
# whitespace rather than the literal output.
591+
combined = " ".join((result.stdout + result.stderr).split())
592+
match = re.search(
593+
r'consistent with the following platform tag: "([^"]+)"', combined
594+
)
595+
assert match, (
596+
"auditwheel reported no platform tag for the wheel, so its contents could "
597+
f"not be checked against what it claims: {combined[-400:]}"
598+
)
599+
# The tag auditwheel derives from the contents has to be the one the file name
600+
# claims. A wheel that names a stricter tag than its libraries support installs
601+
# on machines it cannot actually run on.
602+
claimed = wheels[-1].name.split("-")[-1].removesuffix(".whl")
603+
assert match.group(1) in claimed, (
604+
f"the wheel claims platform tag {claimed} but its contents only support "
605+
f"{match.group(1)}"
606+
)
607+
print(f"✓ the wheel contents match its declared platform tag {match.group(1)}")
608+
609+
287610
def run_tests(work_dir: Path) -> None:
288611
test_single_backend_registry()
289612
test_python_extensions_import()
613+
test_shipped_libraries_load()
614+
test_shipped_libraries_resolve_without_build_tree()
615+
test_wheel_platform_tag()
616+
test_custom_op_compiles(work_dir)
290617
test_single_threadpool()
291618
test_cpp_consumer(work_dir)

0 commit comments

Comments
 (0)