Skip to content

Commit 6be8a31

Browse files
committed
Ship the quantized kernels as their own library
A quantized model uses smaller numbers than a normal one, so the tensors take less memory. Running one needs the quantized operator kernels. The only copy the wheel shipped is the one torch loads to export a model, which a C++ application cannot use. Such an application links the runtime, loads a quantized model, and the model fails at run time with a missing operator, which looks like a model problem rather than a packaging one. Build the quantized kernels as their own shared library and name it as a CMake component, the same way the other kernel sets are named. ```cmake find_package(executorch REQUIRED COMPONENTS kernels_quantized) target_link_libraries(my_app PRIVATE executorch::runtime executorch::kernels_quantized) ``` The wheel now ships `lib/libexecutorch_kernels_quantized.so`. Note that the wheel also ships a second copy of these kernels, inside the library torch loads when you export a model. That copy is built into the plugin rather than resolved from the shared library, so a process holding both registers the same operators twice, and the runtime treats that as fatal: ``` Re-registering quantized_decomposed::add.out ``` This affects only a process that does both, for example an application that embeds a Python interpreter. A plain C++ application can link the component freely. Because of that, this is the one component `EXECUTORCH_LIBRARIES` does not include, so an application that links whatever the package offers cannot end up in that position without asking. A consumer that wants the quantized kernels names the component, or on CMake older than 3.28, where no component targets exist, links `EXECUTORCH_QUANTIZED_KERNELS_LIBRARY` as well. That variable is now populated on both CMake routes, so a consumer that adopts the older-CMake recipe and later upgrades keeps the library on their link line instead of silently losing it. Built the wheel, installed it into a clean environment, and: - exported a quantized model and ran it from Python, matching eager PyTorch to within the quantization step (measured worst difference 0.0048 against a tolerance of 0.02). - built a C++ application that links `executorch::kernels_quantized`, ran the same program, and got the same output as Python, byte for byte. - confirmed the Python extension does not depend on the run-time copy, and that a process holding the shipped library and the export plugin aborts in either load order. - checked every shipped library the same way, to establish that this is the only pair that collides: the CPU kernels, the delegate, the thread pool, the profiler and the runtime all coexist with both the extension and the export plugin. - an application linking only `EXECUTORCH_LIBRARIES` does not depend on the quantized library while still depending on the CPU kernels, on CMake 3.28 and on real CMake 3.24. A new check asserts this, and it fails on the previous behaviour. - `EXECUTORCH_QUANTIZED_KERNELS_LIBRARY` resolves to the shipped library on both the modern-CMake route (as the imported target) and the pre-3.28 route (as a file path). - a missing quantized library now fails the checks instead of skipping them. The preset that builds the wheel enables these kernels unconditionally, so their absence is a regression rather than a configuration to tolerate, and both the ownership table and the C++ check previously treated it as an acceptable state and reported coverage they had not run. Ran on Linux x86_64 and aarch64. ghstack-source-id: d5aa850 ghstack-comment-id: 5217087046 Pull-Request: #21642
1 parent a7b7591 commit 6be8a31

6 files changed

Lines changed: 429 additions & 36 deletions

File tree

