Skip to content

Commit 11ca17a

Browse files
committed
tests: compile each cross-arch source once and cache it between runs
Compilation, not emulation, dominated the cross-arch engine tests: the eight binaries per target came to roughly 195 translation units against about 1.4 seconds of qemu-user execution. The engine core and all 92 files under src/rules were compiled twice per target, once into test_engine and once into test_engine_integration, and every invocation passed several sources at once, which is the form ccache declines, forwarding to the compiler and caching nothing. Seventeen targets rebuilt from scratch on every run at about 69 seconds of processor time each. Compile each source once to an object and link the binaries from those. The core and rule objects are built once per target and shared by both engine binaries, bringing a target to about 105 translation units. Single-source compiles are also the form ccache can serve, so it is used when installed and skipped when not, and USE_CCACHE=0 forces the direct compiler. A target costs about 42 seconds of processor time with no cache and about 2.5 with a warm one; the full local set falls from roughly 1178 seconds to 61. The cross-build workflow pins USE_CCACHE=0. Each matrix job is a fresh runner and nothing restores a cache, so every compile would miss while still paying to hash and store its result, and pinning it keeps the step independent of whether the runner image ships ccache. At capacity the launch loop waits for the oldest running target rather than draining all JOBS of them, so a slow architecture delays only the targets queued behind it. wait on a pid returns however that child ended, including killed by a signal, so a target that dies without returning a slot cannot stall the run. The testing document names all eight suites the harness builds rather than four, and records the shared-object build and the cache setting.
1 parent 00dbdf7 commit 11ca17a

3 files changed

Lines changed: 109 additions & 45 deletions

File tree

.github/workflows/_cross-build.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,17 @@ jobs:
128128
# qemu-user, so its arch-gated rule bodies and assertions run their real
129129
# path — the cross-compile above only proves they compile. Self-contained
130130
# (no fixtures); compiles the test binaries with this arch's toolchain.
131+
#
132+
# No compiler cache here. Each job is a fresh runner and nothing restores
133+
# one, so every compile would miss while still paying to hash and store
134+
# its result — a cold cache costs more than it saves. Pinning the setting
135+
# also keeps the step's cost independent of whether the runner image
136+
# happens to ship ccache. The harness still shares its rule objects
137+
# between the two engine binaries, which is where the saving comes from.
131138
- name: Cross-arch engine tests (qemu-user)
132139
if: inputs.run_test_cross
140+
env:
141+
USE_CCACHE: 0
133142
run: tests/test-cross "${{ matrix.musl_triple }}"
134143

135144
# Replay mode (manual, via replay.yml): run the real binary just built here

docs/testing.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,18 @@ elsewhere, such as a self-built qemu in a non-standard prefix.
255255
make test-cross # or: tests/test-cross
256256
```
257257

258-
Compiles `test_engine`, `test_engine_integration`, `test_kasld` and
259-
`test_render` with each cross toolchain and runs them under qemu-user, so
260-
arch-gated rule bodies
258+
Compiles eight suites — `test_engine`, `test_engine_integration`,
259+
`test_estimate`, `test_kasld`, `test_render`, `test_addr_parse`,
260+
`test_target_width` and `test_proc_kallsyms` — with each cross toolchain and
261+
runs them under qemu-user, so arch-gated rule bodies
261262
(`#if defined(__aarch64__)` …) execute on their own architecture instead of
262263
compiling to no-ops on the host. The engine tests are pure, syscall-free C, so
263264
this is sound under emulation.
264265

266+
The engine core and `src/rules/*.c` are compiled once per target and linked into
267+
both engine binaries; `USE_CCACHE=0` compiles without ccache, which is what CI
268+
sets because a fresh runner restores no cache for a hit to come from.
269+
265270
Covers 17 targets: nine 64-bit (aarch64, riscv64, s390x, mips64, mips64el,
266271
ppc64, ppc64le, loongarch64, x86_64) and eight 32-bit (i686, arm, armv7, armeb,
267272
mips, mipsel, riscv32, powerpc — ppc32 big-endian). 64-bit-only tests are

tests/test-cross

