summaryrefslogtreecommitdiff
path: root/tests
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
parentf3f640b6bcf10ba463fc81cec97cfa212c520cfe (diff)
Add load tests to sanitize check the trackerlogic, based on udp requests
Diffstat (limited to 'tests')
-rw-r--r--tests/udp_load.c202
-rw-r--r--tests/udp_modes.c240
-rw-r--r--tests/udp_modes.py139
-rw-r--r--tests/udp_scaling.py135
-rw-r--r--tests/udp_wake_probe.c99
5 files changed, 815 insertions, 0 deletions
diff --git a/tests/udp_load.c b/tests/udp_load.c
new file mode 100644
index 0000000..d86c157
--- /dev/null
+++ b/tests/udp_load.c
@@ -0,0 +1,202 @@
1/* A UDP load generator speaking the tracker's own protocol.
2
3 Each thread keeps a socket of its own, does the BEP 15 connect handshake
4 once and then announces in a loop, counting replies. Separate sockets
5 matter: the kernel spreads datagrams across a SO_REUSEPORT group by a hash
6 of the four tuple, so a generator sending everything from one source port
7 would pile every packet onto one of the tracker's workers and measure the
8 wrong thing.
9
10 cc -O2 -pthread -o udp_load tests/udp_load.c
11 ./udp_load 127.0.0.1 6969 4 5 10000 64
12
13 The last argument is how many announces each thread keeps in flight. With
14 one, a thread sends and waits, so the offered load is threads divided by
15 the round trip and the figure says more about scheduling than about the
16 tracker: measured here, a tracker answering 60k/s that way was using
17 three quarters of one core. Raising the window offers load without
18 needing a thread per outstanding request, which is what lets the tracker
19 be pushed to the point where it is actually the limit.
20
21 The last argument is how many distinct torrents to announce. It is what
22 keeps the figure honest: a fresh info hash every time measures how fast
23 the tracker can insert a million torrents into sorted vectors, which is
24 not what a tracker spends its life doing, and which punishes whichever
25 configuration is quickest because it reaches the expensive end of the run
26 soonest. Cycling a fixed set reaches a steady state where almost every
27 announce renews a peer that is already there. */
28
29#include <arpa/inet.h>
30#include <netinet/in.h>
31#include <pthread.h>
32#include <stdatomic.h>
33#include <stdint.h>
34#include <stdio.h>
35#include <stdlib.h>
36#include <string.h>
37#include <sys/socket.h>
38#include <sys/time.h>
39#include <time.h>
40#include <unistd.h>
41
42#define CONNECT_MAGIC 0x41727101980ULL
43
44static struct sockaddr_in g_tracker;
45static long g_torrents = 10000;
46static long g_window = 64;
47static _Atomic long long g_sent = 0;
48static _Atomic int g_running = 1;
49static _Atomic long long g_replies = 0;
50static _Atomic long long g_timeouts = 0;
51
52static double seconds(void) {
53 struct timespec now;
54 clock_gettime(CLOCK_MONOTONIC, &now);
55 return now.tv_sec + now.tv_nsec / 1e9;
56}
57
58static void put64(unsigned char *at, uint64_t value) {
59 int i;
60 for (i = 0; i < 8; ++i)
61 at[i] = (unsigned char)(value >> (56 - 8 * i));
62}
63static void put32(unsigned char *at, uint32_t value) {
64 at[0] = (unsigned char)(value >> 24);
65 at[1] = (unsigned char)(value >> 16);
66 at[2] = (unsigned char)(value >> 8);
67 at[3] = (unsigned char)value;
68}
69static uint64_t get64(const unsigned char *at) {
70 uint64_t value = 0;
71 int i;
72 for (i = 0; i < 8; ++i)
73 value = (value << 8) | at[i];
74 return value;
75}
76
77static void *loader(void *args) {
78 long index = (long)args;
79 unsigned char request[98], reply[1500];
80 uint64_t connection_id = 0;
81 int sock = socket(AF_INET, SOCK_DGRAM, 0);
82 struct timeval patience = {0, 50000}; /* 50 ms */
83 unsigned seed = (unsigned)index * 2654435761u + 1;
84
85 if (sock < 0)
86 return NULL;
87 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &patience, sizeof(patience));
88
89 /* connect handshake */
90 memset(request, 0, sizeof(request));
91 put64(request, CONNECT_MAGIC);
92 put32(request + 8, 0);
93 put32(request + 12, (uint32_t)index);
94 if (sendto(sock, request, 16, 0, (struct sockaddr *)&g_tracker, sizeof(g_tracker)) != 16) {
95 close(sock);
96 return NULL;
97 }
98 if (recvfrom(sock, reply, sizeof(reply), 0, NULL, NULL) < 16) {
99 fprintf(stderr, " thread %ld: no answer to connect\n", index);
100 close(sock);
101 return NULL;
102 }
103 connection_id = get64(reply + 8);
104
105 /* Keep g_window announces outstanding: send that many, then drain what has
106 come back, rather than waiting for each reply in turn */
107 while (atomic_load_explicit(&g_running, memory_order_relaxed)) {
108 ssize_t got;
109 int i;
110 long outstanding;
111
112 for (outstanding = 0; outstanding < g_window; ++outstanding) {
113 memset(request, 0, sizeof(request));
114 put64(request, connection_id);
115 put32(request + 8, 1); /* announce */
116 put32(request + 12, (uint32_t)index);
117 /* One of a fixed set, so that after the first pass almost every announce
118 renews a peer rather than creating a torrent. The index is hashed into
119 all twenty bytes so the buckets, which key off the first four, are
120 spread rather than walked in order. */
121 {
122 unsigned which = (unsigned)((seed = seed * 1103515245u + 12345u) >> 8) % (unsigned)g_torrents;
123 unsigned spread;
124 for (i = 0; i < 20; ++i) {
125 spread = which * 2654435761u + (unsigned)i * 40503u;
126 spread ^= spread >> 13;
127 request[16 + i] = (unsigned char)(spread >> 11);
128 }
129 }
130 for (i = 0; i < 20; ++i)
131 request[36 + i] = (unsigned char)('a' + (i % 26));
132 put64(request + 56, 0); /* downloaded */
133 put64(request + 64, 0); /* left: a seeder */
134 put64(request + 72, 0); /* uploaded */
135 put32(request + 80, 0); /* event */
136 put32(request + 84, 0); /* ip */
137 put32(request + 88, (uint32_t)index); /* key */
138 put32(request + 92, (uint32_t)-1); /* num_want */
139 /* A port of this thread's own, so each thread is a distinct peer and the
140 swarms hold more than one */
141 request[96] = (unsigned char)((6881 + index) >> 8);
142 request[97] = (unsigned char)((6881 + index) & 0xff);
143
144 if (sendto(sock, request, 98, 0, (struct sockaddr *)&g_tracker, sizeof(g_tracker)) != 98)
145 break;
146 atomic_fetch_add_explicit(&g_sent, 1, memory_order_relaxed);
147 }
148
149 for (outstanding = 0; outstanding < g_window; ++outstanding) {
150 got = recvfrom(sock, reply, sizeof(reply), 0, NULL, NULL);
151 if (got >= 20)
152 atomic_fetch_add_explicit(&g_replies, 1, memory_order_relaxed);
153 else {
154 atomic_fetch_add_explicit(&g_timeouts, 1, memory_order_relaxed);
155 break; /* nothing more is coming; go round and send again */
156 }
157 }
158 }
159
160 close(sock);
161 return NULL;
162}
163
164int main(int argc, char **argv) {
165 const char *host = argc > 1 ? argv[1] : "127.0.0.1";
166 int port = argc > 2 ? atoi(argv[2]) : 6969;
167 long threads = argc > 3 ? atol(argv[3]) : 16;
168 double run_for = argc > 4 ? atof(argv[4]) : 5.0;
169 long torrents = argc > 5 ? atol(argv[5]) : 10000;
170 long window = argc > 6 ? atol(argv[6]) : 64;
171 pthread_t *ids = calloc((size_t)threads, sizeof(pthread_t));
172 long i;
173 double started, elapsed;
174
175 g_torrents = torrents > 0 ? torrents : 1;
176 g_window = window > 0 ? window : 1;
177
178 memset(&g_tracker, 0, sizeof(g_tracker));
179 g_tracker.sin_family = AF_INET;
180 g_tracker.sin_port = htons((uint16_t)port);
181 if (inet_pton(AF_INET, host, &g_tracker.sin_addr) != 1) {
182 fprintf(stderr, "cannot parse address %s\n", host);
183 return 1;
184 }
185
186 for (i = 0; i < threads; ++i)
187 pthread_create(ids + i, NULL, loader, (void *)i);
188
189 started = seconds();
190 usleep((useconds_t)(run_for * 1e6));
191 atomic_store(&g_running, 0);
192 elapsed = seconds() - started;
193
194 for (i = 0; i < threads; ++i)
195 pthread_join(ids[i], NULL);
196
197 printf("%.0f replies/s (%.0f sent/s, %lld replies, %lld lost, %ld threads x %ld in flight, %.1f s)\n",
198 (double)atomic_load(&g_replies) / elapsed, (double)atomic_load(&g_sent) / elapsed,
199 (long long)atomic_load(&g_replies), (long long)atomic_load(&g_timeouts), threads, g_window, elapsed);
200 free(ids);
201 return 0;
202}
diff --git a/tests/udp_modes.c b/tests/udp_modes.c
new file mode 100644
index 0000000..654cfd8
--- /dev/null
+++ b/tests/udp_modes.c
@@ -0,0 +1,240 @@
1/* A receiver with the udp arrangements we are choosing between, so they can
2 be compared without any of them being built into the tracker first.
3
4 It echoes what it is sent, so tests/udp_load.c drives it unchanged. There
5 is no tracker work here on purpose: with nothing else happening, whatever
6 the kernel's receive path costs is the whole figure, which is the clearest
7 view of it. Pass a work time to dilute that back towards realism.
8
9 cc -O2 -pthread -o udp_modes tests/udp_modes.c
10 ./udp_modes 6969 4 shared 1
11 ./udp_modes 6969 4 shared 32
12 ./udp_modes 6969 4 per-thread 1
13 ./udp_modes 6969 4 per-thread 32
14
15 Arguments: port, threads, shared|per-thread, datagrams per syscall,
16 and optionally nanoseconds of pretend work per datagram. */
17
18#define _GNU_SOURCE /* struct mmsghdr on glibc */
19
20#include <errno.h>
21#include <netinet/in.h>
22#include <pthread.h>
23#include <signal.h>
24#include <stdatomic.h>
25#include <stdio.h>
26#include <stdlib.h>
27#include <string.h>
28#include <sys/socket.h>
29#include <time.h>
30#include <unistd.h>
31
32#if defined(__linux__) || defined(__FreeBSD__)
33#define HAVE_MMSG 1
34#endif
35
36#define MAX_BATCH 256
37#define BUFFER_SIZE 2048
38
39static int g_port;
40static long g_threads;
41static long g_batch;
42static long g_work_ns;
43static int g_shared_socket = -1; /* -1 means one socket per thread */
44static _Atomic int g_running = 1;
45
46static void stop(int sig) {
47 (void)sig;
48 atomic_store(&g_running, 0);
49}
50
51/* Which option the platform offers for spreading datagrams over a group of
52 sockets bound to one address. They are not the same thing: FreeBSD's plain
53 SO_REUSEPORT permits the bind without spreading anything, and Darwin's
54 hands every datagram to whichever socket bound last. */
55static const char *share_the_port(int sock) {
56 int one = 1;
57
58 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
59#ifdef SO_REUSEPORT_LB
60 if (!setsockopt(sock, SOL_SOCKET, SO_REUSEPORT_LB, &one, sizeof(one)))
61 return "SO_REUSEPORT_LB";
62#endif
63#ifdef SO_REUSEPORT
64 if (!setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)))
65 return "SO_REUSEPORT";
66#endif
67 return "neither";
68}
69
70static int bind_one(int share, const char **option) {
71 struct sockaddr_in address;
72 int sock = socket(AF_INET, SOCK_DGRAM, 0);
73
74 if (sock < 0)
75 return -1;
76 *option = share ? share_the_port(sock) : "exclusive";
77
78 memset(&address, 0, sizeof(address));
79 address.sin_family = AF_INET;
80 address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
81 address.sin_port = htons((uint16_t)g_port);
82 if (bind(sock, (struct sockaddr *)&address, sizeof(address)) < 0) {
83 close(sock);
84 return -1;
85 }
86 return sock;
87}
88
89static void pretend_to_work(void) {
90 struct timespec started, now;
91 long waited = 0;
92
93 if (!g_work_ns)
94 return;
95 clock_gettime(CLOCK_MONOTONIC, &started);
96 while (waited < g_work_ns) {
97 clock_gettime(CLOCK_MONOTONIC, &now);
98 waited = (now.tv_sec - started.tv_sec) * 1000000000L + (now.tv_nsec - started.tv_nsec);
99 }
100}
101
102static void one_at_a_time(int sock) {
103 char buffer[BUFFER_SIZE];
104 struct sockaddr_in from;
105 socklen_t length;
106 ssize_t got;
107
108 while (atomic_load_explicit(&g_running, memory_order_relaxed)) {
109 length = sizeof(from);
110 got = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr *)&from, &length);
111 if (got < 0) {
112 if (errno == EINTR || errno == EAGAIN)
113 continue;
114 return;
115 }
116 pretend_to_work();
117 sendto(sock, buffer, (size_t)got, 0, (struct sockaddr *)&from, length);
118 }
119}
120
121#ifdef HAVE_MMSG
122static void in_batches(int sock) {
123 static _Thread_local char buffers[MAX_BATCH][BUFFER_SIZE];
124 static _Thread_local struct sockaddr_in froms[MAX_BATCH];
125 static _Thread_local struct iovec iovecs[MAX_BATCH];
126 static _Thread_local struct mmsghdr messages[MAX_BATCH];
127 int got, i;
128
129 while (atomic_load_explicit(&g_running, memory_order_relaxed)) {
130 for (i = 0; i < g_batch; ++i) {
131 iovecs[i].iov_base = buffers[i];
132 iovecs[i].iov_len = BUFFER_SIZE;
133 memset(messages + i, 0, sizeof(messages[i]));
134 messages[i].msg_hdr.msg_name = froms + i;
135 messages[i].msg_hdr.msg_namelen = sizeof(froms[i]);
136 messages[i].msg_hdr.msg_iov = iovecs + i;
137 messages[i].msg_hdr.msg_iovlen = 1;
138 }
139
140 /* WAITFORONE so a quiet receiver behaves like the unbatched one rather
141 than holding the first datagram until the batch fills */
142 got = recvmmsg(sock, messages, (unsigned)g_batch, MSG_WAITFORONE, NULL);
143 if (got < 0) {
144 if (errno == EINTR || errno == EAGAIN)
145 continue;
146 return;
147 }
148
149 for (i = 0; i < got; ++i) {
150 pretend_to_work();
151 iovecs[i].iov_len = messages[i].msg_len;
152 messages[i].msg_hdr.msg_namelen = sizeof(froms[i]);
153 }
154 sendmmsg(sock, messages, (unsigned)got, 0);
155 }
156}
157#endif
158
159static void *worker(void *args) {
160 const char *option = "";
161 int sock = g_shared_socket;
162
163 (void)args;
164 if (sock < 0) {
165 sock = bind_one(1, &option);
166 if (sock < 0) {
167 fprintf(stderr, " a worker could not bind: %s\n", strerror(errno));
168 return NULL;
169 }
170 }
171
172#ifdef HAVE_MMSG
173 if (g_batch > 1)
174 in_batches(sock);
175 else
176#endif
177 one_at_a_time(sock);
178
179 if (g_shared_socket < 0)
180 close(sock);
181 return NULL;
182}
183
184int main(int argc, char **argv) {
185 const char *how = argc > 3 ? argv[3] : "shared";
186 const char *option = "";
187 pthread_t *threads;
188 long i;
189
190 g_port = argc > 1 ? atoi(argv[1]) : 6969;
191 g_threads = argc > 2 ? atol(argv[2]) : 1;
192 g_batch = argc > 4 ? atol(argv[4]) : 1;
193 g_work_ns = argc > 5 ? atol(argv[5]) : 0;
194 if (g_batch < 1)
195 g_batch = 1;
196 if (g_batch > MAX_BATCH)
197 g_batch = MAX_BATCH;
198
199#ifndef HAVE_MMSG
200 if (g_batch > 1) {
201 fprintf(stderr, " no recvmmsg on this platform; batching unavailable\n");
202 return 2;
203 }
204#endif
205
206 signal(SIGINT, stop);
207 signal(SIGTERM, stop);
208
209 if (!strcmp(how, "shared")) {
210 g_shared_socket = bind_one(0, &option);
211 if (g_shared_socket < 0) {
212 fprintf(stderr, " bind failed: %s\n", strerror(errno));
213 return 1;
214 }
215 } else {
216 /* Probe once so the mode line can say what the platform gave us */
217 int probe = bind_one(1, &option);
218 if (probe < 0) {
219 fprintf(stderr, " bind failed: %s\n", strerror(errno));
220 return 1;
221 }
222 close(probe);
223 }
224
225 printf(" port %d, %ld threads, %s socket%s (%s), %ld per syscall, %ld ns work\n",
226 g_port, g_threads, strcmp(how, "shared") ? "one per thread" : "one shared",
227 strcmp(how, "shared") ? "s" : "", option, g_batch, g_work_ns);
228 fflush(stdout);
229
230 threads = calloc((size_t)g_threads, sizeof(pthread_t));
231 for (i = 0; i < g_threads; ++i)
232 pthread_create(threads + i, NULL, worker, NULL);
233 for (i = 0; i < g_threads; ++i)
234 pthread_join(threads[i], NULL);
235
236 if (g_shared_socket >= 0)
237 close(g_shared_socket);
238 free(threads);
239 return 0;
240}
diff --git a/tests/udp_modes.py b/tests/udp_modes.py
new file mode 100644
index 0000000..19cd264
--- /dev/null
+++ b/tests/udp_modes.py
@@ -0,0 +1,139 @@
1"""The four udp arrangements, side by side.
2
3 cc -O2 -pthread -o /tmp/udp_modes tests/udp_modes.c
4 cc -O2 -pthread -o /tmp/udp_load tests/udp_load.c
5 python3 tests/udp_modes.py
6
7Two questions at once. Down the table: does a shared socket stop scaling as
8threads are added? Across it: does taking several datagrams per syscall make
9that better, or only cheaper?
10
11The receiver does no tracker work by default, so the figure is the kernel's
12receive path and nothing else. WORK_NS=1500 dilutes it back towards what the
13tracker actually costs per announce.
14
15The number reported is cpu microseconds the receiver spent per datagram
16answered, not throughput. Throughput here says more about the load generator
17than the receiver, because both run on the same machine and an echo costs so
18little that the generator runs out of cores first. Cpu per datagram does not
19care: it is what the receive path costs, whoever is the bottleneck.
20"""
21import os
22import signal
23import socket
24import subprocess
25import sys
26import time
27
28MODES = os.environ.get("MODES", "shared per-thread").split()
29BATCHES = [int(n) for n in os.environ.get("BATCHES", "1 8 32").split()]
30THREAD_COUNTS = [int(n) for n in os.environ.get("THREADS", "1 2 4 8").split()]
31LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4"))
32WINDOW = int(os.environ.get("WINDOW", "32"))
33SECONDS = float(os.environ.get("SECONDS", "3"))
34REPEATS = int(os.environ.get("REPEATS", "2"))
35WORK_NS = int(os.environ.get("WORK_NS", "0"))
36RECEIVER = os.environ.get("UDP_MODES", "/tmp/udp_modes")
37LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load")
38PORT_BASE = int(os.environ.get("PORT_BASE", "20100"))
39
40
41def cpu_seconds(pid):
42 try:
43 out = subprocess.run(["ps", "-o", "time=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()
44 except OSError:
45 return None
46 if not out:
47 return None
48 total = 0.0
49 for part in out.replace("-", ":").split(":"):
50 total = total * 60 + float(part)
51 return total
52
53
54def one_run(port, threads, mode, batch):
55 receiver = subprocess.Popen(
56 [RECEIVER, str(port), str(threads), mode, str(batch), str(WORK_NS)],
57 stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
58 )
59 for _ in range(200):
60 if receiver.poll() is not None:
61 return None, None
62 probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
63 probe.settimeout(0.1)
64 try:
65 probe.sendto(b"\x00" * 16, ("127.0.0.1", port))
66 probe.recv(64)
67 probe.close()
68 break
69 except OSError:
70 probe.close()
71 time.sleep(0.02)
72 else:
73 receiver.send_signal(signal.SIGINT)
74 receiver.wait()
75 return None, None
76
77 try:
78 subprocess.run([LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), "1", "10000", str(WINDOW)],
79 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
80 spent, wall = cpu_seconds(receiver.pid), time.time()
81 out = subprocess.run(
82 [LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), str(SECONDS), "10000", str(WINDOW)],
83 capture_output=True, text=True,
84 ).stdout.strip()
85 used = cpu_seconds(receiver.pid)
86 finally:
87 receiver.send_signal(signal.SIGINT)
88 try:
89 receiver.wait(timeout=5)
90 except subprocess.TimeoutExpired:
91 receiver.kill()
92 receiver.wait()
93
94 head = out.split()
95 if not head or not head[0].replace(".", "").isdigit():
96 return None, None
97 cores = (used - spent) / max(time.time() - wall, 1e-9) if (used is not None and spent is not None) else None
98 return float(head[0]), cores
99
100
101def main():
102 for binary in (RECEIVER, LOADER):
103 if not os.path.exists(binary):
104 raise SystemExit("missing %s; see the comment at the top of tests/udp_modes.c" % binary)
105
106 print(" %d load threads x %d in flight, %.0f s x %d runs, %d ns work per datagram"
107 % (LOAD_THREADS, WINDOW, SECONDS, REPEATS, WORK_NS))
108 print(" cpu microseconds per datagram, and the throughput it was measured at")
109 header = " %-14s %-8s" % ("sockets", "threads")
110 for batch in BATCHES:
111 header += "%20s" % ("%d per syscall" % batch)
112 print(header)
113
114 port = PORT_BASE
115 for mode in MODES:
116 for threads in THREAD_COUNTS:
117 row = " %-14s %-8d" % (mode, threads)
118 for batch in BATCHES:
119 results = []
120 for _ in range(REPEATS):
121 port += 1
122 rate, cores = one_run(port, threads, mode, batch)
123 if rate:
124 results.append((rate, cores))
125 if not results:
126 row += "%18s" % "-"
127 continue
128 results.sort()
129 rate, cores = results[len(results) // 2]
130 if cores:
131 row += "%9.2f us %6.0fk/s" % (cores * 1e6 / rate, rate / 1000.0)
132 else:
133 row += "%20s" % ("%.0fk/s" % (rate / 1000.0))
134 print(row)
135 sys.stdout.flush()
136
137
138if __name__ == "__main__":
139 main()
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()
diff --git a/tests/udp_wake_probe.c b/tests/udp_wake_probe.c
new file mode 100644
index 0000000..8a0f7a8
--- /dev/null
+++ b/tests/udp_wake_probe.c
@@ -0,0 +1,99 @@
1/* Three ways to get a thread out of a blocking recvfrom, tested rather than
2 assumed: close the socket, shut it down, or give it a receive timeout.
3
4 This decides how udp workers can be stopped. They sit in recvfrom and are
5 never joined today, and the sockets of a SO_REUSEPORT group all share one
6 address, so a worker cannot be singled out by sending it a datagram.
7
8 cc -O2 -pthread -o /tmp/wake tests/udp_wake_probe.c && /tmp/wake
9
10 On Linux 6.1: close does not wake it, shutdown does, SO_RCVTIMEO does. */
11#include <errno.h>
12#include <netinet/in.h>
13#include <pthread.h>
14#include <stdatomic.h>
15#include <stdio.h>
16#include <string.h>
17#include <sys/socket.h>
18#include <sys/time.h>
19#include <unistd.h>
20
21static _Atomic int returned;
22static _Atomic int saved_errno;
23static int sock;
24
25static void *reader(void *args) {
26 char buffer[64];
27 (void)args;
28 if (recvfrom(sock, buffer, sizeof(buffer), 0, NULL, NULL) < 0)
29 atomic_store(&saved_errno, errno);
30 atomic_store(&returned, 1);
31 return NULL;
32}
33
34static int bind_udp(int timeout_ms) {
35 struct sockaddr_in address;
36 int s = socket(AF_INET, SOCK_DGRAM, 0);
37
38 memset(&address, 0, sizeof(address));
39 address.sin_family = AF_INET;
40 address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
41 address.sin_port = 0;
42 bind(s, (struct sockaddr *)&address, sizeof(address));
43
44 if (timeout_ms) {
45 struct timeval how_long;
46 how_long.tv_sec = timeout_ms / 1000;
47 how_long.tv_usec = (timeout_ms % 1000) * 1000;
48 setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &how_long, sizeof(how_long));
49 }
50 return s;
51}
52
53static void try_it(const char *name, int timeout_ms, int do_close, int do_shutdown) {
54 pthread_t thread;
55 int waited;
56
57 atomic_store(&returned, 0);
58 atomic_store(&saved_errno, 0);
59 sock = bind_udp(timeout_ms);
60 pthread_create(&thread, NULL, reader, NULL);
61 usleep(300000); /* let it reach recvfrom */
62
63 if (do_shutdown)
64 shutdown(sock, SHUT_RD);
65 if (do_close)
66 close(sock);
67
68 for (waited = 0; waited < 2000 && !atomic_load(&returned); ++waited)
69 usleep(1000);
70
71 if (atomic_load(&returned)) {
72 printf(" %-24s returned after %4d ms, errno %s\n", name, waited,
73 atomic_load(&saved_errno) ? strerror(atomic_load(&saved_errno)) : "none");
74 pthread_join(thread, NULL);
75 } else {
76 printf(" %-24s STILL BLOCKED after 2000 ms\n", name);
77 /* unblock it so the probe can finish */
78 struct sockaddr_in to;
79 socklen_t length = sizeof(to);
80 int poke = socket(AF_INET, SOCK_DGRAM, 0);
81 if (!getsockname(sock, (struct sockaddr *)&to, &length))
82 sendto(poke, "x", 1, 0, (struct sockaddr *)&to, length);
83 close(poke);
84 usleep(200000);
85 if (atomic_load(&returned))
86 pthread_join(thread, NULL);
87 else
88 pthread_detach(thread);
89 }
90 if (!do_close)
91 close(sock);
92}
93
94int main(void) {
95 try_it("close() the socket", 0, 1, 0);
96 try_it("shutdown(SHUT_RD)", 0, 0, 1);
97 try_it("SO_RCVTIMEO 500 ms", 500, 0, 0);
98 return 0;
99}