ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
stream_bench.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"""Measure sustained CDC ACM throughput without per-chunk serialization.
5
6This is the right tool for asking "what is the actual line rate?".
7``benchmark.py`` deliberately waits for each chunk to echo before sending the
8next, which bounds it by round-trip latency rather than bandwidth; this one
9keeps the pipe full and so measures the link.
10
11Methodology:
12 * Writer thread fires the whole payload back-to-back, no waits.
13 * Reader thread drains in a tight non-blocking select() loop, no sleeps.
14 * Wall-time runs from first byte written to last byte received.
15
16Because both directions are in flight at once, the device must be able to
17absorb the whole payload; this is a throughput measurement, not the
18correctness gate.
19
20Usage:
21 python3 stream_bench.py <device> [--bytes N]
22
23Prints one-way wire throughput (bytes/elapsed) plus a sanity check that
24every byte echoed back matches what was sent.
25"""
26
27from __future__ import annotations
28
29import argparse
30import contextlib
31import os
32import select
33import subprocess
34import sys
35import termios
36import threading
37import time
38from pathlib import Path
39from typing import NoReturn
40
41
42def open_raw(device: str) -> int:
43 """Open a tty in fully raw mode and return the blocking fd.
44
45 Clears every line-discipline transformation, ICRNL above all: on a
46 byte-exact echo measurement it would rewrite 0x0D to 0x0A and show up as
47 device-side corruption.
48
49 Unlike the correctness benchmark's version this returns the fd alone and
50 leaves O_NONBLOCK SET, because every reader and writer here is driven by
51 select() and never wants to block.
52 """
53 fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
54 attr = termios.tcgetattr(fd)
55 attr[2] |= termios.CLOCAL
56 attr[2] &= ~termios.CRTSCTS
57 attr[3] &= ~(termios.ICANON | termios.ECHO | termios.ECHOE | termios.ECHONL | termios.ISIG)
58 attr[0] &= ~(
59 termios.INPCK
60 | termios.ISTRIP
61 | termios.IXON
62 | termios.IXOFF
63 | termios.INLCR
64 | termios.IGNCR
65 | termios.ICRNL
66 )
67 attr[1] &= ~termios.OPOST
68 termios.tcsetattr(fd, termios.TCSANOW, attr)
69 termios.tcflush(fd, termios.TCIOFLUSH)
70 return fd
71
72
73def _drain(fd: int, window_s: float = 0.3) -> None:
74 """Discard anything already buffered so the timing starts clean."""
75 end = time.monotonic() + window_s
76 while time.monotonic() < end:
77 r, _, _ = select.select([fd], [], [], 0.05)
78 if r:
79 with contextlib.suppress(BlockingIOError):
80 os.read(fd, 4096)
81
82
83def _spawn_writer(
84 fd: int, payload: bytes, sent: list[int], err: list[tuple[str, Exception]]
85) -> threading.Thread:
86 """Start the background writer that keeps the bulk-OUT pipe full."""
87
88 def writer() -> None:
89 try:
90 while sent[0] < len(payload):
91 _, w, _ = select.select([], [fd], [], 1.0)
92 if not w:
93 continue
94 with contextlib.suppress(BlockingIOError):
95 sent[0] += os.write(fd, payload[sent[0] : sent[0] + 65536])
96 except Exception as e: # noqa: BLE001 -- thread boundary: transport any failure to the joiner
97 err.append(("writer", e))
98
99 th = threading.Thread(target=writer, daemon=True)
100 th.start()
101 return th
102
103
104def _read_back(fd: int, want: int, deadline: float) -> bytearray:
105 """Read until ``want`` bytes have arrived or ``deadline`` passes."""
106 recv = bytearray()
107 while len(recv) < want and time.monotonic() < deadline:
108 r, _, _ = select.select([fd], [], [], 0.5)
109 if r:
110 with contextlib.suppress(BlockingIOError):
111 d = os.read(fd, 4096)
112 if d:
113 recv += d
114 return recv
115
116
117def _report_mismatch(recv: bytes | bytearray, payload: bytes, total_bytes: int) -> None:
118 """Print the first differing offset, with context on both sides."""
119 n = min(len(recv), len(payload))
120 for i in range(n):
121 if recv[i] != payload[i]:
122 lo, hi = max(0, i - 8), min(n, i + 16)
123 print(f" first mismatch at offset {i}")
124 print(f" expected: {payload[lo:hi].hex()}")
125 print(f" received: {bytes(recv[lo:hi]).hex()}")
126 break
127 if len(recv) != total_bytes:
128 print(f" short read: got {len(recv)}/{total_bytes}")
129
130
131def _report_throughput(
132 sent: int, recv: bytes | bytearray, payload: bytes, total_bytes: int, elapsed: float
133) -> bool:
134 """Print the timing and integrity verdict. Returns True when clean."""
135 ok = bytes(recv) == payload[: len(recv)] and len(recv) == total_bytes
136 oneway = (total_bytes / elapsed) if elapsed > 0 else 0.0
137 aggreg = 2.0 * oneway
138 print(f" sent {sent} B, recv {len(recv)} B in {elapsed:.3f} s")
139 print(f" one-way wire throughput : {oneway / 1024:9.1f} KB/s ({oneway / 1e6 * 8:.2f} Mbps)")
140 print(f" aggregate (both dirs) : {aggreg / 1024:9.1f} KB/s ({aggreg / 1e6 * 8:.2f} Mbps)")
141 print(f" data integrity : {'OK' if ok else 'FAIL'}")
142 if not ok:
143 _report_mismatch(recv, payload, total_bytes)
144 return ok
145
146
147def stream_echo(device: str, total_bytes: int) -> bool:
148 """Full-duplex echo bench: a writer thread fills OUT while we drain IN."""
149 fd = open_raw(device)
150 _drain(fd)
151
152 payload = bytes((i * 131 + 17) & 0xFF for i in range(total_bytes))
153 sent = [0]
154 err = []
155
156 t0 = time.monotonic()
157 th = _spawn_writer(fd, payload, sent, err)
158 recv = _read_back(fd, total_bytes, t0 + 30.0)
159 elapsed = time.monotonic() - t0
160 th.join(timeout=1.0)
161 os.close(fd)
162
163 if err:
164 print(f" ERROR: {err}")
165 return False
166 return _report_throughput(sent[0], recv, payload, total_bytes, elapsed)
167
168
169def find_cdc_device() -> str | None:
170 """Find the first /dev/ttyACM* whose USB vendor id is pid.codes (0x1209).
171
172 Matches on vendor id via udevadm rather than on device name order, since
173 ttyACM numbering depends on enumeration order and a J-Link or another
174 board can take ttyACM0.
175
176 Returns the device path, or None when no matching device is present --
177 the caller distinguishes that from a test failure.
178 """
179 dev_dir = Path("/dev")
180 for entry in sorted(dev_dir.iterdir()):
181 if not entry.name.startswith("ttyACM"):
182 continue
183 try:
184 info = subprocess.check_output( # noqa: S603 # trusted: fixed udevadm argv
185 ["udevadm", "info", str(entry)], # noqa: S607 # trusted: fixed udevadm argv
186 text=True,
187 )
188 except subprocess.CalledProcessError:
189 continue
190 if "ID_VENDOR_ID=1209" in info:
191 return str(entry)
192 return None
193
194
195def main() -> NoReturn:
196 """Stream-benchmark one CDC ACM device and exit with the verdict.
197
198 Never returns. Exit 2 means no device was found (a rig fault), 1 means the
199 echoed data did not match or the writer thread raised, 0 means clean.
200
201 Default payload is 64 KiB, large enough to amortise start-up cost while
202 still fitting comfortably in the 30 s read deadline on a slow link.
203 """
204 ap = argparse.ArgumentParser()
205 ap.add_argument("device", nargs="?", default=None)
206 ap.add_argument("--bytes", type=int, default=65536)
207 args = ap.parse_args()
208 dev = args.device or find_cdc_device()
209 if dev is None:
210 print("No /dev/ttyACMx with VID 1209 found.")
211 sys.exit(2)
212 print(f"Stream-benchmarking {dev}, payload = {args.bytes} B")
213 ok = stream_echo(dev, args.bytes)
214 sys.exit(0 if ok else 1)
215
216
217if __name__ == "__main__":
218 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