Lines changed: 92 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,18 @@
1818
# this is a local/CI-with-qemu aid, not part of the host `make test`.
1919
# Exit status is non-zero if any present target fails (CI-friendly).
2020
#
21+
# Compilation, not the qemu runs, dominates the cost: the eight binaries per
22+
# target come to ~105 translation units against a couple of seconds of emulated
23+
# execution. Two properties hold that down. The engine core and rule objects are
24+
# built once per target and linked into both engine binaries, so the rule set
25+
# costs one compile rather than one per binary. And every compile is a
26+
# single-source `-c`, the only form ccache can serve; handed several sources at
27+
# once it declines and forwards to the compiler, caching nothing.
28+
#
2129
# Usage: tests/test-cross [triple...] (default: the local cross set; CI passes
2230
# one triple per matrix job)
23-
# Env: QEMU_DIR (default: resolve qemu-<arch> from PATH), CFLAGS, KEEP=1
31+
# Env: QEMU_DIR (default: resolve qemu-<arch> from PATH), CFLAGS, KEEP=1,
32+
# JOBS (default: one per core), USE_CCACHE=0 to compile without ccache
2433
# ---
2534
# <bcoles@gmail.com>
2635

@@ -40,7 +49,8 @@ resolve_qemu() {
4049
# -Werror on the overflow/shift classes: these only manifest on a given word
4150
# size, so the cross (esp. 32-bit) compile is where a `1ul << 40` shift or a
4251
# constant overflow gets caught. Kept targeted (not full -Werror) so the test
43-
# TUs' pre-existing unused-static warnings don't turn fatal.
52+
# TUs' pre-existing unused-static warnings don't turn fatal. Both are front-end
53+
# diagnostics and fire at any optimisation level.
4454
CFLAGS=${CFLAGS:--std=c99 -O2 -static -I$ROOT/src -Werror=shift-count-overflow -Werror=overflow}
4555
# Every binary this script builds is a test, so the hermeticity probe applies to
4656
# all of them: a test that reads a kernel fact from the machine running it is
@@ -51,6 +61,16 @@ CFLAGS="$CFLAGS -DKASLD_HERMETIC_PROBE"
5161
# them JOBS at a time. Default to one per core; JOBS=1 restores serial order.
5262
JOBS=${JOBS:-$(nproc 2>/dev/null || echo 1)}
5363

64+
# ccache when it is installed, nothing when it is not — a compiler cache is an
65+
# optimisation, never a requirement, and a host without one must still run the
66+
# suite. USE_CCACHE=0 forces the direct compiler. The cache lives wherever
67+
# ccache itself is configured to put it, so its size limit and eviction stay
68+
# under the operator's control rather than this script's.
69+
CCPREFIX=""
70+
if [ "${USE_CCACHE:-1}" != 0 ]; then
71+
CCPREFIX=$(command -v ccache 2>/dev/null) || CCPREFIX=""
72+
fi
73+
5474
# A UNIQUE build dir per invocation. Two overlapping runs -- or an orphaned
5575
# run_target subshell whose parent `make` was killed but which lives on and
5676
# still runs its `rm -rf "$d"` cleanup -- must not share `build/tests/<triple>`,
@@ -79,6 +99,29 @@ RULES=$(echo "$ROOT"/src/rules/*.c)
7999

80100
pass=0; fail=0; skip=0
81101

102+
# compile_obj <src> <out.o> [extra cflags...] — one source, one object, so the
103+
# result is cacheable. Reads $cc / $triple / $d from the calling run_target.
104+
compile_obj() {
105+
co_src=$1; co_out=$2; shift 2
106+
# shellcheck disable=SC2086 # $CCPREFIX/$cc/$CFLAGS are command + flag lists, split intentionally
107+
if ! $CCPREFIX $cc $CFLAGS "$@" -c "$co_src" -o "$co_out" 2>"$d/cc.log"; then
108+
printf 'FAIL %-30s (compile %s)\n' "$triple" "${co_src##*/}"
109+
show_cc_log "$d/cc.log"
110+
return 1
111+
fi
112+
}
113+
114+
# link_bin <out> <object...> [-l...] — link step; libraries follow the objects.
115+
link_bin() {
116+
lb_out=$1; shift
117+
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
118+
if ! $cc $CFLAGS "$@" -o "$lb_out" 2>"$d/cc.log"; then
119+
printf 'FAIL %-30s (link %s)\n' "$triple" "${lb_out##*/}"
120+
show_cc_log "$d/cc.log"
121+
return 1
122+
fi
123+
}
124+
82125
# run_target writes its human-readable line(s) to stdout (the caller redirects
83126
# this to a per-target .res file) and a single pass/fail/skip token to its .st
84127
# file, so invocations can run concurrently without sharing shell state.
@@ -92,61 +135,55 @@ run_target() {
92135
if [ -z "$qemu" ]; then
93136
printf 'SKIP %-30s (no qemu: %s)\n' "$triple" "$qbin"; echo skip >"$st"; return
94137
fi
95-
d="$OUT/$triple"; mkdir -p "$d"
138+
d="$OUT/$triple"; obj="$d/obj"; sh_obj="$obj/shared"; mkdir -p "$sh_obj"
139+
# The engine core and every rule, one object each, shared by the two engine
140+
# binaries below so the rule set is built once per target. engine_rules.c is
141+
# the registry and belongs only to the integration binary, so it stays out of
142+
# the shared set.
143+
for src in $CORE $RULES; do
144+
compile_obj "$src" "$sh_obj/$(basename "$src" .c).o" || { echo fail >"$st"; return; }
145+
done
146+
compile_obj "$ROOT/src/engine_rules.c" "$obj/engine_rules.o" || { echo fail >"$st"; return; }
96147
# Per-rule engine test (every rule via the wildcard) AND the full-registry
97148
# integration test, both under qemu so each arch-gated rule body executes its
98149
# active path on its own arch. The tests anchor addresses to the arch's
99150
# quantity tops / PHYS_OFFSET, so they are portable across 64-bit arches.
100151
# (This harness has caught real arch bugs this way: a 64-bit shift overflow
101152
# and PHYS_OFFSET-relative assertion errors.)
102-
# shellcheck disable=SC2086 # $cc/$CFLAGS/$CORE/$RULES are command + flag/file lists, split intentionally
103-
if ! $cc $CFLAGS "$ROOT/tests/test_engine.c" $CORE $RULES -o "$d/test_engine" 2>"$d/cc.log"; then
104-
printf 'FAIL %-30s (compile test_engine)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
105-
fi
106-
# shellcheck disable=SC2086 # $cc/$CFLAGS/$CORE/$RULES are command + flag/file lists, split intentionally
107-
if ! $cc $CFLAGS "$ROOT/tests/test_engine_integration.c" $CORE "$ROOT/src/engine_rules.c" $RULES \
108-
-o "$d/test_integration" 2>"$d/cc.log"; then
109-
printf 'FAIL %-30s (compile test_integration)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
110-
fi
153+
compile_obj "$ROOT/tests/test_engine.c" "$obj/test_engine.o" || { echo fail >"$st"; return; }
154+
compile_obj "$ROOT/tests/test_engine_integration.c" "$obj/test_integration.o" || { echo fail >"$st"; return; }
111155
# The parser / result-model / merge / engine-sync unit suite (its renderer
112156
# half is now test_render, below). It includes the orchestrator + render
113157
# translation units directly (hence -DKASLD_TESTING and -lpthread) and derives
114158
# every fixture address from the arch's own layout constants, so it runs on
115159
# every width and endianness.
116-
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
117-
if ! $cc $CFLAGS -DKASLD_TESTING "$ROOT/tests/test_kasld.c" -o "$d/test_kasld" -lpthread 2>"$d/cc.log"; then
118-
printf 'FAIL %-30s (compile test_kasld)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
119-
fi
160+
compile_obj "$ROOT/tests/test_kasld.c" "$obj/test_kasld.o" -DKASLD_TESTING || { echo fail >"$st"; return; }
120161
# The renderer unit suite (split from test_kasld); same single-TU model.
121-
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
122-
if ! $cc $CFLAGS -DKASLD_TESTING "$ROOT/tests/test_render.c" -o "$d/test_render" -lpthread 2>"$d/cc.log"; then
123-
printf 'FAIL %-30s (compile test_render)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
124-
fi
162+
compile_obj "$ROOT/tests/test_render.c" "$obj/test_render.o" -DKASLD_TESTING || { echo fail >"$st"; return; }
125163
# The estimate-algebra + honest-top soundness suite (links only estimate.c +
126164
# quantities.c). Its arch-gated interval_admits() asserts validate each arch's
127165
# honest-top floor/ceiling on its own arch — dormant on the x86_64 host, live
128166
# here, so a phys/virt floor that excludes a valid base fails on the arch it
129167
# affects.
130-
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
131-
if ! $cc $CFLAGS "$ROOT/tests/test_estimate.c" "$ROOT/src/estimate.c" "$ROOT/src/quantities.c" -o "$d/test_estimate" 2>"$d/cc.log"; then
132-
printf 'FAIL %-30s (compile test_estimate)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
133-
fi
168+
compile_obj "$ROOT/tests/test_estimate.c" "$obj/test_estimate.o" || { echo fail >"$st"; return; }
134169
# The width-sensitive suites. These are the reason the 32-bit targets matter:
135170
# kasld_addr_t is the build's word, so on a 64-bit host the too-wide branch of
136171
# kasld_addr_parse is unreachable and the refusal that the parser exists for
137172
# never executes. Here it does.
138-
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
139-
if ! $cc $CFLAGS "$ROOT/tests/test_addr_parse.c" -o "$d/test_addr_parse" 2>"$d/cc.log"; then
140-
printf 'FAIL %-30s (compile test_addr_parse)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
141-
fi
142-
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
143-
if ! $cc $CFLAGS "$ROOT/tests/test_target_width.c" -o "$d/test_target_width" 2>"$d/cc.log"; then
144-
printf 'FAIL %-30s (compile test_target_width)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
145-
fi
146-
# shellcheck disable=SC2086 # $cc/$CFLAGS are command + flag list, split intentionally
147-
if ! $cc $CFLAGS "$ROOT/tests/test_proc_kallsyms.c" -o "$d/test_proc_kallsyms" 2>"$d/cc.log"; then
148-
printf 'FAIL %-30s (compile test_proc_kallsyms)\n' "$triple"; show_cc_log "$d/cc.log"; echo fail >"$st"; return
149-
fi
173+
for t in test_addr_parse test_target_width test_proc_kallsyms; do
174+
compile_obj "$ROOT/tests/$t.c" "$obj/$t.o" || { echo fail >"$st"; return; }
175+
done
176+
177+
# shellcheck disable=SC2086 # the shared-object glob is a file list, split intentionally
178+
link_bin "$d/test_engine" "$obj/test_engine.o" $sh_obj/*.o || { echo fail >"$st"; return; }
179+
# shellcheck disable=SC2086 # the shared-object glob is a file list, split intentionally
180+
link_bin "$d/test_integration" "$obj/test_integration.o" "$obj/engine_rules.o" $sh_obj/*.o || { echo fail >"$st"; return; }
181+
link_bin "$d/test_kasld" "$obj/test_kasld.o" -lpthread || { echo fail >"$st"; return; }
182+
link_bin "$d/test_render" "$obj/test_render.o" -lpthread || { echo fail >"$st"; return; }
183+
link_bin "$d/test_estimate" "$obj/test_estimate.o" "$sh_obj/estimate.o" "$sh_obj/quantities.o" || { echo fail >"$st"; return; }
184+
for t in test_addr_parse test_target_width test_proc_kallsyms; do
185+
link_bin "$d/$t" "$obj/$t.o" || { echo fail >"$st"; return; }
186+
done
150187
ok=1
151188
for t in test_engine test_integration test_estimate test_kasld test_render \
152189
test_addr_parse test_target_width test_proc_kallsyms; do
@@ -213,11 +250,18 @@ if [ "$#" -eq 0 ]; then
213250
set -- $default_triples
214251
fi
215252

216-
# Launch up to JOBS targets concurrently, each writing to its own .res/.st;
217-
# barrier every JOBS launches (POSIX sh has no `wait -n`). Order is recorded so
218-
# the summary prints deterministically regardless of finish order.
253+
# Launch up to JOBS targets concurrently, each writing to its own .res/.st.
254+
# At capacity, wait for the OLDEST target still running and launch the next as
255+
# soon as it ends, rather than draining all JOBS of them first: one slow arch
256+
# then delays at most the targets ahead of it in the queue, not a whole batch.
257+
# `wait <pid>` returns however that child ended — including killed by a signal —
258+
# so a target that dies without cleaning up after itself cannot stall the run.
259+
# Order is recorded so the summary prints deterministically regardless of finish
260+
# order.
219261
order=""
220-
running=0
262+
running="" # PIDs of live targets, oldest first
263+
inflight=0
264+
221265
for triple in "$@"; do
222266
order="$order $triple"
223267
qbin=$(qemu_for "$triple")
@@ -227,8 +271,14 @@ for triple in "$@"; do
227271
continue
228272
fi
229273
run_target "$triple" "$qbin" >"$OUT/$triple.res" 2>&1 &
230-
running=$((running + 1))
231-
if [ "$running" -ge "$JOBS" ]; then wait; running=0; fi
274+
running="${running:+$running }$!"
275+
inflight=$((inflight + 1))
276+
if [ "$inflight" -ge "$JOBS" ]; then
277+
oldest=${running%% *}
278+
if [ "$oldest" = "$running" ]; then running=""; else running=${running#* }; fi
279+
wait "$oldest" 2>/dev/null
280+
inflight=$((inflight - 1))
281+
fi
232282
done
233283
wait
234284

0 commit comments

Comments
 (0)