mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-07 04:53:47 +08:00
293 lines
11 KiB
Python
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)
|