Files
StarPilot/msgq_repo/msgq/examples/benchmark.py
T
2026-10-20 02:19:24 +00:00

293 lines
11 KiB
Python

# /// script
# requires-python = ">=3.11"
# dependencies = ["msgq-ipc>=1.0", "pyzmq", "eclipse-zenoh>=1.10", "lcm", "matplotlib"]
# [tool.uv.sources]
# msgq-ipc = { path = "../.." }
# [tool.ty.rules]
# unresolved-import = "ignore"
# ///
import os
import lcm
import zmq
import msgq
import time
import zenoh
import random
import argparse
import platform
import tempfile
import matplotlib
import statistics
import multiprocessing
from pathlib import Path
from threading import Event
from collections import deque # codespell:ignore deque
from functools import partial
from contextlib import ExitStack
matplotlib.use("Agg")
import matplotlib.pyplot as plt
BACKENDS = ("msgq", "pyzmq IPC", "Pipe bytes", "zenoh IPC", "LCM UDP")
ROUNDS = 1000
SECONDS = 2
REPEATS = 5
TIMEOUT = 60
def setup_backend(name, role, directory, prefix, barrier, pipe, cleanup, size):
outgoing, incoming = ("request", "reply") if role == "sender" else ("reply", "request")
if name == "msgq":
os.environ["OPENPILOT_PREFIX"] = prefix
publisher = msgq.pub_sock(outgoing)
# Creating a publisher resets its queue, so both must exist before subscribing.
barrier.wait(10)
subscriber = msgq.sub_sock(incoming)
return publisher.send, subscriber.receive
if name == "pyzmq IPC":
context = cleanup.enter_context(zmq.Context())
publisher = context.socket(zmq.XPUB)
subscriber = context.socket(zmq.SUB)
cleanup.callback(publisher.close, linger=0)
cleanup.callback(subscriber.close, linger=0)
publisher.setsockopt(zmq.RCVTIMEO, 5000)
subscriber.setsockopt(zmq.RCVTIMEO, 5000)
subscriber.setsockopt(zmq.SUBSCRIBE, b"")
publisher.bind(f"ipc://{directory}/{outgoing}")
subscriber.connect(f"ipc://{directory}/{incoming}")
if publisher.recv() != b"\x01":
raise RuntimeError("Unexpected ZeroMQ subscription")
return publisher.send, subscriber.recv
if name == "Pipe bytes":
cleanup.enter_context(pipe)
return pipe.send_bytes, pipe.recv_bytes
if name == "zenoh IPC":
endpoint = f"unixsock-stream/{directory}/zenoh"
config = zenoh.Config()
config.insert_json5("listen/endpoints", repr([endpoint] if role == "sender" else []))
config.insert_json5("connect/endpoints", repr([] if role == "sender" else [endpoint]))
config.insert_json5("scouting/multicast/enabled", "false")
config.insert_json5("scouting/gossip/enabled", "false")
config.insert_json5("transport/unicast/qos/enabled", "false")
# Low-latency transport cannot fragment a 64 KiB payload.
config.insert_json5("transport/unicast/lowlatency", str(size <= 1024).lower())
session = cleanup.enter_context(zenoh.open(config))
zenoh_subscriber = session.declare_subscriber(incoming)
cleanup.callback(zenoh_subscriber.undeclare)
publisher = session.declare_publisher(outgoing)
cleanup.callback(publisher.undeclare)
ready = Event()
listener = publisher.declare_matching_listener(lambda status: ready.set() if status.matching else None)
cleanup.callback(listener.undeclare)
if publisher.matching_status.matching:
ready.set()
if not ready.wait(10):
raise RuntimeError("Zenoh subscriber did not become ready")
return publisher.put, lambda: zenoh_subscriber.recv().payload.to_bytes()
if name == "LCM UDP":
bus = lcm.LCM("udpm://239.255.76.67:7667?ttl=0")
messages = deque() # codespell:ignore deque
token = Path(directory).name
subscriber = bus.subscribe(token + incoming, lambda channel, data: messages.append(data))
cleanup.callback(bus.unsubscribe, subscriber)
def receive():
while not messages:
if bus.handle_timeout(5000) == 0:
raise TimeoutError("LCM receive timed out; check host multicast support")
return messages.popleft()
return partial(bus.publish, token + outgoing), receive
raise ValueError(name)
def payload(sequence, padding):
return sequence.to_bytes(8, "little") + padding
def sender(send, receive, size):
padding = b"x" * (size - 8)
sequence = 0
def exchange():
nonlocal sequence
message = payload(sequence, padding)
send(message)
reply = receive()
if reply != message:
raise RuntimeError(f"Invalid reply at sequence {sequence}: {None if reply is None else (len(reply), reply[:8])}")
sequence += 1
start = time.perf_counter()
while sequence < 100 or time.perf_counter() - start < 0.1:
exchange()
send(b"BEGIN")
if receive() != b"BEGIN":
raise RuntimeError("Invalid measurement handshake")
warm_up_count = sequence
start = time.perf_counter()
while True:
for _ in range(64):
exchange()
elapsed = time.perf_counter() - start
count = sequence - warm_up_count
if count >= ROUNDS and elapsed >= SECONDS:
break
# Stop timing before the completion handshake.
send(b"END")
if receive() != b"END":
raise RuntimeError("Invalid completion handshake")
return {"round_trips": count, "seconds": elapsed}
def echo(send, receive, size):
padding = b"x" * (size - 8)
sequence = 0
measured_start = None
while True:
message = receive()
if message == b"BEGIN" and measured_start is None:
measured_start = sequence
send(message)
elif message == b"END" and measured_start is not None:
send(message)
return {"round_trips": sequence - measured_start}
else:
if message != payload(sequence, padding):
raise RuntimeError(f"Invalid request at sequence {sequence}: {None if message is None else (len(message), message[:8])}")
sequence += 1
send(message)
def peer(name, role, directory, prefix, barrier, pipe, control, size):
try:
with ExitStack() as cleanup:
send, receive = setup_backend(name, role, directory, prefix, barrier, pipe, cleanup, size)
barrier.wait(10)
result = sender(send, receive, size) if role == "sender" else echo(send, receive, size)
control.send(("ok", result))
except BaseException as error:
control.send(("error", f"{name} {role}: {type(error).__name__}: {error}"))
finally:
pipe.close()
control.close()
def measure(name, size):
context = multiprocessing.get_context("spawn")
shared_root = "/tmp" if platform.system() == "Darwin" else "/dev/shm"
with tempfile.TemporaryDirectory(prefix="mq_", dir="/tmp") as directory, \
tempfile.TemporaryDirectory(prefix="msgq_", dir=shared_root) as shared:
# An owned prefix isolates and removes MSGQ files without touching existing queues.
prefix = Path(shared).name.removeprefix("msgq_")
barrier = context.Barrier(2)
pipes = context.Pipe()
processes, controls = [], []
deadline = time.monotonic() + TIMEOUT
try:
for role, pipe in zip(("sender", "echo"), pipes, strict=True):
parent, child = context.Pipe(duplex=False)
controls.append(parent)
process = context.Process(target=peer, args=(name, role, directory, prefix, barrier, pipe, child, size))
process.start()
processes.append(process)
child.close()
for pipe in pipes:
pipe.close()
results = {}
while len(results) < 2:
if time.monotonic() >= deadline:
raise TimeoutError(f"{name}: trial exceeded {TIMEOUT}s")
for index, control in enumerate(controls):
if index in results:
continue
if control.poll(0.05):
try:
status, result = control.recv()
except EOFError as error:
raise RuntimeError(f"{name}: peer {index} closed its result pipe unexpectedly") from error
if status != "ok":
raise RuntimeError(result)
results[index] = result
elif processes[index].exitcode is not None:
raise RuntimeError(f"{name}: peer exited without results ({processes[index].exitcode})")
for process in processes:
process.join(max(0, deadline - time.monotonic()))
if process.exitcode != 0:
raise RuntimeError(f"{name}: peer failed to exit cleanly")
result = results[0]
if result["round_trips"] != results[1]["round_trips"]:
raise RuntimeError(f"{name}: peers disagree on verified message count")
return result
finally:
for process in processes:
if process.is_alive():
process.terminate()
for process in processes:
process.join(2)
if process.is_alive():
process.kill()
process.join()
for connection in (*controls, *pipes):
connection.close()
def plot_results(results, path):
from matplotlib.ticker import EngFormatter, MaxNLocator
labels = {"msgq": "msgq", "pyzmq IPC": "pyzmq", "Pipe bytes": "multiprocessing.Pipe", "zenoh IPC": "zenoh", "LCM UDP": "LCM"}
rows = sorted(((name, rate) for name, size, rate in results if size == 1024), key=lambda row: row[1])
maximum = max(rate for _, rate in rows)
formatter = EngFormatter(sep="", places=2)
with plt.rc_context({"font.family": "sans-serif", "font.sans-serif": ["Helvetica Neue", "Arial", "DejaVu Sans"]}):
fig, ax = plt.subplots(figsize=(10, max(2.2, len(rows) * 0.48 + 0.7)))
fig.set_facecolor("white")
ax.set(xlim=(0, maximum * 1.2), ylim=(len(rows) - 0.5, -0.5), yticks=[])
# Keep grid lines out of the values on the right.
ticks = MaxNLocator(nbins=4).tick_values(0, maximum)
ax.set_xticks([tick for tick in ticks if 0 <= tick < maximum])
ax.xaxis.set_major_formatter(EngFormatter(sep=""))
ax.set_xlabel("messages/sec — higher is better", fontsize=14, labelpad=8, color="#172b3a")
ax.tick_params(length=0, pad=6, labelsize=11, colors="#425b6c")
ax.set_axisbelow(True)
ax.grid(axis="x", color="#b7b7b7", linewidth=1.5)
for side, spine in ax.spines.items():
spine.set(visible=side == "left", color="#192a32", linewidth=2)
for index, (name, rate) in enumerate(rows):
gray = str(0.88 - 0.28 * index / max(len(rows) - 2, 1))
ax.barh(index, rate, height=1, color="#31cf43" if name == "msgq" else gray, zorder=2)
ax.text(maximum * 0.029, index, labels[name], va="center", fontsize=16, fontweight="bold", color="#172b3a")
ax.text(maximum * 1.173, index, formatter(rate), ha="right", va="center", fontsize=16, fontweight="bold", color="#172b3a")
fig.subplots_adjust(left=0.055, right=0.98, top=0.98, bottom=0.21)
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(path, dpi=180, facecolor=fig.get_facecolor())
plt.close(fig)
print(f"Chart saved to {path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Cross-process pub/sub ping-pong benchmark.")
parser.add_argument("--plot", type=Path, help="save a 1 KiB chart")
args = parser.parse_args()
if "CEREAL_FAKE" in os.environ:
parser.error("Unset CEREAL_FAKE to benchmark the real MSGQ backend")
print("Cross-process ping-pong; messages/sec counts requests and replies.", flush=True)
samples = {(name, size): [] for name in BACKENDS for size in (64, 1024, 65536)}
randomizer = random.Random(0)
for _ in range(REPEATS):
jobs = list(samples)
randomizer.shuffle(jobs)
for name, size in jobs:
sample = measure(name, size)
rate = 2 * sample["round_trips"] / sample["seconds"]
samples[name, size].append(rate)
print(f"{name:<12} {size:>6} bytes: {rate:>12,.0f} messages/sec", flush=True)
results = [(name, size, statistics.median(rates)) for (name, size), rates in samples.items()]
print("\nMedians:")
for name, size, rate in results:
print(f"{name:<12} {size:>6} bytes: {rate:>12,.0f} messages/sec")
if args.plot:
plot_results(results, args.plot)