summaryrefslogtreecommitdiff
path: root/tests/udp_scaling.py
diff options
context:
space:
mode:
authorDirk Engling <erdgeist@erdgeist.org>2026-10-28 17:11:33 +0100
committerDirk Engling <erdgeist@erdgeist.org>2026-10-28 17:11:33 +0100
commit9ec918187ab65488ebcce427f5a14a6b38e87b78 (patch)
tree4110ae8b03a67628cd020675159b64c765fb4f07 /tests/udp_scaling.py
parentf3f640b6bcf10ba463fc81cec97cfa212c520cfe (diff)
Add load tests to sanitize check the trackerlogic, based on udp requests
Diffstat (limited to 'tests/udp_scaling.py')
-rw-r--r--tests/udp_scaling.py135
1 files changed, 135 insertions, 0 deletions
diff --git a/tests/udp_scaling.py b/tests/udp_scaling.py
new file mode 100644
index 0000000..b5048cd
--- /dev/null
+++ b/tests/udp_scaling.py
@@ -0,0 +1,135 @@
1"""Does listen.udp.workers help or hurt, on this platform, with this tracker?
2
3Starts a tracker once per worker count and drives it with tests/udp_load.c,
4which speaks the real protocol, so what comes out is the tracker's own
5throughput rather than an echo server's.
6
7 cc -O2 -pthread -o /tmp/udp_load tests/udp_load.c
8 make opentracker
9 python3 tests/udp_scaling.py
10
11Worker count 0 means no dedicated workers: udp is served from the main event
12loop, which is what a default build does.
13
14LOAD_THREADS and WINDOW decide how much load is offered. Both matter. Too
15little and the tracker idles while the figure measures round trips; too much
16and the kernel drops datagrams, every lost reply costs the generator a
17timeout, and the figure collapses. The generator prints how many were lost:
18tune until that is near zero and the throughput has stopped rising.
19"""
20import os
21import signal
22import socket
23import subprocess
24import sys
25import tempfile
26import time
27
28TRACKER = os.environ.get("OPENTRACKER", "./opentracker")
29LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load")
30WORKER_COUNTS = [int(n) for n in os.environ.get("WORKERS", "0 1 2 4 8").split()]
31LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4"))
32SECONDS = float(os.environ.get("SECONDS", "5"))
33TORRENTS = int(os.environ.get("TORRENTS", "10000"))
34WINDOW = int(os.environ.get("WINDOW", "32"))
35REPEATS = int(os.environ.get("REPEATS", "3"))
36PORT_BASE = int(os.environ.get("PORT_BASE", "19960"))
37
38
39def start(port, workers):
40 directory = tempfile.mkdtemp(prefix="ot-udpscale-")
41 config = "listen.tcp 127.0.0.1:%d\nlisten.udp 127.0.0.1:%d\n" % (port, port)
42 if workers:
43 config += "listen.udp.workers %d\n" % workers
44 with open(os.path.join(directory, "conf"), "w") as handle:
45 handle.write(config)
46 log = open(os.path.join(directory, "log"), "w+b")
47 tracker = subprocess.Popen([TRACKER, "-f", os.path.join(directory, "conf")], stdout=log, stderr=log)
48 for _ in range(300):
49 if tracker.poll() is not None:
50 log.seek(0)
51 raise SystemExit("tracker exited at once on port %d with %d workers:\n%s"
52 % (port, workers, log.read().decode("utf-8", "replace")))
53 try:
54 socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
55 return tracker, log
56 except OSError:
57 time.sleep(0.02)
58 tracker.kill()
59 raise SystemExit("tracker never listened on %d" % port)
60
61
62def cpu_seconds(pid):
63 """Cumulative cpu time of a process, the one spelling ps has everywhere."""
64 try:
65 out = subprocess.run(["ps", "-o", "time=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()
66 except OSError:
67 return None
68 if not out:
69 return None
70 total, parts = 0.0, out.replace("-", ":").split(":")
71 for part in parts:
72 total = total * 60 + float(part)
73 return total
74
75
76def stop(tracker):
77 tracker.send_signal(signal.SIGINT)
78 try:
79 tracker.wait(timeout=10)
80 except subprocess.TimeoutExpired:
81 tracker.kill()
82 tracker.wait()
83
84
85def main():
86 if not os.path.exists(LOADER):
87 raise SystemExit("build the generator first: cc -O2 -pthread -o %s tests/udp_load.c" % LOADER)
88
89 print(" tracker %s, %d load threads x %d in flight, %d torrents, %.0f s x %d runs"
90 % (TRACKER, LOAD_THREADS, WINDOW, TORRENTS, SECONDS, REPEATS))
91 print(" %-8s %12s %12s %12s %7s %s"
92 % ("workers", "median/s", "lowest", "highest", "cores", "vs 0 workers"))
93 baseline = None
94 for index, workers in enumerate(WORKER_COUNTS):
95 rates, cores = [], []
96 for repeat in range(REPEATS):
97 port = PORT_BASE + index * 16 + repeat
98 tracker, log = start(port, workers)
99 try:
100 # Warm up until the torrent set exists and the peers are in
101 # place, so the measured run is renewals rather than insertions
102 subprocess.run([LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), "2", str(TORRENTS), str(WINDOW)],
103 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
104 spent, wall = cpu_seconds(tracker.pid), time.time()
105 result = subprocess.run(
106 [LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), str(SECONDS), str(TORRENTS), str(WINDOW)],
107 capture_output=True, text=True,
108 )
109 used = cpu_seconds(tracker.pid)
110 finally:
111 stop(tracker)
112 log.close()
113 head = result.stdout.strip().split()
114 if head and head[0].replace(".", "").isdigit():
115 rates.append(float(head[0]))
116 if used is not None and spent is not None:
117 cores.append((used - spent) / max(time.time() - wall, 1e-9))
118
119 if not rates:
120 print(" %-8s no result" % workers)
121 continue
122 rates.sort()
123 median = rates[len(rates) // 2]
124 if baseline is None:
125 baseline = median
126 # A spread wider than the differences between rows means the rows are
127 # not telling you anything
128 busy = ("%7.2f" % (sorted(cores)[len(cores) // 2])) if cores else " ?"
129 print(" %-8s %12.0f %12.0f %12.0f %s %.2fx (spread %.0f%%)"
130 % (workers, median, rates[0], rates[-1], busy, median / baseline,
131 100.0 * (rates[-1] - rates[0]) / median))
132
133
134if __name__ == "__main__":
135 main()