.ci/scripts/wheel/test_cpp_sdk.py

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ def forward(self, x, image):
5757
with torch.no_grad():
5858
expected = model(*example)
5959
60+
if mode == "quantized":
61+
# Quantize with the same flow the documentation shows, so the exported program
62+
# references the quantized operator set rather than the plain one.
63+
# Importing this loads the ahead-of-time library, which is what registers the out
64+
# variants of the quantized operators with torch. Without it the export fails with
65+
# "Missing out variants: quantized_decomposed::quantize_per_tensor", because the
66+
# lowering step has no out variant to select.
67+
import executorch.kernels.quantized # noqa: F401
68+
from executorch.backends.xnnpack.quantizer.xnnpack_quantizer import (
69+
get_symmetric_quantization_config,
70+
XNNPACKQuantizer,
71+
)
72+
from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e
73+
74+
quantizer = XNNPACKQuantizer().set_global(get_symmetric_quantization_config())
75+
prepared = prepare_pt2e(torch.export.export(model, example).module(), quantizer)
76+
prepared(*example)
77+
model = convert_pt2e(prepared)
78+
6079
partitioners = []
6180
if mode == "delegate":
6281
from executorch.backends.xnnpack.partition.xnnpack_partitioner import (
@@ -85,6 +104,11 @@ def forward(self, x, image):
85104
"expected": expected.flatten().tolist(),
86105
"delegated": mode == "delegate",
87106
"has_xnnpack": b"XnnpackBackend" in bytes(buffer),
107+
# Whether the program actually carries quantized operators. The numeric comparison alone
108+
# cannot tell: an unquantized export of the same model produces a closer match than the
109+
# tolerance a quantized one needs, so it would pass while proving nothing about the
110+
# quantized kernels.
111+
"has_quantized": b"quantized_decomposed" in bytes(buffer),
88112
}
89113
)
90114
)
@@ -102,6 +126,7 @@ def forward(self, x, image):
102126
103127
#include <cmath>
104128
#include <cstdio>
129+
#include <cstdlib>
105130
#include <fstream>
106131
#include <string>
107132
#include <vector>
@@ -196,8 +221,14 @@ def forward(self, x, image):
196221
}
197222
worst = std::fmax(worst, diff);
198223
}
199-
if (worst > 1e-4) {
200-
std::printf("output differs from eager PyTorch by %g\n", worst);
224+
// Passed in rather than fixed, because the acceptable difference depends on the
225+
// model. A float32 model should match to within rounding, while an int8 quantized one
226+
// legitimately differs by about one quantization step, and using the looser number
227+
// for both would stop the float path catching a real regression.
228+
const double tolerance = argc > 7 ? std::atof(argv[7]) : 1e-4;
229+
if (worst > tolerance) {
230+
std::printf(
231+
"output differs from eager PyTorch by %g, tolerance %g\n", worst, tolerance);
201232
return 1;
202233
}
203234
@@ -335,8 +366,16 @@ def _build_consumer(work_dir: Path, name: str, components) -> Path:
335366
return consumer
336367

337368

338-
def _run_consumer(consumer: Path, model: Path, reference, work_dir: Path) -> str:
339-
"""Run the application and require it to match eager PyTorch."""
369+
def _run_consumer(
370+
consumer: Path, model: Path, reference, work_dir: Path, tolerance: float = 1e-4
371+
) -> str:
372+
"""Run the application and require it to match eager PyTorch within `tolerance`.
373+
374+
The tolerance is a parameter because the acceptable difference depends on the model.
375+
A float32 model should match to within rounding, while an int8 quantized one
376+
legitimately differs by about one quantization step, and using the looser number for
377+
both would stop the float path catching a real regression.
378+
"""
340379
inputs = reference["inputs"]
341380
shape_a, data_a = _write_tensor(work_dir, "a", inputs[0])
342381
shape_b, data_b = _write_tensor(work_dir, "b", inputs[1])
@@ -358,6 +397,7 @@ def _run_consumer(consumer: Path, model: Path, reference, work_dir: Path) -> str
358397
str(shape_b),
359398
str(data_b),
360399
str(expected),
400+
str(tolerance),
361401
],
362402
capture_output=True,
363403
text=True,
@@ -1219,6 +1259,125 @@ def test_pre_3_28_route_builds_a_consumer_through_variables(work_dir: Path) -> N
12191259
)
12201260

12211261

