ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
benchmark.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Exhaustive correctness + throughput test for a CDC ACM echo device.
5
6Used by the HIL suite to validate both USBFS (J11) and USBHS (J7) on the
7EK-RA8D2 against /dev/ttyACM*.
8
9Every payload is drawn from a FIXED seed, so a failing run can be replayed
10byte-for-byte: the seeds are named constants rather than literals precisely
11so that a reported mismatch offset means the same thing on the next run.
12
13Usage:
14 python3 benchmark.py <device> # all tests
15 python3 benchmark.py <device> --quick # short correctness pass only
16
17Exit status: 0 if every test passes, non-zero otherwise.
18"""
19
20from __future__ import annotations
21
22import argparse
23import fcntl
24import os
25import random
26import subprocess
27import sys
28import termios
29import time
30from pathlib import Path
31from typing import NoReturn
32
33
34def open_raw(device: str) -> tuple[int, int]:
35 """Open a tty in fully raw mode and return ``(fd, original_flags)``.
36
37 Every line-discipline transformation is cleared -- canonical mode, echo,
38 signal generation, XON/XOFF, CR/LF translation and output post-processing.
39 That is not tidiness: on a byte-exact echo test, ICRNL alone silently
40 rewrites 0x0D to 0x0A and would surface as a data-corruption failure
41 attributed to the device.
42
43 O_NONBLOCK is used to get past a blocking open and then CLEARED, so the
44 returned fd is blocking. The original flags come back with it because the
45 read helpers toggle O_NONBLOCK on and off and need the value to restore.
46 """
47 fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
48 attr = termios.tcgetattr(fd)
49 attr[2] |= termios.CLOCAL
50 attr[2] &= ~termios.CRTSCTS
51 attr[3] &= ~(termios.ICANON | termios.ECHO | termios.ECHOE | termios.ECHONL | termios.ISIG)
52 attr[0] &= ~(
53 termios.INPCK
54 | termios.ISTRIP
55 | termios.IXON
56 | termios.IXOFF
57 | termios.INLCR
58 | termios.IGNCR
59 | termios.ICRNL
60 )
61 attr[1] &= ~termios.OPOST
62 termios.tcsetattr(fd, termios.TCSANOW, attr)
63 flags = fcntl.fcntl(fd, fcntl.F_GETFL)
64 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
65 return fd, flags
66
67
68def drain(fd: int, flags: int, quiet_for: float = 0.30) -> None:
69 """Drain pending echo data until we see no new bytes for `quiet_for` seconds.
70
71 Also calls tcflush so any kernel-side queued bytes from a previous test
72 are discarded before the next test starts measuring.
73 """
74 termios.tcflush(fd, termios.TCIOFLUSH)
75 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
76 last_data = time.monotonic()
77 try:
78 while time.monotonic() - last_data < quiet_for:
79 try:
80 d = os.read(fd, 4096)
81 if d:
82 last_data = time.monotonic()
83 else:
84 time.sleep(0.01)
85 except BlockingIOError: # non-blocking drain inside timed loop
86 time.sleep(0.01)
87 finally:
88 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
89 termios.tcflush(fd, termios.TCIOFLUSH)
90
91
92def echo_one(fd: int, flags: int, msg: bytes, settle: float = 0.25, timeout: float = 1.5) -> bytes:
93 """Write one message and read back whatever echoes within ``timeout``.
94
95 Returns the bytes received, NOT a pass/fail verdict -- comparison is the
96 caller's job, so a short or corrupted read is reported with its actual
97 content rather than collapsing to False.
98
99 ``settle`` is an unconditional sleep before reading. It exists because the
100 device needs time to turn the transfer around, and reading immediately
101 would return empty and then race the deadline.
102 """
103 os.write(fd, msg)
104 time.sleep(settle)
105 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
106 total = b""
107 deadline = time.monotonic() + timeout
108 try:
109 while len(total) < len(msg) and time.monotonic() < deadline:
110 try:
111 d = os.read(fd, 4096)
112 if d:
113 total += d
114 else:
115 time.sleep(0.05)
116 except BlockingIOError: # non-blocking read-back inside timed loop
117 time.sleep(0.05)
118 finally:
119 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
120 return total
121
122
123def test_lengths(fd: int, flags: int, max_len: int) -> bool:
124 """Echo every payload length from 1 to ``max_len`` and check each round-trips.
125
126 Sweeping lengths one at a time is what catches an off-by-one at a packet
127 boundary: a device that mishandles exactly the max-packet-size payload
128 passes any test that only sends round numbers.
129
130 Payload bytes are a counter mod 256, so a reported mismatch offset reads
131 directly as a position rather than needing a lookup.
132
133 Returns True only if every length round-tripped exactly.
134 """
135 print(f"=== Test: all lengths 1..{max_len} (exhaustive correctness) ===")
136 passes = fails = 0
137 for length in range(1, max_len + 1):
138 msg = bytes([(i % 256) for i in range(length)])
139 recv = echo_one(fd, flags, msg)
140 if recv == msg:
141 passes += 1
142 else:
143 fails += 1
144 print(f" FAIL L={length:3d}: sent {msg.hex()[:40]}... recv {recv.hex()[:40]}...")
145 print(f" -> {passes}/{passes + fails} pass")
146 return fails == 0
147
148
149RANDOM_SEED_CORRECTNESS = 0xCAFE # fixed seed for reproducible test sequence
150RANDOM_SEED_THROUGHPUT = 0xC0FFEE # fixed seed for recognisable payload pattern
151RANDOM_SEED_CHUNKED = 0xBEEF1234 # fixed seed for chunked throughput payload
152
153
154def test_random(fd: int, flags: int, n_iters: int) -> bool:
155 """Echo ``n_iters`` random payloads of random length and check each round-trips.
156
157 Complements the exhaustive length sweep by varying content as well as
158 size. The generator is seeded from a fixed constant, so "random" here means
159 unpredictable-but-reproducible: a failure can be replayed exactly.
160
161 Returns True only if every iteration round-tripped exactly.
162 """
163 print(f"=== Test: {n_iters} random payloads of length 1..255 ===")
164 passes = fails = 0
165 rng = random.Random(RANDOM_SEED_CORRECTNESS) # noqa: S311 # non-crypto test data
166 for i in range(n_iters):
167 length = rng.randint(1, 255)
168 msg = bytes(rng.randint(0, 255) for _ in range(length))
169 recv = echo_one(fd, flags, msg, settle=0.3)
170 if recv == msg:
171 passes += 1
172 else:
173 fails += 1
174 print(f" FAIL #{i} L={length}: sent {msg.hex()[:40]}... recv {recv.hex()[:40]}...")
175 print(f" -> {passes}/{passes + fails} pass")
176 return fails == 0
177
178
179def _report_corruption(
180 recv: bytes | bytearray, payload: bytes, chunk_size: int, total_bytes: int
181) -> None:
182 """Diagnose a failed throughput transfer by locating the first bad byte.
183
184 Prints the nearest chunk boundary alongside the offset, which is the
185 discriminating detail: corruption landing ON a boundary points at framing
186 or a FIFO edge, while corruption in the middle of a chunk points at the
187 data path.
188
189 A transfer that is merely SHORT -- every received byte correct, but too few
190 of them -- is reported as a short read instead, since there is no
191 mismatching byte to point at and the two failures have different causes.
192 """
193 n = min(len(recv), len(payload))
194 first_bad = next((i for i in range(n) if recv[i] != payload[i]), None)
195 if first_bad is not None:
196 print(f" FIRST MISMATCH at offset {first_bad}:")
197 lo = max(0, first_bad - 8)
198 hi = min(n, first_bad + 16)
199 print(f" expected: {payload[lo:hi].hex()}")
200 print(f" received: {bytes(recv[lo:hi]).hex()}")
201 print(f" chunk_boundary nearest: {(first_bad // chunk_size) * chunk_size}")
202 elif len(recv) != total_bytes:
203 print(f" short read: got {len(recv)}/{total_bytes}")
204
205
206def test_throughput(fd: int, flags: int, total_bytes: int, chunk_size: int, label: str) -> bool:
207 """Free-running write-and-read throughput measurement with integrity check.
208
209 Writes and reads interleaved without waiting for each chunk to echo, which
210 measures peak rate but can overrun the device IN FIFO for payloads past the
211 bulk max-packet size -- ``test_throughput_chunked`` is the flow-controlled
212 variant used for the pass/fail gate.
213
214 On mismatch it locates the FIRST differing byte and prints the surrounding
215 window plus the nearest chunk boundary, because a corruption that lands on
216 a boundary points at framing while one that does not points at the data
217 path.
218
219 The reported KB/s is per direction; the aggregate figure alongside it is
220 doubled since every byte traverses the link twice.
221
222 Returns True only when the full payload came back byte-identical.
223 """
224 print(f"=== Test: throughput {label} ({total_bytes}B in {chunk_size}B chunks) ===")
225 drain(fd, flags)
226 # Use a recognisable pseudorandom payload so any data-shuffle is visible
227 # in the first mismatching byte rather than blending into a counter
228 # pattern that wraps at 256.
229 rng = random.Random(RANDOM_SEED_THROUGHPUT) # noqa: S311 # non-crypto test data
230 payload = bytes(rng.randint(0, 255) for _ in range(total_bytes))
231 sent_off = 0
232 recv = bytearray()
233 start = time.monotonic()
234 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
235 throughput_timeout_s = 30.0 # generous wall-time cap for large transfers
236 try:
237 while sent_off < total_bytes or len(recv) < total_bytes:
238 if sent_off < total_bytes:
239 chunk = payload[sent_off : sent_off + chunk_size]
240 try:
241 n = os.write(fd, chunk)
242 sent_off += n
243 except BlockingIOError:
244 pass
245 try:
246 d = os.read(fd, 4096)
247 if d:
248 recv += d
249 except BlockingIOError:
250 pass
251 if time.monotonic() - start > throughput_timeout_s:
252 print(" TIMEOUT")
253 break
254 finally:
255 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
256 elapsed = time.monotonic() - start
257 bps = total_bytes / elapsed if elapsed > 0 else 0
258 integrity = bytes(recv) == payload[: len(recv)] and len(recv) == total_bytes
259 print(
260 f" sent {sent_off}B, recv {len(recv)}B in {elapsed:.2f}s "
261 f"-> {bps / 1024:.1f} KB/s (each direction, {2 * bps / 1024:.1f} KB/s aggregate)"
262 )
263 if not integrity:
264 _report_corruption(recv, payload, chunk_size, total_bytes)
265 print(f" data integrity: {'OK' if integrity else 'FAIL'}")
266 return integrity
267
268
269def test_throughput_chunked(
270 fd: int, flags: int, total_bytes: int, chunk_bytes: int, label: str
271) -> bool:
272 """Measure sustainable echo rate one chunk at a time, verifying as it goes.
273
274 Writes a chunk, waits for that chunk to echo back, verifies it, and only
275 then sends the next. The single-buffering is the point: a free-running
276 "write all, then read all" overruns the device-side IN FIFO for any
277 payload larger than the bulk-IN max-packet size, so it would measure the
278 overflow rather than the link.
279
280 Mismatch reporting is capped at a few chunks -- once framing has slipped
281 every subsequent chunk mismatches, and printing them all buries the first
282 and most informative one.
283
284 Returns True only when every chunk round-tripped exactly.
285 """
286 print(f"=== Test: chunked throughput {label} ({total_bytes}B in {chunk_bytes}B chunks) ===")
287 drain(fd, flags)
288 rng = random.Random(RANDOM_SEED_CHUNKED) # noqa: S311 # non-crypto test data
289 payload = bytes(rng.randint(0, 255) for _ in range(total_bytes))
290 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
291 start = time.monotonic()
292 offset = 0
293 chunk_failures = 0
294 while offset < total_bytes:
295 n = min(chunk_bytes, total_bytes - offset)
296 sent = payload[offset : offset + n]
297 os.write(fd, sent)
298 recv = b""
299 deadline = time.monotonic() + 2.0
300 fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
301 try:
302 while len(recv) < n and time.monotonic() < deadline:
303 try:
304 d = os.read(fd, 4096)
305 if d:
306 recv += d
307 else:
308 time.sleep(0.001)
309 except BlockingIOError: # non-blocking read inside timed loop
310 time.sleep(0.001)
311 finally:
312 fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
313 chunk_report_limit = 3 # limit repeated mismatch output
314 if recv != sent:
315 chunk_failures += 1
316 if chunk_failures <= chunk_report_limit:
317 sent_hex = sent.hex()[:40]
318 recv_hex = recv.hex()[:40]
319 print(f" chunk@{offset} mismatch ({n}B): sent {sent_hex}... recv {recv_hex}...")
320 offset += n
321 elapsed = time.monotonic() - start
322 bps = total_bytes / elapsed if elapsed > 0 else 0
323 print(
324 f" {total_bytes}B round-trip in {elapsed:.3f}s -> {bps / 1024:.1f} KB/s "
325 f"({2 * bps / 1024:.1f} KB/s aggregate); chunk failures = {chunk_failures}"
326 )
327 return chunk_failures == 0
328
329
330def find_cdc_device() -> str | None:
331 """Return /dev/ttyACMx that maps to a 1209:xxxx (pid.codes) device."""
332 dev_dir = Path("/dev")
333 for entry in sorted(dev_dir.iterdir()):
334 if not entry.name.startswith("ttyACM"):
335 continue
336 try:
337 info = subprocess.check_output( # noqa: S603 # trusted: fixed udevadm argv
338 ["udevadm", "info", str(entry)], # noqa: S607 # trusted: fixed udevadm argv
339 text=True,
340 )
341 except subprocess.CalledProcessError:
342 continue
343 if "ID_VENDOR_ID=1209" in info:
344 return str(entry)
345 return None
346
347
348def main() -> NoReturn:
349 """Run the correctness and throughput suite against one CDC ACM device.
350
351 Never returns -- every path exits. Exit 2 means the device could not be
352 found at all (a rig fault, distinct from a test failure), exit 1 means at
353 least one test failed, exit 0 means all passed. The HIL suite depends on
354 that distinction to tell a broken bench from broken firmware.
355
356 ``--quick`` runs only a short length sweep and skips the random and
357 throughput passes; it is for iterating on enumeration, not for validating
358 a build.
359
360 Only the chunked throughput test contributes to the verdict. The
361 free-running one is available but not called here, since its FIFO overrun
362 makes it a diagnostic rather than a gate.
363 """
364 parser = argparse.ArgumentParser()
365 parser.add_argument("device", nargs="?", help="/dev/ttyACMx (auto-detected if omitted)")
366 parser.add_argument("--quick", action="store_true", help="only run lengths 1..32")
367 parser.add_argument("--throughput-bytes", type=int, default=4096)
368 parser.add_argument("--throughput-chunk", type=int, default=64)
369 args = parser.parse_args()
370
371 device = args.device or find_cdc_device()
372 if device is None:
373 print("No /dev/ttyACMx with VID 1209 found; pass --device explicitly.")
374 sys.exit(2)
375 print(f"Benchmarking {device}")
376
377 fd, flags = open_raw(device)
378 time.sleep(0.5)
379 drain(fd, flags)
380
381 if args.quick:
382 ok = test_lengths(fd, flags, 32)
383 os.close(fd)
384 sys.exit(0 if ok else 1)
385
386 r1 = test_lengths(fd, flags, 64)
387 drain(fd, flags)
388 r2 = test_random(fd, flags, 50)
389 drain(fd, flags)
390 r3 = test_throughput_chunked(fd, flags, args.throughput_bytes, args.throughput_chunk, "echo")
391
392 print()
393 print(f" Test 1 (lengths 1..64) : {'PASS' if r1 else 'FAIL'}")
394 print(f" Test 2 (50 random 1..255B) : {'PASS' if r2 else 'FAIL'}")
395 print(f" Test 3 (chunked throughput) : {'PASS' if r3 else 'FAIL'}")
396 print()
397 overall = r1 and r2 and r3
398 print("OVERALL: " + ("ALL PASS" if overall else "FAIL"))
399 os.close(fd)
400 sys.exit(0 if overall else 1)
401
402
403if __name__ == "__main__":
404 main()
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157