From f5a8c70b20aba8be5151dd196fd8955a79eb9d48 Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 24 Aug 2026 10:14:51 -0700 Subject: [PATCH 1/2] Cover a CUDA model with weights in the wheel smoke test The CUDA rows of the wheel smoke test export one model through the CUDA delegate and run it. That model is an add of two inputs, so it has no weights. A model with no weights needs nothing from the delegate's external data file, so it runs whether or not that path works. The CUDA delegate does not put weights in the .pte. It writes them to a separate file named aoti_cuda_blob.ptd, and the caller has to hand that file back at load time. Every runner script in this repository passes it by name. Nothing in the smoke test loaded one, so a wheel that could not produce or consume that file passed the check and shipped. Add a second model, a small linear layer, to the same child process: - export it through the CUDA partitioner and write both the .pte and the data file, - assert the program actually carries a CUDA delegate, so a partitioner that silently declined the model cannot pass this check on the portable kernels, - assert the data file is present under the name the runners expect, - assert the data file is larger than the one the weightless model above produces, by at least the size of this model's weight matrix, - load the program with the data file and compare the result against eager. The size check is written as a difference rather than a threshold on purpose. A data file is written even when there are no weights, and it is not small: measured on sm_80, the weightless model produces a 256-byte container around an empty payload. Any fixed lower bound below that is satisfied by an empty file, so the check has to calibrate itself. It writes the weightless program's data file in the same run and subtracts. Comparing against the sum over parameters() would not work either: measured on sm_80, the delegate wrote this Linear's 128-byte weight into the data file and kept its 16-byte bias out, so that sum is not a bound on what the file has to hold. Test plan: ran the new child code on a Linux x86_64 machine with an sm_80 GPU against a published nightly CUDA wheel. Both models pass: PASS: a CUDA-delegated model ran on sm_80 and matched eager PASS: a CUDA-delegated model with weights ran on sm_80 from a 384-byte aoti_cuda_blob.ptd carrying 128 bytes of weights, and matched eager The delegate log on that run names a real content hash for the weights blob, not the hash of an empty one. Also confirmed the check is meaningful in both directions. Loading the same weighted program without the data file makes the delegate report the weights as not found, and the run then fails inside forward with an illegal memory access. And if the delegate wrote an empty data file for the weighted model, the measured numbers give a difference of 0 against a 128-byte weight matrix, so the assertion fires. --- .ci/scripts/wheel/test_cuda_linux.py | 91 +++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/.ci/scripts/wheel/test_cuda_linux.py b/.ci/scripts/wheel/test_cuda_linux.py index 5a8e20b1665..7f5a7aae842 100644 --- a/.ci/scripts/wheel/test_cuda_linux.py +++ b/.ci/scripts/wheel/test_cuda_linux.py @@ -320,6 +320,9 @@ def test_the_delegate_registers() -> None: # search path is in place before glibc caches it. Kept as source rather than a separate file # because the smoke test is invoked directly and ships as one module. _EXECUTION_CHILD = """ +import os +import tempfile + import torch from executorch.backends.cuda.cuda_partitioner import CudaPartitioner @@ -357,6 +360,87 @@ def forward(self, x, y): torch.testing.assert_close(actual.cpu(), eager, rtol=1e-3, atol=1e-3) capability = torch.cuda.get_device_capability(0) print(f"PASS: a CUDA-delegated model ran on sm_{capability[0]}{capability[1]} and matched eager") + + +# The model above has no weights, so it needs nothing from the data file and runs whether or not +# the delegate's external data path works. Anything a user would actually ship has weights, and +# the CUDA delegate does not put them in the .pte: it writes them to a separate aoti_cuda_blob.ptd +# that the caller has to supply at load time. Nothing here loaded one, so a wheel that shipped a +# broken data path passed this check. Measured on sm_80: given the same model without the blob, +# the delegate reports the weights as not found, and the run then dies inside forward with an +# illegal memory access rather than returning wrong numbers. +class Weighted(torch.nn.Module): + def __init__(self): + super().__init__() + self.fc = torch.nn.Linear(8, 4) + + def forward(self, x): + return self.fc(x) + + +weighted = Weighted().eval() +weighted_example = (torch.randn(2, 8),) +with torch.no_grad(): + weighted_eager = weighted(*weighted_example) + +weighted_program = to_edge_transform_and_lower( + torch.export.export(weighted, weighted_example), partitioner=[CudaPartitioner([])] +).to_executorch() + +assert b"CudaBackend" in weighted_program.buffer, ( + "the weighted program carries no CUDA delegate, so it would run on the portable kernels and " + "never reach the external weights path this check exists for" +) + +# Only the weight matrix, not every parameter. Measured on sm_80, the delegate wrote the 128-byte +# weight of this Linear into the data file and kept its 16-byte bias out, so the sum over +# parameters() is not a bound on what the file has to hold. +weight_bytes = weighted.fc.weight.numel() * weighted.fc.weight.element_size() + +with tempfile.TemporaryDirectory() as work_dir: + pte_path = os.path.join(work_dir, "weighted.pte") + with open(pte_path, "wb") as handle: + weighted_program.write_to_file(handle) + weighted_program.write_tensor_data_to_file(work_dir) + + blob_path = os.path.join(work_dir, "aoti_cuda_blob.ptd") + assert os.path.exists(blob_path), ( + "the export wrote no aoti_cuda_blob.ptd, so the weights of this model went nowhere and " + f"every runner script that passes that file by name would fail: {sorted(os.listdir(work_dir))}" + ) + + # A data file is written even for a model with no weights, and it is not small: measured on + # sm_80, the weightless model above produces a 256-byte container around an empty payload. So + # the file's presence says nothing and its raw size says nothing either. What means something + # is the amount by which this file exceeds that empty container, so write one and subtract it + # instead of comparing against a constant, which would stop catching anything the next time + # the container grows. + empty_dir = os.path.join(work_dir, "weightless") + os.mkdir(empty_dir) + lowered.write_tensor_data_to_file(empty_dir) + empty_size = os.path.getsize(os.path.join(empty_dir, "aoti_cuda_blob.ptd")) + + blob_size = os.path.getsize(blob_path) + assert blob_size - empty_size >= weight_bytes, ( + f"aoti_cuda_blob.ptd is {blob_size} bytes against {empty_size} bytes for a model with no " + f"weights, a difference of {blob_size - empty_size}, which does not cover this model's " + f"{weight_bytes}-byte weight matrix, so the weights were not written to it" + ) + + with open(pte_path, "rb") as handle: + pte_bytes = handle.read() + with open(blob_path, "rb") as handle: + blob_bytes = handle.read() + +module = _load_for_executorch_from_buffer(pte_bytes, blob_bytes) +actual = module.forward(list(weighted_example))[0] + +torch.testing.assert_close(actual.cpu(), weighted_eager, rtol=1e-3, atol=1e-3) +print( + f"PASS: a CUDA-delegated model with weights ran on sm_{capability[0]}{capability[1]} from a " + f"{blob_size}-byte aoti_cuda_blob.ptd carrying {blob_size - empty_size} bytes of weights, and " + f"matched eager" +) """ @@ -396,12 +480,17 @@ def _glibcxx_versions(library: Path) -> Set[str]: def test_a_model_runs_through_the_delegate() -> None: - """Export a model to the CUDA delegate and run it, comparing against eager. + """Export two models to the CUDA delegate and run them, comparing against eager. Every other check in this file reads a shipped artifact. This one executes, because a wheel whose libraries are all present and correctly linked can still fail to compute, and nothing above would notice. The x86_64 rows land on a GPU runner, so this is the row where that can be proven. + The second model carries weights. The CUDA delegate keeps those out of the .pte and writes them + to a separate aoti_cuda_blob.ptd, which the caller then has to supply, so a wheel can run the + weightless model and still be unable to run anything real. That path is only covered by the + second model. + Only the aarch64 rows may skip, and they say why, so a green result never stands for work that did not happen. On x86_64 the absent device or the mismatched torch build is itself the failure: that row is the one place execution is proven, and letting it report success for a check it did From 82d24d89b818d21a9a685d9fdcbcabe8fd2d8123 Mon Sep 17 00:00:00 2001 From: r Date: Mon, 24 Aug 2026 13:05:14 -0700 Subject: [PATCH 2/2] Exercise the path-based loader, and describe it accurately The runner scripts hand this blob to the runtime by path rather than as bytes, so load it that way here too while the file still exists; otherwise a regression in the path-based loader stays green. The earlier comment named a single runner flag, but three spellings are in use across the CI scripts, so it no longer enumerates them. --- .ci/scripts/wheel/test_cuda_linux.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.ci/scripts/wheel/test_cuda_linux.py b/.ci/scripts/wheel/test_cuda_linux.py index 7f5a7aae842..914fafaa3f3 100644 --- a/.ci/scripts/wheel/test_cuda_linux.py +++ b/.ci/scripts/wheel/test_cuda_linux.py @@ -328,6 +328,7 @@ def test_the_delegate_registers() -> None: from executorch.backends.cuda.cuda_partitioner import CudaPartitioner from executorch.exir import to_edge_transform_and_lower from executorch.extension.pybindings.portable_lib import ( + _load_for_executorch, _load_for_executorch_from_buffer, ) @@ -432,14 +433,26 @@ def forward(self, x): with open(blob_path, "rb") as handle: blob_bytes = handle.read() + # The runner scripts pass this file by path, not as bytes, so exercise the + # path-based loader here while the file still exists, or a regression in it + # stays green. + file_module = _load_for_executorch(pte_path, blob_path) + file_actual = file_module.forward(list(weighted_example))[0] + torch.testing.assert_close( + file_actual.cpu(), weighted_eager, rtol=1e-3, atol=1e-3 + ) + +# Loading from bytes as well, because _load_for_executorch_from_buffer is what a +# caller with the data already in memory uses, and it builds the data map by a +# different route. module = _load_for_executorch_from_buffer(pte_bytes, blob_bytes) actual = module.forward(list(weighted_example))[0] torch.testing.assert_close(actual.cpu(), weighted_eager, rtol=1e-3, atol=1e-3) print( f"PASS: a CUDA-delegated model with weights ran on sm_{capability[0]}{capability[1]} from a " - f"{blob_size}-byte aoti_cuda_blob.ptd carrying {blob_size - empty_size} bytes of weights, and " - f"matched eager" + f"{blob_size}-byte aoti_cuda_blob.ptd carrying {blob_size - empty_size} bytes of weights, " + f"loaded both by path and from bytes, and matched eager" ) """