Skip to content

Commit 622e9d6

Browse files
committed
Test the ESP-IDF component through the component manager on every emulated target
A new CI job builds the component the way a project using the published component gets it. It generates and packs the component, adds the archive to a file:// component mirror (.ci/scripts/esp_idf_mirror.py writes the layout the registry serves, and refuses conditional dependencies, which it cannot evaluate the way the registry does), and builds idf_component_consumer, which resolves pytorch/executorch from that mirror at the version just generated and esp-nn from the registry. The builds use a fresh ESP-IDF Python environment that the job checks cannot import executorch, with EXECUTORCH_PYTHON unset, so they take the kernel bundles made beforehand. They build a copy of the project, which keeps the component manager's state out of the source tree, and the source, model, kernel bundle and build paths have a space. On ESP-IDF 6.1 the job builds for ESP32 (run on ESP-IDF's QEMU), ESP32-S3, -C3, -C5, -C6 and -P4 (run on esp-emu); on 6.0.1, a second job, for ESP32-S3 and -C6. It also lints the component's manifest, and checks that the lock file records the mirror's archive. Every build runs the smoke model. The ESP32-S3 and -C6 builds also run the MLPerf Tiny streaming wake word model, quantized with the Cadence quantizer and exported by the new idf_component_smoke/export_wakeword_model.py, a test fixture with random weights, as a bundled program of 40 consecutive frames, all 40 of which must match the quantized PyTorch model, and the quantized mul on the esp_nn:: op library; the ESP32-S3 build also runs the ESP-NN delegate program, so the delegate and op library are built from the packed archive against the esp-nn the component manager resolves. The kernel bundles come from the archive's own gen_kernels.py, the copy consumers run. To run a stateful model, the smoke app now runs all of a bundled program's test cases in order on one loaded method, and split_bundled_program.py can repeat them so single-case models still check a second execute. idf_component_consumer reuses the smoke app's main component, so the two cannot drift apart. setup.sh gains --idf-version and, unless --skip-emulator, installs QEMU when esp32 is a target. The workflow now also runs for changes to what the component vendors (runtime, the portable, quantized and prim op kernels, schema, the buffer data loader and flat_tensor schema, codegen, the Cadence AOT and generic kernels, the flatbuffers submodule and build_variables.bzl) and to version.txt. Authored with Claude Code.
1 parent 2d8cd35 commit 622e9d6

14 files changed

Lines changed: 592 additions & 32 deletions

File tree

