Skip to content

Commit 7ebc752

Browse files
[DROPME] comparison workflow
1 parent b0d5942 commit 7ebc752

3 files changed

Lines changed: 294 additions & 38 deletions

File tree

.github/workflows/build_all_c_apps.yml

Lines changed: 109 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,36 @@ jobs:
2828
id: get_c_apps
2929
run: |
3030
python .github/workflows/scripts/get_c_apps.py ${{ secrets.GITHUB_TOKEN }}
31-
echo "c_apps=$(cat c_apps.json)" >> $GITHUB_OUTPUT
31+
# PoC LTO: restrict to a small set of tier-1 apps to keep the matrix cheap.
32+
python - <<'PY'
33+
import json
34+
35+
REQUIRED_APPS = [
36+
"app-bitcoin-new",
37+
"app-ethereum",
38+
"app-solana",
39+
"app-exchange",
40+
]
41+
42+
with open("c_apps.json", encoding="utf-8") as f:
43+
all_apps = json.load(f)
44+
45+
by_name = {a.get("app-name"): a for a in all_apps if isinstance(a, dict)}
46+
selected, missing = [], []
47+
for name in REQUIRED_APPS:
48+
app = by_name.get(name)
49+
if app is None:
50+
missing.append(name)
51+
else:
52+
selected.append(app)
53+
54+
if missing:
55+
raise SystemExit("Missing required apps in matrix source: " + ", ".join(missing))
56+
57+
with open("c_apps_limited.json", "w", encoding="utf-8") as f:
58+
json.dump(selected, f)
59+
PY
60+
echo "c_apps=$(cat c_apps_limited.json)" >> $GITHUB_OUTPUT
3261
3362
print-matrix:
3463
needs: [prepare-matrix]
@@ -45,18 +74,21 @@ jobs:
4574
fail-fast: false
4675
matrix:
4776
apps: ${{ fromJSON(needs.prepare-matrix.outputs.c_apps) }}
77+
# PoC LTO: same image & SDK ref for every config, only the make flags differ,
78+
# so size and (indicative) build-time deltas are attributable to LTO.
79+
config:
80+
- name: baseline
81+
make_flags: ''
82+
- name: lto
83+
make_flags: 'ENABLE_LINK_TIME_OPTIMIZATION=1 ENABLE_STACK_PROTECTOR=0'
84+
- name: lto-sp
85+
make_flags: 'ENABLE_LINK_TIME_OPTIMIZATION=1 ENABLE_STACK_PROTECTOR=1'
4886
runs-on: ubuntu-latest
4987
container:
5088
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:latest
5189
defaults:
5290
run:
5391
shell: bash
54-
env:
55-
# Some C apps embed a Rust component pinned (via rust-toolchain.toml) to a
56-
# toolchain that differs from the one the builder image exports through
57-
# RUSTUP_TOOLCHAIN. A set RUSTUP_TOOLCHAIN overrides that pin and breaks
58-
# the build, so it is unset before make for these apps (see build steps).
59-
RUSTUP_TOOLCHAIN_UNSET_APPS: "app-namada app-stacks"
6092

6193
steps:
6294
- name: Clone App
@@ -69,48 +101,87 @@ jobs:
69101
uses: actions/checkout@v6
70102
with:
71103
path: sdk
72-
ref: ${{ inputs.sdk_branch || github.head_ref }}
104+
ref: ${{ inputs.sdk_branch || 'mbr/lto' }}
73105

74106
- name: Install prerequisites
75107
run: pip install --break-system-packages ledgered
76108

