Skip to content

Commit a09cc62

Browse files
committed
build_release_files: predict translation sizes instead of building them on PRs
On pull requests the translations other than en_US are built only to prove that they still fit in flash. The compiled code is identical for every translation; only three generated data files differ: the compressed strings, the compression dictionary and the terminal font. When the first language leaves less than 10 KB of headroom, run only the generators (the make targets for translations-<lang>.c and autogen_display_resources-<lang>.c, about 4 s, no compiler), count the bytes of the generated tables and predict the flash usage. Only translations predicted within LANGUAGE_MARGIN (1 KB) of the limit, or whose build configuration differs (clean build), are really built. LANGUAGE_PREDICT=dryrun builds everything and prints the prediction error; LANGUAGE_PREDICT=off restores the old behaviour. Measured on feather_m4_can, pybadge and metro_m4_express with every language (41 predictions): error -5..+174 bytes, almost always an over-estimate. On metro_m4_express the prediction flagged fr as not fitting; the real build then overflowed by 56 bytes. feather_m4_can board job: 443 s -> 228 s; the remainder is the two clean builds. Push and release builds are unchanged.
1 parent 880082b commit a09cc62

1 file changed

Lines changed: 143 additions & 10 deletions

File tree

tools/build_release_files.py

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import os
88
import multiprocessing
9+
import re
910
import sys
1011
import subprocess
1112
import shutil
@@ -35,6 +36,91 @@
3536
LANGUAGE_FIRST = "en_US"
3637
LANGUAGE_THRESHOLD = 10 * 1024
3738

39+
# On pull requests the translations other than en_US are built only to prove that they
40+
# still fit in flash. The compiled code is identical for every translation; only three
41+
# generated data files differ: the compressed strings, the compression dictionary and
42+
# the terminal font. So instead of relinking the firmware for each translation, generate
43+
# those files, count their bytes and predict the flash usage. Only translations predicted
44+
# within LANGUAGE_MARGIN of the region limit, or whose build configuration differs, are
45+
# really built. LANGUAGE_PREDICT=dryrun builds everything and prints the prediction
46+
# error; LANGUAGE_PREDICT=off restores the old behaviour.
47+
LANGUAGE_MARGIN = int(os.environ.get("LANGUAGE_MARGIN", 1024))
48+
LANGUAGE_PREDICT = os.environ.get("LANGUAGE_PREDICT", "skip")
49+
50+
C_TYPE_SIZES = {
51+
"char": 1,
52+
"int8_t": 1,
53+
"uint8_t": 1,
54+
"int16_t": 2,
55+
"uint16_t": 2,
56+
"int32_t": 4,
57+
"uint32_t": 4,
58+
}
59+
C_ARRAY_RE = re.compile(r"const\s+(\w+)\s+\w+\[\d*\]\s*=\s*\{([^}]*)\}")
60+
TRANSLATION_RE = re.compile(r"\.data = \d+, \.tail = \{([^}]*)\}")
61+
62+
63+
def flash_usage(port, build_dir):
64+
"""Return (used, region) bytes of the firmware flash region, or None if unknown."""
65+
try:
66+
with open(f"../ports/{port}/{build_dir}/firmware.size.json", "r") as f:
67+
firmware = json.load(f)
68+
return firmware["used_flash"], firmware["firmware_region"]
69+
except FileNotFoundError:
70+
return None
71+
72+
73+
def c_array_bytes(path):
74+
"""Sum the bytes of the const arrays initialised in a generated C file."""
75+
if not path.exists():
76+
return 0
77+
text = path.read_text()
78+
total = 0
79+
for match in C_ARRAY_RE.finditer(text):
80+
c_type, body = match.groups()
81+
if c_type not in C_TYPE_SIZES:
82+
typedef = re.search(r"typedef\s+(\w+)\s+" + c_type + ";", text)
83+
c_type = typedef.group(1) if typedef else "uint8_t"
84+
total += C_TYPE_SIZES[c_type] * len([x for x in body.split(",") if x.strip()])
85+
return total
86+
87+
88+
def translation_bytes(port, build_dir, language):
89+
"""Bytes of flash that depend on the translation: strings, dictionary and font."""
90+
build = pathlib.Path(f"../ports/{port}/{build_dir}")
91+
strings = 0
92+
for match in TRANSLATION_RE.finditer(
93+
(build / "py" / f"translations-{language}.c").read_text()
94+
):
95+
strings += 1 + len([x for x in match.group(1).split(",") if x.strip()])
96+
dictionary = c_array_bytes(build / "genhdr" / "compressed_translations.generated.h")
97+
font = c_array_bytes(build / f"autogen_display_resources-{language}.c")
98+
return strings + dictionary + font
99+
100+
101+
def generate_translation(port, board, build_dir, language):
102+
"""Generate the translation data files of a language without compiling anything."""
103+
targets = [f"{build_dir}/py/translations-{language}.c"]
104+
if os.path.exists(f"../ports/{port}/{build_dir}/autogen_display_resources-{LANGUAGE_FIRST}.c"):
105+
targets.append(f"{build_dir}/autogen_display_resources-{language}.c")
106+
result = subprocess.run(
107+
"make -C ../ports/{port} TRANSLATION={language} BOARD={board} BUILD={build} -j {cores} {targets}".format(
108+
port=port,
109+
language=language,
110+
board=board,
111+
build=build_dir,
112+
cores=cores,
113+
targets=" ".join(targets),
114+
),
115+
shell=True,
116+
stdout=subprocess.PIPE,
117+
stderr=subprocess.STDOUT,
118+
)
119+
if result.returncode != 0:
120+
print(result.stdout.decode("utf-8"))
121+
return result.returncode == 0
122+
123+
38124
languages = build_info.get_languages()
39125

