4"""Measure sustained CDC ACM throughput without per-chunk serialization.
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.
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.
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
21 python3 stream_bench.py <device> [--bytes N]
23Prints one-way wire throughput (bytes/elapsed) plus a sanity check that
24every byte echoed back matches what was sent.
27from __future__
import annotations
38from pathlib
import Path
39from typing
import NoReturn
42def open_raw(device: str) -> int:
43 """Open a tty in fully raw mode and return the blocking fd.
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.
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.
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)
67 attr[1] &= ~termios.OPOST
68 termios.tcsetattr(fd, termios.TCSANOW, attr)
69 termios.tcflush(fd, termios.TCIOFLUSH)
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)
79 with contextlib.suppress(BlockingIOError):
84 fd: int, payload: bytes, sent: list[int], err: list[tuple[str, Exception]]
86 """Start the background writer that keeps the bulk-OUT pipe full."""
90 while sent[0] < len(payload):
91 _, w, _ = select.select([], [fd], [], 1.0)
94 with contextlib.suppress(BlockingIOError):
95 sent[0] += os.write(fd, payload[sent[0] : sent[0] + 65536])
96 except Exception
as e:
97 err.append((
"writer", e))
99 th = threading.Thread(target=writer, daemon=
True)
104def _read_back(fd: int, want: int, deadline: float) -> bytearray:
105 """Read until ``want`` bytes have arrived or ``deadline`` passes."""
107 while len(recv) < want
and time.monotonic() < deadline:
108 r, _, _ = select.select([fd], [], [], 0.5)
110 with contextlib.suppress(BlockingIOError):
111 d = os.read(fd, 4096)
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))
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()}")
127 if len(recv) != total_bytes:
128 print(f
" short read: got {len(recv)}/{total_bytes}")
131def _report_throughput(
132 sent: int, recv: bytes | bytearray, payload: bytes, total_bytes: int, elapsed: float
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'}")
143 _report_mismatch(recv, payload, total_bytes)
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)
152 payload = bytes((i * 131 + 17) & 0xFF
for i
in range(total_bytes))
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
164 print(f
" ERROR: {err}")
166 return _report_throughput(sent[0], recv, payload, total_bytes, elapsed)
169def find_cdc_device() -> str | None:
170 """Find the first /dev/ttyACM* whose USB vendor id is pid.codes (0x1209).
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.
176 Returns the device path, or None when no matching device is present --
177 the caller distinguishes that from a test failure.
179 dev_dir = Path(
"/dev")
180 for entry
in sorted(dev_dir.iterdir()):
181 if not entry.name.startswith(
"ttyACM"):
184 info = subprocess.check_output(
185 [
"udevadm",
"info", str(entry)],
188 except subprocess.CalledProcessError:
190 if "ID_VENDOR_ID=1209" in info:
195def main() -> NoReturn:
196 """Stream-benchmark one CDC ACM device and exit with the verdict.
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.
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.
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()
210 print(
"No /dev/ttyACMx with VID 1209 found.")
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)
217if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.