Visitar URL original
Add script to run pair-wise Students t-test on a pair of binaries (#667) · ARM-software/astc-encoder@18b20e6 · GitHub
Skip to content

Commit 18b20e6

Browse files
authored
Add script to run pair-wise Students t-test on a pair of binaries (#667)
1 parent 58c5ca0 commit 18b20e6

3 files changed

Lines changed: 307 additions & 2 deletions

File tree

‎.github/workflows/build_test_py.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
- name: Get Python modules
3434
run: |
3535
python -m pip install --upgrade pip
36-
python -m pip install numpy Pillow mypy pylint pycodestyle
36+
python -m pip install numpy scipy Pillow mypy pylint pycodestyle
3737
3838
- name: Run Python tests
3939
run: |

‎Docs/Building.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ python3 -m venv pve
393393
. ./pve/bin/activate
394394

395395
# Test tools
396-
python3 -m pip install numpy pillow pycodestyle pylint
396+
python3 -m pip install numpy scipy pillow pycodestyle pylint
397397

398398
# Profile tools
399399
python3 -m pip install gprof2dot

‎Test/astc_test_performance.py‎

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: Apache-2.0
3+
# -----------------------------------------------------------------------------
4+
# Copyright 2026 Arm Limited
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
7+
# use this file except in compliance with the License. You may obtain a copy
8+
# of the License at:
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15+
# License for the specific language governing permissions and limitations
16+
# under the License.
17+
# -----------------------------------------------------------------------------
18+
'''
19+
Utility script to compare the compression performance of two astcenc binaries.
20+
21+
The binaries are run in alternating order and their reported coding times are
22+
analyzed with a paired, two-sided Student's t-test.
23+
'''
24+
25+
import argparse
26+
from pathlib import Path
27+
import re
28+
import shutil
29+
import statistics
30+
import subprocess as sp
31+
import sys
32+
33+
from scipy import stats
34+
35+
36+
CODING_TIME_PATTERN = re.compile(r'^\s*Coding time:\s*([0-9.]+)\s+s\s*$')
37+
QUALITY_LEVELS = [
38+
'fastest', 'fast', 'medium', 'thorough', 'verythorough', 'exhaustive'
39+
]
40+
41+
42+
def paired_t_test(sample_a: list[float],
43+
sample_b: list[float]) -> tuple[float, float]:
44+
'''
45+
Run a paired, two-sided Student's t-test.
46+
47+
Args:
48+
sample_a: The timing samples for binary A.
49+
sample_b: The timing samples for binary B.
50+
51+
Return:
52+
The t-statistic and two-sided p-value.
53+
54+
Raises:
55+
ValueError: The sample sizes differ or contain fewer than two values.
56+
'''
57+
if len(sample_a) != len(sample_b) or len(sample_a) < 2:
58+
raise ValueError('Paired t-test requires equal sample set sizes')
59+
60+
result = stats.ttest_rel(sample_a, sample_b)
61+
return float(result.statistic), float(result.pvalue)
62+
63+
64+
def resolve_binary(binary: str) -> str:
65+
'''
66+
Resolve a binary path, including commands found on PATH.
67+
68+
Args:
69+
binary: The binary path or command name.
70+
71+
Return:
72+
The resolved binary path.
73+
74+
Raises:
75+
FileNotFoundError: The binary cannot be found.
76+
'''
77+
path = Path(binary)
78+
if path.is_file():
79+
return str(path)
80+
81+
resolved = shutil.which(binary)
82+
if not resolved:
83+
raise FileNotFoundError(binary)
84+
85+
return resolved
86+
87+
88+
def run_encoder(binary: str, image_in: str, image_out: str,
89+
block_size: str, quality: str) -> float:
90+
'''
91+
Run one astcenc test-mode compression.
92+
93+
Args:
94+
binary: The astcenc binary path.
95+
image_in: The input image path.
96+
image_out: The output image path.
97+
block_size: The ASTC block size.
98+
quality: The compression quality level.
99+
100+
Return:
101+
The reported coding time in seconds.
102+
103+
Raises:
104+
CalledProcessError: The encoder process failed.
105+
ValueError: The encoder did not report a coding time.
106+
'''
107+
command = [
108+
binary, '-tl', image_in, image_out, block_size, f'-{quality}',
109+
'-silent', '-j', '4'
110+
]
111+
112+
result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE,
113+
check=True, text=True)
114+
115+
for line in result.stdout.splitlines():
116+
if match := CODING_TIME_PATTERN.match(line):
117+
return float(match.group(1))
118+
119+
raise ValueError(f'{binary} did not report a coding time')
120+
121+
122+
def collect_samples(binary_a: str, binary_b: str, image_in: str,
123+
image_out: str, block_size: str, quality: str,
124+
warmups: int,
125+
runs: int) -> tuple[list[float], list[float]]:
126+
'''
127+
Warm up the encoders and collect interleaved paired timing samples.
128+
129+
Args:
130+
binary_a: The binary A path.
131+
binary_b: The binary B path.
132+
image_in: The input image path.
133+
image_out: The output image path.
134+
block_size: The ASTC block size.
135+
quality: The compression quality level.
136+
warmups: The number of warm-up pairs.
137+
runs: The number of measured pairs.
138+
139+
Return:
140+
The timing samples for binary A and binary B.
141+
'''
142+
for _ in range(warmups):
143+
run_encoder(binary_a, image_in, image_out, block_size, quality)
144+
run_encoder(binary_b, image_in, image_out, block_size, quality)
145+
146+
samples_a = []
147+
samples_b = []
148+
for run in range(runs):
149+
if run % 2 == 0:
150+
time_a = run_encoder(binary_a, image_in, image_out,
151+
block_size, quality)
152+
time_b = run_encoder(binary_b, image_in, image_out,
153+
block_size, quality)
154+
else:
155+
time_b = run_encoder(binary_b, image_in, image_out,
156+
block_size, quality)
157+
time_a = run_encoder(binary_a, image_in, image_out,
158+
block_size, quality)
159+
160+
samples_a.append(time_a)
161+
samples_b.append(time_b)
162+
print(f'Pair {run + 1:>2}/{runs}: '
163+
f'A {time_a:.4f} s, '
164+
f'B {time_b:.4f} s')
165+
166+
return samples_a, samples_b
167+
168+
169+
def confidence_value(value: str) -> float:
170+
'''
171+
Parse and validate a confidence level argument.
172+
173+
Args:
174+
value: The confidence level string.
175+
176+
Return:
177+
The parsed confidence level.
178+
179+
Raises:
180+
ArgumentTypeError: The confidence level is outside the valid range.
181+
'''
182+
result = float(value)
183+
if not 0.0 < result < 1.0:
184+
raise argparse.ArgumentTypeError('Must be between 0 and 1')
185+
return result
186+
187+
188+
def parse_command_line() -> argparse.Namespace:
189+
'''
190+
Parse the command line.
191+
192+
Return:
193+
The parsed command-line arguments.
194+
'''
195+
parser = argparse.ArgumentParser(description=__doc__)
196+
197+
parser.add_argument('binary_a', help='the first astcenc binary')
198+
199+
parser.add_argument('binary_b', help='the second astcenc binary')
200+
201+
parser.add_argument('input', help='the input image')
202+
203+
parser.add_argument('output', help='the output image')
204+
205+
parser.add_argument('--block-size', default='6x6',
206+
help='the ASTC block size (default: %(default)s)')
207+
208+
parser.add_argument('--quality', choices=QUALITY_LEVELS, default='medium',
209+
help='the compression quality (default: %(default)s)')
210+
211+
parser.add_argument('--runs', type=int, default=10,
212+
help='number of measured pairs (default: %(default)s)')
213+
214+
parser.add_argument('--warmups', type=int, default=1,
215+
help='number of warm-up pairs (default: %(default)s)')
216+
217+
parser.add_argument('--confidence', type=confidence_value, default=0.95,
218+
help='confidence level (default: %(default)s)')
219+
220+
args = parser.parse_args()
221+
222+
if args.runs < 2:
223+
parser.error('--runs must be at least 2')
224+
225+
if args.warmups < 0:
226+
parser.error('--warmups cannot be negative')
227+
228+
if not Path(args.input).is_file():
229+
parser.error(f'input image does not exist: {args.input}')
230+
231+
return args
232+
233+
234+
def main() -> int:
235+
'''
236+
Run the comparison and print the statistical result.
237+
238+
Return:
239+
The process return code.
240+
'''
241+
args = parse_command_line()
242+
243+
try:
244+
binary_a = resolve_binary(args.binary_a)
245+
binary_b = resolve_binary(args.binary_b)
246+
247+
samples_a, samples_b = collect_samples(
248+
binary_a, binary_b, args.input, args.output,
249+
args.block_size, args.quality, args.warmups, args.runs)
250+
251+
statistic, p_value = paired_t_test(samples_a, samples_b)
252+
253+
except FileNotFoundError as ex:
254+
print(f'ERROR: Binary not found: {ex}', file=sys.stderr)
255+
return 1
256+
257+
except OSError as ex:
258+
print(f'ERROR: Could not execute encoder: {ex}', file=sys.stderr)
259+
return 1
260+
261+
except sp.CalledProcessError as ex:
262+
print(f'ERROR: Encoder failed: {" ".join(ex.cmd)}', file=sys.stderr)
263+
output = ex.stderr.strip() or ex.stdout.strip()
264+
if output:
265+
print(output, file=sys.stderr)
266+
267+
return 1
268+
269+
except ValueError as ex:
270+
print(f'ERROR: {ex}', file=sys.stderr)
271+
return 1
272+
273+
mean_a = statistics.mean(samples_a)
274+
mean_b = statistics.mean(samples_b)
275+
alpha = 1.0 - args.confidence
276+
277+
print()
278+
print(f'A mean: {mean_a:.6f} s ({binary_a})')
279+
print(f'B mean: {mean_b:.6f} s ({binary_b})')
280+
print(f'Paired t-test: t={statistic:.4f}, p={p_value:.6g}')
281+
282+
if p_value >= alpha or mean_a == mean_b:
283+
print(f'Result: inconclusive at {args.confidence:.1%} confidence')
284+
285+
else:
286+
if mean_a < mean_b:
287+
winner = 'A'
288+
faster_time = mean_a
289+
slower_time = mean_b
290+
else:
291+
winner = 'B'
292+
faster_time = mean_b
293+
slower_time = mean_a
294+
295+
reduction = (slower_time - faster_time) / slower_time * 100.0
296+
297+
print(f'Result: {winner} is faster at '
298+
f'{args.confidence:.1%} confidence '
299+
f'({reduction:.2f}% lower mean coding time)')
300+
301+
return 0
302+
303+
304+
if __name__ == '__main__':
305+
sys.exit(main())

0 commit comments

Comments
 (0)