diff options
| -rw-r--r-- | opentracker.c | 37 | ||||
| -rw-r--r-- | ot_event.c | 18 | ||||
| -rw-r--r-- | ot_socket.c | 26 | ||||
| -rw-r--r-- | ot_socket.h | 14 | ||||
| -rw-r--r-- | ot_udp.c | 7 | ||||
| -rw-r--r-- | ot_udp.h | 3 | ||||
| -rw-r--r-- | tests/integration/harness.py | 6 | ||||
| -rw-r--r-- | tests/udp_load.c | 202 | ||||
| -rw-r--r-- | tests/udp_modes.c | 240 | ||||
| -rw-r--r-- | tests/udp_modes.py | 139 | ||||
| -rw-r--r-- | tests/udp_scaling.py | 159 | ||||
| -rw-r--r-- | tests/udp_wake_probe.c | 99 |
12 files changed, 932 insertions, 18 deletions
diff --git a/opentracker.c b/opentracker.c index 34b8fd5..f579b92 100644 --- a/opentracker.c +++ b/opentracker.c | |||
| @@ -84,7 +84,7 @@ static void install_signal_handlers(void) { | |||
| 84 | 84 | ||
| 85 | sa.sa_handler = signal_handler; | 85 | sa.sa_handler = signal_handler; |
| 86 | sigemptyset(&sa.sa_mask); | 86 | sigemptyset(&sa.sa_mask); |
| 87 | sa.sa_flags = SA_RESTART; | 87 | sa.sa_flags = 0; |
| 88 | if ((sigaction(SIGINT, &sa, NULL) == -1) || (sigaction(SIGALRM, &sa, NULL) == -1)) | 88 | if ((sigaction(SIGINT, &sa, NULL) == -1) || (sigaction(SIGALRM, &sa, NULL) == -1)) |
| 89 | panic("install_signal_handlers"); | 89 | panic("install_signal_handlers"); |
| 90 | 90 | ||
| @@ -320,8 +320,17 @@ static void *server_mainloop(void *args) { | |||
| 320 | } | 320 | } |
| 321 | 321 | ||
| 322 | 322 | ||
| 323 | /* A group of sockets sharing one address only spreads datagrams if every | ||
| 324 | member asked for it before binding, the first one included, so whether | ||
| 325 | this entry will have workers has to be known here. */ | ||
| 326 | static socket_sharing listen_sharing_for(const ot_listen *entry) { | ||
| 327 | if (entry->proto == FLAG_UDP && entry->workers > 1 && socket_can_balance()) | ||
| 328 | return SOCKET_BALANCED; | ||
| 329 | return entry->shared ? SOCKET_SHARED : SOCKET_EXCLUSIVE; | ||
| 330 | } | ||
| 331 | |||
| 323 | static void listen_bind_one(ot_listen *entry) { | 332 | static void listen_bind_one(ot_listen *entry) { |
| 324 | socket_sharing sharing = entry->shared ? SOCKET_SHARED : SOCKET_EXCLUSIVE; | 333 | socket_sharing sharing = listen_sharing_for(entry); |
| 325 | 334 | ||
| 326 | #ifdef _DEBUG | 335 | #ifdef _DEBUG |
| 327 | { | 336 | { |
| @@ -370,6 +379,10 @@ static void listen_bind_all(void) { | |||
| 370 | listen_bind_one(entries + i); | 379 | listen_bind_one(entries + i); |
| 371 | } | 380 | } |
| 372 | 381 | ||
| 382 | /* The socket the event loop was using becomes the first worker's. Where the | ||
| 383 | kernel spreads datagrams, every further worker binds one of its own, so | ||
| 384 | none of them share a receive queue. | ||
| 385 | All others read from the first */ | ||
| 373 | static void listen_start_udp_workers(void) { | 386 | static void listen_start_udp_workers(void) { |
| 374 | size_t count, i; | 387 | size_t count, i; |
| 375 | ot_listen *entries = config_listen_entries(&count); | 388 | ot_listen *entries = config_listen_entries(&count); |
| @@ -377,14 +390,30 @@ static void listen_start_udp_workers(void) { | |||
| 377 | udp_init(); | 390 | udp_init(); |
| 378 | 391 | ||
| 379 | for (i = 0; i < count; ++i) { | 392 | for (i = 0; i < count; ++i) { |
| 380 | ot_listen *entry = entries + i; | 393 | ot_listen *entry = entries + i; |
| 394 | int balance = listen_sharing_for(entry) == SOCKET_BALANCED; | ||
| 395 | int worker; | ||
| 381 | 396 | ||
| 382 | if (entry->proto != FLAG_UDP || entry->workers <= 0) | 397 | if (entry->proto != FLAG_UDP || entry->workers <= 0) |
| 383 | continue; | 398 | continue; |
| 384 | 399 | ||
| 385 | socket_set_blocking((int)entry->sock, 1); | 400 | socket_set_blocking((int)entry->sock, 1); |
| 386 | event_del((int)entry->sock); | 401 | event_del((int)entry->sock); |
| 387 | udp_workers_install(entry->sock, (unsigned int)entry->workers); | 402 | udp_worker_install(entry->sock); |
| 403 | |||
| 404 | for (worker = 1; worker < entry->workers; ++worker) { | ||
| 405 | int64_t sock = entry->sock; | ||
| 406 | |||
| 407 | if (balance) { | ||
| 408 | sock = socket_udp_bind(entry->ip, entry->port, SOCKET_BALANCED); | ||
| 409 | if (sock == -1) { | ||
| 410 | fprintf(stderr, "Warning: could not bind another socket for udp worker %d: %s. Sharing the first instead.\n", worker, strerror(errno)); | ||
| 411 | sock = entry->sock; | ||
| 412 | } else | ||
| 413 | socket_set_blocking((int)sock, 1); | ||
| 414 | } | ||
| 415 | udp_worker_install(sock); | ||
| 416 | } | ||
| 388 | } | 417 | } |
| 389 | } | 418 | } |
| 390 | 419 | ||
| @@ -83,8 +83,10 @@ int event_wait(ot_event *out, int max, int timeout_ms) { | |||
| 83 | g_ready_space = max; | 83 | g_ready_space = max; |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | while ((count = epoll_wait(g_epoll, g_ready, max, timeout_ms)) == -1 && errno == EINTR) | 86 | /* An interrupted wait is reported as no events rather than retried here, |
| 87 | ; | 87 | so that the caller gets to look at whatever the signal was about */ |
| 88 | if ((count = epoll_wait(g_epoll, g_ready, max, timeout_ms)) == -1) | ||
| 89 | return errno == EINTR ? 0 : -1; | ||
| 88 | for (i = 0; i < count; ++i) { | 90 | for (i = 0; i < count; ++i) { |
| 89 | uint32_t events = g_ready[i].events; | 91 | uint32_t events = g_ready[i].events; |
| 90 | 92 | ||
| @@ -189,8 +191,10 @@ int event_wait(ot_event *out, int max, int timeout_ms) { | |||
| 189 | deadline = &timeout; | 191 | deadline = &timeout; |
| 190 | } | 192 | } |
| 191 | 193 | ||
| 192 | while ((count = kevent(g_kqueue, NULL, 0, g_ready, max, deadline)) == -1 && errno == EINTR) | 194 | /* An interrupted wait is reported as no events rather than retried here, |
| 193 | ; | 195 | so that the caller gets to look at whatever the signal was about */ |
| 196 | if ((count = kevent(g_kqueue, NULL, 0, g_ready, max, deadline)) == -1) | ||
| 197 | return errno == EINTR ? 0 : -1; | ||
| 194 | for (i = 0; i < count; ++i) { | 198 | for (i = 0; i < count; ++i) { |
| 195 | uint64_t udata = (uint64_t)(uintptr_t)g_ready[i].udata; | 199 | uint64_t udata = (uint64_t)(uintptr_t)g_ready[i].udata; |
| 196 | int eof = (g_ready[i].flags & (EV_EOF | EV_ERROR)) != 0; | 200 | int eof = (g_ready[i].flags & (EV_EOF | EV_ERROR)) != 0; |
| @@ -310,8 +314,10 @@ int event_del(int fd) { | |||
| 310 | int event_wait(ot_event *out, int max, int timeout_ms) { | 314 | int event_wait(ot_event *out, int max, int timeout_ms) { |
| 311 | int ready, found = 0, i; | 315 | int ready, found = 0, i; |
| 312 | 316 | ||
| 313 | while ((ready = poll(g_pfds, (nfds_t)g_count, timeout_ms)) == -1 && errno == EINTR) | 317 | /* An interrupted wait is reported as no events rather than retried here, |
| 314 | ; | 318 | so that the caller gets to look at whatever the signal was about */ |
| 319 | if ((ready = poll(g_pfds, (nfds_t)g_count, timeout_ms)) == -1) | ||
| 320 | return errno == EINTR ? 0 : -1; | ||
| 315 | if (ready <= 0) | 321 | if (ready <= 0) |
| 316 | return ready; | 322 | return ready; |
| 317 | 323 | ||
diff --git a/ot_socket.c b/ot_socket.c index 576087f..158459f 100644 --- a/ot_socket.c +++ b/ot_socket.c | |||
| @@ -75,13 +75,37 @@ static int socket_v6(int type) { | |||
| 75 | return s; | 75 | return s; |
| 76 | } | 76 | } |
| 77 | 77 | ||
| 78 | /* SO_REUSEPORT means three different things. Linux spreads datagrams over | ||
| 79 | the group. FreeBSD permits the bind and spreads nothing, which is what | ||
| 80 | SO_REUSEPORT_LB was added for. Darwin hands every datagram to whichever | ||
| 81 | socket bound last, so a group there starves all but one reader. */ | ||
| 82 | #if defined(SO_REUSEPORT_LB) | ||
| 83 | #define OT_BALANCE_OPTION SO_REUSEPORT_LB | ||
| 84 | #elif defined(SO_REUSEPORT) && !defined(__APPLE__) | ||
| 85 | #define OT_BALANCE_OPTION SO_REUSEPORT | ||
| 86 | #endif | ||
| 87 | |||
| 88 | int socket_can_balance(void) { | ||
| 89 | #ifdef OT_BALANCE_OPTION | ||
| 90 | return 1; | ||
| 91 | #else | ||
| 92 | return 0; | ||
| 93 | #endif | ||
| 94 | } | ||
| 95 | |||
| 78 | /* The only place in the tree that needs to know what a kernel calls this */ | 96 | /* The only place in the tree that needs to know what a kernel calls this */ |
| 79 | static void socket_set_sharing(int s, socket_sharing sharing) { | 97 | static void socket_set_sharing(int s, socket_sharing sharing) { |
| 80 | int one = 1; | 98 | int one = 1; |
| 81 | 99 | ||
| 82 | setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); | 100 | setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); |
| 101 | #ifdef OT_BALANCE_OPTION | ||
| 102 | if (sharing == SOCKET_BALANCED) { | ||
| 103 | setsockopt(s, SOL_SOCKET, OT_BALANCE_OPTION, &one, sizeof(one)); | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | #endif | ||
| 83 | #ifdef SO_REUSEPORT | 107 | #ifdef SO_REUSEPORT |
| 84 | if (sharing == SOCKET_SHARED) | 108 | if (sharing != SOCKET_EXCLUSIVE) |
| 85 | setsockopt(s, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); | 109 | setsockopt(s, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); |
| 86 | #endif | 110 | #endif |
| 87 | } | 111 | } |
diff --git a/ot_socket.h b/ot_socket.h index aa4e2d2..f73a5c9 100644 --- a/ot_socket.h +++ b/ot_socket.h | |||
| @@ -16,8 +16,18 @@ | |||
| 16 | as the v4 mapped address */ | 16 | as the v4 mapped address */ |
| 17 | 17 | ||
| 18 | /* How an address may be shared with other sockets. Which socket option says | 18 | /* How an address may be shared with other sockets. Which socket option says |
| 19 | so differs between kernels, which is socket_set_sharing's business */ | 19 | so differs between kernels, which is socket_set_sharing's business. |
| 20 | typedef enum { SOCKET_EXCLUSIVE, SOCKET_SHARED } socket_sharing; | 20 | |
| 21 | SHARED lets another socket bind the same address. BALANCED asks the kernel | ||
| 22 | to spread arriving datagrams over the sockets that did, which is a | ||
| 23 | different thing and not everywhere available: ask socket_can_balance | ||
| 24 | before binding a group that depends on it. */ | ||
| 25 | typedef enum { SOCKET_EXCLUSIVE, SOCKET_SHARED, SOCKET_BALANCED } socket_sharing; | ||
| 26 | |||
| 27 | /* Whether this kernel spreads datagrams over a group of sockets bound to one | ||
| 28 | address. Where it does not, binding a group is worse than one socket with | ||
| 29 | several readers, so the caller wants to know first. */ | ||
| 30 | int socket_can_balance(void); | ||
| 21 | 31 | ||
| 22 | /* Create, bind and, for TCP, listen. The returned socket is non blocking | 32 | /* Create, bind and, for TCP, listen. The returned socket is non blocking |
| 23 | Returns -1 on failure with errno set */ | 33 | Returns -1 on failure with errno set */ |
| @@ -235,13 +235,12 @@ void udp_init(void) { | |||
| 235 | udp_generate_rijndael_round_key(); | 235 | udp_generate_rijndael_round_key(); |
| 236 | } | 236 | } |
| 237 | 237 | ||
| 238 | void udp_workers_install(int64_t sock, unsigned int worker_count) { | 238 | void udp_worker_install(int64_t sock) { |
| 239 | pthread_t thread_id; | 239 | pthread_t thread_id; |
| 240 | 240 | ||
| 241 | #ifdef _DEBUG | 241 | #ifdef _DEBUG |
| 242 | fprintf(stderr, "Installing %d workers on udp socket %ld\n", worker_count, (unsigned long)sock); | 242 | fprintf(stderr, "Installing a worker on udp socket %ld\n", (unsigned long)sock); |
| 243 | #endif | 243 | #endif |
| 244 | while (worker_count--) | 244 | pthread_create(&thread_id, NULL, udp_worker, (void *)sock); |
| 245 | pthread_create(&thread_id, NULL, udp_worker, (void *)sock); | ||
| 246 | } | 245 | } |
| 247 | 246 | ||
| @@ -11,7 +11,8 @@ | |||
| 11 | #include "trackerlogic.h" | 11 | #include "trackerlogic.h" |
| 12 | 12 | ||
| 13 | void udp_init(void); | 13 | void udp_init(void); |
| 14 | void udp_workers_install(int64_t sock, unsigned int worker_count); | 14 | /* One thread reading one socket. Called once per worker. */ |
| 15 | void udp_worker_install(int64_t sock); | ||
| 15 | int handle_udp6(int64_t serversocket, struct ot_workstruct *ws); | 16 | int handle_udp6(int64_t serversocket, struct ot_workstruct *ws); |
| 16 | 17 | ||
| 17 | #endif | 18 | #endif |
diff --git a/tests/integration/harness.py b/tests/integration/harness.py index 03a0561..122c36d 100644 --- a/tests/integration/harness.py +++ b/tests/integration/harness.py | |||
| @@ -121,6 +121,7 @@ class Tracker: | |||
| 121 | all: LeakSanitizer reports from an exit handler, so a killed tracker | 121 | all: LeakSanitizer reports from an exit handler, so a killed tracker |
| 122 | reports nothing and the suite passes over leaks it never looked for. | 122 | reports nothing and the suite passes over leaks it never looked for. |
| 123 | """ | 123 | """ |
| 124 | refused_to_exit = False | ||
| 124 | if self.process is not None and self.process.poll() is None: | 125 | if self.process is not None and self.process.poll() is None: |
| 125 | self.process.send_signal(signal.SIGINT) | 126 | self.process.send_signal(signal.SIGINT) |
| 126 | try: | 127 | try: |
| @@ -128,11 +129,16 @@ class Tracker: | |||
| 128 | except subprocess.TimeoutExpired: | 129 | except subprocess.TimeoutExpired: |
| 129 | self.process.kill() | 130 | self.process.kill() |
| 130 | self.process.wait() | 131 | self.process.wait() |
| 132 | refused_to_exit = True | ||
| 131 | complaints = [ | 133 | complaints = [ |
| 132 | line | 134 | line |
| 133 | for line in self.output().splitlines() | 135 | for line in self.output().splitlines() |
| 134 | if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR") | 136 | if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR") |
| 135 | ] | 137 | ] |
| 138 | if refused_to_exit: | ||
| 139 | # Killing it throws away whatever the sanitizer had to say on the | ||
| 140 | # way out, so a test that ends this way has checked nothing. | ||
| 141 | complaints.append("the tracker did not exit within 5 s of SIGINT and had to be killed") | ||
| 136 | self._cleanup() | 142 | self._cleanup() |
| 137 | return complaints | 143 | return complaints |
| 138 | 144 | ||
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 | |||
| 44 | static struct sockaddr_in g_tracker; | ||
| 45 | static long g_torrents = 10000; | ||
| 46 | static long g_window = 64; | ||
| 47 | static _Atomic long long g_sent = 0; | ||
| 48 | static _Atomic int g_running = 1; | ||
| 49 | static _Atomic long long g_replies = 0; | ||
| 50 | static _Atomic long long g_timeouts = 0; | ||
| 51 | |||
| 52 | static double seconds(void) { | ||
| 53 | struct timespec now; | ||
| 54 | clock_gettime(CLOCK_MONOTONIC, &now); | ||
| 55 | return now.tv_sec + now.tv_nsec / 1e9; | ||
| 56 | } | ||
| 57 | |||
| 58 | static 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 | } | ||
| 63 | static 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 | } | ||
| 69 | static 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 | |||
| 77 | static 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 | |||
| 164 | int 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 | |||
| 39 | static int g_port; | ||
| 40 | static long g_threads; | ||
| 41 | static long g_batch; | ||
| 42 | static long g_work_ns; | ||
| 43 | static int g_shared_socket = -1; /* -1 means one socket per thread */ | ||
| 44 | static _Atomic int g_running = 1; | ||
| 45 | |||
| 46 | static 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. */ | ||
| 55 | static 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 | |||
| 70 | static 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 | |||
| 89 | static 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 | |||
| 102 | static 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 | ||
| 122 | static 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 | |||
| 159 | static 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 | |||
| 184 | int 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 | |||
| 7 | Two questions at once. Down the table: does a shared socket stop scaling as | ||
| 8 | threads are added? Across it: does taking several datagrams per syscall make | ||
| 9 | that better, or only cheaper? | ||
| 10 | |||
| 11 | The receiver does no tracker work by default, so the figure is the kernel's | ||
| 12 | receive path and nothing else. WORK_NS=1500 dilutes it back towards what the | ||
| 13 | tracker actually costs per announce. | ||
| 14 | |||
| 15 | The number reported is cpu microseconds the receiver spent per datagram | ||
| 16 | answered, not throughput. Throughput here says more about the load generator | ||
| 17 | than the receiver, because both run on the same machine and an echo costs so | ||
| 18 | little that the generator runs out of cores first. Cpu per datagram does not | ||
| 19 | care: it is what the receive path costs, whoever is the bottleneck. | ||
| 20 | """ | ||
| 21 | import os | ||
| 22 | import signal | ||
| 23 | import socket | ||
| 24 | import subprocess | ||
| 25 | import sys | ||
| 26 | import time | ||
| 27 | |||
| 28 | MODES = os.environ.get("MODES", "shared per-thread").split() | ||
| 29 | BATCHES = [int(n) for n in os.environ.get("BATCHES", "1 8 32").split()] | ||
| 30 | THREAD_COUNTS = [int(n) for n in os.environ.get("THREADS", "1 2 4 8").split()] | ||
| 31 | LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4")) | ||
| 32 | WINDOW = int(os.environ.get("WINDOW", "32")) | ||
| 33 | SECONDS = float(os.environ.get("SECONDS", "3")) | ||
| 34 | REPEATS = int(os.environ.get("REPEATS", "2")) | ||
| 35 | WORK_NS = int(os.environ.get("WORK_NS", "0")) | ||
| 36 | RECEIVER = os.environ.get("UDP_MODES", "/tmp/udp_modes") | ||
| 37 | LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load") | ||
| 38 | PORT_BASE = int(os.environ.get("PORT_BASE", "20100")) | ||
| 39 | |||
| 40 | |||
| 41 | def 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 | |||
| 54 | def 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 | |||
| 101 | def 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 | |||
| 138 | if __name__ == "__main__": | ||
| 139 | main() | ||
diff --git a/tests/udp_scaling.py b/tests/udp_scaling.py new file mode 100644 index 0000000..7691fcb --- /dev/null +++ b/tests/udp_scaling.py | |||
| @@ -0,0 +1,159 @@ | |||
| 1 | """Does listen.udp.workers help or hurt, on this platform, with this tracker? | ||
| 2 | |||
| 3 | Starts a tracker once per worker count and drives it with tests/udp_load.c, | ||
| 4 | which speaks the real protocol, so what comes out is the tracker's own | ||
| 5 | throughput 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 | |||
| 11 | Worker count 0 means no dedicated workers: udp is served from the main event | ||
| 12 | loop, which is what a default build does. | ||
| 13 | |||
| 14 | LOAD_THREADS and WINDOW decide how much load is offered. Both matter. Too | ||
| 15 | little and the tracker idles while the figure measures round trips; too much | ||
| 16 | and the kernel drops datagrams, every lost reply costs the generator a | ||
| 17 | timeout, and the figure collapses. | ||
| 18 | |||
| 19 | Three columns say which side the number came from. "cores" is the tracker's | ||
| 20 | cpu, "load" the generator's. Whichever is near its machine's core count is | ||
| 21 | the one being measured. "lost" counts datagrams that went unanswered: once | ||
| 22 | it leaves zero the throughput figure is timeouts, not service, and the run | ||
| 23 | needs a smaller WINDOW. | ||
| 24 | """ | ||
| 25 | import os | ||
| 26 | import resource | ||
| 27 | import signal | ||
| 28 | import socket | ||
| 29 | import subprocess | ||
| 30 | import sys | ||
| 31 | import tempfile | ||
| 32 | import time | ||
| 33 | |||
| 34 | TRACKER = os.environ.get("OPENTRACKER", "./opentracker") | ||
| 35 | LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load") | ||
| 36 | WORKER_COUNTS = [int(n) for n in os.environ.get("WORKERS", "0 1 2 4 8").split()] | ||
| 37 | LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4")) | ||
| 38 | SECONDS = float(os.environ.get("SECONDS", "5")) | ||
| 39 | TORRENTS = int(os.environ.get("TORRENTS", "10000")) | ||
| 40 | WINDOW = int(os.environ.get("WINDOW", "32")) | ||
| 41 | REPEATS = int(os.environ.get("REPEATS", "3")) | ||
| 42 | PORT_BASE = int(os.environ.get("PORT_BASE", "19960")) | ||
| 43 | |||
| 44 | |||
| 45 | def start(port, workers): | ||
| 46 | directory = tempfile.mkdtemp(prefix="ot-udpscale-") | ||
| 47 | config = "listen.tcp 127.0.0.1:%d\nlisten.udp 127.0.0.1:%d\n" % (port, port) | ||
| 48 | if workers: | ||
| 49 | config += "listen.udp.workers %d\n" % workers | ||
| 50 | with open(os.path.join(directory, "conf"), "w") as handle: | ||
| 51 | handle.write(config) | ||
| 52 | log = open(os.path.join(directory, "log"), "w+b") | ||
| 53 | tracker = subprocess.Popen([TRACKER, "-f", os.path.join(directory, "conf")], stdout=log, stderr=log) | ||
| 54 | for _ in range(300): | ||
| 55 | if tracker.poll() is not None: | ||
| 56 | log.seek(0) | ||
| 57 | raise SystemExit("tracker exited at once on port %d with %d workers:\n%s" | ||
| 58 | % (port, workers, log.read().decode("utf-8", "replace"))) | ||
| 59 | try: | ||
| 60 | socket.create_connection(("127.0.0.1", port), timeout=0.2).close() | ||
| 61 | return tracker, log | ||
| 62 | except OSError: | ||
| 63 | time.sleep(0.02) | ||
| 64 | tracker.kill() | ||
| 65 | raise SystemExit("tracker never listened on %d" % port) | ||
| 66 | |||
| 67 | |||
| 68 | def cpu_seconds(pid): | ||
| 69 | """Cumulative cpu time of a process, the one spelling ps has everywhere.""" | ||
| 70 | try: | ||
| 71 | out = subprocess.run(["ps", "-o", "time=", "-p", str(pid)], capture_output=True, text=True).stdout.strip() | ||
| 72 | except OSError: | ||
| 73 | return None | ||
| 74 | if not out: | ||
| 75 | return None | ||
| 76 | total, parts = 0.0, out.replace("-", ":").split(":") | ||
| 77 | for part in parts: | ||
| 78 | total = total * 60 + float(part) | ||
| 79 | return total | ||
| 80 | |||
| 81 | |||
| 82 | def reaped_cpu_seconds(): | ||
| 83 | """Cpu of every child already waited for. The generator is the only one | ||
| 84 | that starts and finishes inside a measured run.""" | ||
| 85 | spent = resource.getrusage(resource.RUSAGE_CHILDREN) | ||
| 86 | return spent.ru_utime + spent.ru_stime | ||
| 87 | |||
| 88 | |||
| 89 | def stop(tracker): | ||
| 90 | tracker.send_signal(signal.SIGINT) | ||
| 91 | try: | ||
| 92 | tracker.wait(timeout=10) | ||
| 93 | except subprocess.TimeoutExpired: | ||
| 94 | tracker.kill() | ||
| 95 | tracker.wait() | ||
| 96 | |||
| 97 | |||
| 98 | def parse(line): | ||
| 99 | """replies/s and the count of announces that were never answered""" | ||
| 100 | words = line.split() | ||
| 101 | if not words or not words[0].replace(".", "").isdigit(): | ||
| 102 | return None, None | ||
| 103 | lost = 0 | ||
| 104 | if "lost," in words: | ||
| 105 | lost = int(words[words.index("lost,") - 1]) | ||
| 106 | return float(words[0]), lost | ||
| 107 | |||
| 108 | |||
| 109 | def main(): | ||
| 110 | if not os.path.exists(LOADER): | ||
| 111 | raise SystemExit("build the generator first: cc -O2 -pthread -o %s tests/udp_load.c" % LOADER) | ||
| 112 | |||
| 113 | print(" tracker %s, %d load threads x %d in flight, %d torrents, %.0f s x %d runs" | ||
| 114 | % (TRACKER, LOAD_THREADS, WINDOW, TORRENTS, SECONDS, REPEATS)) | ||
| 115 | print(" %-8s %12s %7s %7s %7s %10s %s" | ||
| 116 | % ("workers", "median/s", "spread", "cores", "load", "lost", "vs 0 workers")) | ||
| 117 | baseline = None | ||
| 118 | for index, workers in enumerate(WORKER_COUNTS): | ||
| 119 | results = [] | ||
| 120 | for repeat in range(REPEATS): | ||
| 121 | port = PORT_BASE + index * 16 + repeat | ||
| 122 | tracker, log = start(port, workers) | ||
| 123 | try: | ||
| 124 | # Warm up until the torrent set exists and the peers are in | ||
| 125 | # place, so the measured run is renewals rather than insertions | ||
| 126 | subprocess.run([LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), "2", str(TORRENTS), str(WINDOW)], | ||
| 127 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | ||
| 128 | spent, offered, wall = cpu_seconds(tracker.pid), reaped_cpu_seconds(), time.time() | ||
| 129 | result = subprocess.run( | ||
| 130 | [LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), str(SECONDS), str(TORRENTS), str(WINDOW)], | ||
| 131 | capture_output=True, text=True, | ||
| 132 | ) | ||
| 133 | used, drove, elapsed = cpu_seconds(tracker.pid), reaped_cpu_seconds(), max(time.time() - wall, 1e-9) | ||
| 134 | finally: | ||
| 135 | stop(tracker) | ||
| 136 | log.close() | ||
| 137 | rate, lost = parse(result.stdout.strip()) | ||
| 138 | if rate is None: | ||
| 139 | continue | ||
| 140 | cores = (used - spent) / elapsed if (used is not None and spent is not None) else None | ||
| 141 | results.append((rate, cores, (drove - offered) / elapsed, lost)) | ||
| 142 | |||
| 143 | if not results: | ||
| 144 | print(" %-8s no result" % workers) | ||
| 145 | continue | ||
| 146 | results.sort() | ||
| 147 | rate, cores, load, lost = results[len(results) // 2] | ||
| 148 | if baseline is None: | ||
| 149 | baseline = rate | ||
| 150 | # A spread wider than the differences between rows means the rows are | ||
| 151 | # not telling you anything | ||
| 152 | spread = 100.0 * (results[-1][0] - results[0][0]) / rate | ||
| 153 | print(" %-8s %12.0f %6.0f%% %7s %7.2f %10d %.2fx" | ||
| 154 | % (workers, rate, spread, ("%.2f" % cores) if cores else "?", load, lost, rate / baseline)) | ||
| 155 | sys.stdout.flush() | ||
| 156 | |||
| 157 | |||
| 158 | if __name__ == "__main__": | ||
| 159 | 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 | |||
| 21 | static _Atomic int returned; | ||
| 22 | static _Atomic int saved_errno; | ||
| 23 | static int sock; | ||
| 24 | |||
| 25 | static 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 | |||
| 34 | static 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 | |||
| 53 | static 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 | |||
| 94 | int 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 | } | ||
