Skip to content

Commit 266cfd0

Browse files
authored
Arm backend: Add test for monitoring memory allocation (#14657)
Simple test to monitor memory allocations when running the "add" model in fvp. Signed-off-by: [email protected]
1 parent 6e7353f commit 266cfd0

File tree

3 files changed

+186
-0
lines changed

3 files changed

+186
-0
lines changed

.github/workflows/trunk.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ jobs:
289289
- test_arm_baremetal: test_models_ethos-u55
290290
- test_arm_baremetal: test_models_ethos-u85
291291
- test_arm_baremetal: test_smaller_stories_llama
292+
- test_arm_baremetal: test_memory_allocation
292293
fail-fast: false
293294
with:
294295
runner: linux.2xlarge.memory

backends/arm/test/test_arm_baremetal.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,5 +366,20 @@ test_smaller_stories_llama() {
366366
echo "${TEST_SUITE_NAME}: PASS"
367367
}
368368

369+
test_memory_allocation() {
370+
echo "${TEST_SUITE_NAME}: Test ethos-u memory allocation with run.sh"
371+
372+
mkdir -p arm_test/test_run
373+
# Ethos-U85
374+
echo "${TEST_SUITE_NAME}: Test target Ethos-U85"
375+
examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u85-128 --model_name=examples/arm/example_modules/add.py &> arm_test/test_run/full.log
376+
python3 backends/arm/test/test_memory_allocator_log.py --log arm_test/test_run/full.log \
377+
--require "model_pte_program_size" "<= 3000 B" \
378+
--require "method_allocator_planned" "<= 64 B" \
379+
--require "method_allocator_loaded" "<= 1024 B" \
380+
--require "method_allocator_input" "<= 4 B" \
381+
--require "Total DRAM used" "<= 0.06 KiB"
382+
echo "${TEST_SUITE_NAME}: PASS"
383+
}
369384

370385
${TEST_SUITE}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright 2025 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+
Check log files for memory metrics and compare them against thresholds.
7+
8+
Usage example:
9+
python3 test_memory_allocator_log.py \
10+
--log path/to/log.txt \
11+
--require "Total SRAM used" "<= 310 KiB" \
12+
--require "method_allocator_input" "<= 4 B"
13+
"""
14+
15+
import argparse
16+
import re
17+
import sys
18+
from typing import List, Optional, Tuple
19+
20+
21+
def unit_factor(u: str) -> float:
22+
if not u:
23+
return 1.0
24+
ul = u.strip().lower()
25+
table = {
26+
"b": 1,
27+
"byte": 1,
28+
"bytes": 1,
29+
"kb": 1000,
30+
"mb": 1000**2,
31+
"gb": 1000**3,
32+
"kib": 1024,
33+
"mib": 1024**2,
34+
"gib": 1024**3,
35+
}
36+
if ul in table:
37+
return float(table[ul])
38+
return 1.0
39+
40+
41+
def parse_value(text_num: str, text_unit: Optional[str]) -> float:
42+
return float(text_num) * unit_factor(text_unit or "")
43+
44+
45+
def parse_cond(cond: str) -> Tuple[str, float, str]:
46+
# Regexp explained. Example of things it will parse:
47+
# "< 310 KiB", ">=10MB", "== 42", "!=3 bytes", "<=0.5 MiB"
48+
49+
# The regexp explained in detail:
50+
# ^: anchor the match to the start and end of the string (no extra chars allowed).
51+
# \s*: optional whitespace (spaces, tabs, etc.).
52+
# (<=|>=|==|!=|<|>): capturing group 1. One of the comparison operators: <=, >=, ==, !=, <, >.
53+
# \s*: optional whitespace.
54+
# ([0-9]+(?:\.[0-9]+)?): capturing group 2. A number:
55+
# [0-9]+: one or more digits (the integer part).
56+
# (?:\.[0-9]+)?: optional non-capturing group for a fractional part like .25.
57+
# \s*: optional whitespace between number and unit
58+
# ([A-Za-z]+)?: capturing group 3, optional. A unit made of letters only (e.g., B, KB, KiB, MB, MiB). Case# insensitive by class choice.
59+
# \s*: optional trailing whitespace.
60+
m = re.match(
61+
r"^\s*(<=|>=|==|!=|<|>)\s*([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?\s*$", cond
62+
)
63+
if not m:
64+
raise ValueError(f"Invalid condition: {cond}")
65+
op, num, unit = m.groups()
66+
return op, float(num), (unit or "")
67+
68+
69+
def compare(a: float, b: float, op: str) -> bool:
70+
return {
71+
"<": a < b,
72+
"<=": a <= b,
73+
">": a > b,
74+
">=": a >= b,
75+
"==": abs(a - b) < 1e-9,
76+
"!=": abs(a - b) >= 1e-9,
77+
}[op]
78+
79+
80+
def find_metric_value(line: str, label: str) -> Tuple[Optional[str], Optional[str]]:
81+
# Same regexp as parse_cond() but without the first group of matching comparison operators
82+
# First go, search for the pattern but escape and ignore cases
83+
# The regexp:
84+
# ([0-9]+(?:\.[0-9]+)?) — capturing group 1: a decimal number
85+
# [0-9]+ — one or more digits (integer part)
86+
# (?:\.[0-9]+)? — optional fractional part like .25 (non-capturing)
87+
# \s* — optional whitespace between number and unit
88+
# ([A-Za-z]+)? — capturing group 2 (optional): a unit made only of letters (e.g., B, KB, KiB, MB)
89+
m = re.search(
90+
re.escape(label) + r".*?([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?",
91+
line,
92+
flags=re.IGNORECASE,
93+
)
94+
if m:
95+
return m.group(1), m.group(2)
96+
# Second go, same regexp as above but not caring about label. If
97+
# no number was tied to a label be happy just salvaging it from
98+
# the line
99+
m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?", line)
100+
if m:
101+
return m.group(1), m.group(2)
102+
return None, None
103+
104+
105+
def first_line_with_label(lines: List[str], label: str) -> Optional[str]:
106+
label_lc = label.lower()
107+
return next((ln for ln in lines if label_lc in ln.lower()), None)
108+
109+
110+
def check_requirement(label: str, cond: str, lines: List[str]) -> Optional[str]:
111+
op, thr_num, thr_unit = parse_cond(cond)
112+
matched = first_line_with_label(lines, label)
113+
if matched is None:
114+
return f"{label}: not found in log"
115+
116+
num_str, unit_str = find_metric_value(matched, label)
117+
if num_str is None:
118+
return f"{label}: value not found on line: {matched.strip()}"
119+
120+
left_bytes = parse_value(num_str, unit_str)
121+
right_bytes = parse_value(str(thr_num), thr_unit or (unit_str or ""))
122+
ok = compare(left_bytes, right_bytes, op)
123+
124+
human_left = f"{num_str} {unit_str or 'B'}"
125+
human_right = f"{thr_num:g} {thr_unit or (unit_str or 'B')}"
126+
print(
127+
f"[check] {label}: {human_left} {op} {human_right} -> {'OK' if ok else 'FAIL'}"
128+
)
129+
130+
if ok:
131+
return None
132+
return f"{label}: {human_left} not {op} {human_right}"
133+
134+
135+
def main() -> int:
136+
parser = argparse.ArgumentParser()
137+
parser.add_argument("--log", required=True, help="Path to log file")
138+
parser.add_argument(
139+
"--require",
140+
action="append",
141+
nargs=2,
142+
metavar=("LABEL", "COND"),
143+
default=[],
144+
help="""Required label and condition consisting
145+
of a number and unit. Example: \"Total DRAM
146+
used\" \"<= 0.06 KiB\"""",
147+
)
148+
args = parser.parse_args()
149+
150+
with open(args.log, "r", encoding="utf-8", errors="ignore") as f:
151+
lines = f.readlines()
152+
153+
failures: List[str] = []
154+
for label, cond in args.require:
155+
msg = check_requirement(label, cond, lines)
156+
if msg:
157+
failures.append(msg)
158+
159+
if failures:
160+
print("Failures:")
161+
for msg in failures:
162+
print(" - " + msg)
163+
return 1
164+
165+
print("All checks passed.")
166+
return 0
167+
168+
169+
if __name__ == "__main__":
170+
sys.exit(main())

0 commit comments

Comments
 (0)