‎.ci/scripts/esp_idf_mirror.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
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+
"""Add a `compote component pack` archive to a local file:// component mirror.
8+
9+
Writes the layout `compote registry sync` produces:
10+
<mirror>/components/<namespace>/<name>.json
11+
<mirror>/components/<namespace>/<name>/<version>/<archive>, CHECKSUMS.json
12+
so that a project with IDF_COMPONENT_STORAGE_URL=file://<mirror> resolves
13+
<namespace>/<name> exactly as it would from the registry.
14+
"""
15+
16+
import argparse
17+
import json
18+
import shutil
19+
import tarfile
20+
import tempfile
21+
from datetime import datetime, timezone
22+
from pathlib import Path
23+
24+
from idf_component_tools.hash_tools.calculate import hash_dir
25+
from idf_component_tools.hash_tools.checksums import ChecksumsManager
26+
from idf_component_tools.manager import ManifestManager
27+
28+
29+
def main() -> None:
30+
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
31+
parser.add_argument("archive", type=Path)
32+
parser.add_argument("mirror", type=Path)
33+
parser.add_argument("--namespace", required=True)
34+
parser.add_argument("--name", required=True)
35+
args = parser.parse_args()
36+
archive, mirror = args.archive.resolve(), args.mirror.resolve()
37+
38+
with tempfile.TemporaryDirectory() as tmp:
39+
with tarfile.open(archive) as tf:
40+
tf.extractall(tmp, filter="data")
41+
manifest = ManifestManager(tmp, args.name).load()
42+
component_hash = hash_dir(
43+
tmp,
44+
use_gitignore=manifest.use_gitignore,
45+
include=manifest.include_set,
46+
exclude=set(manifest.exclude_set)
47+
| {"**/.component_hash", "**/CHECKSUMS.json"},
48+
exclude_default=False,
49+
)
50+
version = str(manifest.version)
51+
rel_dir = Path("components") / args.namespace / args.name / version
52+
(mirror / rel_dir).mkdir(parents=True, exist_ok=True)
53+
shutil.copy2(archive, mirror / rel_dir / archive.name)
54+
ChecksumsManager(Path(tmp)).dump(mirror / rel_dir)
55+
56+
dependencies = []
57+
for requirement in manifest.raw_requirements:
58+
# The registry evaluates rules and matches; this mirror does not.
59+
conditions = requirement.optional_requirement
60+
if conditions.matches or conditions.rules:
61+
raise SystemExit(
62+
f"{requirement.name} is a conditional dependency, which "
63+
f"{Path(__file__).name} cannot mirror"
64+
)
65+
namespace, _, name = requirement.name.rpartition("/")
66+
is_idf = requirement.source.type == "idf"
67+
dependencies.append(
68+
{
69+
"spec": requirement.version_spec,
70+
"source": requirement.source.type,
71+
"registry_url": None,
72+
"is_public": requirement.is_public,
73+
"require": requirement.is_required,
74+
"name": None if is_idf else name,
75+
"namespace": None if is_idf else namespace,
76+
"rules": [],
77+
"matches": [],
78+
}
79+
)
80+
created_at = datetime.now(timezone.utc).isoformat()
81+
entry = {
82+
"id": "local",
83+
"version": version,
84+
"created_at": created_at,
85+
"description": manifest.description,
86+
"component_hash": component_hash,
87+
"url": (rel_dir / archive.name).as_posix(),
88+
"targets": list(manifest.targets or []),
89+
"maintainers": [],
90+
"docs": {},
91+
"dependencies": dependencies,
92+
"license": None,
93+
"examples": [],
94+
"tags": [],
95+
"checksums": (rel_dir / "CHECKSUMS.json").as_posix(),
96+
"yanked_at": None,
97+
"yanked_message": None,
98+
}
99+
index = mirror / "components" / args.namespace / f"{args.name}.json"
100+
data = (
101+
json.loads(index.read_text())
102+
if index.exists()
103+
else {
104+
"name": args.name,
105+
"namespace": args.namespace,
106+
"created_at": created_at,
107+
"featured": False,
108+
"versions": [],
109+
}
110+
)
111+
data["versions"] = [v for v in data["versions"] if v["version"] != version]
112+
data["versions"].append(entry)
113+
index.write_text(json.dumps(data))
114+
print(f"{args.namespace}/{args.name} {version} -> {index}")
115+
116+
117+
if __name__ == "__main__":
118+
main()