77109
- name: Build App for Nano X
78110
if: contains(matrix.apps.devices, 'nanox')
79-
run: |
80-
cd ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
81-
echo "Building for Nano X"
82-
if [[ " $RUSTUP_TOOLCHAIN_UNSET_APPS " == *" ${{ matrix.apps.app-name }} "* ]]; then unset RUSTUP_TOOLCHAIN; fi
83-
make clean
84-
make TARGET=nanox BOLOS_SDK=$GITHUB_WORKSPACE/sdk
111+
run: bash "$GITHUB_WORKSPACE/sdk/.github/workflows/scripts/poc_lto_build_target.sh" nanox '${{ matrix.config.make_flags }}'
112+
working-directory: ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
113+
- name: Upload Nano X build
114+
if: always() && contains(matrix.apps.devices, 'nanox')
115+
uses: actions/upload-artifact@v4
116+
with:
117+
name: elf-${{ matrix.apps.app-name }}-nanox-${{ matrix.config.name }}
118+
path: poc_out/nanox
119+
85120
- name: Build App for Nano S+
86121
if: contains(matrix.apps.devices, 'nanos+')
87-
run: |
88-
cd ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
89-
echo "Building for Nano S+"
90-
if [[ " $RUSTUP_TOOLCHAIN_UNSET_APPS " == *" ${{ matrix.apps.app-name }} "* ]]; then unset RUSTUP_TOOLCHAIN; fi
91-
make clean
92-
make TARGET=nanos2 BOLOS_SDK=$GITHUB_WORKSPACE/sdk
122+
run: bash "$GITHUB_WORKSPACE/sdk/.github/workflows/scripts/poc_lto_build_target.sh" nanos2 '${{ matrix.config.make_flags }}'
123+
working-directory: ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
124+
- name: Upload Nano S+ build
125+
if: always() && contains(matrix.apps.devices, 'nanos+')
126+
uses: actions/upload-artifact@v4
127+
with:
128+
name: elf-${{ matrix.apps.app-name }}-nanos2-${{ matrix.config.name }}
129+
path: poc_out/nanos2
130+
93131
- name: Build App for Stax
94132
if: contains(matrix.apps.devices, 'stax')
95-
run: |
96-
cd ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
97-
echo "Building for Stax"
98-
if [[ " $RUSTUP_TOOLCHAIN_UNSET_APPS " == *" ${{ matrix.apps.app-name }} "* ]]; then unset RUSTUP_TOOLCHAIN; fi
99-
make clean
100-
make TARGET=stax BOLOS_SDK=$GITHUB_WORKSPACE/sdk
133+
run: bash "$GITHUB_WORKSPACE/sdk/.github/workflows/scripts/poc_lto_build_target.sh" stax '${{ matrix.config.make_flags }}'
134+
working-directory: ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
135+
- name: Upload Stax build
136+
if: always() && contains(matrix.apps.devices, 'stax')
137+
uses: actions/upload-artifact@v4
138+
with:
139+
name: elf-${{ matrix.apps.app-name }}-stax-${{ matrix.config.name }}
140+
path: poc_out/stax
141+
101142
- name: Build App for Flex
102143
if: contains(matrix.apps.devices, 'flex')
103-
run: |
104-
cd ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
105-
echo "Building for Flex"
106-
if [[ " $RUSTUP_TOOLCHAIN_UNSET_APPS " == *" ${{ matrix.apps.app-name }} "* ]]; then unset RUSTUP_TOOLCHAIN; fi
107-
make clean
108-
make TARGET=flex BOLOS_SDK=$GITHUB_WORKSPACE/sdk
144+
run: bash "$GITHUB_WORKSPACE/sdk/.github/workflows/scripts/poc_lto_build_target.sh" flex '${{ matrix.config.make_flags }}'
145+
working-directory: ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
146+
- name: Upload Flex build
147+
if: always() && contains(matrix.apps.devices, 'flex')
148+
uses: actions/upload-artifact@v4
149+
with:
150+
name: elf-${{ matrix.apps.app-name }}-flex-${{ matrix.config.name }}
151+
path: poc_out/flex
152+
109153
- name: Build App for Apex+
110154
if: contains(matrix.apps.devices, 'apex_p')
111-
run: |
112-
cd ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
113-
echo "Building for Apex+"
114-
if [[ " $RUSTUP_TOOLCHAIN_UNSET_APPS " == *" ${{ matrix.apps.app-name }} "* ]]; then unset RUSTUP_TOOLCHAIN; fi
115-
make clean
116-
make TARGET=apex_p BOLOS_SDK=$GITHUB_WORKSPACE/sdk
155+
run: bash "$GITHUB_WORKSPACE/sdk/.github/workflows/scripts/poc_lto_build_target.sh" apex_p '${{ matrix.config.make_flags }}'
156+
working-directory: ${{ matrix.apps.app-name }}/${{ matrix.apps.build-directory }}
157+
- name: Upload Apex+ build
158+
if: always() && contains(matrix.apps.devices, 'apex_p')
159+
uses: actions/upload-artifact@v4
160+
with:
161+
name: elf-${{ matrix.apps.app-name }}-apex_p-${{ matrix.config.name }}
162+
path: poc_out/apex_p
163+
164+
compare-builds:
165+
name: Compare builds (size & time)
166+
needs: [test-build]
167+
if: always()
168+
runs-on: ubuntu-latest
169+
steps:
170+
- name: Checkout repository
171+
uses: actions/checkout@v6
172+
- name: Download all artifacts
173+
uses: actions/download-artifact@v4
174+
with:
175+
pattern: elf-*-*-*
176+
path: downloaded_artifacts
177+
- name: Install dependencies
178+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends binutils
179+
- name: Build comparison tables
180+
run: python3 .github/workflows/scripts/poc_lto_compare.py downloaded_artifacts poc_lto.md
181+
- name: Publish to summary
182+
run: cat poc_lto.md >> $GITHUB_STEP_SUMMARY
183+
- name: Upload comparison
184+
uses: actions/upload-artifact@v4
185+
with:
186+
name: poc-lto-comparison
187+
path: poc_lto.md
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
# PoC LTO: build one target for the current config, recording build time and the
3+
# make return code so the compare job can report timings and regressions.
4+
#
5+
# Usage (run from the app build directory):
6+
# poc_lto_build_target.sh <TARGET> "<make_flags>"
7+
#
8+
# Never fails the step on a build error: a failed build is surfaced as a
9+
# regression by the compare job (missing ELF / non-zero rc).
10+
set -u
11+
12+
T="$1"
13+
FLAGS="${2:-}"
14+
15+
echo "=== Building TARGET=$T flags: [$FLAGS] ==="
16+
make clean >/dev/null 2>&1 || true
17+
18+
start=$SECONDS
19+
set +e
20+
# shellcheck disable=SC2086 # FLAGS must word-split into separate make arguments
21+
make TARGET="$T" BOLOS_SDK="$GITHUB_WORKSPACE/sdk" $FLAGS
22+
rc=$?
23+
set -e
24+
elapsed=$((SECONDS - start))
25+
26+
# Collect outputs in a clean, build-directory-independent location for upload.
27+
outdir="$GITHUB_WORKSPACE/poc_out/$T"
28+
mkdir -p "$outdir"
29+
echo "$elapsed" > "$outdir/poc_time.txt"
30+
echo "$rc" > "$outdir/poc_rc.txt"
31+
elf=$(find build -path "*/bin/app.elf" 2>/dev/null | head -1)
32+
if [ -n "$elf" ] && [ -f "$elf" ]; then
33+
cp "$elf" "$outdir/app.elf"
34+
fi
35+
36+
echo "TARGET=$T flags=[$FLAGS] rc=$rc time=${elapsed}s"
37+
exit 0
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
"""PoC LTO: aggregate per-build artifacts into size & build-time comparison tables.
3+
4+
Each artifact directory is named ``elf-<app>-<target>-<config>`` and contains the
5+
uploaded ``build/<target>`` directory, i.e. ``bin/app.elf`` plus ``poc_time.txt`` and
6+
``poc_rc.txt`` written by poc_lto_build_target.sh.
7+
8+
Usage: poc_lto_compare.py <artifacts_root> <output.md>
9+
"""
10+
import pathlib
11+
import subprocess
12+
import sys
13+
14+
CONFIGS = ["baseline", "lto-sp", "lto"] # most-specific suffix first
15+
TARGETS = ["nanos2", "nanox", "stax", "flex", "apex_p"]
16+
17+
18+
def parse_name(dirname):
19+
name = dirname[len("elf-"):]
20+
cfg = next((c for c in CONFIGS if name.endswith("-" + c)), None)
21+
if cfg is None:
22+
return None
23+
name = name[: -(len(cfg) + 1)]
24+
tgt = next((t for t in TARGETS if name.endswith("-" + t)), None)
25+
if tgt is None:
26+
return None
27+
app = name[: -(len(tgt) + 1)]
28+
return app, tgt, cfg
29+
30+
31+
def elf_sizes(elf):
32+
text = None
33+
out = subprocess.check_output(["size", "-A", str(elf)], text=True)
34+
for line in out.splitlines():
35+
cols = line.split()
36+
if len(cols) >= 2 and cols[0] == ".text":
37+
text = int(cols[1])
38+
break
39+
total = None
40+
berkeley = subprocess.check_output(["size", str(elf)], text=True).splitlines()
41+
if len(berkeley) >= 2:
42+
total = int(berkeley[-1].split()[3]) # text data bss dec hex filename
43+
return text, total
44+
45+
46+
def read_int(path):
47+
try:
48+
return int(path.read_text().strip())
49+
except (OSError, ValueError):
50+
return None
51+
52+
53+
def main():
54+
artifacts_root = pathlib.Path(sys.argv[1])
55+
out_path = pathlib.Path(sys.argv[2])
56+
57+
rows = {} # (app, target) -> {config: {...}}
58+
for d in sorted(artifacts_root.glob("elf-*-*-*")):
59+
if not d.is_dir():
60+
continue
61+
parsed = parse_name(d.name)
62+
if parsed is None:
63+
print(f"skip unrecognized artifact: {d.name}")
64+
continue
65+
app, target, cfg = parsed
66+
67+
rc = read_int(d / "poc_rc.txt")
68+
secs = read_int(d / "poc_time.txt")
69+
elf = next(iter(sorted(d.rglob("app.elf"))), None)
70+
text = total = None
71+
if elf is not None:
72+
text, total = elf_sizes(elf)
73+
ok = rc == 0 and elf is not None
74+
rows.setdefault((app, target), {})[cfg] = {
75+
"text": text, "total": total, "secs": secs, "ok": ok,
76+
}
77+
78+
def delta(base, val):
79+
if base is None or val is None or base == 0:
80+
return "N/A"
81+
return f"{val - base:+d} ({(val - base) / base * 100:+.1f}%)"
82+
83+
def size_cell(d, key):
84+
if d is None:
85+
return "—"
86+
if not d["ok"]:
87+
return "⚠️ REGRESSION"
88+
v = d.get(key)
89+
return str(v) if v is not None else "N/A"
90+
91+
out = ["# PoC LTO — comparison", ""]
92+
93+
out += [
94+
"## ELF size (bytes)", "",
95+
"| App | Target | .text base | .text lto | .text lto-sp | Δ.text lto | Δ.text lto-sp | total base | total lto | total lto-sp |",
96+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
97+
]
98+
for app, target in sorted(rows):
99+
c = rows[(app, target)]
100+
b, l, s = c.get("baseline"), c.get("lto"), c.get("lto-sp")
101+
bt = b["text"] if b and b["ok"] else None
102+
lt = l["text"] if l and l["ok"] else None
103+
st = s["text"] if s and s["ok"] else None
104+
out.append("| " + " | ".join([
105+
app, target,
106+
size_cell(b, "text"), size_cell(l, "text"), size_cell(s, "text"),
107+
delta(bt, lt), delta(bt, st),
108+
size_cell(b, "total"), size_cell(l, "total"), size_cell(s, "total"),
109+
]) + " |")
110+
111+
out += [
112+
"", "## Build time (s) — indicative, runners differ across jobs", "",
113+
"| App | Target | base | lto | lto-sp | Δ lto | × lto | Δ lto-sp | × lto-sp |",
114+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
115+
]
116+
for app, target in sorted(rows):
117+
c = rows[(app, target)]
118+
b, l, s = c.get("baseline"), c.get("lto"), c.get("lto-sp")
119+
bs = b["secs"] if b else None
120+
121+
def tcells(x):
122+
if x is None:
123+
return "—", "—"
124+
if not x["ok"]:
125+
return "⚠️ REGRESSION", "—"
126+
xs = x["secs"]
127+
if bs is None or xs is None or bs == 0:
128+
return "N/A", "N/A"
129+
return f"{xs - bs:+d}s", f"{xs / bs:.2f}×"
130+
131+
dl, xl = tcells(l)
132+
ds, xs_ = tcells(s)
133+
out.append("| " + " | ".join([
134+
app, target,
135+
size_cell(b, "secs"), size_cell(l, "secs"), size_cell(s, "secs"),
136+
dl, xl, ds, xs_,
137+
]) + " |")
138+
139+
regressions = sum(1 for c in rows.values() for d in c.values() if not d["ok"])
140+
out += ["", f"**Regressions (failed builds): {regressions}**"]
141+
142+
text = "\n".join(out) + "\n"
143+
out_path.write_text(text, encoding="utf-8")
144+
print(text)
145+
146+
147+
if __name__ == "__main__":
148+
main()

0 commit comments

Comments
 (0)