summaryrefslogtreecommitdiff
path: root/tests/udp_scaling.py
blob: 7691fcb349a8486ad08c6afc518438395aabc83c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""Does listen.udp.workers help or hurt, on this platform, with this tracker?

Starts a tracker once per worker count and drives it with tests/udp_load.c,
which speaks the real protocol, so what comes out is the tracker's own
throughput rather than an echo server's.

    cc -O2 -pthread -o /tmp/udp_load tests/udp_load.c
    make opentracker
    python3 tests/udp_scaling.py

Worker count 0 means no dedicated workers: udp is served from the main event
loop, which is what a default build does.

LOAD_THREADS and WINDOW decide how much load is offered. Both matter. Too
little and the tracker idles while the figure measures round trips; too much
and the kernel drops datagrams, every lost reply costs the generator a
timeout, and the figure collapses.

Three columns say which side the number came from. "cores" is the tracker's
cpu, "load" the generator's. Whichever is near its machine's core count is
the one being measured. "lost" counts datagrams that went unanswered: once
it leaves zero the throughput figure is timeouts, not service, and the run
needs a smaller WINDOW.
"""
import os
import resource
import signal
import socket
import subprocess
import sys
import tempfile
import time

TRACKER = os.environ.get("OPENTRACKER", "./opentracker")
LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load")
WORKER_COUNTS = [int(n) for n in os.environ.get("WORKERS", "0 1 2 4 8").split()]
LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4"))
SECONDS = float(os.environ.get("SECONDS", "5"))
TORRENTS = int(os.environ.get("TORRENTS", "10000"))
WINDOW = int(os.environ.get("WINDOW", "32"))
REPEATS = int(os.environ.get("REPEATS", "3"))
PORT_BASE = int(os.environ.get("PORT_BASE", "19960"))


def start(port, workers):
    directory = tempfile.mkdtemp(prefix="ot-udpscale-")
    config = "listen.tcp 127.0.0.1:%d\nlisten.udp 127.0.0.1:%d\n" % (port, port)
    if workers:
        config += "listen.udp.workers %d\n" % workers
    with open(os.path.join(directory, "conf"), "w") as handle:
        handle.write(config)
    log = open(os.path.join(directory, "log"), "w+b")
    tracker = subprocess.Popen([TRACKER, "-f", os.path.join(directory, "conf")], stdout=log, stderr=log)
    for _ in range(300):
        if tracker.poll() is not None:
            log.seek(0)
            raise SystemExit("tracker exited at once on port %d with %d workers:\n%s"
                             % (port, workers, log.read().decode("utf-8", "replace")))
        try:
            socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
            return tracker, log
        except OSError:
            time.sleep(0.02)
    tracker.kill()
    raise SystemExit("tracker never listened on %d" % port)


def cpu_seconds(pid):
    """Cumulative cpu time of a process, the one spelling ps has everywhere."""
    try:
        out = subprocess.run(["ps", "-o", "time=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()
    except OSError:
        return None
    if not out:
        return None
    total, parts = 0.0, out.replace("-", ":").split(":")
    for part in parts:
        total = total * 60 + float(part)
    return total


def reaped_cpu_seconds():
    """Cpu of every child already waited for. The generator is the only one
       that starts and finishes inside a measured run."""
    spent = resource.getrusage(resource.RUSAGE_CHILDREN)
    return spent.ru_utime + spent.ru_stime


def stop(tracker):
    tracker.send_signal(signal.SIGINT)
    try:
        tracker.wait(timeout=10)
    except subprocess.TimeoutExpired:
        tracker.kill()
        tracker.wait()


def parse(line):
    """replies/s and the count of announces that were never answered"""
    words = line.split()
    if not words or not words[0].replace(".", "").isdigit():
        return None, None
    lost = 0
    if "lost," in words:
        lost = int(words[words.index("lost,") - 1])
    return float(words[0]), lost


def main():
    if not os.path.exists(LOADER):
        raise SystemExit("build the generator first: cc -O2 -pthread -o %s tests/udp_load.c" % LOADER)

    print("  tracker %s, %d load threads x %d in flight, %d torrents, %.0f s x %d runs"
          % (TRACKER, LOAD_THREADS, WINDOW, TORRENTS, SECONDS, REPEATS))
    print("  %-8s %12s %7s %7s %7s %10s   %s"
          % ("workers", "median/s", "spread", "cores", "load", "lost", "vs 0 workers"))
    baseline = None
    for index, workers in enumerate(WORKER_COUNTS):
        results = []
        for repeat in range(REPEATS):
            port = PORT_BASE + index * 16 + repeat
            tracker, log = start(port, workers)
            try:
                # Warm up until the torrent set exists and the peers are in
                # place, so the measured run is renewals rather than insertions
                subprocess.run([LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), "2", str(TORRENTS), str(WINDOW)],
                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                spent, offered, wall = cpu_seconds(tracker.pid), reaped_cpu_seconds(), time.time()
                result = subprocess.run(
                    [LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), str(SECONDS), str(TORRENTS), str(WINDOW)],
                    capture_output=True, text=True,
                )
                used, drove, elapsed = cpu_seconds(tracker.pid), reaped_cpu_seconds(), max(time.time() - wall, 1e-9)
            finally:
                stop(tracker)
                log.close()
            rate, lost = parse(result.stdout.strip())
            if rate is None:
                continue
            cores = (used - spent) / elapsed if (used is not None and spent is not None) else None
            results.append((rate, cores, (drove - offered) / elapsed, lost))

        if not results:
            print("  %-8s no result" % workers)
            continue
        results.sort()
        rate, cores, load, lost = results[len(results) // 2]
        if baseline is None:
            baseline = rate
        # A spread wider than the differences between rows means the rows are
        # not telling you anything
        spread = 100.0 * (results[-1][0] - results[0][0]) / rate
        print("  %-8s %12.0f %6.0f%% %7s %7.2f %10d   %.2fx"
              % (workers, rate, spread, ("%.2f" % cores) if cores else "?", load, lost, rate / baseline))
        sys.stdout.flush()


if __name__ == "__main__":
    main()