aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments/021-memory/run.py
blob: 49bb5a9b312cc2482736460edbfe12ebe8e34938 (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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
#!/usr/bin/env python3
"""Where a Pure node's memory goes when what it keeps cannot leave (review
004: an app's whole memory budget, not its requests' alone). pure-serve runs
an app while clients post as fast as they may for --seconds; its resident
memory (VmRSS) is read every 0.5 s beside what was posted, and the store's
size at the end.

  outbox      the sender's output wired nowhere: every send stays in its outbox
  budget      the same with --memory 4000000 (docs/design/013-memory-budget.md):
              sends past it are refused, 503 memory
  link-down   the output linked (pure://) to a port no one listens on
  wire-down   the output sent over HTTP to a port no one listens on
  held        a list of items whose notes are set again and again, a surface
              holding the snapshot of all of them (hold-surface.so) the whole time
  unheld      the same, no surface

    python3 research/experiments/021-memory/run.py [--seconds 30] [--cases outbox,budget,held] [--out FILE]
        [--label NAME]

PURE_SERVE=PATH runs another build's pure-serve (--label names it in the rows).
"""
import argparse
import http.client
import json
import os
import random
import select
import signal
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
HERE = Path(__file__).resolve().parent
SERVE = Path(os.environ.get('PURE_SERVE', ROOT / 'build/pure-serve'))  # another build's, to compare
sys.path.insert(0, str(ROOT / 'tests'))
import check  # noqa: E402

ITEMS = """record Item {
  id = 0
  note: text
}
state items: list of Item by id
input add = message {
  id: number
  note: text
}
input set = message {
  id: number
  note: text
}
on add { items.add(Item { id: add.id, note: add.note }) }
on set { items[set.id].note = set.note }
view count = items.count
"""
SEEDED, NOTE, MEMORY = 20000, 100, 4000000


def build(work):
    if 'PURE_SERVE' not in os.environ:
        subprocess.run(['make', '-s', '-C', str(ROOT), 'build/pure-serve'], check=True)
    aup = Path(os.environ.get('AUP', ROOT.parent / 'aup/build/aup'))
    for name, text in (('sender', check.LINK_SENDER), ('items', ITEMS)):
        (work / f'{name}.aup').write_text(text)
        check.compile_module(aup, work / f'{name}.aup', work / f'{name}.pmod')
    subprocess.run(['cc', '-O2', '-std=c17', '-Wall', '-Wextra', '-Werror', '-fPIC', '-shared', f'-I{ROOT}/core',
                    f'-I{ROOT}/serve', str(HERE / 'hold-surface.c'), '-o', str(work / 'hold-surface.so')], check=True)
    key = subprocess.Popen([str(SERVE), '--module', str(work / 'items.pmod'), '--store',
                            str(work / 'key-store'), '--key', str(work / 'node.key'), '--port', '0'],
                           stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    select.select([key.stdout], [], [], 10)
    key.stdout.readline()
    key.terminate()
    key.communicate(timeout=30)


def start(args):
    args = [str(a) for a in args]
    at = args.index('--') if '--' in args else len(args)  # the surface's arguments come last
    process = subprocess.Popen(args[:at] + ['--port', '0'] + args[at:], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    ready, _, _ = select.select([process.stdout], [], [], 10)
    line = process.stdout.readline() if ready else b''
    assert line, process.stderr.read(2000) if process.poll() is not None else 'no start'
    return process, json.loads(line)['port']


def rss_kib(pid):
    with open(f'/proc/{pid}/status') as status:
        for line in status:
            if line.startswith('VmRSS:'):
                return int(line.split()[1])
    return 0


def store_bytes(path):
    return sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())


def post_all(port, bodies, stop, counts, index, refused):
    """Posts (path, body) from bodies() on one keep-alive connection until
    stop: counts[index] the ones taken, refused[index] those refused 503."""
    connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
    while not stop.is_set():
        path, body = bodies()
        data = json.dumps(body).encode()
        try:
            connection.request('POST', path, data, {'Content-Length': str(len(data)),
                                                    'Content-Type': 'application/json'})
            response = connection.getresponse()
            response.read()
            if response.status == 200:
                counts[index] += 1
            elif response.status == 503:
                refused[index] += 1
        except OSError:
            connection.close()
            connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
    connection.close()


def run_case(case, work, seconds, clients=4):
    store = work / f'store-{case}'
    args = [SERVE, '--store', store, '--threads', '2']
    note = 'n' * NOTE
    if case in ('outbox', 'budget', 'link-down', 'wire-down'):
        args += ['--module', work / 'sender.pmod']
        if case == 'budget':
            args += ['--memory', MEMORY]
        if case == 'link-down':
            args += ['--key', work / 'node.key', '--send', 'ordered=pure://127.0.0.1:9/order',
                     '--seal', f'ordered={os.urandom(32).hex()}']
        elif case == 'wire-down':
            args += ['--send', 'ordered=http://127.0.0.1:9/inputs/order']

        def bodies():
            return '/inputs/buy', {'item': note, 'count': 1}
    else:
        args += ['--module', work / 'items.pmod']
        if case == 'held':  # the snapshot of all the items, held from then on
            args += ['--surface', work / 'hold-surface.so', '--', 'count', str(SEEDED)]
        rng = random.Random(7)

        def bodies():
            return '/inputs/set', {'id': rng.randrange(SEEDED), 'note': f'{rng.getrandbits(32):08x}' + note[8:]}
    process, port = start(args)
    try:
        if case in ('held', 'unheld'):  # the items first, then the sets
            seeded = [0]
            done = threading.Event()
            batches = [iter(range(k, SEEDED, clients)) for k in range(clients)]

            def seeding(k):
                connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
                for i in batches[k]:
                    data = json.dumps({'id': i, 'note': note}).encode()
                    connection.request('POST', '/inputs/add', data, {'Content-Length': str(len(data)),
                                                                      'Content-Type': 'application/json'})
                    response = connection.getresponse()
                    response.read()
                    assert response.status == 200, response.status
                connection.close()
            threads = [threading.Thread(target=seeding, args=(k,)) for k in range(clients)]
            for t in threads:
                t.start()
            for t in threads:
                t.join()
            del seeded, done
            time.sleep(1.0)  # the surface reads the first snapshot after the seeding's saves
        stop = threading.Event()
        counts, refused = [0] * clients, [0] * clients
        began, rss_began = time.time(), rss_kib(process.pid)
        samples = [(0.0, rss_began, 0)]
        threads = [threading.Thread(target=post_all, args=(port, bodies, stop, counts, k, refused))
                   for k in range(clients)]
        for t in threads:
            t.start()
        while time.time() - began < seconds:
            time.sleep(0.5)
            samples.append((round(time.time() - began, 2), rss_kib(process.pid), sum(counts)))
        stop.set()
        for t in threads:
            t.join()
        rss_end = rss_kib(process.pid)
    finally:
        process.send_signal(signal.SIGTERM)
        _, error = process.communicate(timeout=60)
    stats = [json.loads(line) for line in error.decode().splitlines() if line.startswith('{"active"')]
    posted = sum(counts)
    return {'case': case, 'seconds': seconds, 'posted': posted, 'refused': sum(refused), 'rss_began_kib': rss_began,
            'rss_end_kib': rss_end,
            'rss_per_post_bytes': round((rss_end - rss_began) * 1024 / posted, 1) if posted else None,
            'store_bytes': store_bytes(store), 'samples': samples[::4] + [samples[-1]],
            'stats': stats[-1] if stats else None}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--seconds', type=int, default=30)
    parser.add_argument('--cases', default='outbox,budget,link-down,wire-down,unheld,held')
    parser.add_argument('--out', type=Path, default=None)
    parser.add_argument('--label', default=None, help='the build, named in each row')
    args = parser.parse_args()
    with tempfile.TemporaryDirectory(prefix='pure-memory-') as directory:
        work = Path(directory)
        build(work)
        for case in args.cases.split(','):
            row = run_case(case, work, args.seconds)
            if args.label:
                row = {'label': args.label, **row}
            line = json.dumps(row)
            print(line, flush=True)
            if args.out:
                with open(args.out, 'a') as out:
                    out.write(line + '\n')


if __name__ == '__main__':
    main()