Skip to content

Commit 3ecb52d

Browse files
Arm backend: Add public API validation and BC tests to CI
Introduce BC tests and add those as well as the validation of the public API manifest to CI in the pull job. Change-Id: I1815223a13109e225f2b9e0f9914b504799db2ae Signed-off-by: Sebastian Larsson <sebastian.larsson@arm.com>
1 parent f715053 commit 3ecb52d

9 files changed

Lines changed: 526 additions & 28 deletions

File tree

.github/workflows/pull.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,33 @@ jobs:
743743
# Test test_arm_baremetal.sh with test
744744
backends/arm/test/test_arm_baremetal.sh "${ARM_TEST}"
745745
746+
test-arm-backend-public-api-backward-compatibility:
747+
name: test-arm-backend-public-api-backward-compatibility
748+
uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main
749+
permissions:
750+
id-token: write
751+
contents: read
752+
with:
753+
runner: linux.2xlarge.memory
754+
docker-image: ci-image:executorch-ubuntu-22.04-arm-sdk
755+
submodules: 'recursive'
756+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
757+
timeout: 120
758+
script: |
759+
# The generic Linux job chooses to use base env, not the one setup by the image
760+
CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]")
761+
conda activate "${CONDA_ENV}"
762+
763+
source .ci/scripts/utils.sh
764+
install_executorch "--use-pt-pinned-commit"
765+
766+
.ci/scripts/setup-arm-baremetal-tools.sh --enable-mlsdk-deps --install-mlsdk-deps-with-pip
767+
source examples/arm/arm-scratch/setup_path.sh
768+
769+
backends/arm/scripts/public_api_manifest/validate_all_public_api_manifests.sh
770+
771+
python backends/arm/test/public_api_bc/run_public_api_bc_scenarios.py
772+
746773
test-llama-runner-qnn-linux:
747774
name: test-llama-runner-qnn-linux
748775
uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main

backends/arm/scripts/pre-push

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -71,33 +71,9 @@ run_docgen_check() {
7171
}
7272

7373
run_public_api_validator() {
74-
echo -e "${INFO} Validating Arm public API manifests"
75-
76-
manifest_failures=0
77-
for manifest_path in backends/arm/public_api_manifests/api_manifest_*.toml; do
78-
if [[ ! -f "${manifest_path}" ]]; then
79-
continue
80-
fi
81-
manifest_name="${manifest_path##*/}"
82-
echo
83-
echo "=== ${manifest_name} ==="
84-
validator_output=$(
85-
python backends/arm/scripts/public_api_manifest/validate_public_api_manifest.py \
86-
--manifest "${manifest_path}" 2>&1
87-
)
88-
validator_status=$?
89-
printf '%s\n' "${validator_output}"
90-
if [[ ${validator_status} -ne 0 ]]; then
91-
manifest_failures=$((manifest_failures + 1))
92-
FAILED=1
93-
fi
94-
done
95-
96-
echo
97-
if [[ ${manifest_failures} -eq 0 ]]; then
98-
echo -e "${SUCCESS} Arm public API manifests OK"
99-
else
100-
echo -e "${ERROR} ${manifest_failures} manifest(s) failed validation"
74+
if ! backends/arm/scripts/public_api_manifest/validate_all_public_api_manifests.sh; then
75+
echo -e "${ERROR} Arm public API manifest validation failed"
76+
FAILED=1
10177
fi
10278
}
10379

backends/arm/scripts/public_api_manifest/README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ To validate the running manifest directly, run
4444
To validate all manifests, use `backends/arm/scripts/pre-push`. This is the
4545
check that must pass before the change is ready to merge.
4646

47-
Manifest validation only checks the API surface and signatures.
47+
Manifest validation only checks the API surface and signatures. Workflow-level
48+
backward compatibility is covered separately by the scenario runner described
49+
below.
4850