1262+
def test_quantized_kernels_component_runs_a_model(work_dir: Path) -> None:
1263+
"""A C++ application can run a quantized model using the shipped quantized kernels.
1264+
1265+
Before the quantized kernels became their own library they existed only inside the
1266+
ahead-of-time extension beside the Python bindings, so a C++ application loading a
1267+
quantized program had nothing to link and failed at run time with the operators
1268+
reported missing.
1269+
1270+
A missing library is a failure rather than a skip. The preset that builds the wheel
1271+
always enables the quantized kernels, so their absence is a regression in packaging
1272+
or in the build, not a configuration this suite has to tolerate. Skipping there
1273+
reported the whole check as coverage while running none of it.
1274+
"""
1275+
package_dir = _installed_package_dir()
1276+
# Globbed for the same reason the profiler check is: the library carries a version suffix outside a
1277+
# wheel build, and an exact name would skip this silently there rather than running it.
1278+
shipped = sorted((package_dir / "lib").glob("libexecutorch_kernels_quantized.so*"))
1279+
assert shipped, (
1280+
"the wheel ships no quantized kernels library. The preset that builds it enables "
1281+
"them unconditionally, so this is a packaging or build regression rather than an "
1282+
"unsupported configuration."
1283+
)
1284+
1285+
model, reference = _export(work_dir, "quantized")
1286+
# The export has to have produced a quantized program, or the rest of this proves nothing about the
1287+
# quantized kernels. The numeric comparison cannot tell the difference: an unquantized export of the
1288+
# same model lands well inside the tolerance a quantized one needs, so it would pass while linking a
1289+
# library it never exercised.
1290+
assert reference["has_quantized"], (
1291+
"the quantized export produced a program with no quantized operators, so this check would "
1292+
"prove nothing about the quantized kernels"
1293+
)
1294+
consumer = _build_consumer(
1295+
work_dir,
1296+
"with-quantized",
1297+
["runtime", "kernels_optimized", "kernels_quantized"],
1298+
)
1299+
# One int8 quantization step over this model's output range is about 5e-3, so a
1300+
# float32 tolerance cannot be met by a correct quantized run.
1301+
output = _run_consumer(consumer, model, reference, work_dir, tolerance=2e-2)
1302+
print(
1303+
f"✓ a C++ app linking executorch::kernels_quantized runs a quantized model "
1304+
f"({output})"
1305+
)
1306+
1307+
1308+
def test_aggregate_variable_excludes_the_quantized_kernels(work_dir: Path) -> None:
1309+
"""`${EXECUTORCH_LIBRARIES}` must not drag in the quantized kernels.
1310+
1311+
The export-time plugin that `executorch.kernels.quantized` loads carries its own
1312+
copy of those kernels rather than depending on the shipped library, so a process
1313+
holding both registers the same operators twice and the runtime stops on the
1314+
second one. An application that links whatever the package offers by default
1315+
would inherit that, so the component is defined but held out of the aggregate and
1316+
a consumer that wants it names it.
1317+
1318+
Checked by reading the link line rather than by running, because the failure is a
1319+
process-wide abort that needs a Python interpreter in the same process to trigger.
1320+
What this owns is the packaging decision: is the library on the link line at all.
1321+
"""
1322+
package_dir = _installed_package_dir()
1323+
# Fatal for the same reason the check above is: the preset that builds the wheel
1324+
# always enables these kernels, so their absence is a regression rather than a
1325+
# configuration to tolerate, and skipping would report this as coverage.
1326+
assert sorted(
1327+
(package_dir / "lib").glob("libexecutorch_kernels_quantized.so*")
1328+
), "the wheel ships no quantized kernels library, so this check cannot run"
1329+
1330+
source_dir = work_dir / "aggregate-only"
1331+
source_dir.mkdir(parents=True, exist_ok=True)
1332+
(source_dir / "consumer.cpp").write_text(_CONSUMER_SOURCE)
1333+
# No COMPONENTS and no named target, which is the shape the older-CMake route
1334+
# forces and the documentation offers as the general case.
1335+
(source_dir / "CMakeLists.txt").write_text(
1336+
"cmake_minimum_required(VERSION 3.28)\n"
1337+
"project(consumer CXX)\n"
1338+
"find_package(executorch REQUIRED)\n"
1339+
"add_executable(consumer consumer.cpp)\n"
1340+
"target_link_libraries(consumer PRIVATE ${EXECUTORCH_LIBRARIES})\n"
1341+
)
1342+
build_dir = work_dir / "aggregate-only-build"
1343+
config = package_dir / "share" / "cmake" / "executorch-config.cmake"
1344+
for command in (
1345+
[
1346+
_tool("cmake"),
1347+
"-S",
1348+
str(source_dir),
1349+
"-B",
1350+
str(build_dir),
1351+
f"-DCMAKE_PREFIX_PATH={config.parent}",
1352+
],
1353+
[_tool("cmake"), "--build", str(build_dir)],
1354+
):
1355+
result = subprocess.run(command, capture_output=True, text=True, check=False)
1356+
assert result.returncode == 0, (
1357+
"an application linking only ${EXECUTORCH_LIBRARIES} could not be built:\n"
1358+
f"{result.stdout[-2000:]}\n{result.stderr[-2000:]}"
1359+
)
1360+
1361+
consumer = build_dir / "consumer"
1362+
dependencies = subprocess.run(
1363+
["readelf", "-d", str(consumer)], capture_output=True, text=True, check=True
1364+
).stdout
1365+
assert "libexecutorch_kernels_quantized" not in dependencies, (
1366+
"an application that linked only ${EXECUTORCH_LIBRARIES} depends on the "
1367+
"quantized kernels. That library collides with the export-time plugin, so it "
1368+
"has to be opted into by name rather than handed to every consumer."
1369+
)
1370+
# The rest of the aggregate still has to be there, or this would pass by shipping
1371+
# nothing at all.
1372+
assert "libexecutorch_kernels_optimized" in dependencies, (
1373+
"the aggregate no longer carries the CPU kernels, so an application linking it "
1374+
"would fail at run time with the operators reported missing"
1375+
)
1376+
print(
1377+
"✓ ${EXECUTORCH_LIBRARIES} carries the CPU kernels and not the quantized ones"
1378+
)
1379+
1380+
12221381
def run_tests(work_dir: Path) -> None:
12231382
test_find_package_honours_a_version_request(work_dir)
12241383
test_profiler_component_is_usable(work_dir)
@@ -1228,6 +1387,8 @@ def run_tests(work_dir: Path) -> None:
12281387
test_runtime_alone_links_but_cannot_compute(work_dir)
12291388
test_kernels_component_runs_a_model(work_dir)
12301389
test_pre_3_28_route_builds_a_consumer_through_variables(work_dir)
1390+
test_quantized_kernels_component_runs_a_model(work_dir)
1391+
test_aggregate_variable_excludes_the_quantized_kernels(work_dir)
12311392
test_delegated_model_needs_the_delegate_component(work_dir)
12321393
test_consumer_is_relocatable(work_dir)
12331394
test_one_registry_in_the_cpp_process(work_dir)

0 commit comments

Comments
 (0)