40126
all_languages = build_info.get_languages(list_all=True)
@@ -71,6 +157,11 @@
71157
languages.remove(LANGUAGE_FIRST)
72158
languages.insert(0, LANGUAGE_FIRST)
73159

160+
# Set after the first language when its flash usage is known and too tight to skip
161+
# the other languages outright.
162+
predict_flash = None
163+
first_language_bytes = 0
164+
74165
for language in languages:
75166
bin_directory = "../bin/{board}/{language}".format(board=board, language=language)
76167
os.makedirs(bin_directory, exist_ok=True)
@@ -99,6 +190,34 @@
99190
extensions = board_settings["CIRCUITPY_BUILD_EXTENSIONS"]
100191

101192
artifacts = [os.path.join(build_dir, "firmware." + extension) for extension in extensions]
193+
194+
prediction = None
195+
if predict_flash is not None and language != LANGUAGE_FIRST and not clean_build:
196+
if generate_translation(board_info["port"], board, build_dir, language):
197+
delta = (
198+
translation_bytes(board_info["port"], build_dir, language)
199+
- first_language_bytes
200+
)
201+
predicted = predict_flash[0] + delta
202+
fits = predicted + LANGUAGE_MARGIN <= predict_flash[1]
203+
skip = fits and LANGUAGE_PREDICT == "skip"
204+
prediction = predicted
205+
print(
206+
"Predict {board} for {language}: {predicted} of {region} bytes ({free} free, {delta:+d} vs {first}) -> {action}".format(
207+
board=board,
208+
language=language,
209+
predicted=predicted,
210+
region=predict_flash[1],
211+
free=predict_flash[1] - predicted,
212+
delta=delta,
213+
first=LANGUAGE_FIRST,
214+
action="skip" if skip else "build",
215+
),
216+
flush=True,
217+
)
218+
if skip:
219+
continue
220+
102221
make_result = subprocess.run(
103222
"make -C ../ports/{port} TRANSLATION={language} BOARD={board} BUILD={build} -j {cores} {artifacts}".format(
104223
port=board_info["port"],
@@ -154,19 +273,33 @@
154273
print(make_result.stdout.decode("utf-8"))
155274
print(other_output)
156275

276+
if prediction is not None and make_result.returncode == 0:
277+
usage = flash_usage(board_info["port"], build_dir)
278+
if usage is not None:
279+
print(
280+
"Predict check {board} for {language}: predicted {predicted}, actual {actual}, error {error:+d}".format(
281+
board=board,
282+
language=language,
283+
predicted=prediction,
284+
actual=usage[0],
285+
error=prediction - usage[0],
286+
)
287+
)
288+
157289
# Flush so we will see something before 10 minutes has passed.
158290
print(flush=True)
159291

160292
if (not build_all) and (language == LANGUAGE_FIRST) and (exit_status == 0):
161-
try:
162-
with open(
163-
f"../ports/{board_info['port']}/{build_dir}/firmware.size.json", "r"
164-
) as f:
165-
firmware = json.load(f)
166-
if firmware["used_flash"] + LANGUAGE_THRESHOLD < firmware["firmware_region"]:
167-
print("Skipping languages")
168-
break
169-
except FileNotFoundError:
170-
pass
293+
usage = flash_usage(board_info["port"], build_dir)
294+
if usage is None:
295+
print("Flash usage unknown, building all languages")
296+
elif usage[0] + LANGUAGE_THRESHOLD < usage[1]:
297+
print("Skipping languages")
298+
break
299+
elif LANGUAGE_PREDICT != "off" and board_info["port"] != "zephyr-cp":
300+
predict_flash = usage
301+
first_language_bytes = translation_bytes(
302+
board_info["port"], build_dir, LANGUAGE_FIRST
303+
)
171304

172305
sys.exit(exit_status)

0 commit comments

Comments
 (0)