‎.ci/scripts/test_espressif_idf_component.sh‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,17 @@ python examples/espressif/export_espnn_test_programs.py --output-dir "${build_di
5050
--only conv-act-pool-linear 2>&1 | tee -a "${build_dir}/export.log"
5151
cp "${build_dir}/espnn/conv-act-pool-linear.bpte" "${build_dir}/delegate.bpte"
5252
split=examples/espressif/idf_component_smoke/split_bundled_program.py
53-
python "${split}" "${build_dir}/smoke.bpte" --atol 0 --rtol 0
53+
# Each case twice, so a second execute of the same method is checked too.
54+
python "${split}" "${build_dir}/smoke.bpte" --atol 0 --rtol 0 --repeat 2
5455
# test_mul.py's tolerance, torch.finfo(torch.float32).eps (1.1920929e-07),
5556
# rounded up.
56-
python "${split}" "${build_dir}/mul.bpte" --atol 0 --rtol 1.2e-7
57+
python "${split}" "${build_dir}/mul.bpte" --atol 0 --rtol 1.2e-7 --repeat 2
5758
read -r atol rtol < <(python -c '
5859
import json, sys
5960
(entry,) = json.load(open(sys.argv[1]))
6061
print(entry["atol"], entry["rtol"])
6162
' "${build_dir}/espnn/manifest.json")
62-
python "${split}" "${build_dir}/delegate.bpte" --atol "${atol}" --rtol "${rtol}"
63+
python "${split}" "${build_dir}/delegate.bpte" --atol "${atol}" --rtol "${rtol}" --repeat 2
6364
cpu_models="${build_dir}/smoke.pte;${build_dir}/mul.pte"
6465
models="${cpu_models};${build_dir}/delegate.pte"
6566

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env bash
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under the BSD-style license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
# Builds the ExecuTorch ESP-IDF component the way a project using the published
9+
# component gets it: generates and packs the component, adds the archive to a
10+
# file:// component mirror, and builds the idf_component_consumer project, which
11+
# resolves executorch from that mirror, with ESP-IDF's own Python environment
12+
# (no ExecuTorch) and with source, model and build paths that have a space.
13+
# Each build runs the smoke model on the emulator; the ESP32-S3 and ESP32-C6
14+
# builds also run a Cadence-quantized streaming wake word model frame by frame
15+
# against PyTorch and a quantized mul on the esp_nn:: op library, and the
16+
# ESP32-S3 build an ESP-NN delegate program.
17+
# ESPRESSIF_IDF_VERSION selects the ESP-IDF release: v6.1 builds every target
18+
# an emulator runs (QEMU for ESP32, esp-emu for the rest), other releases
19+
# ESP32-S3 and ESP32-C6. Also lints the component's manifest.
20+
21+
set -euo pipefail
22+
23+
et_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
24+
idf_version="${ESPRESSIF_IDF_VERSION:-v6.1}"
25+
build_dir="${ESPRESSIF_BUILD_DIR:-${et_root}/cmake-out-esp-idf-consumer-${idf_version}}"
26+
export ESPRESSIF_TOOLS_DIR="${ESPRESSIF_TOOLS_DIR:-${RUNNER_TEMP:-/tmp}/executorch-espressif-${idf_version}}"
27+
mkdir -p "${build_dir}"
28+
build_dir=$(cd "${build_dir}" && pwd)
29+
cd "${et_root}"
30+
31+
if [[ "${idf_version}" == v6.1 ]]; then
32+
targets=(esp32s3 esp32 esp32c3 esp32c5 esp32c6 esp32p4)
33+
else
34+
targets=(esp32s3 esp32c6)
35+
fi
36+
37+
collect_artifacts() {
38+
if [[ -n "${RUNNER_ARTIFACT_DIR:-}" ]]; then
39+
mkdir -p "${RUNNER_ARTIFACT_DIR}/esp-idf-consumer"
40+
cp "${build_dir}"/*.log "${RUNNER_ARTIFACT_DIR}/esp-idf-consumer/" || true
41+
fi
42+
}
43+
trap collect_artifacts EXIT
44+
45+
# ESP-IDF's QEMU needs these libraries, and setup.sh fails its install check
46+
# without them.
47+
if [[ " ${targets[*]} " == *" esp32 "* ]] && ! python -c '
48+
import ctypes
49+
ctypes.CDLL("libSDL2-2.0.so.0")
50+
ctypes.CDLL("libslirp.so.0")' 2> /dev/null; then
51+
sudo apt-get update
52+
sudo apt-get install -y --no-install-recommends libsdl2-2.0-0 libslirp0
53+
fi
54+
bash examples/espressif/setup.sh --minimal --idf-version="${idf_version}" \
55+
--targets="$(IFS=,; echo "${targets[*]}")" 2>&1 | tee "${build_dir}/setup.log"
56+
# shellcheck source=/dev/null
57+
source "${ESPRESSIF_TOOLS_DIR}/setup_path.sh"
58+
59+
# Projects keep their models and kernel bundles in their tree, so these get a
60+
# path with a space too.
61+
assets="${build_dir}/consumer assets"
62+
rm -rf "${assets}"
63+
mkdir -p "${assets}"
64+
python examples/espressif/export_smoke_model.py --output "${assets}/smoke.bpte" \
65+
2>&1 | tee "${build_dir}/export.log"
66+
python examples/espressif/idf_component_smoke/export_wakeword_model.py \
67+
--output "${assets}/wakeword.bpte" --frames 40 2>&1 | tee -a "${build_dir}/export.log"
68+
python examples/espressif/idf_component_smoke/export_op_library_model.py \
69+
--output "${assets}/mul.bpte" 2>&1 | tee -a "${build_dir}/export.log"
70+
python examples/espressif/export_espnn_test_programs.py --output-dir "${build_dir}/espnn" \
71+
--only conv-act-pool-linear 2>&1 | tee -a "${build_dir}/export.log"
72+
cp "${build_dir}/espnn/conv-act-pool-linear.bpte" "${assets}/delegate.bpte"
73+
split=examples/espressif/idf_component_smoke/split_bundled_program.py
74+
python "${split}" "${assets}/smoke.bpte" --atol 0 --rtol 0 --repeat 2
75+
python "${split}" "${assets}/wakeword.bpte" --atol 1e-6 --rtol 0
76+
# test_mul.py's tolerance, torch.finfo(torch.float32).eps, rounded up.
77+
python "${split}" "${assets}/mul.bpte" --atol 0 --rtol 1.2e-7
78+
read -r atol rtol < <(python -c '
79+
import json, sys
80+
(entry,) = json.load(open(sys.argv[1]))
81+
print(entry["atol"], entry["rtol"])
82+
' "${build_dir}/espnn/manifest.json")
83+
python "${split}" "${assets}/delegate.bpte" --atol "${atol}" --rtol "${rtol}"
84+
85+
component_dir="${build_dir}/component/executorch"
86+
bash examples/espressif/build_idf_component.sh --out "${component_dir}" \
87+
2>&1 | tee "${build_dir}/generate.log"
88+
compote manifest lint "${component_dir}/idf_component.yml"
89+
version=$(sed -n 's/^version: "\(.*\)"$/\1/p' "${component_dir}/idf_component.yml")
90+
(cd "${component_dir}" && compote component pack --name executorch --dest-dir "${build_dir}/dist")
91+
archive="${build_dir}/dist/executorch_${version}.tgz"
92+
mirror="${build_dir}/mirror"
93+
rm -rf "${mirror}"
94+
python .ci/scripts/esp_idf_mirror.py "${archive}" "${mirror}" \
95+
--namespace pytorch --name executorch
96+
# Bundles come from the archive's gen_kernels.py, the one consumers run, so a
97+
# file the packing drops fails here.
98+
packed="${build_dir}/packed"
99+
rm -rf "${packed}"
100+
mkdir -p "${packed}"
101+
tar -xzf "${archive}" -C "${packed}"
102+
103+
gen_kernels() {
104+
local out=$1
105+
shift
106+
python "${packed}/tools/gen_kernels.py" "$@" --out "${out}" \
107+
2>&1 | tee -a "${build_dir}/gen_kernels.log"
108+
}
109+
cpu_models="${assets}/smoke.pte;${assets}/wakeword.pte;${assets}/mul.pte"
110+
all_models="${cpu_models};${assets}/delegate.pte"
111+
gen_kernels "${assets}/smoke_kernels" --pte "${assets}/smoke.pte"
112+
gen_kernels "${assets}/cpu_kernels" --pte "${assets}/smoke.pte" \
113+
--pte "${assets}/wakeword.pte" --pte "${assets}/mul.pte"
114+
gen_kernels "${assets}/all_kernels" --pte "${assets}/smoke.pte" \
115+
--pte "${assets}/wakeword.pte" --pte "${assets}/mul.pte" --pte "${assets}/delegate.pte"
116+
117+
# A fresh ESP-IDF Python environment, which cannot import executorch, and no
118+
# EXECUTORCH_PYTHON to bring one back.
119+
idf_python_env="${build_dir}/idf_python_env"
120+
mkdir -p "${idf_python_env}"
121+
IDF_PYTHON_ENV_PATH="${idf_python_env}" python "${IDF_PATH}/tools/idf_tools.py" \
122+
install-python-env 2>&1 | tee "${build_dir}/idf_python_env.log"
123+
if "${idf_python_env}/bin/python" -c "import executorch" 2> /dev/null; then
124+
echo "FAIL: ESP-IDF's Python environment can import executorch" >&2
125+
exit 1
126+
fi
127+
unset EXECUTORCH_PYTHON
128+
# Built from a copy, so the component manager's state and the consumer's
129+
# generated manifest stay out of the source tree, under a path with a space.
130+
sources="${build_dir}/consumer sources"
131+
rm -rf "${sources}"
132+
mkdir -p "${sources}/idf_component_smoke"
133+
cp -r examples/espressif/idf_component_consumer "${sources}/"
134+
cp -r examples/espressif/idf_component_smoke/main \
135+
examples/espressif/idf_component_smoke/sdkconfig.defaults "${sources}/idf_component_smoke/"
136+
project="${sources}/idf_component_consumer"
137+
firmware_root="${build_dir}/consumer builds"
138+
139+
# Runs idf.py on the consumer with ESP-IDF's Python and the mirror: build name,
140+
# target, then idf.py arguments.
141+
consumer() {
142+
local name=$1 target=$2
143+
shift 2
144+
IDF_TARGET="${target}" IDF_PYTHON_ENV_PATH="${idf_python_env}" \
145+
IDF_COMPONENT_STORAGE_URL="file://${mirror};default" \
146+
"${idf_python_env}/bin/python" "${IDF_PATH}/tools/idf.py" -C "${project}" \
147+
-B "${firmware_root}/${name}" -DSDKCONFIG="${firmware_root}/${name}/sdkconfig" \
148+
-DET_COMPONENT_VERSION="${version}" "$@"
149+
}
150+
151+
# Runs a build on its emulator and checks its output: build name, target, then
152+
# the per-model markers.
153+
run_firmware() {
154+
local name=$1 target=$2
155+
shift 2
156+
local firmware_dir="${firmware_root}/${name}" log="${build_dir}/emulator-${name}.log"
157+
if [[ "${target}" == esp32 ]]; then
158+
# QEMU has no exit-on string, so stop it once the app is done.
159+
setsid bash -c 'consumer "$@" qemu' _ "${name}" "${target}" < /dev/null > "${log}" 2>&1 &
160+
local pid=$!
161+
for _ in $(seq 240); do
162+
if grep -qF "Program complete." "${log}" || ! kill -0 "${pid}" 2> /dev/null; then
163+
break
164+
fi
165+
sleep 0.5
166+
done
167+
kill -- "-${pid}" 2> /dev/null || true
168+
wait "${pid}" || true
169+
else
170+
consumer "${name}" "${target}" merge-bin > "${build_dir}/merge-${name}.log" 2>&1
171+
timeout 130s esp-emu --chip "${target}" \
172+
--firmware "${firmware_dir}/merged-binary.bin" \
173+
--elf "${firmware_dir}/et_component_smoke.elf" \
174+
--net user --timeout 120s --exit-on 'Program complete.' \
175+
> "${log}" 2>&1 || true
176+
fi
177+
for marker in "$@" "ET_COMPONENT_SMOKE: PASS" "Program complete."; do
178+
if ! grep -qF "${marker}" "${log}"; then
179+
echo "FAIL: ${name} printed no '${marker}'; see emulator-${name}.log" >&2
180+
exit 1
181+
fi
182+
done
183+
echo "OK: ${name} passed on the ${target} emulator"
184+
}
185+
export -f consumer
186+
export idf_python_env mirror project firmware_root version
187+
188+
for target in "${targets[@]}"; do
189+
if [[ "${target}" == esp32s3 ]]; then
190+
consumer "${target}" "${target}" -DET_SMOKE_MODELS="${all_models}" \
191+
-DET_SMOKE_KERNELS="${assets}/all_kernels" build \
192+
> "${build_dir}/firmware-${target}.log" 2>&1
193+
run_firmware "${target}" "${target}" "smoke: PASS" "wakeword: PASS, 40/40 cases" \
194+
"mul: PASS" "delegate: PASS"
195+
elif [[ "${target}" == esp32c6 ]]; then
196+
consumer "${target}" "${target}" -DET_SMOKE_MODELS="${cpu_models}" \
197+
-DET_SMOKE_KERNELS="${assets}/cpu_kernels" build \
198+
> "${build_dir}/firmware-${target}.log" 2>&1
199+
run_firmware "${target}" "${target}" "smoke: PASS" "wakeword: PASS, 40/40 cases" \
200+
"mul: PASS"
201+
else
202+
consumer "${target}" "${target}" -DET_SMOKE_MODELS="${assets}/smoke.pte" \
203+
-DET_SMOKE_KERNELS="${assets}/smoke_kernels" build \
204+
> "${build_dir}/firmware-${target}.log" 2>&1
205+
run_firmware "${target}" "${target}" "smoke: PASS"
206+
fi
207+
python - "${project}/dependencies.lock" "${mirror}/components/pytorch/executorch.json" <<'PY'
208+
import json
209+
import sys
210+
211+
import yaml
212+
213+
entry = yaml.safe_load(open(sys.argv[1]))["dependencies"]["pytorch/executorch"]
214+
(mirrored,) = json.load(open(sys.argv[2]))["versions"]
215+
if (str(entry["version"]), entry.get("component_hash")) != (
216+
mirrored["version"],
217+
mirrored["component_hash"],
218+
):
219+
sys.exit(f"FAIL: pytorch/executorch did not come from the mirror: {entry}")
220+
PY
221+
done
222+
echo "PASS: the packed ESP-IDF component built from a mirror and ran on ${targets[*]}."

0 commit comments

Comments
 (0)