4951
Running-manifest validation uses exact signature matching. Any intentional API
5052
change must update `api_manifest_running.toml`.
@@ -55,6 +57,35 @@ adding a trailing optional parameter is accepted for static manifests, while
5557
removing a parameter, reordering parameters, or adding a new required
5658
parameter still fails validation.
5759

60+
## Backward-compatibility scenarios
61+
62+
Workflow-level backward compatibility is checked by
63+
`python backends/arm/test/public_api_bc/run_public_api_bc_scenarios.py`.
64+
65+
The runner hardcodes the current canonical public API workflow scripts:
66+
67+
- `backends/arm/test/public_api_bc/test_ethosu_flow.py`
68+
- `backends/arm/test/public_api_bc/test_vgf_fp_flow.py`
69+
- `backends/arm/test/public_api_bc/test_vgf_int_flow.py`
70+
71+
These scripts should be updated continuously to reflect the current public API.
72+
The runner materializes those same paths into a temporary harness and executes
73+
them there with pytest so they import the latest installed
74+
`executorch.backends.arm` package instead of the repository source tree.
75+
76+
The rolling support window is controlled by the `OLDEST_SUPPORTED_REF` constant
77+
in `backends/arm/test/public_api_bc/run_public_api_bc_scenarios.py`:
78+
79+
- If `OLDEST_SUPPORTED_REF` is empty, the runner uses the current workspace.
80+
This is the bootstrap mode until a release contains the scenario scripts.
81+
- Once a release contains the scripts, the release epic should update
82+
`OLDEST_SUPPORTED_REF` to the oldest still-supported release ref.
83+
- At that point the runner uses `git show <ref>:<path>` to fetch the old
84+
release's scripts and run them against the latest code.
85+
86+
When an old release falls out of the support window, update
87+
`OLDEST_SUPPORTED_REF` to the next newer supported release. That is how the
88+
backward-compatibility window rolls forward.
5889
Reasons for passing validation may include:
5990
- Adding a new API symbol and adding it to the running manifest.
6091
- Removing an API that was marked as deprecated and no longer exists in any
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
# Copyright 2026 Arm Limited and/or its affiliates.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
set -u
8+
9+
echo "Validating Arm public API manifests"
10+
11+
manifest_failures=0
12+
for manifest_path in backends/arm/public_api_manifests/api_manifest_*.toml; do
13+
if [[ ! -f "${manifest_path}" ]]; then
14+
continue
15+
fi
16+
manifest_name="${manifest_path##*/}"
17+
echo
18+
echo "=== ${manifest_name} ==="
19+
validator_output=$(
20+
python backends/arm/scripts/public_api_manifest/validate_public_api_manifest.py \
21+
--manifest "${manifest_path}" 2>&1
22+
)
23+
validator_status=$?
24+
printf '%s\n' "${validator_output}"
25+
if [[ ${validator_status} -ne 0 ]]; then
26+
manifest_failures=$((manifest_failures + 1))
27+
fi
28+
done
29+
30+
echo
31+
if [[ ${manifest_failures} -eq 0 ]]; then
32+
echo "Arm public API manifests OK"
33+
else
34+
echo "${manifest_failures} manifest(s) failed validation"
35+
exit 1
36+
fi
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
"""Run Arm public API backward-compatibility scenarios."""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import shutil
11+
import sys
12+
import tempfile
13+
from pathlib import Path
14+
from subprocess import run # nosec B404
15+
16+
17+
REPO_ROOT = Path(__file__).resolve().parents[4]
18+
PYTEST_CONFIG = Path("backends/arm/test/pytest.ini")
19+
SCENARIO_FILES = (
20+
Path("backends/arm/test/public_api_bc/test_ethosu_flow.py"),
21+
Path("backends/arm/test/public_api_bc/test_vgf_fp_flow.py"),
22+
Path("backends/arm/test/public_api_bc/test_vgf_int_flow.py"),
23+
)
24+
# Leave empty until a release contains these scenario files. The release epic
25+
# should update this to the oldest still-supported release ref.
26+
OLDEST_SUPPORTED_REF = ""
27+
28+
29+
def _resolve_git() -> str:
30+
git = shutil.which("git")
31+
if git is None:
32+
raise RuntimeError("Could not find git in PATH")
33+
return git
34+
35+
36+
GIT = _resolve_git()
37+
38+
39+
def _materialize_file(repo_relative_path: Path, output_root: Path) -> Path:
40+
destination_path = output_root / repo_relative_path
41+
destination_path.parent.mkdir(parents=True, exist_ok=True)
42+
43+
if OLDEST_SUPPORTED_REF:
44+
result = run( # nosec B603
45+
[
46+
GIT,
47+
"show",
48+
f"{OLDEST_SUPPORTED_REF}:{repo_relative_path.as_posix()}",
49+
],
50+
cwd=REPO_ROOT,
51+
check=True,
52+
capture_output=True,
53+
text=True,
54+
)
55+
destination_path.write_text(result.stdout, encoding="utf-8")
56+
return destination_path
57+
58+
source_path = REPO_ROOT / repo_relative_path
59+
if not source_path.is_file():
60+
raise FileNotFoundError(f"Missing scenario file: {source_path}")
61+
62+
shutil.copy2(source_path, destination_path)
63+
return destination_path
64+
65+
66+
def _run_pytest(entrypoints: list[Path], output_root: Path) -> None:
67+
env = os.environ.copy()
68+
env.setdefault("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
69+
run( # nosec B603
70+
[
71+
sys.executable,
72+
"-m",
73+
"pytest",
74+
"--config-file",
75+
str(REPO_ROOT / PYTEST_CONFIG),
76+
*[str(path) for path in entrypoints],
77+
],
78+
cwd=output_root,
79+
env=env,
80+
check=True,
81+
)
82+
83+
84+
def main() -> None:
85+
with tempfile.TemporaryDirectory(
86+
prefix="arm-public-api-bc-",
87+
ignore_cleanup_errors=True,
88+
) as temporary_dir:
89+
materialized_root = Path(temporary_dir)
90+
entrypoints = [
91+
_materialize_file(repo_relative_path, materialized_root)
92+
for repo_relative_path in SCENARIO_FILES
93+
]
94+
95+
source_name = OLDEST_SUPPORTED_REF or "workspace snapshot"
96+
print("Materialized Arm public API BC scenarios:")
97+
print(f" source: {source_name}")
98+
print(f" root: {materialized_root}")
99+
100+
_run_pytest(entrypoints, materialized_root)
101+
102+
103+
if __name__ == "__main__":
104+
main()
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
10+
import torch
11+
from executorch.backends.arm import (
12+
EthosUBackend,
13+
EthosUCompileSpec,
14+
EthosUPartitioner,
15+
EthosUQuantizer,
16+
get_symmetric_a16w8_quantization_config,
17+
get_symmetric_quantization_config,
18+
)
19+
from executorch.exir import ExecutorchBackendConfig, to_edge_transform_and_lower
20+
from executorch.extension.export_util.utils import save_pte_program
21+
from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e
22+
23+
24+
class TinyConvRelu(torch.nn.Module):
25+
def __init__(self) -> None:
26+
super().__init__()
27+
self.conv = torch.nn.Conv2d(3, 4, kernel_size=3)
28+
self.relu = torch.nn.ReLU()
29+
30+
def forward(self, x: torch.Tensor) -> torch.Tensor:
31+
return self.relu(self.conv(x))
32+
33+
34+
class TinyAdd(torch.nn.Module):
35+
def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
36+
return x + y
37+
38+
39+
def _configured_compile_spec(tmp_path: Path) -> EthosUCompileSpec:
40+
compile_spec = EthosUCompileSpec(
41+
"ethos-u55-128",
42+
system_config="Ethos_U55_High_End_Embedded",
43+
memory_mode="Shared_Sram",
44+
)
45+
46+
assert compile_spec == EthosUCompileSpec(
47+
"ethos-u55-128",
48+
system_config="Ethos_U55_High_End_Embedded",
49+
memory_mode="Shared_Sram",
50+
)
51+
assert "EthosUCompileSpec" in repr(compile_spec)
52+
53+
compile_spec.dump_intermediate_artifacts_to(str(tmp_path / "ethosu_intermediates"))
54+
returned = compile_spec.dump_debug_info(EthosUCompileSpec.DebugMode.TOSA)
55+
assert returned is compile_spec
56+
return compile_spec
57+
58+
59+
def _exercise_quantizer_api(compile_spec: EthosUCompileSpec) -> None:
60+
quantizer = EthosUQuantizer(compile_spec)
61+
symmetric_config = get_symmetric_quantization_config(is_per_channel=False)
62+
a16w8_config = get_symmetric_a16w8_quantization_config(is_per_channel=False)
63+
64+
quantizer.set_global(symmetric_config)
65+
quantizer.set_io(a16w8_config)
66+
quantizer.set_module_name("conv", symmetric_config)
67+
quantizer.set_module_type(torch.nn.ReLU, symmetric_config)
68+
69+
example_inputs = (torch.randn(1, 3, 8, 8),)
70+
graph_module = torch.export.export(TinyConvRelu().eval(), example_inputs).module(
71+
check_guards=False
72+
)
73+
transformed = quantizer.transform_for_annotation(graph_module)
74+
annotated = quantizer.annotate(transformed)
75+
quantizer.validate(annotated)
76+
77+
78+
def _build_quantized_program(compile_spec: EthosUCompileSpec):
79+
model = TinyAdd().eval()
80+
example_inputs = (
81+
torch.ones(1, 1, 1, 1),
82+
torch.ones(1, 1, 1, 1),
83+
)
84+
exported_program = torch.export.export(model, example_inputs)
85+
graph_module = exported_program.module(check_guards=False)
86+
87+
quantizer = EthosUQuantizer(compile_spec)
88+
quantizer.set_global(get_symmetric_quantization_config())
89+
90+
prepared = prepare_pt2e(graph_module, quantizer)
91+
prepared(*example_inputs)
92+
converted = convert_pt2e(prepared)
93+
94+
return torch.export.export(converted, example_inputs)
95+
96+
97+
def test_u55_u85_public_api_scenario(tmp_path: Path) -> None:
98+
backend = EthosUBackend()
99+
assert isinstance(backend, EthosUBackend)
100+
101+
compile_spec = _configured_compile_spec(tmp_path)
102+
_exercise_quantizer_api(compile_spec)
103+
104+
partitioner = EthosUPartitioner(compile_spec)
105+
quantized_program_for_partition = _build_quantized_program(compile_spec)
106+
ops_to_preserve, filter_fn = partitioner.ops_to_not_decompose(
107+
quantized_program_for_partition
108+
)
109+
partition_result = partitioner.partition(quantized_program_for_partition)
110+
111+
assert isinstance(ops_to_preserve, list)
112+
assert filter_fn is None or callable(filter_fn)
113+
assert partition_result.tagged_exported_program is quantized_program_for_partition
114+
115+
quantized_program = _build_quantized_program(compile_spec)
116+
edge_manager = to_edge_transform_and_lower(
117+
programs=quantized_program,
118+
partitioner=[partitioner],
119+
)
120+
executorch_program_manager = edge_manager.to_executorch(
121+
config=ExecutorchBackendConfig(extract_delegate_segments=False)
122+
)
123+
124+
pte_path = tmp_path / "ethosu_public_api_bc.pte"
125+
save_pte_program(executorch_program_manager, str(pte_path))
126+
127+
assert pte_path.is_file()
128+
assert pte_path.stat().st_size > 0
129+
assert any((tmp_path / "ethosu_intermediates").rglob("*"))

0 commit comments

Comments
 (0)